diff --git a/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt index b08f283b..b1bd2a4d 100644 --- a/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt +++ b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt @@ -3,47 +3,97 @@ package com.anonymous.bulwarkmobile.sync import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Base64 import android.util.Log +import java.io.File +import kotlin.concurrent.thread /** - * Debug builds only: lets adb set up device sync accounts without the settings - * UI, for the device tests in docs/device-sync.md ("Testing"). Protected by - * android.permission.DUMP, which only the shell and the system hold. Syncs - * themselves are triggered with `adb shell requestsync`. + * Debug builds only: lets adb set up device sync accounts and drive the + * provider bridge without the settings UI or JS, for the device tests in + * docs/device-sync.md ("Testing"). Protected by android.permission.DUMP, + * which only the shell and the system hold. Syncs themselves are triggered + * with `adb shell requestsync`. * * adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \ * --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \ * --es enable contacts,calendar * * Commands: `ensure` (name, registry, optional enable), `enable` (name, - * authority, optional --ez on false), `remove` (name) and `list`. + * authority, optional --ez on false), `remove` (name), `list`, and the + * provider calls `query` and `batch` (name, authority, and the + * `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares + * the shell's quoting, or pushed to the app's external files directory and + * named by `file` when it is too long for a command line), `syncstate` + * (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and + * the sync-problem notification, `problem` (name, title, text, uri, optional + * channel) and `clearproblem` (name). Results go to logcat (tag + * BulwarkDeviceSync) and the broadcast's result data. */ class DeviceSyncDebugReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - val result = try { - val name = intent.getStringExtra("name") - when (intent.getStringExtra("cmd")) { - "ensure" -> { - val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!) - for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) { - DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true) - } - "created=$created" - } - "enable" -> { - val on = intent.getBooleanExtra("on", true) - DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on) - "enabled=$on" - } - "remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}" - "list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" } - else -> "unknown cmd" + val pending = goAsync() + // Provider calls block; keep them off the main thread. + thread(name = "DeviceSyncDebugReceiver") { + val result = try { + run(context, intent) + } catch (e: Exception) { + "error: ${e.javaClass.simpleName}: ${e.message}" } - } catch (e: Exception) { - "error: ${e.javaClass.simpleName}: ${e.message}" + Log.i(TAG, "debug command: $result") + pending.resultData = result + pending.finish() } - Log.i(TAG, "debug command: $result") - resultData = result + } + + private fun run(context: Context, intent: Intent): String { + val name = intent.getStringExtra("name") + return when (intent.getStringExtra("cmd")) { + "ensure" -> { + val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!) + for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) { + DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true) + } + "created=$created" + } + "enable" -> { + val on = intent.getBooleanExtra("on", true) + DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on) + "enabled=$on" + } + "remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}" + "list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" } + "query" -> io(context, intent) { it.query(json(context, intent)) } + "batch" -> io(context, intent) { it.applyBatch(json(context, intent)) } + "syncstate" -> io(context, intent) { it.readSyncState().toString() } + "photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use { + it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString() + } + "problem" -> { + SyncProblemNotifier.show( + context, + name!!, + intent.getStringExtra("title").orEmpty(), + intent.getStringExtra("text").orEmpty(), + intent.getStringExtra("uri").orEmpty(), + intent.getStringExtra("channel"), + ) + "shown" + } + "clearproblem" -> { + SyncProblemNotifier.clear(context, name!!) + "cleared" + } + else -> "unknown cmd" + } + } + + private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String = + ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call) + + private fun json(context: Context, intent: Intent): String { + intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() } + return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8) } private fun authority(which: String) = when (which) { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt index e60da9f2..34b95d3f 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt @@ -19,11 +19,15 @@ import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import org.json.JSONObject +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** - * `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings and the - * run handshake of device sync (#34). The contract is `DeviceSyncNativeModule` - * in src/device-sync/types.ts; docs/device-sync.md explains it. + * `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run + * handshake, provider I/O, push routes and the sync-problem notification of + * device sync (#34). The contract is `DeviceSyncNativeModule` in + * src/device-sync/types.ts; docs/device-sync.md explains it. */ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -32,6 +36,15 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : emit(ACCOUNTS_CHANGED_EVENT, null) } + /** + * Provider I/O runs here, one call at a time: a batch can hold the + * provider for seconds (it sleeps at a contended yield point), which would + * stall every other native module call on React's native-modules thread. + */ + private val io: ExecutorService = Executors.newSingleThreadExecutor { task -> + Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true } + } + init { synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this } } @@ -59,6 +72,7 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : } catch (e: RuntimeException) { // Not registered. } + io.shutdown() super.invalidate() } @@ -181,6 +195,91 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : promise.resolve(DeviceSyncRuns.isCancelled(runId)) } + // ── provider I/O ──────────────────────────────────────────── + + /** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */ + @ReactMethod + fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) } + } + + /** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */ + @ReactMethod + fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) { + val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) } + try { + io.execute { + val result = try { + ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) } + } catch (e: Throwable) { + failed(e) + } + promise.resolve(result) + } + } catch (e: RejectedExecutionException) { + promise.resolve(failed(e)) + } + } + + @ReactMethod + fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() } + } + + /** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */ + @ReactMethod + fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY) + .use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) } + } + + /** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */ + private fun onIo(promise: Promise, block: () -> Any?) { + try { + io.execute { + try { + promise.resolve(block()) + } catch (e: Throwable) { + promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e) + } + } + } catch (e: RejectedExecutionException) { + promise.reject("provider", "Device sync is shutting down", e) + } + } + + // ── push routes and notifications ─────────────────────────── + + /** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */ + @ReactMethod + fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) { + DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson) + null + } + + /** + * Posts (or replaces) the account's sync-problem notification. [channelName] + * names the notification channel in the app's language; null keeps English. + */ + @ReactMethod + fun showSyncProblem( + accountName: String, + title: String, + text: String, + uri: String, + channelName: String?, + promise: Promise, + ) = settle(promise) { + SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName) + null + } + + @ReactMethod + fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) { + SyncProblemNotifier.clear(reactApplicationContext, accountName) + null + } + // NativeEventEmitter bookkeeping. @ReactMethod fun addListener(eventName: String) {} @@ -191,6 +290,8 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : private inline fun settle(promise: Promise, block: () -> Any?) { try { promise.resolve(block()) + } catch (e: AccountConflictException) { + promise.reject("conflict", e.message, e) } catch (e: SecurityException) { promise.reject("permission", e.message, e) } catch (e: IllegalArgumentException) { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt index 2bbbcf86..29e2fa7c 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt @@ -36,18 +36,32 @@ object DeviceSyncAccounts { return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) } } + fun exists(context: Context, name: String): Boolean = + AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name } + /** - * Adds the account when it is missing and (re)writes its registry id. - * Both authorities start syncable with automatic sync off; the app turns - * on the ones the user picked. Returns true when the account was created. + * Adds the account when it is missing. Both authorities start syncable + * with automatic sync off; the app turns on the ones the user picked. + * Returns true when the account was created, false when it exists for + * [registryId] already. An account that belongs to another registry id + * keeps it ([AccountConflictException]): its rows and SyncState were + * written for that app account. */ fun ensure(context: Context, name: String, registryId: String): Boolean { val manager = AccountManager.get(context) val account = account(context, name) val exists = manager.getAccountsByType(account.type).any { it.name == name } - if (exists) { - manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId) - return false + val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null + when (ensureAction(exists, current, registryId)) { + EnsureAction.KEEP -> return false + EnsureAction.ADOPT -> { + manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId) + return false + } + EnsureAction.CONFLICT -> throw AccountConflictException( + "The Android account $name belongs to another app account ($current)", + ) + EnsureAction.CREATE -> Unit } val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) } check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" } @@ -58,6 +72,16 @@ object DeviceSyncAccounts { return true } + enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT } + + /** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */ + fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when { + !exists -> EnsureAction.CREATE + currentRegistryId == registryId -> EnsureAction.KEEP + currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT + else -> EnsureAction.CONFLICT + } + /** Removes the account; the Contacts and Calendar providers drop all of its rows. */ fun remove(context: Context, name: String): Boolean { val manager = AccountManager.get(context) @@ -83,8 +107,14 @@ object DeviceSyncAccounts { if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds) } + /** + * Requests a sync. SyncManager drops a request that matches a sync it is + * running, so a run of that account and authority is also flagged to + * ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]). + */ fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) { requireAuthority(authority) + DeviceSyncRuns.flagSyncAgain(name, authority) ContentResolver.requestSync(account(context, name), authority, flags.toExtras()) } @@ -93,6 +123,9 @@ object DeviceSyncAccounts { } } +/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */ +class AccountConflictException(message: String) : Exception(message) + /** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */ data class SyncRequestFlags( val manual: Boolean = false, diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt index 6bf83444..aa4b851d 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt @@ -40,6 +40,13 @@ class DeviceSyncAdapter(context: Context) : private val main = Handler(Looper.getMainLooper()) + /** + * Anything thrown out of here is rethrown on the sync thread + * (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI + * included. So every failure becomes a result: a soft error SyncManager + * retries with back-off, or a database error for a missing permission, + * as the framework would report it. + */ override fun onPerformSync( account: Account, extras: Bundle, @@ -47,6 +54,18 @@ class DeviceSyncAdapter(context: Context) : provider: ContentProviderClient, syncResult: SyncResult, ) { + try { + performSync(account, extras, authority, syncResult) + } catch (e: SecurityException) { + Log.w(TAG, "Sync of $authority refused by a permission check", e) + syncResult.databaseError = true + } catch (t: Throwable) { + Log.e(TAG, "Sync of $authority failed", t) + syncResult.stats.numIoExceptions++ + } + } + + private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID) if (registryId.isNullOrEmpty()) { Log.w(TAG, "Sync of $authority skipped: the account has no app account") @@ -69,9 +88,9 @@ class DeviceSyncAdapter(context: Context) : run.end("no report before the deadline") } } - val outcome = SyncReports.outcome(run.report.get(), run.endReason.get()) + val outcome = SyncReports.outcome(run.report, run.endReason) SyncReports.apply(outcome, syncResult) - Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason.get()}) after " + + Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " + "${SystemClock.elapsedRealtime() - startedAt} ms: $outcome") } catch (e: InterruptedException) { // onSyncCanceled interrupts this thread. JS sees the run as @@ -84,6 +103,12 @@ class DeviceSyncAdapter(context: Context) : // After startRun, which was posted first, so a listener it added is removed. main.post { run.detach() } } + // A change pushed while the run was open: SyncManager dropped its + // request as a duplicate of this sync, so ask for the next one here. + if (run.syncAgainRequested) { + Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again") + syncResult.fullSyncRequested = true + } } @MainThread diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt index cbe67561..9e46adeb 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt @@ -6,24 +6,34 @@ import com.facebook.react.jstasks.HeadlessJsTaskEventListener import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** * The device sync runs an adapter is waiting for, keyed by a random run id. * - * A run ends on the first of: JS calling finishRun, the headless task ending - * (JS settled, timed out or was reloaded away), React refusing or failing to - * start, the adapter's deadline, or a cancel. Headless task ids restart at 1 - * with every React context, so they never identify a run; the run id does, - * and a report for a run nobody waits for any more is ignored. + * A run ends exactly once, on the first of: JS calling finishRun, the + * headless task ending (JS settled, timed out or was reloaded away), React + * refusing or failing to start, the adapter's deadline, or a cancel. A report + * that arrives after that is refused whole rather than half-applied. Headless + * task ids restart at 1 with every React context, so they never identify a + * run; the run id does. */ object DeviceSyncRuns { const val NO_TASK = -1 + enum class State { WAITING, ENDED } + class Run(val runId: String, val accountName: String, val authority: String) { + private val state = AtomicReference(State.WAITING) + private val syncAgain = AtomicBoolean(false) val done = CountDownLatch(1) - val report = AtomicReference(null) - val endReason = AtomicReference(null) + + /** Written before [done] opens, read after it: the latch orders them. */ + @Volatile var report: String? = null + private set + @Volatile var endReason: String? = null + private set @Volatile var cancelled = false @Volatile var boot: TaskInterface? = null /** Written and compared on the UI thread only. */ @@ -31,10 +41,24 @@ object DeviceSyncRuns { @Volatile private var tasks: HeadlessJsTaskContext? = null @Volatile private var listener: HeadlessJsTaskEventListener? = null - /** Releases the waiting adapter. Idempotent: the first reason wins. */ - fun end(reason: String) { - endReason.compareAndSet(null, reason) + val isWaiting: Boolean get() = state.get() == State.WAITING + + /** True once a sync was requested for this account and authority while the run was open. */ + val syncAgainRequested: Boolean get() = syncAgain.get() + + /** Releases the waiting adapter. Only the first end counts; returns whether this was it. */ + fun end(reason: String): Boolean = end(reason, null) + + internal fun end(reason: String, reportJson: String?): Boolean { + if (!state.compareAndSet(State.WAITING, State.ENDED)) return false + report = reportJson + endReason = reason done.countDown() + return true + } + + internal fun requestSyncAgain() { + syncAgain.set(true) } fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) { @@ -56,27 +80,46 @@ object DeviceSyncRuns { fun open(accountName: String, authority: String): Run = Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it } - /** Forgets the run: a late finishRun is then ignored and isCancelled answers true. */ + /** + * Forgets the run: a late finishRun is then refused and isCancelled + * answers true. Read [Run.syncAgainRequested] after this, so a request + * can no longer slip in between reading the flag and closing. + */ fun close(run: Run) { + run.end("closed") run.cancelled = true runs.remove(run.runId) } - /** From JS. Returns false when nothing waits for this run (stale, duplicate or finished). */ - fun finish(runId: String, reportJson: String): Boolean { - val run = runs[runId] ?: return false - if (!run.report.compareAndSet(null, reportJson)) return false - run.end("reported") - return true - } + /** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */ + fun finish(runId: String, reportJson: String): Boolean = + runs[runId]?.end("reported", reportJson) ?: false - /** Unknown runs count as cancelled: the adapter has stopped waiting for them. */ - fun isCancelled(runId: String): Boolean = runs[runId]?.cancelled ?: true + /** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */ + fun isCancelled(runId: String): Boolean { + val run = runs[runId] ?: return true + return run.cancelled || !run.isWaiting + } fun cancel(runId: String) { runs[runId]?.cancelled = true } - /** Runs currently waited for, newest last; for status and the push router's "sync again" flag. */ - fun active(): List = runs.values.toList() + /** + * Flags the open runs of [accountName] and [authority] to request another + * sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a + * `requestSync` that matches a sync it is running, so a change pushed + * mid-run would otherwise wait for the next trigger. Returns whether a + * run was flagged. + */ + fun flagSyncAgain(accountName: String, authority: String): Boolean { + var flagged = false + for (run in runs.values) { + if (run.accountName == accountName && run.authority == authority) { + run.requestSyncAgain() + flagged = true + } + } + return flagged + } } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt new file mode 100644 index 00000000..cb0e250d --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt @@ -0,0 +1,368 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentProviderOperation +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.OperationApplicationException +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.TransactionTooLargeException +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.provider.SyncStateContract +import android.util.Base64 +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException + +/** + * Runs device sync's provider calls for one Android account of our type and + * one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of + * `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this + * class only builds the calls and reads the answers. + * + * Every URI carries `caller_is_syncadapter=true` and the account + * (CalendarProvider accepts no other query parameter), so writes neither set + * DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by + * the account. The scope lookups are the exception: they read ids and owners + * of rows of any account. + * + * The client is unstable: a provider process that dies fails the call instead + * of taking this process (and the UI) with it. Call from a background thread; + * close when done. + */ +class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable { + private val appContext: Context = context.applicationContext + private val account: AccountRef + private val client: ContentProviderClient + + init { + if (authority !in DeviceSyncAccounts.AUTHORITIES) { + throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority") + } + account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext)) + client = appContext.contentResolver.acquireUnstableContentProviderClient(authority) + ?: throw IllegalStateException("No content provider for $authority") + } + + override fun close() { + client.close() + } + + // ── the bridge calls ──────────────────────────────────────── + + /** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */ + fun query(queryJson: String): String { + val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson)) + val writer = ProviderRowsWriter(plan.columns) + val projection = plan.columns.toTypedArray() + for (selection in plan.selections) { + query(uri(plan.table), projection, selection, plan.orderBy).use { cursor -> + while (cursor.moveToNext()) { + writer.beginRow() + for (i in projection.indices) { + when (cursor.getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i)) + Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i)) + Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i)) + // Blobs (photo thumbnails) never cross the bridge; readPhoto reads them. + else -> writer.nullCell() + } + } + writer.endRow() + } + } + } + return writer.finish() + } + + /** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */ + fun applyBatch(opsJson: String): String = try { + val ops = ProviderOpParser.parseBatch(authority, opsJson) + // Rows written for an account that is gone would outlive it: the + // providers purge an account's rows only when accounts change. + if (!DeviceSyncAccounts.exists(appContext, account.name)) { + throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists") + } + if (ops.isEmpty()) { + ProviderResults.ok(emptyList()) + } else { + val planned = ScopePlanner(account, this).planBatch(ops) + val results = client.applyBatch(ArrayList(planned.map(::build))) + ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) }) + } + } catch (e: Throwable) { + ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName) + } + + /** The account's SyncState blob as text, or null when there is none. */ + fun readSyncState(): String? = + SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type)) + ?.toString(Charsets.UTF_8) + + /** + * The raw contact's display photo, or its thumbnail when it has none, + * scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null + * when the raw contact has no photo. + */ + fun readPhoto(rawContactId: Long, maxPx: Int): String? { + require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" } + require(maxPx > 0) { "maxPx must be positive" } + if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) { + throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account") + } + var fileId: Long? = null + var thumbnail: ByteArray? = null + val photoRow = Selection( + "raw_contact_id = ? AND mimetype = ?", + listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE), + ) + query( + uri(ProviderTable.DATA), + arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO), + photoRow, + "${ContactsContract.Data.IS_PRIMARY} DESC", + ).use { cursor -> + if (cursor.moveToFirst()) { + if (!cursor.isNull(0)) fileId = cursor.getLong(0) + if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1) + } + } + val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null + val jpeg = scaledJpeg(bytes, maxPx) ?: return null + return JSONObject() + .put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP)) + .put("fileId", fileId ?: JSONObject.NULL) + .toString() + } + + private fun readDisplayPhoto(rawContactId: Long): ByteArray? { + val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon() + .appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY) + .scoped() + .build() + return try { + client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } } + } catch (e: FileNotFoundException) { + // No display photo stored (small photos only have the thumbnail). + null + } + } + + private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = BitmapFactory.Options().apply { + inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx) + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null + val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx) + val scaled = if (width == decoded.width && height == decoded.height) { + decoded + } else { + Bitmap.createScaledBitmap(decoded, width, height, true) + } + return ByteArrayOutputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + out.toByteArray() + } + } + + // ── ScopeLookup ───────────────────────────────────────────── + + override fun ownCalendarIds(): Set { + val ids = HashSet() + val selection = Selection( + "${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?", + listOf(account.name, account.type), + ) + query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c -> + while (c.moveToNext()) ids += c.getLong(0) + } + return ids + } + + override fun rootAccounts(table: ProviderTable, ids: Set): Map { + val owners = HashMap() + forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c -> + owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty()) + } + return owners + } + + override fun dataRawContacts(ids: Set): Map = + idPairs(ProviderTable.DATA, "raw_contact_id", ids) + + override fun eventCalendars(ids: Set): Map = + idPairs(ProviderTable.EVENTS, "calendar_id", ids) + + override fun childEvents(table: ProviderTable, ids: Set): Map = + idPairs(table, "event_id", ids) + + override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set { + val events = HashSet() + query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c -> + while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0) + } + return events + } + + /** Row id → [column] for the rows of [ids] that exist, in any account. */ + private fun idPairs(table: ProviderTable, column: String, ids: Set): Map { + val pairs = HashMap() + forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c -> + if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1) + } + return pairs + } + + private fun forEachRow( + table: ProviderTable, + projection: Array, + idColumn: String, + ids: Set, + read: (Cursor) -> Unit, + ) { + for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) { + val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList()) + query(uri(table, scoped = false), projection, selection, null).use { c -> + while (c.moveToNext()) read(c) + } + } + } + + private fun query(uri: Uri, projection: Array, selection: Selection, orderBy: String?): Cursor = + client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy) + ?: throw IllegalStateException("$authority returned no cursor for $uri") + + // ── operations ────────────────────────────────────────────── + + private fun build(op: PlannedOp): ContentProviderOperation = when (op) { + is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table)) + .withValues(contentValues(op.values)) + .apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) } + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Update -> ContentProviderOperation + .newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table)) + .withValues(contentValues(op.values)) + .selectRows(op.selection) + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table)) + .selectRows(op.selection) + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table)) + .selectRows(op.selection) + .apply { + op.values?.let { values -> + withValues(ContentValues().apply { for ((column, text) in values) put(column, text) }) + } + } + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + // An insert that replaces the account's row; never a yield point. + is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation( + syncStateUri(), + Account(account.name, account.type), + op.value.toByteArray(Charsets.UTF_8), + ) + } + + private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply { + // An item URI takes no selection at all (CalendarProvider refuses one). + if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray()) + } + + private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply { + if (count != null) withExpectedCount(count) + } + + private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) { + // Settings inserts answer with the account's URI, which has no id. + is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull()) + is PlannedOp.SetSyncState -> OpOutcome() + else -> OpOutcome(count = count) + } + + private fun contentValues(values: Map) = ContentValues(values.size).apply { + for ((column, cell) in values) { + when (cell) { + is Cell.Text -> put(column, cell.value) + is Cell.Integer -> put(column, cell.value) + is Cell.Real -> put(column, cell.value) + is Cell.Blob -> put(column, decodeBlob(column, cell.base64)) + Cell.Null -> putNull(column) + } + } + } + + private fun decodeBlob(column: String, base64: String): ByteArray = try { + Base64.decode(base64, Base64.DEFAULT) + } catch (e: IllegalArgumentException) { + throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64") + } + + // ── URIs ──────────────────────────────────────────────────── + + private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri { + val base = when (table) { + ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI + ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI + ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI + ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI + ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI + ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI + ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI + ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI + ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI + ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI + } + val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon() + return if (scoped) builder.scoped().build() else builder.syncAdapter().build() + } + + private fun syncStateUri(): Uri = when (authority) { + DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI + else -> CalendarContract.SyncState.CONTENT_URI + }.buildUpon().scoped().build() + + private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true") + + private fun Uri.Builder.scoped() = syncAdapter() + .appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name) + .appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type) + + companion object { + private const val ID = "_id" + private const val JPEG_QUALITY = 85 + + /** Same parameter name in ContactsContract and CalendarContract. */ + private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER + + /** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */ + fun failureOf(e: Throwable): BatchFailure { + var t: Throwable? = e + while (t != null) { + when (t) { + is ProviderRefusal -> return t.reason + is TransactionTooLargeException -> return BatchFailure.TOO_LARGE + is SecurityException -> return BatchFailure.PERMISSION + is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message) + } + t = t.cause?.takeIf { it !== t } + } + return BatchFailure.PROVIDER + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt new file mode 100644 index 00000000..167bbd4d --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt @@ -0,0 +1,410 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.math.BigDecimal +import java.math.BigInteger +import kotlin.math.abs + +/** + * The provider operations of device sync (#34) as the engine sends them: the + * `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed + * into a validated model. [ScopePlanner] turns the model into provider calls + * scoped to one account and [ProviderIo] runs them. Nothing here touches + * Android types, so JVM tests cover all of it. + */ + +/** `BatchFailure` of types.ts: why a batch was refused or failed. */ +enum class BatchFailure(val wire: String) { + ASSERT("assert"), + TOO_LARGE("tooLarge"), + SCOPE("scope"), + PERMISSION("permission"), + PROVIDER("provider"), +} + +/** A query or batch refused before it reached the provider. */ +class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message) + +/** How the rows of a table are tied to an account; see [ScopePlanner]. */ +enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS } + +/** The tables the engine may touch, per authority. Any other table is refused. */ +enum class ProviderTable( + val wire: String, + val authority: String, + val scope: TableScope, + /** The table whose row each row here belongs to, named by [parentColumn]. */ + val parent: ProviderTable? = null, + val parentColumn: String? = null, + /** + * How a selection names the row id; null when rows have none (Settings + * rows are keyed by their account). CalendarProvider queries attendees + * joined with events and calendars, where a bare `_id` is ambiguous. + */ + val idColumn: String? = "_id", +) { + RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"), + GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null), + CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"), + ATTENDEES( + "attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id", + ), + REMINDERS( + "reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id", + ), + EXTENDED_PROPERTIES( + "extended_properties", + DeviceSyncAccounts.CALENDAR_AUTHORITY, + TableScope.EVENT_IDS, + EVENTS, + "event_id", + "ExtendedProperties._id", + ), + COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + ; + + /** An insert returns the new row id, which later ops can back-reference. */ + val insertReturnsId: Boolean get() = idColumn != null + + companion object { + fun of(authority: String, wire: String): ProviderTable = + entries.firstOrNull { it.wire == wire && it.authority == authority } + ?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority") + } +} + +/** + * A cell as it crosses the bridge: text, a number, null, or (written to + * `data.data15` only) photo bytes as base64. Numbers that are integers become + * [Integer], so an assert compares `5` with the provider's `5`, not `5.0`. + */ +sealed interface Cell { + data class Text(val value: String) : Cell + data class Integer(val value: Long) : Cell + data class Real(val value: Double) : Cell + data class Blob(val base64: String) : Cell + data object Null : Cell + + /** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */ + fun text(): String? = when (this) { + is Text -> value + is Integer -> value.toString() + is Real -> value.toString() + is Blob -> throw IllegalStateException("Blobs have no text form") + Null -> null + } + + /** The row id this cell names, if it names one (`12` or `"12"`). */ + fun rowId(): Long? = when (this) { + is Integer -> value + is Text -> value.toLongOrNull() + else -> null + } +} + +/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */ +data class RowTarget(val id: Long?, val where: String?, val args: List) + +data class ProviderQuery( + val table: ProviderTable, + val columns: List, + val where: String?, + val args: List, + val orderBy: String?, +) + +/** One `ProviderOp` of types.ts, validated. */ +sealed interface ProviderOp { + val yieldAllowed: Boolean + + data class Insert( + val table: ProviderTable, + val values: Map, + /** Column → index of an earlier insert of the batch whose new row id fills it. */ + val refs: Map, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Update( + val table: ProviderTable, + val target: RowTarget, + val values: Map, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Delete( + val table: ProviderTable, + val target: RowTarget, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Assert( + val table: ProviderTable, + val target: RowTarget, + val values: Map?, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp +} + +const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership" + +/** + * The table a written column points into when the row it names must belong to + * the account as well: the parent of a row, the master of an exception event, + * the group of a membership row. + */ +internal fun referencedTable(table: ProviderTable, column: String, values: Map): ProviderTable? = when { + column == table.parentColumn -> table.parent + table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS + table == ProviderTable.DATA && column == "data1" && + values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS + else -> null +} + +object ProviderOpParser { + /** + * ContactsProvider throws when its op counter reaches this. It counts an + * op before resetting at a yield point, so at most 499 ops may follow a + * yield point up to and including the next one. + */ + const val CONTACTS_OPS_PER_YIELD_POINT = 500 + + /** JS numbers beyond this are not exact integers any more. */ + private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 + + fun parseQuery(authority: String, json: String): ProviderQuery { + val o = parseObject(json, "query") + val table = ProviderTable.of(authority, o.requireString("table")) + val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array") + val columns = (0 until rawColumns.length()).map { i -> + rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string") + } + if (columns.isEmpty()) throw invalid("query needs at least one column") + val (where, args) = o.whereArgs("query") + return ProviderQuery(table, columns, where, args, o.optionalString("orderBy")) + } + + fun parseBatch(authority: String, json: String): List { + val array = try { + JSONArray(json) + } catch (e: JSONException) { + throw invalid("ops are not a JSON array: ${e.message}") + } + val ops = (0 until array.length()).map { i -> + val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object") + parseOp(authority, i, o) + } + checkSyncState(ops) + checkBackReferences(ops) + if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops) + return ops + } + + private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp { + val where = "op $index" + val kind = o.opt("op") as? String ?: throw invalid("$where has no op") + val yieldAllowed = o.optBoolean("yieldAllowed", false) + if (kind == "syncState") { + val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value") + return ProviderOp.SetSyncState(value, yieldAllowed) + } + val table = ProviderTable.of(authority, o.requireString("table")) + return when (kind) { + "insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed) + "update" -> { + val values = o.writeValues(table, where) + if (values.isEmpty()) throw invalid("$where: an update needs values") + ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed) + } + "delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed) + "assert" -> { + val target = o.target(table, where) + val values = o.assertValues(where) + val expectCount = o.expectCount(where) + if (values.isNullOrEmpty() && expectCount == null && target.id == null) { + throw invalid("$where: an assert needs values, an expected count or an id") + } + ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed) + } + else -> throw invalid("$where: unknown op $kind") + } + } + + /** + * The state describes the rows of its batch, so it must commit with them: + * as the last op, and not at a yield point, where the provider may commit + * everything before it on its own. + */ + private fun checkSyncState(ops: List) { + ops.forEachIndexed { i, op -> + if (op !is ProviderOp.SetSyncState) return@forEachIndexed + if (i != ops.lastIndex) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch") + } + if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point") + } + } + + private fun checkBackReferences(ops: List) { + ops.forEachIndexed { i, op -> + if (op !is ProviderOp.Insert) return@forEachIndexed + for ((column, ref) in op.refs) { + val target = ops.getOrNull(ref) + if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) { + throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id") + } + val expected = referencedTable(op.table, column, op.values) + if (expected != null && target.table != expected) { + throw ProviderRefusal( + BatchFailure.SCOPE, + "op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}", + ) + } + } + val parentColumn = op.table.parentColumn ?: return@forEachIndexed + val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null + if (!named) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn") + } + } + } + + private fun checkYieldPoints(ops: List) { + var count = 0 + ops.forEachIndexed { i, op -> + if (++count >= CONTACTS_OPS_PER_YIELD_POINT) { + throw invalid( + "op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " + + "(ContactsProvider refuses the batch)", + ) + } + if (i > 0 && op.yieldAllowed) count = 0 + } + } + + // ── JSON helpers ──────────────────────────────────────────── + + private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message) + + private fun parseObject(json: String, what: String): JSONObject = try { + JSONObject(json) + } catch (e: JSONException) { + throw invalid("$what is not a JSON object: ${e.message}") + } + + private fun JSONObject.requireString(key: String): String = + opt(key) as? String ?: throw invalid("$key must be a string") + + private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) { + null, JSONObject.NULL -> null + is String -> v.takeIf { it.isNotBlank() } + else -> throw invalid("$key must be a string") + } + + private fun JSONObject.target(table: ProviderTable, where: String): RowTarget { + val id = when (val raw = opt("id")) { + null, JSONObject.NULL -> null + else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 } + ?: throw invalid("$where: id must be a row id") + } + if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id") + val (sql, args) = whereArgs(where) + return RowTarget(id, sql, args) + } + + private fun JSONObject.whereArgs(where: String): Pair> { + val sql = optionalString("where") + val args = when (val raw = opt("args")) { + null, JSONObject.NULL -> emptyList() + is JSONArray -> (0 until raw.length()).map { i -> + scalar(raw.opt(i), "$where: args[$i]").text() + ?: throw invalid("$where: args[$i] must be a string or a number") + } + else -> throw invalid("$where: args must be an array") + } + if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where") + return sql to args + } + + private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) { + null, JSONObject.NULL -> null + else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value + ?.takeIf { it in 0..Int.MAX_VALUE }?.toInt() + ?: throw invalid("$where: expectCount must be a count") + } + + private fun JSONObject.refs(where: String): Map { + val raw = opt("refs") ?: return emptyMap() + if (raw == JSONObject.NULL) return emptyMap() + if (raw !is JSONObject) throw invalid("$where: refs must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) { + val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value + ?.takeIf { it in 0..Int.MAX_VALUE } + ?: throw invalid("$where: refs.$column must be an op index") + out[column] = index.toInt() + } + return out + } + + private fun JSONObject.writeValues(table: ProviderTable, where: String): Map { + val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) { + val v = raw.opt(column) + out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column") + } + return out + } + + private fun JSONObject.assertValues(where: String): Map? { + val raw = opt("values") ?: return null + if (raw == JSONObject.NULL) return null + if (raw !is JSONObject) throw invalid("$where: values must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column") + return out + } + + /** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */ + private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob { + if (table != ProviderTable.DATA || column != "data15") { + throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column") + } + val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }") + return Cell.Blob(base64) + } + + private fun scalar(v: Any?, what: String): Cell = when (v) { + null, JSONObject.NULL -> Cell.Null + is String -> Cell.Text(v) + is Boolean -> Cell.Integer(if (v) 1 else 0) + is Number -> numberCell(v) + else -> throw invalid("$what must be text, a number or null") + } + + /** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */ + internal fun numberCell(n: Number): Cell = when (n) { + is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong()) + is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble()) + is BigDecimal -> try { + Cell.Integer(n.longValueExact()) + } catch (e: ArithmeticException) { + Cell.Real(n.toDouble()) + } + else -> { + val d = n.toDouble() + if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d) + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt new file mode 100644 index 00000000..01729241 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt @@ -0,0 +1,489 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Plans provider calls for one account: every selection gets the account + * condition ANDed in, and every row an operation names by id, or points to + * from a written column, is checked to belong to the account first + * (docs/device-sync.md, "Native module API", Scoping). + * + * How a table is tied to the account ([TableScope]): + * - raw contacts, groups, settings, calendars and colors carry it in their + * `account_name`/`account_type` columns; + * - contacts data rows are filtered by ContactsProvider itself through the + * `account_name`/`account_type` URI parameters. Their account columns are + * never selected on: Android 17 drops them from the Data view for apps + * targeting more than API 36; + * - events get `calendar_id IN (…)` of the account's calendars, not the + * account columns: CalendarProvider updates events on the Events table, + * which has none; + * - attendees, reminders and extended properties get `event_id IN (…)` of the + * account's events among those the operation touches. CalendarProvider + * applies no account filter to them. + * + * Selections never use subqueries: the providers may compile them strictly. + */ +class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) { + private val calendarIds: Set by lazy { lookup.ownCalendarIds() } + + fun planQuery(query: ProviderQuery): PlannedQuery { + val target = RowTarget(null, query.where, query.args) + val selections = if (query.table.scope == TableScope.EVENT_IDS) { + // Mostly a handful of events (the engine reads children per chunk + // of events); a query over all of them is split so no selection + // outgrows a Binder transaction. Rows are then ordered per chunk. + ownEventsMatching(query.table, target) + .sorted() + .chunked(ID_LIST_CHUNK) + .map { chunk -> selection(query.table, target, chunk) } + } else { + listOf(selection(query.table, target, null)) + } + return PlannedQuery(query.table, query.columns, selections, query.orderBy) + } + + fun planBatch(ops: List): List { + val owners = Owners(ownershipRequests(ops)) + return ops.mapIndexed { i, op -> plan(i, op, owners) } + } + + private fun plan(i: Int, op: ProviderOp, owners: Owners): PlannedOp = when (op) { + is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value) + is ProviderOp.Insert -> { + checkAccountColumns(i, op.table, op.values) + checkReferences(i, op.table, op.values - op.refs.keys, owners) + val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values + PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed) + } + is ProviderOp.Update -> { + checkAccountColumns(i, op.table, op.values) + checkReferences(i, op.table, op.values, owners) + checkTarget(i, op.table, op.target, owners) + if (op.table == ProviderTable.EXTENDED_PROPERTIES) { + // CalendarProvider updates extended properties only through + // their item URI, which takes no selection: the id check above + // is the scope. + val id = op.target.id + if (id == null || op.target.where != null) { + throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id") + } + PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed) + } else { + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + } + is ProviderOp.Delete -> { + checkTarget(i, op.table, op.target, owners) + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + is ProviderOp.Assert -> { + checkTarget(i, op.table, op.target, owners) + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + val values = op.values?.mapValues { (_, cell) -> cell.text() } + PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + } + + /** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */ + private fun expectedCount(target: RowTarget, expectCount: Int?): Int? = + expectCount ?: if (target.id != null) 1 else null + + private fun accountCells() = mapOf( + ACCOUNT_NAME to Cell.Text(account.name), + ACCOUNT_TYPE to Cell.Text(account.type), + ) + + /** + * Account columns may only restate the account, on the tables that have + * them; a data row, event or attendee follows its parent's account. + */ + private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map) { + for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) { + val cell = values[column] ?: continue + val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) { + ACCOUNT_NAME -> cell == Cell.Text(account.name) + ACCOUNT_TYPE -> cell == Cell.Text(account.type) + else -> cell == Cell.Null + } + if (!restatesAccount) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account") + } + } + } + + /** + * Parents, masters and groups a written column names must be rows of the + * account that still exist. (Back-references were checked by the parser: + * they name inserts of the same batch.) + */ + private fun checkReferences(i: Int, table: ProviderTable, values: Map, owners: Owners) { + for ((column, cell) in values) { + val referenced = referencedTable(table, column, values) ?: continue + if (cell == Cell.Null) { + if (column == table.parentColumn) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row") + } + continue + } + val id = cell.rowId() + ?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id") + when (owners.of(referenced, id)) { + Owner.OURS -> Unit + Owner.FOREIGN -> throw ProviderRefusal( + BatchFailure.SCOPE, + "op $i: ${table.wire}.$column names ${referenced.wire} $id of another account", + ) + // Deleted meanwhile, as far as the engine can tell: re-read and re-plan. + Owner.MISSING -> throw ProviderRefusal( + BatchFailure.ASSERT, + "op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists", + ) + } + } + } + + /** A row named by id must not belong to another account; a missing one fails on the expected count instead. */ + private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) { + val id = target.id ?: return + if (owners.of(table, id) == Owner.FOREIGN) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account") + } + } + + /** For attendees, reminders and extended properties: the account's events the op may touch. */ + private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection? { + if (table.scope != TableScope.EVENT_IDS) return null + val id = target.id ?: return ownEventsMatching(table, target) + return listOfNotNull(owners.eventOf(table, id)) + } + + private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set { + val candidates = lookup.childEventIdsMatching(table, target.where, target.args) + if (candidates.isEmpty()) return emptySet() + return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys + } + + private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection?): Selection { + val parts = ArrayList(3) + val args = ArrayList() + target.id?.let { + parts += "${table.idColumn} = ?" + args += it.toString() + } + target.where?.let { + parts += it + args += target.args + } + when (table.scope) { + TableScope.ACCOUNT_COLUMNS -> { + parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?" + args += account.name + args += account.type + } + TableScope.URI_PARAMETERS -> Unit + TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds) + TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty()) + } + return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args) + } + + /** Every row id the batch names, by table: op targets and referenced rows. */ + private fun ownershipRequests(ops: List): Map> { + val requests = HashMap>() + fun request(table: ProviderTable, id: Long) { + requests.getOrPut(table) { HashSet() } += id + } + fun requestReferences(table: ProviderTable, values: Map) { + for ((column, cell) in values) { + val referenced = referencedTable(table, column, values) ?: continue + cell.rowId()?.let { request(referenced, it) } + } + } + for (op in ops) { + when (op) { + is ProviderOp.Insert -> requestReferences(op.table, op.values) + is ProviderOp.Update -> { + requestReferences(op.table, op.values) + op.target.id?.let { request(op.table, it) } + } + is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) } + is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) } + is ProviderOp.SetSyncState -> Unit + } + } + return requests + } + + private enum class Owner { OURS, FOREIGN, MISSING } + + /** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */ + private inner class Owners(requests: Map>) { + private val owners = HashMap>() + private val childEvents = HashMap>() + + init { + // Children lead to events, data rows to raw contacts; both are + // added to their parent's lookup. + val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty()) + for (table in CHILD_TABLES) { + val ids = requests[table] ?: continue + val events = lookup.childEvents(table, ids) + childEvents[table] = events + eventIds += events.values + } + val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty()) + val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty() + rawContactIds += dataParents.values + + if (eventIds.isNotEmpty()) { + owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds) + .mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN } + } + for ((table, events) in childEvents) { + // A child whose event is gone is an orphan, not ours. + owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) } + } + val roots = HashMap>() + for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it } + if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds + for ((table, ids) in roots) { + owners[table] = lookup.rootAccounts(table, ids) + .mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN } + } + if (dataParents.isNotEmpty()) { + owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) -> + ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId) + } + } + } + + private fun ownerOrForeign(table: ProviderTable, id: Long): Owner = + owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN + + fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING + + /** The event of a child row, when both exist and are the account's. */ + fun eventOf(table: ProviderTable, id: Long): Long? = + childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS } + } + + companion object { + const val ACCOUNT_NAME = "account_name" + const val ACCOUNT_TYPE = "account_type" + const val DATA_SET = "data_set" + + /** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */ + const val ID_LIST_CHUNK = 500 + + private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS } + private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS } + + /** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */ + fun idList(column: String, ids: Collection): String = + if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")") + } +} + +/** An Android account of our type: the scope of every provider call. */ +data class AccountRef(val name: String, val type: String) + +/** + * What [ScopePlanner] needs to know about rows beyond the operations + * themselves. [ProviderIo] answers with provider queries: except for + * [ownCalendarIds], these read rows of any account, but only their ids and + * owners, to tell rows of another account from rows that are gone. + */ +interface ScopeLookup { + /** `_id`s of the account's calendars. */ + fun ownCalendarIds(): Set + + /** The account of each row of a root table that exists. */ + fun rootAccounts(table: ProviderTable, ids: Set): Map + + /** The raw contact of each data row that exists. */ + fun dataRawContacts(ids: Set): Map + + /** The calendar of each event that exists. */ + fun eventCalendars(ids: Set): Map + + /** The event of each attendee, reminder or extended property that exists. */ + fun childEvents(table: ProviderTable, ids: Set): Map + + /** The events of the rows of a child table that match [where]. */ + fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set +} + +/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */ +data class Selection(val sql: String?, val args: List) { + companion object { + val NONE = Selection(null, emptyList()) + } +} + +/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */ +data class PlannedQuery( + val table: ProviderTable, + val columns: List, + val selections: List, + val orderBy: String?, +) + +/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */ +sealed interface PlannedOp { + val yieldAllowed: Boolean + + data class Insert( + val table: ProviderTable, + val values: Map, + val backReferences: Map, + override val yieldAllowed: Boolean, + ) : PlannedOp + + /** [itemId] set: an update through the row's own URI, without a selection. */ + data class Update( + val table: ProviderTable, + val selection: Selection, + val itemId: Long?, + val values: Map, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + data class Delete( + val table: ProviderTable, + val selection: Selection, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + /** [values] compared as text, as ContentProviderOperation does. */ + data class Assert( + val table: ProviderTable, + val selection: Selection, + val values: Map?, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + data class SetSyncState(val value: String) : PlannedOp { + override val yieldAllowed: Boolean get() = false + } +} + +/** The result of one op, as `OpResult` of types.ts. */ +data class OpOutcome(val id: Long? = null, val count: Int? = null) + +object ProviderResults { + fun ok(results: List): String { + val array = JSONArray() + for (result in results) { + array.put(JSONObject().apply { + result.id?.let { put("id", it) } + result.count?.let { put("count", it) } + }) + } + return JSONObject().put("ok", true).put("results", array).toString() + } + + fun failed(reason: BatchFailure, message: String?): String = JSONObject() + .put("ok", false) + .put("reason", reason.wire) + .put("message", message ?: reason.wire) + .toString() + + /** + * An OperationApplicationException is an assert that did not hold or an + * expected count that did not match (`assert`), except when an insert got + * no row back or ContactsProvider refused the batch's yield points. + */ + fun failureOfOperationApplication(message: String?): BatchFailure = when { + message == null -> BatchFailure.ASSERT + message.startsWith("Insert into ") -> BatchFailure.PROVIDER + message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER + else -> BatchFailure.ASSERT + } +} + +/** + * Query results as `ProviderRows` JSON, built by hand: a chunk of contacts + * can carry megabytes of shadows, and this is the hot path of every run. + */ +class ProviderRowsWriter(columns: List) { + private val out = StringBuilder(4096) + private var rows = 0 + private var cells = 0 + + init { + out.append("{\"columns\":[") + columns.forEachIndexed { i, column -> + if (i > 0) out.append(',') + appendString(column) + } + out.append("],\"rows\":[") + } + + fun beginRow() { + if (rows++ > 0) out.append(',') + out.append('[') + cells = 0 + } + + fun endRow() { + out.append(']') + } + + fun nullCell() = cell { out.append("null") } + + fun longCell(value: Long) = cell { out.append(value) } + + fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") } + + fun textCell(value: String) = cell { appendString(value) } + + fun finish(): String = out.append("]}").toString() + + private inline fun cell(write: () -> Unit) { + if (cells++ > 0) out.append(',') + write() + } + + private fun appendString(s: String) { + out.append('"') + for (c in s) { + when { + c == '"' -> out.append("\\\"") + c == '\\' -> out.append("\\\\") + c == '\n' -> out.append("\\n") + c == '\r' -> out.append("\\r") + c == '\t' -> out.append("\\t") + c < ' ' || c.code == 0x2028 || c.code == 0x2029 -> + out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0')) + else -> out.append(c) + } + } + out.append('"') + } +} + +/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */ +object PhotoScaling { + /** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */ + fun sampleSize(width: Int, height: Int, maxPx: Int): Int { + require(maxPx > 0) { "maxPx must be positive" } + var sample = 1 + val longer = maxOf(width, height) + while (longer / (sample * 2) >= maxPx) sample *= 2 + return sample + } + + fun targetSize(width: Int, height: Int, maxPx: Int): Pair { + val longer = maxOf(width, height) + if (longer <= maxPx) return width to height + val scale = maxPx.toDouble() / longer + return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt()) + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt new file mode 100644 index 00000000..3bdcca1b --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt @@ -0,0 +1,162 @@ +package com.anonymous.bulwarkmobile.sync + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * What a push asks for (#34): syncs of the Android accounts that contacts and + * calendar changes feed, and whether the mail push task should run at all. + * + * The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data + * key `changed`: JMAP account id → type → state; only its first account is + * repeated as `accountId`), so every account key is looked at. The mail task + * runs only for mail: when some account's map has `EmailDelivery`, for an + * EmailPush, or when `changed` is missing or unreadable (older relays), so a + * contact change never ends in a mail notification. + */ +object PushRouting { + /** An Android account and the authorities it syncs, fed by one JMAP account. */ + data class Route(val accountName: String, val authorities: Set) + + data class Decision( + /** (Android account name, authority) pairs to request a sync for. */ + val syncs: Set>, + val startMailTask: Boolean, + ) + + private const val EMAIL_PUSH_KIND = "jmap-email-push" + private const val EMAIL_DELIVERY = "EmailDelivery" + private val CONTACT_TYPES = setOf("ContactCard", "AddressBook") + private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar") + + fun decide(data: Map, routes: Map>): Decision { + val changed = data["changed"]?.let(::parseChanged) + var mail = data["kind"] == EMAIL_PUSH_KIND || changed == null + val syncs = LinkedHashSet>() + for ((jmapAccountId, types) in changed.orEmpty()) { + if (types == null) { + // One account's entry is unreadable: it may be mail. + mail = true + continue + } + if (EMAIL_DELIVERY in types) mail = true + val contacts = types.any { it in CONTACT_TYPES } + val calendar = types.any { it in CALENDAR_TYPES } + for (route in routes[jmapAccountId].orEmpty()) { + if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) { + syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY + } + if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) { + syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY + } + } + } + return Decision(syncs, mail) + } + + /** + * JMAP account id → its changed types (null for an entry that is not an + * object). Null for a map that is unreadable or names no type at all, + * which the relay sends as `{}` when it had no map. + */ + private fun parseChanged(json: String): Map?>? { + val o = try { + JSONObject(json) + } catch (e: JSONException) { + return null + } + val out = LinkedHashMap?>() + for (jmapAccountId in o.keys()) { + val types = o.opt(jmapAccountId) as? JSONObject + out[jmapAccountId] = types?.keys()?.asSequence()?.toSet() + } + return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } } + } + + /** + * `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it. + * [strict] refuses anything malformed (IllegalArgumentException); otherwise + * malformed entries are skipped, so a bad route never stops mail. + */ + fun parseRoutes(json: String?, strict: Boolean = false): Map> { + if (json.isNullOrEmpty()) return emptyMap() + fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message") + val o = try { + JSONObject(json) + } catch (e: JSONException) { + if (strict) bad("not a JSON object") else return emptyMap() + } + val out = LinkedHashMap>() + for (jmapAccountId in o.keys()) { + val entries = o.opt(jmapAccountId) as? JSONArray + ?: if (strict) bad("$jmapAccountId must map to an array") else continue + val routes = ArrayList() + for (i in 0 until entries.length()) { + val entry = entries.opt(i) as? JSONObject + val name = entry?.opt("accountName") as? String + val authorities = entry?.opt("authorities") as? JSONArray + if (name.isNullOrEmpty() || authorities == null) { + if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue + } + val known = (0 until authorities.length()).mapNotNull { j -> + val authority = authorities.opt(j) as? String + if (authority !in DeviceSyncAccounts.AUTHORITIES) { + if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null + } else { + authority + } + } + routes += Route(name, known.toSet()) + } + out[jmapAccountId] = routes + } + return out + } +} + +/** + * Applies [PushRouting] to incoming pushes (BulwarkMessagingService, + * BulwarkUnifiedPushService) with the routes JS stored through + * `setPushRoutes`. + */ +object DeviceSyncPushRouter { + private const val TAG = "BulwarkDeviceSync" + private const val PREFS = "bulwark_device_sync" + private const val KEY_ROUTES = "pushRoutes" + + /** Validates and stores the routes; they must survive the process, pushes arrive without JS. */ + fun saveRoutes(context: Context, json: String) { + PushRouting.parseRoutes(json, strict = true) + val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_ROUTES, json) + .commit() + check(saved) { "Push routes could not be saved" } + } + + /** + * Requests the syncs the push asks for, with constant (empty) extras so + * repeated pushes coalesce into one pending sync. Returns whether the mail + * push task should run; any failure here says yes, so mail never depends + * on device sync. + */ + fun route(context: Context, data: Map): Boolean = try { + val routes = PushRouting.parseRoutes( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null), + ) + val decision = PushRouting.decide(data, routes) + for ((accountName, authority) in decision.syncs) { + try { + DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags()) + } catch (e: RuntimeException) { + Log.w(TAG, "Push: no sync requested for $authority", e) + } + } + decision.startMailTask + } catch (e: Exception) { + Log.w(TAG, "Push not routed", e) + true + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt new file mode 100644 index 00000000..d76a88fc --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt @@ -0,0 +1,75 @@ +package com.anonymous.bulwarkmobile.sync + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import com.anonymous.bulwarkmobile.MainActivity +import com.anonymous.bulwarkmobile.R + +/** + * The one notification per Android account that device sync needs the user + * for (#34), e.g. "Sign in again" after the server rejected the credentials. + * The engine posts it through `showSyncProblem`; the app clears it once the + * problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the + * app. + */ +object SyncProblemNotifier { + const val CHANNEL_ID = "bulwark_sync" + private const val DEFAULT_CHANNEL_NAME = "Sync problems" + private const val TAG_PREFIX = "bulwark-sync:" + private const val NOTIFICATION_ID = 1 + private const val DEEP_LINK_SCHEME = "bulwarkmobile" + + /** + * Creates the channel, or renames it: JS passes the name in the app's + * language. Nothing urgent, so default importance and no vibration. + */ + fun ensureChannel(context: Context, name: String?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { enableVibration(false) } + manager.createNotificationChannel(channel) + } + + fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) { + val link = Uri.parse(uri) + require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" } + ensureChannel(context, channelName) + val tag = TAG_PREFIX + accountName + // Explicit, so no other app can answer the tap. + val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pending = PendingIntent.getActivity( + context, + tag.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // Runs repeat the same problem until it is solved; say it once. + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setContentIntent(pending) + .build() + context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification) + } + + fun clear(context: Context, accountName: String) { + context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt new file mode 100644 index 00000000..abbaa92e --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt @@ -0,0 +1,18 @@ +package com.anonymous.bulwarkmobile.sync + +import com.anonymous.bulwarkmobile.sync.DeviceSyncAccounts.EnsureAction +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceSyncAccountsTest { + @Test + fun `ensureAccount creates, keeps, adopts or refuses`() { + assertEquals(EnsureAction.CREATE, DeviceSyncAccounts.ensureAction(false, null, "alice@example.org@mail")) + assertEquals(EnsureAction.KEEP, DeviceSyncAccounts.ensureAction(true, "alice@example.org@mail", "alice@example.org@mail")) + // An account whose setup never wrote a registry id. + assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, null, "alice@example.org@mail")) + assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, "", "alice@example.org@mail")) + // Its rows and SyncState belong to another app account: never rewritten. + assertEquals(EnsureAction.CONFLICT, DeviceSyncAccounts.ensureAction(true, "alice@example.org@old", "alice@example.org@mail")) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt new file mode 100644 index 00000000..dbdfc040 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt @@ -0,0 +1,62 @@ +package com.anonymous.bulwarkmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeviceSyncRunsTest { + private val contacts = "com.android.contacts" + private val calendar = "com.android.calendar" + + @Test + fun `a report is accepted once, while the adapter waits`() { + val run = DeviceSyncRuns.open("alice", contacts) + assertTrue(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"io\"}")) + assertFalse(run.end("no report before the deadline")) + assertEquals(0L, run.done.count) + assertEquals("{\"outcome\":\"ok\"}", run.report) + assertEquals("reported", run.endReason) + DeviceSyncRuns.close(run) + } + + @Test + fun `a report after the run ended is refused whole`() { + val run = DeviceSyncRuns.open("alice", contacts) + assertTrue(run.end("no report before the deadline")) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + assertNull(run.report) + assertEquals("no report before the deadline", run.endReason) + DeviceSyncRuns.close(run) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + } + + @Test + fun `runs nobody waits for, or that ended, count as cancelled`() { + assertTrue(DeviceSyncRuns.isCancelled("unknown")) + val run = DeviceSyncRuns.open("alice", calendar) + assertFalse(DeviceSyncRuns.isCancelled(run.runId)) + run.end("task finished") + assertTrue(DeviceSyncRuns.isCancelled(run.runId)) + DeviceSyncRuns.close(run) + assertTrue(DeviceSyncRuns.isCancelled(run.runId)) + } + + @Test + fun `a sync requested during a run flags that run only, and the flag outlives the close`() { + val mine = DeviceSyncRuns.open("alice", contacts) + val otherAuthority = DeviceSyncRuns.open("alice", calendar) + val otherAccount = DeviceSyncRuns.open("bob", contacts) + assertTrue(DeviceSyncRuns.flagSyncAgain("alice", contacts)) + DeviceSyncRuns.close(mine) + assertTrue(mine.syncAgainRequested) + assertFalse(otherAuthority.syncAgainRequested) + assertFalse(otherAccount.syncAgainRequested) + // Closed runs are not flagged any more. + assertFalse(DeviceSyncRuns.flagSyncAgain("alice", contacts)) + DeviceSyncRuns.close(otherAuthority) + DeviceSyncRuns.close(otherAccount) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt new file mode 100644 index 00000000..751f0ad5 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt @@ -0,0 +1,515 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +private const val CONTACTS = "com.android.contacts" +private const val CALENDAR = "com.android.calendar" +private val ME = AccountRef("alice@example.org", "com.anonymous.bulwarkmobile.account") +private val OTHER = AccountRef("bob@gmail.com", "com.google") + +/** The provider as far as the planner asks: who owns which row; [me] is the account the lookup is bound to. */ +private class FakeLookup(private val me: AccountRef = ME) : ScopeLookup { + val rawContacts = HashMap() + val groups = HashMap() + val calendars = HashMap() + val colors = HashMap() + /** data id → raw contact */ + val data = HashMap() + /** event id → calendar */ + val events = HashMap() + /** child table → child id → event */ + val children = HashMap>() + /** (child table, where) → events of the matching rows; a null where matches every row. */ + val matching = HashMap, Set>() + + override fun ownCalendarIds() = calendars.filterValues { it == me }.keys + + override fun rootAccounts(table: ProviderTable, ids: Set): Map = when (table) { + ProviderTable.RAW_CONTACTS -> rawContacts + ProviderTable.GROUPS -> groups + ProviderTable.CALENDARS -> calendars + ProviderTable.COLORS -> colors + else -> throw AssertionError("no root lookup for $table") + }.filterKeys { it in ids } + + override fun dataRawContacts(ids: Set) = data.filterKeys { it in ids } + + override fun eventCalendars(ids: Set) = events.filterKeys { it in ids } + + override fun childEvents(table: ProviderTable, ids: Set): Map = + children[table].orEmpty().filterKeys { it in ids } + + override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set = + matching[table to where] ?: if (where == null) children[table].orEmpty().values.toSet() else emptySet() + + fun child(table: ProviderTable, id: Long, event: Long) { + children.getOrPut(table) { HashMap() }[id] = event + } +} + +class ProviderOpsTest { + private val lookup = FakeLookup().apply { + rawContacts[1] = ME + rawContacts[2] = OTHER + groups[10] = ME + groups[11] = OTHER + data[100] = 1 + data[101] = 2 + calendars[20] = ME + calendars[21] = ME + calendars[22] = OTHER + colors[40] = ME + events[30] = 20 + events[31] = 21 + events[32] = 22 + child(ProviderTable.ATTENDEES, 50, 30) + child(ProviderTable.ATTENDEES, 51, 32) + child(ProviderTable.REMINDERS, 60, 31) + child(ProviderTable.EXTENDED_PROPERTIES, 70, 30) + child(ProviderTable.EXTENDED_PROPERTIES, 71, 32) + } + private val planner = ScopePlanner(ME, lookup) + + private fun ops(vararg op: String) = "[${op.joinToString(",")}]" + + private fun plan(authority: String, vararg op: String) = + planner.planBatch(ProviderOpParser.parseBatch(authority, ops(*op))) + + private fun refused(reason: BatchFailure, block: () -> Unit): String { + try { + block() + } catch (e: ProviderRefusal) { + assertEquals(e.message, reason, e.reason) + return e.message.orEmpty() + } + fail("expected a $reason refusal") + throw AssertionError() + } + + // ── tables and parsing ────────────────────────────────────── + + @Test + fun `tables are enumerated per authority and anything else is out of scope`() { + assertEquals(ProviderTable.EXTENDED_PROPERTIES, ProviderTable.of(CALENDAR, "extended_properties")) + assertEquals(ProviderTable.SETTINGS, ProviderTable.of(CONTACTS, "settings")) + refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "events") } + refused(BatchFailure.SCOPE) { ProviderTable.of(CALENDAR, "raw_contacts") } + refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "contacts") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"instances","where":"1"}""") } + } + + @Test + fun `numbers keep integers exact whatever org_json parsed them into`() { + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5)) + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5.0)) + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(BigDecimal("5.0"))) + assertEquals(Cell.Integer(1L shl 60), ProviderOpParser.numberCell(BigInteger.ONE.shiftLeft(60))) + assertEquals(Cell.Integer(1_700_000_000_000), ProviderOpParser.numberCell(1_700_000_000_000L)) + assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(BigDecimal("1.5"))) + assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(1.5)) + } + + @Test + fun `values keep text, numbers, booleans as 0 and 1, and nulls`() { + val insert = ProviderOpParser.parseBatch( + CONTACTS, + ops("""{"op":"insert","table":"raw_contacts","values":{"sync1":"a","dirty":0,"starred":true,"sync2":null}}"""), + ).single() as ProviderOp.Insert + assertEquals( + mapOf("sync1" to Cell.Text("a"), "dirty" to Cell.Integer(0), "starred" to Cell.Integer(1), "sync2" to Cell.Null), + insert.values, + ) + } + + @Test + fun `malformed ops are provider failures`() { + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, "{}") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"upsert","table":"groups","values":{}}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{}}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"assert","table":"groups","where":"dirty = 1"}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"settings","id":1}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","args":["x"]}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","id":1.5}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","where":"a = ?","args":[null]}""") } + } + + @Test + fun `bytes are only accepted for data_data15`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo","data15":{"b64":"AAEC"}}}""", + ).single() as PlannedOp.Insert + assertEquals(Cell.Blob("AAEC"), planned.values["data15"]) + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"update","table":"raw_contacts","id":1,"values":{"sync2":{"b64":"AAEC"}}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data1":{"b64":"AA"}}}""") + } + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data15":{"bytes":"AA"}}}""") + } + } + + // ── batch rules ───────────────────────────────────────────── + + @Test + fun `a syncState op must be the last op and not a yield point`() { + val planned = plan( + CONTACTS, + """{"op":"update","table":"groups","id":10,"values":{"sync2":"x"},"yieldAllowed":true}""", + """{"op":"syncState","value":"{\"v\":1}"}""", + ) + assertEquals(PlannedOp.SetSyncState("{\"v\":1}"), planned.last()) + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"syncState","value":"{}"}""", """{"op":"delete","table":"groups","id":10}""") + } + refused(BatchFailure.SCOPE) { + plan( + CONTACTS, + """{"op":"delete","table":"groups","id":10}""", + """{"op":"syncState","value":"{}","yieldAllowed":true}""", + ) + } + } + + private fun deletes(count: Int, yieldAt: Set = emptySet()) = (0 until count).joinToString(",", "[", "]") { i -> + """{"op":"delete","table":"groups","where":"sync1 = ?","args":["$i"]${if (i in yieldAt) ""","yieldAllowed":true""" else ""}}""" + } + + @Test + fun `contacts batches stop at 499 ops between yield points, counted as ContactsProvider does`() { + assertEquals(499, ProviderOpParser.parseBatch(CONTACTS, deletes(499)).size) + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500)) } + // The yielding op still counts toward the ops before it. + assertEquals(998, ProviderOpParser.parseBatch(CONTACTS, deletes(998, setOf(498))).size) + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(999, setOf(498))) } + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(600, setOf(499))) } + // A yield on the first op is ignored, as the provider ignores it. + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500, setOf(0))) } + } + + @Test + fun `calendar batches have no yield point limit`() { + val many = (0 until 600).joinToString(",", "[", "]") { """{"op":"delete","table":"colors","where":"color_index = '$it'"}""" } + assertEquals(600, ProviderOpParser.parseBatch(CALENDAR, many).size) + } + + @Test + fun `back-references name earlier inserts, and parents name their parent table`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1"}}""", + """{"op":"insert","table":"data","values":{"mimetype":"vnd.android.cursor.item/name","data1":"A"},"refs":{"raw_contact_id":0}}""", + ) + assertEquals(mapOf("raw_contact_id" to 0), (planned[1] as PlannedOp.Insert).backReferences) + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""") + } + refused(BatchFailure.PROVIDER) { + plan( + CONTACTS, + """{"op":"delete","table":"groups","id":10}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + // Settings rows have no id to refer to. + refused(BatchFailure.PROVIDER) { + plan( + CONTACTS, + """{"op":"insert","table":"settings","values":{"ungrouped_visible":1}}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + refused(BatchFailure.SCOPE) { + plan( + CONTACTS, + """{"op":"insert","table":"groups","values":{"title":"g"}}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"}}""") } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":null,"attendeeEmail":"a@b"}}""") + } + } + + // ── scope: inserts ────────────────────────────────────────── + + @Test + fun `root inserts are written into the account, whatever the op says`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1","account_name":"alice@example.org"}}""", + ).single() as PlannedOp.Insert + assertEquals(Cell.Text(ME.name), planned.values["account_name"]) + assertEquals(Cell.Text(ME.type), planned.values["account_type"]) + val calendar = plan(CALENDAR, """{"op":"insert","table":"calendars","values":{"name":"c"}}""").single() + assertEquals(Cell.Text(ME.type), (calendar as PlannedOp.Insert).values["account_type"]) + } + + @Test + fun `account columns never move a row out of the account`() { + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"raw_contacts","values":{"account_name":"bob@gmail.com"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{"account_type":"com.google"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"groups","values":{"title":"g","data_set":"plugin"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"account_name":"alice@example.org"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"account_name":"alice@example.org"}}""") + } + } + + @Test + fun `inserts must name a parent of the account that still exists`() { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x"}}""") + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"1","mimetype":"x"}}""") + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":2,"mimetype":"x"}}""") } + refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":9,"mimetype":"x"}}""") } + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"me","mimetype":"x"}}""") + } + plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":21,"dtstart":0}}""") + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":22,"dtstart":0}}""") } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":20,"original_id":32}}""") + } + plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":31,"minutes":10}}""") + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":32,"minutes":10}}""") } + refused(BatchFailure.ASSERT) { plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":99}}""") } + } + + @Test + fun `a membership row may only name a group of the account`() { + val membership = """"mimetype":"vnd.android.cursor.item/group_membership"""" + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":10}}""") + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"group_sourceid":"c/g"}}""") + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":11}}""") + } + // data1 of other kinds is plain data. + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x","data1":11}}""") + } + + @Test + fun `moving a row to another parent is checked like an insert`() { + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":22}}""") } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"raw_contact_id":null}}""") } + plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":21}}""") + } + + // ── scope: selections ─────────────────────────────────────── + + @Test + fun `root tables are selected by their account columns`() { + val op = plan( + CONTACTS, + """{"op":"update","table":"raw_contacts","where":"dirty = ? AND deleted = 0","args":[1],"values":{"dirty":0}}""", + ).single() as PlannedOp.Update + assertEquals( + Selection("(dirty = ? AND deleted = 0) AND (account_name = ? AND account_type = ?)", listOf("1", ME.name, ME.type)), + op.selection, + ) + assertNull(op.expectedCount) + } + + @Test + fun `an op by id selects that row of the account and expects exactly one row`() { + val op = plan(CONTACTS, """{"op":"delete","table":"groups","id":10}""").single() as PlannedOp.Delete + assertEquals(Selection("(_id = ?) AND (account_name = ? AND account_type = ?)", listOf("10", ME.name, ME.type)), op.selection) + assertEquals(1, op.expectedCount) + val counted = plan(CONTACTS, """{"op":"assert","table":"raw_contacts","id":1,"expectCount":0}""").single() + assertEquals(0, (counted as PlannedOp.Assert).expectedCount) + } + + @Test + fun `an id of another account is refused, a vanished one is left to the expected count`() { + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":2}""") } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":101,"values":{"data1":"x"}}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"assert","table":"events","id":32,"values":{"dirty":0}}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","id":51}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"calendars","id":22,"values":{"name":"x"}}""") } + val gone = plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":9}""").single() as PlannedOp.Delete + assertEquals(1, gone.expectedCount) + } + + @Test + fun `data rows rely on the URI parameters and never select on the Data view's account columns`() { + val op = plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":"x"}}""").single() as PlannedOp.Update + assertEquals(Selection("(_id = ?)", listOf("100")), op.selection) + val query = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"],"where":"raw_contact_id IN (1)"}""")) + assertEquals(listOf(Selection("(raw_contact_id IN (1))", emptyList())), query.selections) + val all = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"]}""")) + assertEquals(listOf(Selection.NONE), all.selections) + } + + @Test + fun `events are selected by the account's calendars`() { + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id","title"],"where":"dirty = 1","orderBy":"_id"}"""), + ) + assertEquals(listOf(Selection("(dirty = 1) AND (calendar_id IN (20,21))", emptyList())), query.selections) + assertEquals("_id", query.orderBy) + val update = plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"dirty":0}}""").single() as PlannedOp.Update + assertEquals(Selection("(_id = ?) AND (calendar_id IN (20,21))", listOf("30")), update.selection) + } + + @Test + fun `an account without calendars selects no events`() { + val carol = AccountRef("carol", ME.type) + val empty = ScopePlanner(carol, FakeLookup(carol).apply { calendars.putAll(lookup.calendars) }) + val query = empty.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id"]}""")) + assertEquals(listOf(Selection("(0)", emptyList())), query.selections) + } + + @Test + fun `children of events are selected by the account's events among those they touch`() { + lookup.matching[ProviderTable.ATTENDEES to "event_id IN (30,32)"] = setOf(30, 32) + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["attendeeEmail"],"where":"event_id IN (30,32)"}"""), + ) + assertEquals(listOf(Selection("(event_id IN (30,32)) AND (event_id IN (30))", emptyList())), query.selections) + + val byId = plan(CALENDAR, """{"op":"delete","table":"attendees","id":50}""").single() as PlannedOp.Delete + assertEquals(Selection("(Attendees._id = ?) AND (event_id IN (30))", listOf("50")), byId.selection) + assertEquals(1, byId.expectedCount) + + val gone = plan(CALENDAR, """{"op":"delete","table":"reminders","id":69}""").single() as PlannedOp.Delete + assertEquals(Selection("(Reminders._id = ?) AND (0)", listOf("69")), gone.selection) + + lookup.matching[ProviderTable.REMINDERS to "event_id = ?"] = setOf(31) + val byWhere = plan(CALENDAR, """{"op":"delete","table":"reminders","where":"event_id = ?","args":[31]}""").single() + assertEquals(Selection("(event_id = ?) AND (event_id IN (31))", listOf("31")), (byWhere as PlannedOp.Delete).selection) + } + + @Test + fun `a child query that touches none of the account's events reads nothing`() { + lookup.matching[ProviderTable.ATTENDEES to "event_id = 32"] = setOf(32) + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":"event_id = 32"}"""), + ) + assertTrue(query.selections.isEmpty()) + } + + @Test + fun `long event lists are split into chunks`() { + val many = (1000L until 1000L + 1200).toSet() + for (id in many) lookup.events[id] = 20 + lookup.matching[ProviderTable.REMINDERS to null] = many + val query = planner.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"reminders","columns":["minutes"]}""")) + assertEquals(3, query.selections.size) + assertTrue(query.selections.all { it.sql!!.startsWith("(event_id IN (") }) + } + + @Test + fun `extended properties are updated through their item URI, so only by id`() { + val op = plan(CALENDAR, """{"op":"update","table":"extended_properties","id":70,"values":{"value":"v"}}""").single() + assertEquals(PlannedOp.Update(ProviderTable.EXTENDED_PROPERTIES, Selection.NONE, 70, mapOf("value" to Cell.Text("v")), 1, false), op) + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"update","table":"extended_properties","id":71,"values":{"value":"v"}}""") + } + refused(BatchFailure.PROVIDER) { + plan(CALENDAR, """{"op":"update","table":"extended_properties","where":"name = 'x'","values":{"value":"v"}}""") + } + } + + @Test + fun `asserts compare text and keep nulls`() { + val op = plan( + CONTACTS, + """{"op":"assert","table":"raw_contacts","id":1,"values":{"version":3,"dirty":0,"sync3":null,"sourceid":"c/1"}}""", + ).single() as PlannedOp.Assert + assertEquals(mapOf("version" to "3", "dirty" to "0", "sync3" to null, "sourceid" to "c/1"), op.values) + assertEquals(1, op.expectedCount) + } + + @Test + fun `settings are written by account`() { + val insert = plan(CONTACTS, """{"op":"insert","table":"settings","values":{"ungrouped_visible":1,"should_sync":1}}""").single() + assertEquals(Cell.Text(ME.name), (insert as PlannedOp.Insert).values["account_name"]) + val update = plan(CONTACTS, """{"op":"update","table":"settings","values":{"ungrouped_visible":1}}""").single() + assertEquals( + Selection("(account_name = ? AND account_type = ?)", listOf(ME.name, ME.type)), + (update as PlannedOp.Update).selection, + ) + } + + // ── results ───────────────────────────────────────────────── + + @Test + fun `batch results are BatchResult JSON`() { + val ok = JSONObject(ProviderResults.ok(listOf(OpOutcome(id = 7), OpOutcome(count = 1), OpOutcome()))) + assertEquals(true, ok.getBoolean("ok")) + val results = ok.getJSONArray("results") + assertEquals(7L, results.getJSONObject(0).getLong("id")) + assertEquals(1, results.getJSONObject(1).getInt("count")) + assertEquals(0, results.getJSONObject(2).length()) + val failed = JSONObject(ProviderResults.failed(BatchFailure.TOO_LARGE, "TransactionTooLargeException")) + assertEquals(false, failed.getBoolean("ok")) + assertEquals("tooLarge", failed.getString("reason")) + assertEquals("TransactionTooLargeException", failed.getString("message")) + } + + @Test + fun `an OperationApplicationException is an assert unless an insert got no row`() { + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Found value 4 when expected 3 for column version")) + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Expected 1 rows but actual 0")) + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("wrong number of rows: 0")) + assertEquals( + BatchFailure.PROVIDER, + ProviderResults.failureOfOperationApplication("Insert into content://com.android.contacts/settings returned no result"), + ) + assertEquals( + BatchFailure.PROVIDER, + ProviderResults.failureOfOperationApplication("Too many content provider operations between yield points."), + ) + } + + @Test + fun `query rows are ProviderRows JSON with escaped text`() { + val writer = ProviderRowsWriter(listOf("_id", "title", "x")) + writer.beginRow() + writer.longCell(9_007_199_254_740_991) + writer.textCell("a \"quoted\"\\ line\nbreak\u0001 and \u2028") + writer.nullCell() + writer.endRow() + writer.beginRow() + writer.longCell(2) + writer.doubleCell(1.5) + writer.doubleCell(Double.NaN) + writer.endRow() + val parsed = JSONObject(writer.finish()) + assertEquals(JSONArray(listOf("_id", "title", "x")).toString(), parsed.getJSONArray("columns").toString()) + val rows = parsed.getJSONArray("rows") + assertEquals(9_007_199_254_740_991L, rows.getJSONArray(0).getLong(0)) + assertEquals("a \"quoted\"\\ line\nbreak\u0001 and \u2028", rows.getJSONArray(0).getString(1)) + assertTrue(rows.getJSONArray(0).isNull(2)) + assertEquals(1.5, rows.getJSONArray(1).getDouble(1), 0.0) + assertTrue(rows.getJSONArray(1).isNull(2)) + assertEquals("{\"columns\":[],\"rows\":[]}", ProviderRowsWriter(emptyList()).finish()) + } + + @Test + fun `photos decode close to the target size and scale their longer side down to it`() { + assertEquals(1, PhotoScaling.sampleSize(720, 720, 512)) + assertEquals(4, PhotoScaling.sampleSize(4000, 3000, 512)) + assertEquals(512 to 384, PhotoScaling.targetSize(1000, 750, 512)) + assertEquals(96 to 96, PhotoScaling.targetSize(96, 96, 512)) + assertEquals(1 to 512, PhotoScaling.targetSize(3, 4000, 512)) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt new file mode 100644 index 00000000..1a13656d --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt @@ -0,0 +1,122 @@ +package com.anonymous.bulwarkmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushRoutingTest { + private val contacts = "com.android.contacts" + private val calendar = "com.android.calendar" + + private val routes = PushRouting.parseRoutes( + """ + { + "c": [{ "accountName": "alice@example.org", "authorities": ["$contacts", "$calendar"] }], + "team": [ + { "accountName": "alice@example.org", "authorities": ["$contacts"] }, + { "accountName": "alice-work", "authorities": ["$calendar"] } + ] + } + """.trimIndent(), + strict = true, + ) + + private fun stateChange(changed: String) = mapOf( + "kind" to "jmap-state-change", + "accountLabel" to "alice", + "accountId" to "c", + "emailIds" to "[]", + "changed" to changed, + ) + + @Test + fun `a contact change requests a contacts sync and no mail task`() { + val decision = PushRouting.decide(stateChange("""{"c":{"ContactCard":"s1"}}"""), routes) + assertEquals(setOf("alice@example.org" to contacts), decision.syncs) + assertFalse(decision.startMailTask) + } + + @Test + fun `calendar types request calendar syncs of the routed accounts only`() { + val decision = PushRouting.decide(stateChange("""{"team":{"Calendar":"s1","CalendarEvent":"s2"}}"""), routes) + assertEquals(setOf("alice-work" to calendar), decision.syncs) + assertFalse(decision.startMailTask) + } + + @Test + fun `every account key is routed, not only the one repeated as accountId`() { + val decision = PushRouting.decide( + stateChange("""{"c":{"AddressBook":"s1"},"team":{"ContactCard":"s2","CalendarEvent":"s3"}}"""), + routes, + ) + // alice@example.org's contacts sync is requested once. + assertEquals(setOf("alice@example.org" to contacts, "alice-work" to calendar), decision.syncs) + } + + @Test + fun `mail anywhere in the map starts the mail task, next to the syncs`() { + val decision = PushRouting.decide( + stateChange("""{"team":{"CalendarEvent":"s1"},"c":{"EmailDelivery":"s2","Email":"s3"}}"""), + routes, + ) + assertTrue(decision.startMailTask) + assertEquals(setOf("alice-work" to calendar), decision.syncs) + } + + @Test + fun `an email push always starts the mail task`() { + val data = mapOf("kind" to "jmap-email-push", "accountId" to "c", "changed" to """{"c":{"EmailDelivery":"s"}}""") + assertTrue(PushRouting.decide(data, routes).startMailTask) + assertTrue(PushRouting.decide(mapOf("kind" to "jmap-email-push"), routes).startMailTask) + } + + @Test + fun `payloads without a readable map are legacy mail pushes`() { + for (changed in listOf(null, "", "{}", "not json", "[1]", """{"c":{}}""")) { + val data = stateChange(changed ?: "").let { if (changed == null) it - "changed" else it } + val decision = PushRouting.decide(data, routes) + assertTrue("changed=$changed", decision.startMailTask) + assertTrue("changed=$changed", decision.syncs.isEmpty()) + } + } + + @Test + fun `an unreadable account entry may be mail`() { + val decision = PushRouting.decide(stateChange("""{"c":"garbled","team":{"ContactCard":"s"}}"""), routes) + assertTrue(decision.startMailTask) + assertEquals(setOf("alice@example.org" to contacts), decision.syncs) + } + + @Test + fun `accounts without routes are ignored`() { + val decision = PushRouting.decide(stateChange("""{"elsewhere":{"ContactCard":"s"}}"""), routes) + assertTrue(decision.syncs.isEmpty()) + assertFalse(decision.startMailTask) + assertTrue(PushRouting.decide(stateChange("""{"c":{"ContactCard":"s"}}"""), emptyMap()).syncs.isEmpty()) + } + + @Test + fun `stored routes are validated strictly and read leniently`() { + val bad = listOf( + "[]", + """{"c":{}}""", + """{"c":[{"authorities":["$contacts"]}]}""", + """{"c":[{"accountName":"a","authorities":["com.example.tasks"]}]}""", + ) + for (json in bad) { + try { + PushRouting.parseRoutes(json, strict = true) + throw AssertionError("accepted $json") + } catch (e: IllegalArgumentException) { + // expected + } + } + assertEquals(emptyMap>(), PushRouting.parseRoutes("[]")) + assertEquals( + mapOf("c" to listOf(PushRouting.Route("a", setOf(contacts)))), + PushRouting.parseRoutes("""{"c":[{"accountName":"a","authorities":["$contacts","com.example.tasks"]},{"x":1}]}"""), + ) + assertEquals(emptyMap>(), PushRouting.parseRoutes(null)) + } +} diff --git a/docs/device-sync.md b/docs/device-sync.md index 75b790d7..16a0e106 100644 --- a/docs/device-sync.md +++ b/docs/device-sync.md @@ -1018,39 +1018,76 @@ nested maps. Every provider call is scoped to one account of our type. | `openAccountSettings(name)` | the account's sync screen (`android.settings.ACCOUNT_SYNC_SETTINGS` with the Account), else `SYNC_SETTINGS` filtered to our type | | `finishRun(runId, reportJson)` / `isRunCancelled(runId)` | the run handshake | | `setPushRoutes(json)` | `{ jmapAccountId: [{ accountName, authorities }] }` for the push router, in SharedPreferences | +| `showSyncProblem(name, title, text, uri, channelName)` / `clearSyncProblem(name)` | the account's one sync-problem notification; a tap opens `uri` (`bulwarkmobile://` only) in the app; `channelName` names the channel in the app's language | | `query(name, authority, queryJson)` | `ProviderQuery` → `ProviderRows` | | `applyBatch(name, authority, opsJson)` | `ProviderOp[]` → `BatchResult`; failures resolve as `{ok: false, reason}` | | `readSyncState(name, authority)` | the SyncState blob as text | | `readPhoto(name, rawContactId, maxPx)` | the display photo (or thumbnail), scaled, as JPEG base64 + file id | +Provider calls run one at a time on a thread of the module's own (a batch +can hold the provider for seconds at a contended yield point), through an +unstable provider client, so a provider process that dies fails the call +instead of this process. `query`, `readSyncState` and `readPhoto` reject with +code `scope`, `permission` or `provider`. + Events: `BulwarkDeviceSync:accountsChanged` (an account of our type was added or removed; JS re-lists). Events without a JS listener are dropped, so they are hints; the UI re-reads state on mount and on foreground. -Scoping, enforced in Kotlin for every op: +Scoping, enforced in Kotlin for every op +(`android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt`): - URIs always carry `caller_is_syncadapter=true`, `account_name`, `account_type` (CalendarProvider accepts no other query parameters). - Queries and updates/deletes by `where` get the account condition ANDed in: - `account_name/account_type` columns for raw_contacts, groups, settings, - calendars, events, colors; for data rows, `raw_contact_id IN` our raw - contacts; for attendees, reminders and extended properties, `event_id IN` - our events (resolved first; the providers may compile selections strictly, - so no subqueries). Ops by `id` verify the row belongs to the account first - and expect exactly one row. + - `account_name`/`account_type` columns for raw_contacts, groups, + settings, calendars and colors; + - data rows are filtered by ContactsProvider itself through the account + URI parameters (their account columns are never selected on: Android 17 + drops them from the Data view for apps targeting API 37); + - events get `calendar_id IN` the account's calendars (CalendarProvider + updates events on the Events table, which has no account columns); + - attendees, reminders and extended properties get `event_id IN` the + account's events among those the op touches (resolved first; the + providers may compile selections strictly, so no subqueries). +- Ops by `id` verify the row belongs to the account first (`scope` + otherwise) and expect exactly one row unless they set `expectCount`, so a + row that vanished fails with `assert`. `id` and `where` together address + the id row if it matches. On attendees, reminders and extended properties + the id column is table-qualified (`Attendees._id`), since CalendarProvider + reads attendees through a join where a bare `_id` is ambiguous; the engine + therefore never writes `_id` into a `where` there. Extended properties are + updated through their item URI, so by `id` only. - Inserts into data/attendees/reminders/extended_properties/events must name a - parent of the account (a back-reference to an insert of the same batch, or - an id that is verified). + parent (a back-reference to an insert of the same batch, or an id); an + insert or update naming a parent, exception master (`original_id`) or group + (`data1` of a membership row) checks it: gone fails with `assert` (re-read + and re-plan), another account's with `scope`. A back-reference must point + to an earlier insert of the right table; settings inserts return no id. +- `account_name`/`account_type`/`data_set` may only be written to restate the + account, on the tables that have them (`scope` otherwise). - Tables are an enumerated set per authority; anything else is refused (`scope`). - Blobs (`{ b64 }`) are only accepted for `data.data15` (photos). - A `syncState` op must be the last op of its batch and not `yieldAllowed`, so a provider yield never commits the state ahead of the rows (refused as `scope` otherwise). -- Batches: at most 499 ops between yield points (ContactsProvider throws at - 500), about 300 KB per call; `TransactionTooLargeException` resolves as +- Batches: at most 499 ops up to and including each yield point + (ContactsProvider counts an op before resetting at a yield point and throws + at 500), about 300 KB per call; `TransactionTooLargeException` resolves as `tooLarge` so JS splits the batch. `OperationApplicationException` resolves - as `assert`, `SecurityException` as `permission`. + as `assert` (except the provider's own "too many operations" and failed + inserts, `provider`), `SecurityException` as `permission`. The whole batch + is also refused (`scope`) once the Android account is gone, so no rows + outlive it. + +What the providers do with values, which the planners account for: + +- contacts `data1`..`data14` are TEXT columns: a number written there reads + back as a string, so mappers normalise types before comparing; +- `Events.dirty` and `Calendars.dirty` are NULL after a sync-adapter insert: + planners write `dirty = 0` on every insert and select dirty rows with + `dirty = 1`. ## App integration diff --git a/src/device-sync/__tests__/fake-provider.test.ts b/src/device-sync/__tests__/fake-provider.test.ts index 0a83ab5b..335c8047 100644 --- a/src/device-sync/__tests__/fake-provider.test.ts +++ b/src/device-sync/__tests__/fake-provider.test.ts @@ -62,7 +62,7 @@ describe('FakeDeviceProviders: contacts', () => { { op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: theirs.rawId, [Data.MIMETYPE]: MimeType.NOTE } }, ]); expect(orphan).toMatchObject({ ok: false, reason: 'scope' }); - await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toThrow(/scope/); + await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toMatchObject({ code: 'scope' }); }); it('never sets DIRTY for the sync adapter but does for app edits, and only apps soft-delete', async () => { @@ -162,7 +162,8 @@ describe('FakeDeviceProviders: contacts', () => { expect(photo?.jpegBase64).toBe('AAAA'); expect(photo?.fileId).toEqual(expect.any(Number)); const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] }); - expect(rows.rows[0]).toEqual([photo!.fileId, null]); + // data14 is a TEXT column: the file id reads back as text. + expect(rows.rows[0]).toEqual([String(photo!.fileId), null]); }); it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => { @@ -268,13 +269,13 @@ describe('FakeDeviceProviders: calendar', () => { if (!res.ok) throw new Error(res.message); const masterId = res.results[0].id!; const ex = fake.user.exceptionViaUri(masterId, 86_400_000, { [Events.SELF_ATTENDEE_STATUS]: 1 }); - expect(fake.row('events', masterId)![Events.DIRTY]).toBe(0); + expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull(); expect(fake.row('events', ex)).toMatchObject({ [Events.DIRTY]: 1, [Events._SYNC_ID]: null, [Events.UID_2445]: 'u1', [Events.ORIGINAL_SYNC_ID]: 'c/e1' }); expect(fake.rows('attendees', 'event_id = ?', [ex])[0][Attendees.ATTENDEE_STATUS]).toBe(1); const split = fake.user.splitViaUri(masterId, 5 * 86_400_000, '20260101T000000Z', {}); expect(fake.row('events', split)![Events._SYNC_ID]).toBe('c/e1'); expect(fake.row('events', masterId)![Events.RRULE]).toContain('UNTIL=20260101T000000Z'); - expect(fake.row('events', masterId)![Events.DIRTY]).toBe(0); + expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull(); }); }); @@ -291,3 +292,179 @@ describe('AOSP format checks', () => { expect(aospRruleValid('COUNT=2')).toBe(false); }); }); + +describe('FakeDeviceProviders: the native bridge rules', () => { + async function calendarWithEvent(fake: FakeDeviceProviders, account = ME) { + const port = fake.port(account, CALENDAR_AUTHORITY); + const res = await port.applyBatch([ + { + op: 'insert', + table: 'calendars', + values: { + [Calendars.NAME]: 'Personal', + [Calendars.CALENDAR_DISPLAY_NAME]: 'Personal', + [Calendars.CALENDAR_COLOR]: -16776961, + [Calendars.CALENDAR_ACCESS_LEVEL]: 700, + [Calendars.OWNER_ACCOUNT]: account, + }, + }, + { + op: 'insert', + table: 'events', + values: { [Events.DTSTART]: 0, [Events.DTEND]: 3_600_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 }, + refs: { [Events.CALENDAR_ID]: 0 }, + }, + { op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: OTHER }, refs: { event_id: 1 } }, + { op: 'insert', table: 'extended_properties', values: { name: 'x', value: '1' }, refs: { event_id: 1 } }, + ]); + if (!res.ok) throw new Error(res.message); + return { + port, + calendarId: res.results[0].id!, + eventId: res.results[1].id!, + attendeeId: res.results[2].id!, + propertyId: res.results[3].id!, + }; + } + + it('stores numbers written to data1..data14 as text', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + const res = await port.applyBatch([ + { + op: 'insert', + table: 'data', + values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHONE, [Data.DATA1]: '+1 555', [Data.DATA2]: 2 }, + }, + ]); + if (!res.ok) throw new Error(res.message); + const rows = await port.query({ + table: 'data', + columns: [Data.DATA2, Data.RAW_CONTACT_ID], + where: `${Data.MIMETYPE} = ?`, + args: [MimeType.PHONE], + }); + expect(rows.rows).toEqual([['2', rawId]]); + // Asserts compare as text, so a number still matches. + const ok = await port.applyBatch([{ op: 'assert', table: 'data', id: res.results[0].id!, values: { [Data.DATA2]: 2 } }]); + expect(ok.ok).toBe(true); + }); + + it('leaves DIRTY NULL on events and calendars the sync adapter inserts', async () => { + const fake = new FakeDeviceProviders(); + const { port, calendarId, eventId } = await calendarWithEvent(fake); + expect(fake.row('calendars', calendarId)![Calendars.DIRTY]).toBeNull(); + expect(fake.row('events', eventId)![Events.DIRTY]).toBeNull(); + expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 0' })).rows).toEqual([]); + expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 1' })).rows).toEqual([]); + }); + + it('expects exactly the id row unless told otherwise, and applies where to it', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + const gone = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000 }]); + expect(gone).toMatchObject({ ok: false, reason: 'assert' }); + const tolerated = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000, expectCount: 0 }]); + expect(tolerated).toEqual({ ok: true, results: [{ count: 0 }] }); + const mismatch = await port.applyBatch([ + { op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 1`, values: { [RawContacts.SYNC1]: 'x' } }, + ]); + expect(mismatch).toMatchObject({ ok: false, reason: 'assert' }); + const matched = await port.applyBatch([ + { op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 0`, values: { [RawContacts.SYNC1]: 'x' } }, + ]); + expect(matched).toEqual({ ok: true, results: [{ count: 1 }] }); + }); + + it('refuses _id in where on calendar child tables, and updates extended properties by id only', async () => { + const fake = new FakeDeviceProviders(); + const { port, eventId, attendeeId, propertyId } = await calendarWithEvent(fake); + await expect( + port.query({ table: 'attendees', columns: ['event_id'], where: '_id = ?', args: [attendeeId] }), + ).rejects.toMatchObject({ code: 'provider' }); + const byWhere = await port.applyBatch([{ op: 'delete', table: 'attendees', where: '_id = ?', args: [attendeeId] }]); + expect(byWhere).toMatchObject({ ok: false, reason: 'provider' }); + const byId = await port.applyBatch([{ op: 'delete', table: 'attendees', id: attendeeId }]); + expect(byId).toEqual({ ok: true, results: [{ count: 1 }] }); + const propertiesByWhere = await port.applyBatch([ + { op: 'update', table: 'extended_properties', where: 'event_id = ?', args: [eventId], values: { value: '2' } }, + ]); + expect(propertiesByWhere).toMatchObject({ ok: false, reason: 'provider' }); + const propertiesById = await port.applyBatch([ + { op: 'update', table: 'extended_properties', id: propertyId, values: { value: '2' } }, + ]); + expect(propertiesById.ok).toBe(true); + }); + + it('fails with assert for a parent that is gone and with scope for one of another account', async () => { + const fake = new FakeDeviceProviders(); + const mine = await calendarWithEvent(fake); + const theirs = await calendarWithEvent(fake, OTHER); + const gone = await mine.port.applyBatch([ + { op: 'insert', table: 'reminders', values: { event_id: theirs.eventId + 1000, minutes: 10 } }, + ]); + expect(gone).toMatchObject({ ok: false, reason: 'assert' }); + const foreign = await mine.port.applyBatch([ + { op: 'insert', table: 'reminders', values: { event_id: theirs.eventId, minutes: 10 } }, + ]); + expect(foreign).toMatchObject({ ok: false, reason: 'scope' }); + const unnamed = await mine.port.applyBatch([{ op: 'insert', table: 'reminders', values: { minutes: 10 } }]); + expect(unnamed).toMatchObject({ ok: false, reason: 'scope' }); + const moveAway = await mine.port.applyBatch([ + { op: 'update', table: 'events', id: mine.eventId, values: { [Events.CALENDAR_ID]: theirs.calendarId } }, + ]); + expect(moveAway).toMatchObject({ ok: false, reason: 'scope' }); + const wrongRef = await mine.port.applyBatch([ + { op: 'insert', table: 'colors', values: { color_type: 0, color_index: '1', color: 1 } }, + { op: 'insert', table: 'reminders', values: { minutes: 10 }, refs: { event_id: 0 } }, + ]); + expect(wrongRef).toMatchObject({ ok: false, reason: 'scope' }); + const leaves = await mine.port.applyBatch([ + { op: 'update', table: 'calendars', id: mine.calendarId, values: { [Calendars.ACCOUNT_NAME]: OTHER } }, + ]); + expect(leaves).toMatchObject({ ok: false, reason: 'scope' }); + }); + + it('wants syncState last and never at a yield point', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + const early = await port.applyBatch([ + { op: 'syncState', value: '{}' }, + { op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } }, + ]); + expect(early).toMatchObject({ ok: false, reason: 'scope' }); + const yielding = await port.applyBatch([ + { op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } }, + { op: 'syncState', value: '{}', yieldAllowed: true }, + ]); + expect(yielding).toMatchObject({ ok: false, reason: 'scope' }); + expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBeNull(); + }); + + it('returns no id for a settings insert, which cannot be addressed by id either', async () => { + const fake = new FakeDeviceProviders(); + const port = fake.port(ME, CONTACTS_AUTHORITY); + const res = await port.applyBatch([{ op: 'insert', table: 'settings', values: { ungrouped_visible: 1 } }]); + expect(res).toEqual({ ok: true, results: [{}] }); + const byId = await port.applyBatch([{ op: 'delete', table: 'settings', id: 1 }]); + expect(byId).toMatchObject({ ok: false, reason: 'provider' }); + }); + + it('counts the op at a yield point before resetting, like ContactsProvider', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + const update = (yieldAllowed = false): ProviderOp => ({ + op: 'update', + table: 'raw_contacts', + id: rawId, + values: { [RawContacts.SYNC2]: 'y' }, + yieldAllowed, + }); + // 499 ops, then a yield point: fine. + const fits = [...Array.from({ length: 498 }, () => update()), update(true), ...Array.from({ length: 10 }, () => update())]; + expect((await port.applyBatch(fits)).ok).toBe(true); + // The yield point is the 500th op since the start: refused. + const late = [...Array.from({ length: 499 }, () => update()), update(true)]; + expect(await port.applyBatch(late)).toMatchObject({ ok: false, reason: 'provider' }); + }); +}); diff --git a/src/device-sync/__tests__/fakes/fake-provider.ts b/src/device-sync/__tests__/fakes/fake-provider.ts index c642f0c6..3a611bb2 100644 --- a/src/device-sync/__tests__/fakes/fake-provider.ts +++ b/src/device-sync/__tests__/fakes/fake-provider.ts @@ -7,7 +7,10 @@ * "Device data model", and the AOSP facts quoted there): * * - Sync-adapter writes never set DIRTY; app writes do (data rows, events and - * their attendees/reminders). The provider never clears DIRTY. + * their attendees/reminders). The provider never clears DIRTY. Events and + * calendars the sync adapter inserts have DIRTY NULL unless it writes one. + * - Contacts `data1`..`data14` are TEXT columns: numbers are stored, and read + * back, as their text. * - RawContacts.VERSION goes up on every data-row insert, update or delete, * whoever makes it, and on a DELETED change; never for DIRTY, SOURCE_ID or * SYNC1-4. Groups bump VERSION on every update. @@ -23,6 +26,15 @@ * `PD`, while `PT0S`-style all-day durations, NULL STATUS updates, * SELF_ATTENDEE_STATUS updates and RRULEs with unknown parts fail the batch. * - Scoping: rows of other accounts are invisible and untouchable. + * - The native bridge's rules (android/.../sync/ProviderOps.kt and + * ProviderScope.kt): an op with `id` expects exactly one row unless it says + * otherwise, and applies `where` too; attendees, reminders and extended + * properties refuse `_id` in `where` (ambiguous in CalendarProvider's + * join), and extended properties are updated by `id` only; a named parent, + * exception master or group that is gone fails with `assert`, one of + * another account with `scope`; `syncState` must be the batch's last op + * and no yield point; settings inserts return no id. Query and photo + * errors carry the native rejection `code`. * * Pure TypeScript, for vitest only. */ @@ -106,8 +118,93 @@ class FakeProviderError extends Error { } } +/** A rejected query or photo read, with the `code` the native module rejects with. */ +function rejection(code: BatchFailure, message: string): Error { + return Object.assign(new Error(message), { code }); +} + const isEmpty = (v: Cell | undefined) => v === null || v === undefined || v === ''; +/** The parent row each row of a table belongs to. */ +const PARENTS: Partial> = { + data: { column: Data.RAW_CONTACT_ID, table: 'raw_contacts' }, + events: { column: Events.CALENDAR_ID, table: 'calendars' }, + attendees: { column: Attendees.EVENT_ID, table: 'events' }, + reminders: { column: Reminders.EVENT_ID, table: 'events' }, + extended_properties: { column: ExtendedProperties.EVENT_ID, table: 'events' }, +}; + +/** Tables read through CalendarProvider's join, where a bare `_id` is ambiguous. */ +const JOINED_CHILD_TABLES: ReadonlySet = new Set(['attendees', 'reminders', 'extended_properties']); + +/** Tables that carry the account in `account_name`/`account_type`. */ +const ACCOUNT_TABLES: ReadonlySet = new Set(['raw_contacts', 'groups', 'settings', 'calendars', 'colors']); + +/** + * The table a written column points into when the row it names must be the + * account's too (ProviderOps.kt `referencedTable`): a parent, an exception's + * master, a membership's group. + */ +function referencedTable(table: ProviderTable, column: string, values: WriteRow): ProviderTable | null { + if (PARENTS[table]?.column === column) return PARENTS[table].table; + if (table === 'events' && column === Events.ORIGINAL_ID) return 'events'; + if (table === 'data' && column === Data.DATA1 && values[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP) return 'groups'; + return null; +} + +const TEXT_DATA_COLUMNS: ReadonlySet = new Set(numbered('data', 1, 14)); + +/** ContactsProvider's `data1`..`data14` have TEXT affinity: SQLite stores numbers as their text. */ +function textifyData(row: StoredRow): void { + for (const column of TEXT_DATA_COLUMNS) { + const v = row[column]; + if (typeof v === 'number') row[column] = String(v); + } +} + +/** + * What the native side refuses before touching the provider (ProviderOps.kt + * `parseBatch`): a misplaced syncState, bad back-references, inserts that + * name no parent, and contacts batches without enough yield points. + */ +function refuseBatch(authority: Authority, ops: ProviderOp[]): { reason: BatchFailure; message: string } | null { + for (let i = 0; i < ops.length; i++) { + const op = ops[i]; + if (op.op !== 'syncState') continue; + if (i !== ops.length - 1) return { reason: 'scope', message: `op ${i}: syncState must be the last op of its batch` }; + if (op.yieldAllowed) return { reason: 'scope', message: `op ${i}: syncState must not be a yield point` }; + } + for (let i = 0; i < ops.length; i++) { + const op = ops[i]; + if (op.op !== 'insert') continue; + for (const [column, ref] of Object.entries(op.refs ?? {})) { + const target = ops[ref]; + if (!Number.isInteger(ref) || ref < 0 || ref >= i || target?.op !== 'insert' || target.table === 'settings') { + return { reason: 'provider', message: `op ${i}: ${column} refers to op ${ref}, which is not an earlier insert with a row id` }; + } + const expected = referencedTable(op.table, column, op.values); + if (expected && target.table !== expected) { + return { reason: 'scope', message: `op ${i}: ${column} must refer to a ${expected} insert, not ${target.table}` }; + } + } + const parent = PARENTS[op.table]; + if (parent && !(parent.column in (op.refs ?? {})) && (op.values[parent.column] ?? null) === null) { + return { reason: 'scope', message: `op ${i}: a ${op.table} insert must name its ${parent.column}` }; + } + } + if (authority === CONTACTS_AUTHORITY) { + // ContactsProvider counts an op before resetting at a yield point. + let sinceYield = 0; + for (let i = 0; i < ops.length; i++) { + if (++sinceYield >= 500) { + return { reason: 'provider', message: 'Too many content provider operations between yield points' }; + } + if (i > 0 && ops[i].yieldAllowed) sinceYield = 0; + } + } + return null; +} + /** AOSP calendarcommon2 Duration.parse: `[+-]P` then ``, `T` ignored. */ export function aospDurationValid(value: string): boolean { return /^[+-]?P(T?\d+[WDHMS])*T?$/.test(value) && /\d/.test(value); @@ -408,15 +505,10 @@ export class FakeDeviceProviders { record(false); return { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' }; } - if (authority === CONTACTS_AUTHORITY) { - let sinceYield = 0; - for (let i = 0; i < ops.length; i++) { - if (i > 0 && ops[i].yieldAllowed) sinceYield = 0; - if (++sinceYield >= 500) { - record(false); - return { ok: false, reason: 'provider', message: 'Too many content provider operations between yield points' }; - } - } + const refusal = refuseBatch(authority, ops); + if (refusal) { + record(false); + return { ok: false, ...refusal }; } for (const hook of this.beforeBatch.splice(0)) hook(); @@ -469,6 +561,13 @@ function checkColumns(table: ProviderTable, columns: Iterable, write: bo } } +/** SQLite on CalendarProvider's attendee join: "ambiguous column name: _id". Address those rows with `id`. */ +function checkNoBareId(table: ProviderTable, where: string | undefined): void { + if (JOINED_CHILD_TABLES.has(table) && whereColumns(where).includes('_id')) { + throw new FakeProviderError('provider', `ambiguous column name: _id (address ${table} rows with id)`); + } +} + class FakePort implements ProviderPort { constructor( private readonly fake: FakeDeviceProviders, @@ -478,12 +577,13 @@ class FakePort implements ProviderPort { async query(q: ProviderQuery): Promise { if (!tablesFor(this.authority).includes(q.table)) { - throw new Error(`scope: table ${q.table} is not part of ${this.authority}`); + throw rejection('scope', `table ${q.table} is not part of ${this.authority}`); } try { checkColumns(q.table, [...q.columns, ...whereColumns(q.where)], false); + checkNoBareId(q.table, q.where); } catch (e) { - throw new Error((e as Error).message); + throw rejection('provider', (e as Error).message); } const pred = compileWhere(q.where, q.args); const order = compileOrderBy(q.orderBy); @@ -507,7 +607,9 @@ class FakePort implements ProviderPort { async readPhoto(rawContactId: number, _maxPx: number): Promise { const rc = this.fake.table('raw_contacts').get(rawContactId); - if (!rc || !this.fake.owned('raw_contacts', rc, this.accountName)) throw new Error('scope: raw contact of another account'); + if (!rc || !this.fake.owned('raw_contacts', rc, this.accountName)) { + throw rejection('scope', `Raw contact ${rawContactId} is not part of the account`); + } for (const row of this.fake.table('data').values()) { if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) { const fileId = row[Data.DATA14] === null ? null : Number(row[Data.DATA14]); @@ -545,22 +647,70 @@ class BatchApplier { if (!tablesFor(this.authority).includes(table)) this.scopeError(`table ${table} is not part of ${this.authority}`); } + /** The rows an op addresses: the `id` row if it also matches `where`, or every row of the account matching `where`. */ private target(table: ProviderTable, id: number | undefined, where: string | undefined, args: Array | undefined): number[] { + if (id !== undefined && table === 'settings') throw new FakeProviderError('provider', 'settings rows have no id'); + checkColumns(table, whereColumns(where), false); + checkNoBareId(table, where); + const pred = compileWhere(where, args); const rows = this.fake.table(table); if (id !== undefined) { const row = rows.get(id); if (!row) return []; if (!this.fake.owned(table, row, this.account)) this.scopeError(`${table} ${id} belongs to another account`); - return [id]; + return pred((c) => this.fake.readCell(table, row, c)) ? [id] : []; } - checkColumns(table, whereColumns(where), false); - const pred = compileWhere(where, args); return [...rows] .filter(([, row]) => this.fake.owned(table, row, this.account)) .filter(([, row]) => pred((c) => this.fake.readCell(table, row, c))) .map(([rowId]) => rowId); } + /** An op that names one row by id expects exactly that row unless it says otherwise. */ + private checkCount(ids: number[], id: number | undefined, expectCount: number | undefined): void { + const expected = expectCount ?? (id !== undefined ? 1 : undefined); + if (expected !== undefined && ids.length !== expected) { + throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}, expected ${expected}`); + } + } + + /** Account columns may only restate the account, and only on the tables that have them. */ + private checkAccountColumns(table: ProviderTable, values: WriteRow): void { + for (const column of ['account_name', 'account_type', 'data_set']) { + if (!(column in values)) continue; + const cell = values[column]; + const restates = + ACCOUNT_TABLES.has(table) && + (column === 'account_name' ? cell === this.account : column === 'account_type' ? cell === this.fake.accountType : cell === null); + if (!restates) this.scopeError(`${table}.${column} would leave the account`); + } + } + + /** + * Parents, masters and groups a written column names must be rows of the + * account that still exist: a gone one fails with `assert` (the engine + * re-reads), one of another account with `scope`. `skip`: columns filled by + * back-references, checked before the batch ran. + */ + private checkReferences(table: ProviderTable, values: WriteRow, skip: ReadonlySet = new Set()): void { + for (const [column, cell] of Object.entries(values)) { + if (skip.has(column)) continue; + const referenced = referencedTable(table, column, values); + if (!referenced) continue; + if (cell === null) { + if (PARENTS[table]?.column === column) this.scopeError(`${table}.${column} must name a ${referenced} row`); + continue; + } + const id = typeof cell === 'number' ? cell : typeof cell === 'string' && /^\d+$/.test(cell) ? Number(cell) : NaN; + if (!Number.isInteger(id)) throw new FakeProviderError('provider', `${table}.${column} must be a row id`); + const row = this.fake.table(referenced).get(id); + if (!row) throw new FakeProviderError('assert', `${table}.${column} names ${referenced} ${id}, which no longer exists`); + if (!this.fake.owned(referenced, row, this.account)) { + this.scopeError(`${table}.${column} names ${referenced} ${id} of another account`); + } + } + } + private values(table: ProviderTable, values: WriteRow): StoredRow { checkColumns(table, Object.keys(values), true); const out: StoredRow = {}; @@ -603,30 +753,21 @@ class BatchApplier { } private insert(table: ProviderTable, values: WriteRow, refs: Record | undefined, index: number): OpResult { + this.checkAccountColumns(table, values); + this.checkReferences(table, values, new Set(Object.keys(refs ?? {}))); const row = this.values(table, values); + // Back-references win over values, as ContentProviderOperation applies them last. for (const [column, ref] of Object.entries(refs ?? {})) { if (ref >= index || this.results[ref]?.id === undefined) throw new FakeProviderError('provider', `bad back-reference ${ref}`); row[column] = this.results[ref].id!; } const id = this.fake.allocId(); const account = { account_name: this.account, account_type: this.fake.accountType }; - const parentOwned = (parentTable: ProviderTable, parentId: Cell) => { - const parent = this.fake.table(parentTable).get(Number(parentId)); - if (!parent || !this.fake.owned(parentTable, parent, this.account)) { - this.scopeError(`${table} insert names ${parentTable} ${String(parentId)} outside the account`); - } - return parent; - }; - const accountMismatch = (r: StoredRow) => - (r.account_name !== undefined && r.account_name !== this.account) || - (r.account_type !== undefined && r.account_type !== this.fake.accountType); switch (table) { case 'raw_contacts': - if (accountMismatch(row)) this.scopeError('raw contact of another account'); Object.assign(row, { ...defaults(RAW_CONTACT_DEFAULTS), ...row, ...account }); break; case 'groups': - if (accountMismatch(row)) this.scopeError('group of another account'); Object.assign(row, { ...defaults(GROUP_DEFAULTS), ...row, ...account }); break; case 'settings': { @@ -634,21 +775,22 @@ class BatchApplier { if (s.account_name === this.account && s.account_type === this.fake.accountType) this.fake.table('settings').delete(sid); } Object.assign(row, { [ContactsSettings.UNGROUPED_VISIBLE]: 0, [ContactsSettings.SHOULD_SYNC]: 1, ...row, ...account }); - break; + this.fake.table(table).set(id, row); + // Keyed by the account: the insert answers without a row id. + return {}; } case 'data': { - parentOwned('raw_contacts', row[Data.RAW_CONTACT_ID]); row[Data.RAW_CONTACT_ID] = Number(row[Data.RAW_CONTACT_ID]); if (isEmpty(row[Data.MIMETYPE])) throw new FakeProviderError('provider', 'data row without mimetype'); row[Data.DATA_VERSION] = 0; if (row[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP) this.resolveMembership(row); this.processPhoto(row); this.fake.normalizeData(row); + textifyData(row); this.insertedDataFor.add(Number(row[Data.RAW_CONTACT_ID])); break; } case 'calendars': { - if (accountMismatch(row)) this.scopeError('calendar of another account'); for (const c of [Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CALENDAR_ACCESS_LEVEL, Calendars.OWNER_ACCOUNT]) { if (isEmpty(row[c])) throw new FakeProviderError('provider', `calendar insert without ${c}`); } @@ -656,8 +798,9 @@ class BatchApplier { break; } case 'events': { - const calendar = parentOwned('calendars', row[Events.CALENDAR_ID])!; - row[Events.CALENDAR_ID] = Number(row[Events.CALENDAR_ID]); + const calendarId = Number(row[Events.CALENDAR_ID]); + row[Events.CALENDAR_ID] = calendarId; + const calendar = this.fake.table('calendars').get(calendarId)!; Object.assign(row, { ...defaults(EVENT_DEFAULTS), ...row }); this.fake.fixEvent(row, true); if (isEmpty(row[Events.ORGANIZER])) row[Events.ORGANIZER] = calendar[Calendars.OWNER_ACCOUNT]; @@ -676,11 +819,9 @@ class BatchApplier { case 'attendees': case 'reminders': case 'extended_properties': - parentOwned('events', row.event_id); row.event_id = Number(row.event_id); break; case 'colors': - if (accountMismatch(row)) this.scopeError('color of another account'); Object.assign(row, { ...row, ...account }); break; } @@ -733,10 +874,14 @@ class BatchApplier { values: WriteRow, expectCount: number | undefined, ): OpResult { - const ids = this.target(table, id, where, args); - if (expectCount !== undefined && ids.length !== expectCount) { - throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); + this.checkAccountColumns(table, values); + this.checkReferences(table, values); + if (table === 'extended_properties' && (id === undefined || where !== undefined)) { + // CalendarProvider updates them only through the item URI, which takes no selection. + throw new FakeProviderError('provider', 'extended properties can only be updated by id'); } + const ids = this.target(table, id, where, args); + this.checkCount(ids, id, expectCount); const patch = this.values(table, values); for (const rowId of ids) { const row = this.fake.table(table).get(rowId)!; @@ -757,6 +902,7 @@ class BatchApplier { row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1; this.processPhoto(row); this.fake.normalizeData(row); + textifyData(row); this.fake.bumpVersion(Number(row[Data.RAW_CONTACT_ID])); break; case 'events': @@ -781,9 +927,7 @@ class BatchApplier { expectCount: number | undefined, ): OpResult { const ids = this.target(table, id, where, args); - if (expectCount !== undefined && ids.length !== expectCount) { - throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); - } + this.checkCount(ids, id, expectCount); for (const rowId of ids) { const row = this.fake.table(table).get(rowId); if (!row) continue; @@ -802,9 +946,7 @@ class BatchApplier { expectCount: number | undefined, ): OpResult { const ids = this.target(table, id, where, args); - if (expectCount !== undefined && ids.length !== expectCount) { - throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); - } + this.checkCount(ids, id, expectCount); if (values) { checkColumns(table, Object.keys(values), false); for (const rowId of ids) { @@ -859,11 +1001,12 @@ const CALENDAR_DEFAULTS: StoredRow = { [Calendars.MAX_REMINDERS]: 5, [Calendars.CAN_ORGANIZER_RESPOND]: 1, [Calendars.CAN_MODIFY_TIME_ZONE]: 1, - [Calendars.DIRTY]: 0, + // No column default: NULL until someone writes it. + [Calendars.DIRTY]: null, }; const EVENT_DEFAULTS: StoredRow = { - [Events.DIRTY]: 0, + [Events.DIRTY]: null, [Events.DELETED]: 0, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0, @@ -909,6 +1052,7 @@ export class FakeUser { const id = this.fake.allocId(); const row: StoredRow = { ...values, [Data.RAW_CONTACT_ID]: rawContactId, [Data.DATA_VERSION]: 0 }; this.fake.normalizeData(row); + textifyData(row); this.fake.table('data').set(id, row); this.fake.bumpVersion(rawContactId); this.dirtyContact(rawContactId); @@ -922,6 +1066,7 @@ export class FakeUser { Object.assign(row, values); row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1; this.fake.normalizeData(row); + textifyData(row); const rcId = Number(row[Data.RAW_CONTACT_ID]); this.fake.bumpVersion(rcId); this.dirtyContact(rcId); diff --git a/src/device-sync/__tests__/native.test.ts b/src/device-sync/__tests__/native.test.ts new file mode 100644 index 00000000..37b83ffd --- /dev/null +++ b/src/device-sync/__tests__/native.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import { + clearSyncProblem, + createProviderPort, + ensureAndroidAccount, + finishRun, + getDeviceSyncModule, + getSyncSettings, + isDeviceSyncAvailable, + isRunCancelled, + listAndroidAccounts, + onAccountsChanged, + openAccountSettings, + removeAndroidAccount, + requestSync, + setPeriodicSync, + setPushRoutes, + setSyncEnabled, + showSyncProblem, +} from '../native'; +import { + ACCOUNTS_CHANGED_EVENT, + CALENDAR_AUTHORITY, + CONTACTS_AUTHORITY, + type AccountSyncSettings, + type Authority, + type ProviderOp, + type RunReport, +} from '../types'; + +const modules = NativeModules as Record; +const ACCOUNT = 'alice@example.org'; + +const SETTINGS: AccountSyncSettings = { + masterAutomatic: true, + authorities: { + [CONTACTS_AUTHORITY]: { syncable: 1, automatic: true, periodicSeconds: 3600, active: false, pending: false }, + [CALENDAR_AUTHORITY]: { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: true }, + }, +}; + +/** Installs a BulwarkDeviceSync module whose methods resolve like the Kotlin one. */ +function installModule(overrides: Record = {}) { + const module = { + getInfo: vi.fn(async () => ({ accountType: 'com.anonymous.bulwarkmobile.account', sdkInt: 34 })), + listAccounts: vi.fn(async () => [{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]), + ensureAccount: vi.fn(async () => true), + removeAccount: vi.fn(async () => true), + getSyncSettings: vi.fn(async () => SETTINGS), + setSyncEnabled: vi.fn(async () => undefined), + setPeriodicSync: vi.fn(async () => undefined), + requestSync: vi.fn(async () => undefined), + openAccountSettings: vi.fn(async () => undefined), + finishRun: vi.fn(async () => true), + isRunCancelled: vi.fn(async () => false), + setPushRoutes: vi.fn(async () => undefined), + query: vi.fn(async () => JSON.stringify({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] })), + applyBatch: vi.fn(async () => JSON.stringify({ ok: true, results: [{ id: 5 }, { count: 1 }] })), + readSyncState: vi.fn(async (): Promise => null), + readPhoto: vi.fn(async (): Promise => null), + showSyncProblem: vi.fn(async () => undefined), + clearSyncProblem: vi.fn(async () => undefined), + addListener: vi.fn(), + removeListeners: vi.fn(), + ...overrides, + }; + modules.BulwarkDeviceSync = module; + return module; +} + +const TWO_OPS: ProviderOp[] = [ + { op: 'insert', table: 'raw_contacts', values: { sourceid: null } }, + { op: 'update', table: 'data', id: 7, values: { data1: 'x' } }, +]; + +afterEach(() => { + delete modules.BulwarkDeviceSync; + (Platform as { OS: string }).OS = 'android'; + vi.restoreAllMocks(); +}); + +describe('module lookup', () => { + it('reads NativeModules on every call instead of caching the module', () => { + expect(getDeviceSyncModule()).toBeNull(); + const first = installModule(); + expect(getDeviceSyncModule()).toBe(first); + const second = installModule(); + expect(getDeviceSyncModule()).toBe(second); + delete modules.BulwarkDeviceSync; + expect(getDeviceSyncModule()).toBeNull(); + }); + + it('is off on iOS even if a module of that name exists', () => { + installModule(); + (Platform as { OS: string }).OS = 'ios'; + expect(getDeviceSyncModule()).toBeNull(); + expect(isDeviceSyncAvailable()).toBe(false); + }); + + it('is available on Android with the module', () => { + expect(isDeviceSyncAvailable()).toBe(false); + installModule(); + expect(isDeviceSyncAvailable()).toBe(true); + }); + + it('rejects everything, and subscribes nothing, without the module', async () => { + const calls: Array<() => Promise> = [ + () => listAndroidAccounts(), + () => ensureAndroidAccount(ACCOUNT, 'r'), + () => removeAndroidAccount(ACCOUNT), + () => getSyncSettings(ACCOUNT), + () => setSyncEnabled(ACCOUNT, CONTACTS_AUTHORITY, true), + () => setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 3600), + () => requestSync(ACCOUNT, CONTACTS_AUTHORITY), + () => openAccountSettings(ACCOUNT), + () => finishRun('run', {} as RunReport), + () => isRunCancelled('run'), + () => setPushRoutes({}), + () => showSyncProblem(ACCOUNT, 't', 'x', 'bulwarkmobile://login'), + () => clearSyncProblem(ACCOUNT), + () => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).query({ table: 'raw_contacts', columns: ['_id'] }), + () => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch([]), + () => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readSyncState(), + () => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readPhoto(1, 512), + ]; + for (const call of calls) { + await expect(call()).rejects.toMatchObject({ code: 'unavailable' }); + } + const unsubscribe = onAccountsChanged(() => undefined); + expect(() => unsubscribe()).not.toThrow(); + }); +}); + +describe('createProviderPort', () => { + it('is bound to its account and authority', () => { + const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY); + expect(port.accountName).toBe(ACCOUNT); + expect(port.authority).toBe(CALENDAR_AUTHORITY); + }); + + it('sends a query as JSON and parses the rows', async () => { + const module = installModule(); + const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY); + const rows = await port.query({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] }); + expect(rows).toEqual({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] }); + expect(module.query).toHaveBeenCalledWith( + ACCOUNT, + CONTACTS_AUTHORITY, + JSON.stringify({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] }), + ); + }); + + it('throws on query replies that are not ProviderRows', async () => { + const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY); + const q = { table: 'raw_contacts' as const, columns: ['_id'] }; + for (const reply of [ + 'not json', + JSON.stringify({ columns: ['_id'] }), + JSON.stringify({ columns: ['_id'], rows: [[1, 2]] }), + JSON.stringify({ columns: ['_id'], rows: [[{ b64: 'AA' }]] }), + JSON.stringify({ columns: [7], rows: [] }), + 42, + ]) { + installModule({ query: vi.fn(async () => reply) }); + await expect(port.query(q)).rejects.toThrow(/BulwarkDeviceSync\.query/); + } + }); + + it('passes native rejections through with their code', async () => { + const scope = Object.assign(new Error('Table instances is not part of com.android.calendar'), { code: 'scope' }); + installModule({ query: vi.fn(async () => Promise.reject(scope)) }); + await expect( + createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).query({ table: 'events', columns: ['_id'] }), + ).rejects.toBe(scope); + }); + + it('sends ops as JSON and returns the batch result', async () => { + const module = installModule(); + const result = await createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS); + expect(result).toEqual({ ok: true, results: [{ id: 5 }, { count: 1 }] }); + expect(module.applyBatch).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, JSON.stringify(TWO_OPS)); + }); + + it('returns failed batches as results', async () => { + installModule({ + applyBatch: vi.fn(async () => JSON.stringify({ ok: false, reason: 'assert', message: 'Expected 1 rows but actual 0' })), + }); + await expect(createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS)).resolves.toEqual({ + ok: false, + reason: 'assert', + message: 'Expected 1 rows but actual 0', + }); + }); + + it('throws on batch replies that are not BatchResults', async () => { + const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY); + for (const reply of [ + JSON.stringify({ ok: false, reason: 'weird', message: 'x' }), + JSON.stringify({ ok: true, results: [{ id: 'five' }, {}] }), + JSON.stringify({ ok: true, results: [{ id: 5 }] }), + JSON.stringify({ ok: true }), + '[]', + ]) { + installModule({ applyBatch: vi.fn(async () => reply) }); + await expect(port.applyBatch(TWO_OPS)).rejects.toThrow(/BulwarkDeviceSync\.applyBatch/); + } + }); + + it('reads the SyncState as text', async () => { + const module = installModule(); + const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY); + await expect(port.readSyncState()).resolves.toBeNull(); + module.readSyncState.mockResolvedValueOnce('{"v":1}'); + await expect(port.readSyncState()).resolves.toBe('{"v":1}'); + expect(module.readSyncState).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY); + }); + + it('reads photos of contacts only', async () => { + const module = installModule(); + const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY); + await expect(port.readPhoto(3, 512)).resolves.toBeNull(); + module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/', fileId: 12 })); + await expect(port.readPhoto(3, 512)).resolves.toEqual({ jpegBase64: '/9j/', fileId: 12 }); + expect(module.readPhoto).toHaveBeenLastCalledWith(ACCOUNT, 3, 512); + module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/' })); + await expect(port.readPhoto(3, 512)).rejects.toThrow(/readPhoto/); + await expect(createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).readPhoto(3, 512)).rejects.toThrow(/contacts/); + }); +}); + +describe('accounts and sync settings', () => { + it('passes account calls through', async () => { + const module = installModule(); + await expect(listAndroidAccounts()).resolves.toEqual([{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]); + await expect(ensureAndroidAccount(ACCOUNT, 'reg')).resolves.toBe(true); + expect(module.ensureAccount).toHaveBeenCalledWith(ACCOUNT, 'reg'); + await expect(removeAndroidAccount(ACCOUNT)).resolves.toBe(true); + expect(module.removeAccount).toHaveBeenCalledWith(ACCOUNT); + await expect(getSyncSettings(ACCOUNT)).resolves.toEqual(SETTINGS); + await setSyncEnabled(ACCOUNT, CALENDAR_AUTHORITY, false); + expect(module.setSyncEnabled).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY, false); + await openAccountSettings(ACCOUNT); + expect(module.openAccountSettings).toHaveBeenCalledWith(ACCOUNT); + }); + + it('lets a registry id conflict reject with its code', async () => { + const conflict = Object.assign(new Error('belongs to another app account'), { code: 'conflict' }); + installModule({ ensureAccount: vi.fn(async () => Promise.reject(conflict)) }); + await expect(ensureAndroidAccount(ACCOUNT, 'other')).rejects.toMatchObject({ code: 'conflict' }); + }); + + it('sends whole seconds for periodic syncs and refuses nonsense', async () => { + const module = installModule(); + await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 899.6); + expect(module.setPeriodicSync).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 900); + await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 0); + expect(module.setPeriodicSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 0); + await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, -1)).rejects.toBeInstanceOf(RangeError); + await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, Number.NaN)).rejects.toBeInstanceOf(RangeError); + }); + + it('sends sync request options as JSON', async () => { + const module = installModule(); + await requestSync(ACCOUNT, CONTACTS_AUTHORITY); + expect(module.requestSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, '{}'); + await requestSync(ACCOUNT, CALENDAR_AUTHORITY, { manual: true, expedited: true }); + expect(module.requestSync).toHaveBeenLastCalledWith( + ACCOUNT, + CALENDAR_AUTHORITY, + JSON.stringify({ manual: true, expedited: true }), + ); + }); +}); + +describe('run handshake', () => { + it('sends the report as JSON and returns whether it was accepted', async () => { + const module = installModule({ finishRun: vi.fn(async () => false) }); + const report = { v: 1, runId: 'r1', authority: CONTACTS_AUTHORITY, outcome: 'ok' } as RunReport; + await expect(finishRun('r1', report)).resolves.toBe(false); + expect(module.finishRun).toHaveBeenCalledWith('r1', JSON.stringify(report)); + }); + + it('asks whether a run was cancelled', async () => { + const module = installModule({ isRunCancelled: vi.fn(async () => true) }); + await expect(isRunCancelled('r1')).resolves.toBe(true); + expect(module.isRunCancelled).toHaveBeenCalledWith('r1'); + }); +}); + +describe('push routes and notifications', () => { + it('stores push routes as JSON', async () => { + const module = installModule(); + const routes: Record> = { + c: [{ accountName: ACCOUNT, authorities: [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY] }], + }; + await setPushRoutes(routes); + expect(module.setPushRoutes).toHaveBeenCalledWith(JSON.stringify(routes)); + }); + + it('posts and clears the sync problem notification', async () => { + const module = installModule(); + await showSyncProblem(ACCOUNT, 'Sign in again', 'Contacts stopped syncing', 'bulwarkmobile://login'); + expect(module.showSyncProblem).toHaveBeenLastCalledWith( + ACCOUNT, + 'Sign in again', + 'Contacts stopped syncing', + 'bulwarkmobile://login', + null, + ); + await showSyncProblem(ACCOUNT, 'Erneut anmelden', 'Kontakte', 'bulwarkmobile://login', 'Synchronisierungsprobleme'); + expect(module.showSyncProblem).toHaveBeenLastCalledWith( + ACCOUNT, + 'Erneut anmelden', + 'Kontakte', + 'bulwarkmobile://login', + 'Synchronisierungsprobleme', + ); + await clearSyncProblem(ACCOUNT); + expect(module.clearSyncProblem).toHaveBeenCalledWith(ACCOUNT); + }); + + it('listens for account changes until unsubscribed', () => { + installModule(); + const remove = vi.fn(); + let handler: (() => void) | undefined; + const addListener = vi + .spyOn(NativeEventEmitter.prototype, 'addListener') + .mockImplementation(((event: string, h: () => void) => { + expect(event).toBe(ACCOUNTS_CHANGED_EVENT); + handler = h; + return { remove }; + }) as never); + const listener = vi.fn(); + const unsubscribe = onAccountsChanged(listener); + expect(addListener).toHaveBeenCalledTimes(1); + handler?.(); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/device-sync/native.ts b/src/device-sync/native.ts new file mode 100644 index 00000000..528fd1e6 --- /dev/null +++ b/src/device-sync/native.ts @@ -0,0 +1,255 @@ +/** + * Typed wrapper around `NativeModules.BulwarkDeviceSync` (#34): the one module + * of device sync that talks to React Native's bridge. The raw contract is + * `DeviceSyncNativeModule` in ./types; docs/device-sync.md ("Native module + * API") explains it. + * + * The native module is looked up on every call and never cached, so a dev + * reload or an APK built before device sync never leaves a stale reference. + * Everything except `getDeviceSyncModule` and `isDeviceSyncAvailable` + * rejects when the module is missing (iOS, or an old APK), and + * `onAccountsChanged` then returns an unsubscribe that does nothing. + * + * Provider data crosses the bridge as JSON strings; replies are checked here, + * so a malformed one fails loudly instead of reaching the engine. + */ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import { + ACCOUNTS_CHANGED_EVENT, + CONTACTS_AUTHORITY, + type AccountSyncSettings, + type AndroidAccount, + type Authority, + type BatchFailure, + type BatchResult, + type Cell, + type DeviceSyncNativeModule, + type OpResult, + type PhotoData, + type ProviderPort, + type ProviderRows, + type RequestSyncOptions, + type RunReport, +} from './types'; + +export function getDeviceSyncModule(): DeviceSyncNativeModule | null { + if (Platform.OS !== 'android') return null; + return ( + ((NativeModules as Record).BulwarkDeviceSync as DeviceSyncNativeModule | undefined) ?? null + ); +} + +/** Android with a build that carries the native module. */ +export function isDeviceSyncAvailable(): boolean { + return getDeviceSyncModule() !== null; +} + +function requireModule(): DeviceSyncNativeModule { + const module = getDeviceSyncModule(); + if (!module) { + throw Object.assign(new Error('Device sync is not available in this build'), { code: 'unavailable' }); + } + return module; +} + +// ─── Provider ────────────────────────────────────────── + +/** The provider bound to one Android account of our type and one authority. */ +export function createProviderPort(accountName: string, authority: Authority): ProviderPort { + return { + accountName, + authority, + async query(q) { + const json = await requireModule().query(accountName, authority, JSON.stringify(q)); + return toRows(parseReply(json, 'query')); + }, + async applyBatch(ops) { + const json = await requireModule().applyBatch(accountName, authority, JSON.stringify(ops)); + const result = toBatchResult(parseReply(json, 'applyBatch')); + if (result.ok && result.results.length !== ops.length) { + throw malformed('applyBatch', `${result.results.length} results for ${ops.length} ops`); + } + return result; + }, + async readSyncState() { + const state: unknown = await requireModule().readSyncState(accountName, authority); + if (state === null || state === undefined) return null; + if (typeof state !== 'string') throw malformed('readSyncState', 'not text'); + return state; + }, + async readPhoto(rawContactId, maxPx) { + if (authority !== CONTACTS_AUTHORITY) throw new Error('readPhoto reads contacts: this port is for calendars'); + const json: unknown = await requireModule().readPhoto(accountName, rawContactId, maxPx); + return json === null || json === undefined ? null : toPhoto(parseReply(json, 'readPhoto')); + }, + }; +} + +function malformed(method: string, detail: string): Error { + return new Error(`BulwarkDeviceSync.${method} replied with malformed data: ${detail}`); +} + +function parseReply(json: unknown, method: string): unknown { + if (typeof json !== 'string') throw malformed(method, `a ${typeof json}, not JSON text`); + try { + return JSON.parse(json); + } catch (e) { + throw malformed(method, (e as Error).message); + } +} + +const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +const isCell = (v: unknown): v is Cell => v === null || typeof v === 'string' || typeof v === 'number'; +const isOptionalNumber = (v: unknown) => v === undefined || typeof v === 'number'; + +function toRows(value: unknown): ProviderRows { + if (!isObject(value) || !Array.isArray(value.columns) || !Array.isArray(value.rows)) { + throw malformed('query', 'no columns and rows'); + } + const { columns, rows } = value; + if (!columns.every((c) => typeof c === 'string')) throw malformed('query', 'a column name is not text'); + for (const row of rows) { + if (!Array.isArray(row) || row.length !== columns.length || !row.every(isCell)) { + throw malformed('query', 'a row does not match its columns'); + } + } + return { columns: columns as string[], rows: rows as Cell[][] }; +} + +const BATCH_FAILURES: readonly BatchFailure[] = ['assert', 'tooLarge', 'scope', 'permission', 'provider']; + +function toBatchResult(value: unknown): BatchResult { + if (isObject(value) && value.ok === true && Array.isArray(value.results)) { + const results: OpResult[] = value.results.map((r) => { + if (!isObject(r) || !isOptionalNumber(r.id) || !isOptionalNumber(r.count)) { + throw malformed('applyBatch', 'an op result is not { id?, count? }'); + } + return r as OpResult; + }); + return { ok: true, results }; + } + if ( + isObject(value) && + value.ok === false && + BATCH_FAILURES.includes(value.reason as BatchFailure) && + typeof value.message === 'string' + ) { + return { ok: false, reason: value.reason as BatchFailure, message: value.message }; + } + throw malformed('applyBatch', 'not a BatchResult'); +} + +function toPhoto(value: unknown): PhotoData { + if ( + !isObject(value) || + typeof value.jpegBase64 !== 'string' || + !(value.fileId === null || typeof value.fileId === 'number') + ) { + throw malformed('readPhoto', 'not { jpegBase64, fileId }'); + } + return { jpegBase64: value.jpegBase64, fileId: value.fileId }; +} + +// ─── Accounts and sync settings ──────────────────────── + +export async function listAndroidAccounts(): Promise { + return requireModule().listAccounts(); +} + +/** + * Adds the Android account for an app account. Resolves true when it was + * created, false when it exists for this registry id already; rejects with + * code `conflict` when an account of that name belongs to another one. + */ +export async function ensureAndroidAccount(name: string, registryId: string): Promise { + return requireModule().ensureAccount(name, registryId); +} + +/** Removes the Android account; the providers drop all of its rows. */ +export async function removeAndroidAccount(name: string): Promise { + return requireModule().removeAccount(name); +} + +export async function getSyncSettings(name: string): Promise { + return requireModule().getSyncSettings(name); +} + +export async function setSyncEnabled(name: string, authority: Authority, enabled: boolean): Promise { + await requireModule().setSyncEnabled(name, authority, enabled); +} + +/** Replaces the periodic sync; 0 removes it. Android raises anything shorter than 15 minutes to that. */ +export async function setPeriodicSync(name: string, authority: Authority, seconds: number): Promise { + if (!Number.isFinite(seconds) || seconds < 0) throw new RangeError(`Invalid sync interval: ${seconds}`); + await requireModule().setPeriodicSync(name, authority, Math.round(seconds)); +} + +/** + * Asks SyncManager for a sync. Repeated requests with the same options + * coalesce; one that arrives while the same sync runs makes that run ask for + * another one when it ends. + */ +export async function requestSync(name: string, authority: Authority, options: RequestSyncOptions = {}): Promise { + await requireModule().requestSync(name, authority, JSON.stringify(options)); +} + +/** The account's sync screen in Android Settings (or the list of synced accounts). */ +export async function openAccountSettings(name: string): Promise { + await requireModule().openAccountSettings(name); +} + +// ─── The run handshake ───────────────────────────────── + +/** Hands the report to the waiting adapter; false when the run already ended (deadline, cancel) or is unknown. */ +export async function finishRun(runId: string, report: RunReport): Promise { + return requireModule().finishRun(runId, JSON.stringify(report)); +} + +/** True once the adapter stopped waiting for the run (cancelled, out of time, or unknown). */ +export async function isRunCancelled(runId: string): Promise { + return requireModule().isRunCancelled(runId); +} + +// ─── Push routes and notifications ───────────────────── + +/** + * Tells the native push router which Android accounts each JMAP account feeds, + * so a pushed contact or calendar change requests their syncs without JS. + * Replaces the previous routes. + */ +export async function setPushRoutes( + routes: Record>, +): Promise { + await requireModule().setPushRoutes(JSON.stringify(routes)); +} + +/** + * Posts (or replaces) the account's "sync problem" notification; tapping it + * opens `uri`, a `bulwarkmobile://` deep link. `channelName` names the + * notification channel in the app's language (English when omitted). + */ +export async function showSyncProblem( + accountName: string, + title: string, + text: string, + uri: string, + channelName?: string, +): Promise { + await requireModule().showSyncProblem(accountName, title, text, uri, channelName ?? null); +} + +export async function clearSyncProblem(accountName: string): Promise { + await requireModule().clearSyncProblem(accountName); +} + +/** + * Calls `listener` when an account of our type is added or removed (also in + * Android Settings). A hint only: events without a JS listener are dropped, + * so re-read the accounts on mount and on foreground too. + */ +export function onAccountsChanged(listener: () => void): () => void { + const module = getDeviceSyncModule(); + if (!module) return () => undefined; + const subscription = new NativeEventEmitter(module).addListener(ACCOUNTS_CHANGED_EVENT, () => listener()); + return () => subscription.remove(); +} diff --git a/src/device-sync/types.ts b/src/device-sync/types.ts index ce1f8fb8..d84bbf19 100644 --- a/src/device-sync/types.ts +++ b/src/device-sync/types.ts @@ -74,19 +74,38 @@ export interface ProviderRows { /** * One provider operation. `id` addresses one row of our account by `_id`; - * `where`/`args` address a set (always ANDed with the account scope). + * `where`/`args` address a set (always ANDed with the account scope). With + * both, the op addresses the id row if it matches `where`. An op with `id` + * expects exactly one row unless `expectCount` says otherwise, so a row that + * vanished fails the batch with `assert`. * * - `insert.refs` maps a column to the index of an earlier `insert` in the * same batch whose new row id fills it (ContentProviderOperation * back-references), so one batch can create a raw contact and its data rows, - * or an event and its attendees. + * or an event and its attendees. A column in both `refs` and `values` takes + * the back-reference. Settings rows return no id and cannot be referenced. * - `assert` fails the whole batch unless the addressed rows hold `values` * (compared as text, as ContentProviderOperation does) and, when given, * their number is `expectCount`. - * - `syncState` replaces this account's SyncState blob. + * - `syncState` replaces this account's SyncState blob. It must be the last + * op of its batch and never a yield point (refused with `scope`). * - `yieldAllowed` marks the first op of an item group: the provider may - * commit everything before it and let other writers in. Batches must mark - * one at least every 400 ops (ContactsProvider refuses 500 without one). + * commit everything before it and let other writers in. Contacts batches + * must mark one at least every 400 ops (ContactsProvider refuses a batch + * with 500 ops up to and including a yield point). + * + * What the real providers do with the cells (the fake provider copies it): + * - contacts `data1`..`data14` are TEXT columns, so a number written there + * reads back as a string (`2` → `"2"`); + * - `Events.dirty` and `Calendars.dirty` are NULL, not 0, after an insert by + * the sync adapter: write `dirty: 0` explicitly, and select dirty rows with + * `dirty = 1`; + * - attendees, reminders and extended properties are read through a join, + * where `_id` is ambiguous: address one of their rows with `id`, never + * with `_id` in `where`. Extended properties are updated by `id` only. + * - an insert (or an update) naming a parent row, exception master or group + * that no longer exists fails with `assert`, so the engine re-reads; one of + * another account fails with `scope`. */ export type ProviderOp = | { op: 'insert'; table: ProviderTable; values: WriteRow; refs?: Record; yieldAllowed?: boolean } @@ -305,7 +324,12 @@ export interface DeviceSyncNativeModule { getInfo(): Promise<{ accountType: string; sdkInt: number }>; listAccounts(): Promise; - /** Adds the account if missing and (re)writes its registryId. Resolves true when it was created. */ + /** + * Adds the account if missing. Resolves true when it was created, false + * when it exists for `registryId` already (one left without a registryId + * is adopted); rejects with code `conflict` when it belongs to another + * registry id, whose rows and SyncState it holds. + */ ensureAccount(name: string, registryId: string): Promise; /** Removes the account; the providers drop its rows. */ removeAccount(name: string): Promise; @@ -328,7 +352,17 @@ export interface DeviceSyncNativeModule { * accounts with a contact or calendar type requests those syncs. */ setPushRoutes(routesJson: string): Promise; + /** + * Posts (or replaces) the account's one sync-problem notification; a tap + * opens `uri`, which must be a `bulwarkmobile://` link. `channelName` names + * the notification channel in the app's language; null keeps English. + */ + showSyncProblem(accountName: string, title: string, text: string, uri: string, channelName: string | null): Promise; + clearSyncProblem(accountName: string): Promise; + // Provider I/O runs on one native thread, a call at a time. `query`, + // `readSyncState` and `readPhoto` reject with code `scope`, `permission` or + // `provider` (as `BatchFailure`). /** `ProviderQuery` JSON in, `ProviderRows` JSON out. */ query(name: string, authority: Authority, queryJson: string): Promise; /** `ProviderOp[]` JSON in, `BatchResult` JSON out (failures resolve, they do not reject). */