diff --git a/App.tsx b/App.tsx
index f8d60275..5e152ebf 100644
--- a/App.tsx
+++ b/App.tsx
@@ -18,6 +18,7 @@ import {
startCalendarReminderTapHandling,
} from './src/lib/calendar-notifications';
import { startLivenessMonitor } from './src/lib/connection-liveness';
+import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
import {
setPendingCalendarOpen,
type CalendarReminderTarget,
@@ -412,6 +413,9 @@ export default function App() {
// Calendar reminders are kept scheduled from launch, not only once the
// Calendar tab has been opened.
startCalendarNotificationSync();
+ // Device sync (Android, #34): live changes, the app's own edits and the
+ // foreground ask Android to sync; accounts are checked against Android's.
+ startDeviceSyncTriggers();
return useNetworkStore.getState().init();
}, []);
diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md
index c6542d1e..9166f36e 100644
--- a/PARITY_CHECKLIST.md
+++ b/PARITY_CHECKLIST.md
@@ -153,7 +153,7 @@ Open split them by tick; the P columns count the priority tags on those items
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
-| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
+| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
diff --git a/README.md b/README.md
index 87861346..6ca38d01 100644
--- a/README.md
+++ b/README.md
@@ -22,11 +22,12 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- Calendar (basic)
- Contacts (basic)
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
+- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
- In-app sideload updates from GitHub Releases
## What's missing or rough
-- iOS builds, but push notifications and client certificates are Android-only so far
+- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d2ed16b4..ad476faf 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -154,6 +154,11 @@ android {
versionCode appVersionCode.toInteger()
versionName appVersionName
+ // Android account type of device sync (#34), derived from the package
+ // name so a later rename needs no other change. The XML in res/xml and
+ // the Kotlin in sync/ read it as @string/device_sync_account_type.
+ resValue "string", "device_sync_account_type", "${applicationId}.account"
+
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
@@ -280,6 +285,11 @@ dependencies {
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5")
+ // JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
+ // android.jar only stubs org.json, so the tests use the real one.
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.json:json:20240303")
+
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 3ec2507b..1146debf 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -3,5 +3,8 @@
-
+
+
+
+
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
new file mode 100644
index 00000000..b1bd2a4d
--- /dev/null
+++ b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt
@@ -0,0 +1,108 @@
+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 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), `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 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}"
+ }
+ Log.i(TAG, "debug command: $result")
+ pending.resultData = result
+ pending.finish()
+ }
+ }
+
+ 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) {
+ "contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
+ "calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
+ else -> which
+ }
+
+ companion object {
+ private const val TAG = "BulwarkDeviceSync"
+ }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 55ff3375..bdf73289 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,14 @@
+
+
+
+
+
+
+
+
@@ -73,6 +81,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
index f384dd56..5df02288 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
@@ -1,5 +1,6 @@
package com.anonymous.bulwarkmobile
+import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
@@ -12,6 +13,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkFcmModule(reactContext))
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
+ add(BulwarkDeviceSyncModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
index 41c553db..3959e5b6 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
@@ -11,6 +11,7 @@ import android.os.Bundle
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
+import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
import com.facebook.react.bridge.Arguments
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() {
val data = message.data
+ // Contact and calendar changes become syncs of the Android accounts
+ // they feed (device sync, #34); only mail needs the push task.
+ val mail = DeviceSyncPushRouter.route(applicationContext, data)
+
// Hand off to JS via a headless task only when the app isn't already
// running in the foreground - HeadlessJsTaskContext throws if started
// while foreground. When the app is open, the main JS instance already
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
- if (!isAppInForeground()) {
+ if (mail && !isAppInForeground()) {
startHeadlessTask(applicationContext, data)
}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt
index cde4fd5f..60a7cc14 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt
@@ -11,16 +11,11 @@ import android.os.Bundle
import android.os.PersistableBundle
import android.os.SystemClock
import android.util.Log
-import com.facebook.react.ReactApplication
-import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.ReactContext
-import com.facebook.react.bridge.UiThreadUtil
-import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
@@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
override fun onStartJob(params: JobParameters): Boolean {
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
- withReadyReactContext { startTask(it, config, params) }
+ // JobService callbacks run on the main thread, where HeadlessJs wants to be called.
+ HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
return true
}
@@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
tasks.addTaskEventListener(this)
taskContext = tasks
}
- UiThreadUtil.runOnUiThread {
- try {
- val taskId = tasks.startTask(config)
- running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
- } catch (e: IllegalStateException) {
+ HeadlessJs.startTask(
+ tasks,
+ config,
+ onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
+ onRefused = { e ->
// The app came to the foreground since the push arrived and
// syncs on its own there.
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
jobFinished(params, false)
- }
- }
- }
-
- /**
- * Calls [onReady] once with a React context whose JS instance is up,
- * starting React Native if the process was woken for this job.
- *
- * A context exists before its instance is ready, and a task started on
- * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
- * not available" and waits for the timeout), which happened when several
- * pushes arrived while the app was starting. So the listener goes in
- * first and the ready check after it; whichever sees the ready instance
- * first runs the task.
- */
- private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
- val done = AtomicBoolean(false)
- val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
- val app = application as ReactApplication
- if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
- val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
- val listener = object : ReactInstanceEventListener {
- override fun onReactContextInitialized(context: ReactContext) {
- host.removeReactInstanceEventListener(this)
- runOnce(context)
- }
- }
- host.addReactInstanceEventListener(listener)
- val current = host.currentReactContext
- if (current != null && current.hasActiveReactInstance()) {
- host.removeReactInstanceEventListener(listener)
- runOnce(current)
- } else {
- host.start()
- }
- } else {
- @Suppress("DEPRECATION")
- val manager = app.reactNativeHost.reactInstanceManager
- val listener = object : ReactInstanceEventListener {
- override fun onReactContextInitialized(context: ReactContext) {
- manager.removeReactInstanceEventListener(this)
- runOnce(context)
- }
- }
- manager.addReactInstanceEventListener(listener)
- val current = manager.currentReactContext
- if (current != null && current.hasActiveReactInstance()) {
- manager.removeReactInstanceEventListener(listener)
- runOnce(current)
- } else if (!manager.hasStartedCreatingInitialContext()) {
- manager.createReactContextInBackground()
- }
- }
+ },
+ )
}
companion object {
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt
index 81f9f196..5fac3d44 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt
@@ -1,6 +1,7 @@
package com.anonymous.bulwarkmobile
import android.content.Context
+import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
import com.facebook.react.bridge.Arguments
import org.json.JSONArray
import org.json.JSONObject
@@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() {
BulwarkMessagingService.ensureChannel(this)
val data = parsePayload(message.content) ?: return
- if (!BulwarkMessagingService.isAppInForeground()) {
+ // Same routing as FCM: contact and calendar changes request device
+ // syncs, and only mail starts the push task.
+ val mail = DeviceSyncPushRouter.route(applicationContext, data)
+ if (mail && !BulwarkMessagingService.isAppInForeground()) {
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt
new file mode 100644
index 00000000..b9722584
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt
@@ -0,0 +1,146 @@
+package com.anonymous.bulwarkmobile
+
+import android.app.Application
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import androidx.annotation.MainThread
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactInstanceEventListener
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.interfaces.TaskInterface
+import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+import com.facebook.react.jstasks.HeadlessJsTaskContext
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Running a headless JS task without HeadlessJsTaskService: starting React
+ * Native when the process was woken without any UI, and starting the task on
+ * its context. Shared by the push job (BulwarkPushJobService) and the device
+ * sync adapters.
+ */
+object HeadlessJs {
+ private const val TAG = "HeadlessJs"
+
+ /** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
+ private const val MAX_WAIT_MS = 600_000L
+ private const val WATCH_MS = 1_000L
+
+ /**
+ * Calls [onReady] once with a React context whose JS instance is up,
+ * starting React Native if the process was woken for this work.
+ *
+ * A context exists before its instance is ready, and a task started on
+ * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
+ * not available" and waits for the timeout), which happened when several
+ * pushes arrived while the app was starting. So the listener goes in
+ * first and the ready check after it; whichever sees the ready instance
+ * first runs the task.
+ *
+ * The wait ends once, and its listener goes with it: with a ready context,
+ * or without one when React fails to start (the start task faults) or
+ * after [MAX_WAIT_MS], when no caller can still be waiting. A context that
+ * comes up later runs nothing of it, so waits given up on don't pile up on
+ * the host with everything their [onReady] holds. (A caller that gives up
+ * sooner, like the sync adapter at its deadline, checks that in [onReady].)
+ *
+ * Call it on the main thread; [onReady] runs there too. MainApplication
+ * creates the ReactHost on first use without a lock (ExpoReactHostFactory),
+ * so a background caller racing MainActivity could create a second one,
+ * with a second JS runtime.
+ *
+ * Returns React Native's start task when it had to be started, so a caller
+ * can notice a start that fails (a debug build with no Metro to load the
+ * bundle from), or null when the context was already up.
+ */
+ @MainThread
+ fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface? {
+ val reactApp = app as ReactApplication
+ if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
+ val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
+ val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
+ host.addReactInstanceEventListener(wait)
+ val current = host.currentReactContext
+ if (current != null && current.hasActiveReactInstance()) {
+ wait.onReactContextInitialized(current)
+ return null
+ }
+ return host.start().also { wait.watch(it) }
+ }
+ @Suppress("DEPRECATION")
+ val manager = reactApp.reactNativeHost.reactInstanceManager
+ val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
+ manager.addReactInstanceEventListener(wait)
+ val current = manager.currentReactContext
+ if (current != null && current.hasActiveReactInstance()) {
+ wait.onReactContextInitialized(current)
+ return null
+ }
+ if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
+ wait.watch(null)
+ return null
+ }
+
+ /** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
+ private class ContextWait(
+ private val onReady: (ReactContext) -> Unit,
+ private val removeListener: (ReactInstanceEventListener) -> Unit,
+ ) : ReactInstanceEventListener {
+ private val ended = AtomicBoolean(false)
+
+ override fun onReactContextInitialized(context: ReactContext) {
+ if (end()) onReady(context)
+ }
+
+ /** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
+ fun watch(boot: TaskInterface?) {
+ val main = Handler(Looper.getMainLooper())
+ val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
+ main.postDelayed(object : Runnable {
+ override fun run() {
+ if (ended.get()) return
+ val faulted = boot?.isFaulted() == true
+ if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
+ main.postDelayed(this, WATCH_MS)
+ } else if (end()) {
+ val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
+ Log.w(TAG, "$why; no longer waiting")
+ }
+ }
+ }, WATCH_MS)
+ }
+
+ private fun end(): Boolean {
+ if (!ended.compareAndSet(false, true)) return false
+ removeListener(this)
+ return true
+ }
+ }
+
+ /**
+ * Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
+ * [onStarted] gets the task id in that same UI-thread turn, so before any
+ * finish callback (HeadlessJsTaskContext posts those to the UI thread).
+ * [onRefused] gets the refusal when the context is resumed and [config]
+ * isn't allowed in the foreground.
+ */
+ fun startTask(
+ tasks: HeadlessJsTaskContext,
+ config: HeadlessJsTaskConfig,
+ onStarted: (taskId: Int) -> Unit,
+ onRefused: (IllegalStateException) -> Unit,
+ ) {
+ UiThreadUtil.runOnUiThread {
+ val taskId = try {
+ tasks.startTask(config)
+ } catch (e: IllegalStateException) {
+ onRefused(e)
+ return@runOnUiThread
+ }
+ onStarted(taskId)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt
new file mode 100644
index 00000000..e26aa44c
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt
@@ -0,0 +1,79 @@
+package com.anonymous.bulwarkmobile.sync
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.IBinder
+import com.anonymous.bulwarkmobile.MainActivity
+
+/**
+ * The authenticator of the "Bulwark" account type. The app holds the
+ * credentials, not the account: accounts are only ever created by the app,
+ * from Settings → Contacts or Calendar, for an account that is signed in.
+ */
+class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
+
+ /** Settings → Accounts → Add account opens the app's device sync settings instead. */
+ override fun addAccount(
+ response: AccountAuthenticatorResponse?,
+ accountType: String?,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle?,
+ ): Bundle {
+ val intent = Intent(context, MainActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .setData(Uri.parse(SETTINGS_URI))
+ return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
+ }
+
+ /** Removal from Android Settings is allowed; the app notices and turns device sync off. */
+ override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle =
+ Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
+
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?,
+ ): Bundle = unsupported()
+
+ override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle =
+ unsupported()
+
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?,
+ ): Bundle = unsupported()
+
+ override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported()
+
+ override fun getAuthTokenLabel(authTokenType: String?): String? = null
+
+ override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array?): Bundle =
+ Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
+
+ private fun unsupported() = Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
+ putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app")
+ }
+
+ companion object {
+ /** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */
+ const val SETTINGS_URI = "bulwarkmobile://settings/contacts"
+ }
+}
+
+class BulwarkAuthenticatorService : Service() {
+ private val authenticator by lazy { BulwarkAuthenticator(this) }
+
+ override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
+}
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
new file mode 100644
index 00000000..230141db
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt
@@ -0,0 +1,331 @@
+package com.anonymous.bulwarkmobile.sync
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.OnAccountsUpdateListener
+import android.content.ActivityNotFoundException
+import android.content.ContentResolver
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.util.Log
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+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, 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) {
+
+ private val accountsListener = OnAccountsUpdateListener {
+ 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 }
+ }
+
+ override fun getName(): String = NAME
+
+ override fun initialize() {
+ super.initialize()
+ val manager = AccountManager.get(reactApplicationContext)
+ val handler = Handler(Looper.getMainLooper())
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType()))
+ } else {
+ manager.addOnAccountsUpdatedListener(accountsListener, handler, false)
+ }
+ } catch (e: RuntimeException) {
+ Log.w(TAG, "Account change listener not registered", e)
+ }
+ }
+
+ override fun invalidate() {
+ try {
+ AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener)
+ } catch (e: RuntimeException) {
+ // Not registered.
+ }
+ io.shutdown()
+ super.invalidate()
+ }
+
+ private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext)
+
+ // ── accounts ────────────────────────────────────────────────
+
+ @ReactMethod
+ fun getInfo(promise: Promise) {
+ promise.resolve(Arguments.createMap().apply {
+ putString("accountType", accountType())
+ putInt("sdkInt", Build.VERSION.SDK_INT)
+ })
+ }
+
+ @ReactMethod
+ fun listAccounts(promise: Promise) = settle(promise) {
+ Arguments.createArray().apply {
+ for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) {
+ pushMap(Arguments.createMap().apply {
+ putString("name", account.name)
+ putString("registryId", registryId)
+ })
+ }
+ }
+ }
+
+ @ReactMethod
+ fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) {
+ DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId)
+ }
+
+ @ReactMethod
+ fun removeAccount(name: String, promise: Promise) = settle(promise) {
+ DeviceSyncAccounts.remove(reactApplicationContext, name)
+ }
+
+ // ── sync settings ───────────────────────────────────────────
+
+ @ReactMethod
+ fun getSyncSettings(name: String, promise: Promise) = settle(promise) {
+ val account = DeviceSyncAccounts.account(reactApplicationContext, name)
+ Arguments.createMap().apply {
+ putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically())
+ putMap("authorities", Arguments.createMap().apply {
+ for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority))
+ })
+ }
+ }
+
+ private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply {
+ putInt("syncable", ContentResolver.getIsSyncable(account, authority))
+ putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority))
+ val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L
+ putDouble("periodicSeconds", periodic.toDouble())
+ putBoolean("active", ContentResolver.isSyncActive(account, authority))
+ putBoolean("pending", ContentResolver.isSyncPending(account, authority))
+ }
+
+ @ReactMethod
+ fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) {
+ DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled)
+ null
+ }
+
+ @ReactMethod
+ fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) {
+ DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong())
+ null
+ }
+
+ @ReactMethod
+ fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) {
+ val options = JSONObject(optionsJson)
+ val flags = SyncRequestFlags(
+ manual = options.optBoolean("manual"),
+ expedited = options.optBoolean("expedited"),
+ upload = options.optBoolean("upload"),
+ overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"),
+ discardLocalDeletions = options.optBoolean("discardLocalDeletions"),
+ )
+ DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags)
+ null
+ }
+
+ /** The account's own sync screen where the platform has one, else the list of synced accounts. */
+ @ReactMethod
+ fun openAccountSettings(name: String, promise: Promise) = settle(promise) {
+ val account = DeviceSyncAccounts.account(reactApplicationContext, name)
+ val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
+ .putExtra(EXTRA_ACCOUNT, account)
+ // Newer Settings apps pass only these arguments on to the account's
+ // screen; without them it closes at once ("Account provided does
+ // not exist: null", Android 12L), and no exception tells us so.
+ .putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) })
+ val overview = Intent(Settings.ACTION_SYNC_SETTINGS)
+ .putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type))
+ val activity = reactApplicationContext.currentActivity
+ for (intent in listOf(perAccount, overview)) {
+ try {
+ if (activity != null) {
+ activity.startActivity(intent)
+ } else {
+ reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ return@settle null
+ } catch (e: ActivityNotFoundException) {
+ // Try the next one.
+ }
+ }
+ throw IllegalStateException("No settings screen for sync accounts")
+ }
+
+ // ── run handshake ───────────────────────────────────────────
+
+ @ReactMethod
+ fun finishRun(runId: String, reportJson: String, promise: Promise) {
+ val accepted = DeviceSyncRuns.finish(runId, reportJson)
+ if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it")
+ promise.resolve(accepted)
+ }
+
+ @ReactMethod
+ fun isRunCancelled(runId: String, promise: Promise) {
+ 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) {}
+
+ @ReactMethod
+ fun removeListeners(count: Int) {}
+
+ 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) {
+ promise.reject("bad_args", e.message, e)
+ } catch (e: Exception) {
+ promise.reject("failed", e.message, e)
+ }
+ }
+
+ companion object {
+ const val NAME = "BulwarkDeviceSync"
+ private const val TAG = "BulwarkDeviceSync"
+ const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged"
+
+ /** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */
+ private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
+ private const val EXTRA_ACCOUNT = "account"
+ /** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */
+ private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
+
+ @Volatile private var currentInstance: BulwarkDeviceSyncModule? = null
+
+ /** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */
+ fun emit(eventName: String, params: WritableMap?) {
+ val module = currentInstance ?: return
+ val ctx = module.reactApplicationContext
+ if (!ctx.hasActiveReactInstance()) return
+ ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
+ }
+ }
+}
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
new file mode 100644
index 00000000..29e2fa7c
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt
@@ -0,0 +1,144 @@
+package com.anonymous.bulwarkmobile.sync
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.ContentResolver
+import android.content.Context
+import android.os.Bundle
+import android.provider.CalendarContract
+import android.provider.ContactsContract
+import com.anonymous.bulwarkmobile.R
+
+/**
+ * The Android accounts of device sync (#34) and their sync settings. One
+ * account per app account that has device sync on; see docs/device-sync.md.
+ *
+ * Android's own sync settings are the source of truth for on/off: the
+ * Contacts and Calendar toggles in Settings → Accounts are
+ * `getSyncAutomatically`, and the app reads them back instead of keeping a
+ * copy.
+ */
+object DeviceSyncAccounts {
+ const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY
+ const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY
+ val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY)
+
+ /** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */
+ const val USER_DATA_REGISTRY_ID = "registryId"
+
+ /** `.account`, set in build.gradle so the package name is never hard-coded. */
+ fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type)
+
+ fun account(context: Context, name: String) = Account(name, accountType(context))
+
+ fun list(context: Context): List> {
+ val manager = AccountManager.get(context)
+ 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. 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 }
+ 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" }
+ for (authority in AUTHORITIES) {
+ ContentResolver.setIsSyncable(account, authority, 1)
+ ContentResolver.setSyncAutomatically(account, authority, false)
+ }
+ 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)
+ val account = account(context, name)
+ if (manager.getAccountsByType(account.type).none { it.name == name }) return false
+ return manager.removeAccountExplicitly(account)
+ }
+
+ fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) {
+ requireAuthority(authority)
+ val account = account(context, name)
+ ContentResolver.setIsSyncable(account, authority, 1)
+ ContentResolver.setSyncAutomatically(account, authority, enabled)
+ }
+
+ /** Replaces our periodic sync for the authority; 0 removes it. */
+ fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) {
+ requireAuthority(authority)
+ val account = account(context, name)
+ for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) {
+ ContentResolver.removePeriodicSync(account, authority, periodic.extras)
+ }
+ 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())
+ }
+
+ fun requireAuthority(authority: String) {
+ require(authority in AUTHORITIES) { "Unknown authority $authority" }
+ }
+}
+
+/** [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,
+ val expedited: Boolean = false,
+ val upload: Boolean = false,
+ val overrideTooManyDeletions: Boolean = false,
+ val discardLocalDeletions: Boolean = false,
+) {
+ fun toExtras(): Bundle = Bundle().apply {
+ if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
+ if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
+ if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true)
+ if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true)
+ if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true)
+ }
+}
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
new file mode 100644
index 00000000..8df852ae
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt
@@ -0,0 +1,213 @@
+package com.anonymous.bulwarkmobile.sync
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.app.Application
+import android.app.Service
+import android.content.AbstractThreadedSyncAdapter
+import android.content.ContentProviderClient
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.SyncResult
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import androidx.annotation.MainThread
+import com.anonymous.bulwarkmobile.HeadlessJs
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+import com.facebook.react.jstasks.HeadlessJsTaskContext
+import com.facebook.react.jstasks.HeadlessJsTaskEventListener
+import java.util.concurrent.TimeUnit
+
+/**
+ * The sync adapter behind both authorities (#34). It does no syncing itself:
+ * it starts the BulwarkDeviceSync headless JS task, where the engine lives
+ * (src/device-sync), waits for its report and hands that to SyncManager. See
+ * docs/device-sync.md.
+ *
+ * onPerformSync runs on its own SyncAdapterThread and may block; React Native
+ * is started from the main thread (see HeadlessJs), which stays free.
+ * SyncManager holds a wake lock for as long as onPerformSync runs.
+ */
+class DeviceSyncAdapter(context: Context) :
+ AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) {
+
+ 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,
+ authority: String,
+ 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")
+ syncResult.databaseError = true
+ return
+ }
+ val startedAt = SystemClock.elapsedRealtime()
+ // A sync scheduled as an expedited job (Android 12+) gets 3 minutes
+ // from JobScheduler instead of 10; stay inside either.
+ val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false)
+ val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS
+ val run = DeviceSyncRuns.open(account.name, authority)
+ main.post { startRun(run, account, registryId, authority, extras, deadline) }
+ try {
+ while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) {
+ val boot = run.boot
+ if (boot != null && boot.isFaulted()) {
+ run.end("React Native did not start: ${boot.getError()?.message}")
+ } else if (SystemClock.elapsedRealtime() >= deadline) {
+ run.end("no report before the deadline")
+ }
+ }
+ val outcome = SyncReports.outcome(run.report, run.endReason)
+ SyncReports.apply(outcome, syncResult)
+ Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
+ "${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome")
+ } catch (e: InterruptedException) {
+ // onSyncCanceled interrupts this thread. JS sees the run as
+ // cancelled at its next checkpoint (isRunCancelled).
+ run.end("cancelled")
+ Log.i(TAG, "Sync ${run.runId} of $authority cancelled")
+ Thread.currentThread().interrupt()
+ } finally {
+ DeviceSyncRuns.close(run)
+ // 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
+ private fun startRun(
+ run: DeviceSyncRuns.Run,
+ account: Account,
+ registryId: String,
+ authority: String,
+ extras: Bundle,
+ deadline: Long,
+ ) {
+ if (run.cancelled) return
+ try {
+ run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext ->
+ if (run.cancelled) return@withReadyReactContext
+ if (!reactContext.hasActiveReactInstance()) {
+ run.end("the React Native instance is gone")
+ return@withReadyReactContext
+ }
+ val tasks = HeadlessJsTaskContext.getInstance(reactContext)
+ run.attach(tasks, object : HeadlessJsTaskEventListener {
+ override fun onHeadlessJsTaskStart(taskId: Int) = Unit
+
+ // JS settled without a report, timed out, or the context was reloaded.
+ override fun onHeadlessJsTaskFinish(taskId: Int) {
+ if (taskId == run.taskId) run.end("task finished")
+ }
+ })
+ val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS)
+ val data = Arguments.createMap().apply {
+ putString("runId", run.runId)
+ putString("accountName", account.name)
+ putString("registryId", registryId)
+ putString("authority", authority)
+ putMap("extras", runExtras(extras))
+ putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble())
+ }
+ HeadlessJs.startTask(
+ tasks,
+ HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true),
+ onStarted = { run.taskId = it },
+ onRefused = { run.end("refused: ${it.message}") },
+ )
+ }
+ } catch (e: RuntimeException) {
+ run.end("could not start: ${e.message}")
+ }
+ }
+
+ private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply {
+ fun flag(key: String, name: String) {
+ if (extras.getBoolean(key, false)) putBoolean(name, true)
+ }
+ flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual")
+ flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload")
+ flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited")
+ flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff")
+ flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions")
+ flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions")
+ }
+
+ companion object {
+ private const val TAG = "BulwarkDeviceSync"
+ const val TASK_KEY = "BulwarkDeviceSync"
+
+ /** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */
+ private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job"
+
+ private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10
+ private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3
+ private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline
+ private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout
+ private const val MIN_TASK_MS = 30_000L
+ private const val POLL_MS = 500L
+ }
+}
+
+/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */
+class ContactsSyncService : Service() {
+ override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
+
+ companion object {
+ @Volatile private var adapter: DeviceSyncAdapter? = null
+
+ private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
+ adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
+ }
+ }
+}
+
+/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */
+class CalendarSyncService : Service() {
+ override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
+
+ companion object {
+ @Volatile private var adapter: DeviceSyncAdapter? = null
+
+ private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
+ adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
+ }
+ }
+}
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
new file mode 100644
index 00000000..9e46adeb
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt
@@ -0,0 +1,125 @@
+package com.anonymous.bulwarkmobile.sync
+
+import com.facebook.react.interfaces.TaskInterface
+import com.facebook.react.jstasks.HeadlessJsTaskContext
+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 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)
+
+ /** 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. */
+ @Volatile var taskId = NO_TASK
+ @Volatile private var tasks: HeadlessJsTaskContext? = null
+ @Volatile private var listener: HeadlessJsTaskEventListener? = null
+
+ 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) {
+ this.tasks = tasks
+ this.listener = listener
+ tasks.addTaskEventListener(listener)
+ }
+
+ fun detach() {
+ val tasks = tasks ?: return
+ listener?.let { tasks.removeTaskEventListener(it) }
+ this.tasks = null
+ this.listener = null
+ }
+ }
+
+ private val runs = ConcurrentHashMap()
+
+ 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 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. 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
+
+ /** 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
+ }
+
+ /**
+ * 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..37f23290
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt
@@ -0,0 +1,376 @@
+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 dataMimetypes(ids: Set): Map {
+ val mimetypes = HashMap()
+ forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c ->
+ if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1)
+ }
+ return mimetypes
+ }
+
+ 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..b5d9555b
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt
@@ -0,0 +1,466 @@
+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"
+
+/**
+ * Checks a selection or sort order before the account's scope is ANDed onto
+ * it as `(text) AND (scope)`: its parentheses must balance outside quoted
+ * literals, or `1) OR (1` would close the scope's own parenthesis. It may
+ * not end or comment out the statement, nor query other tables. The engine's
+ * selections are constants with `?` arguments; this guards against bugs.
+ */
+object SqlFragments {
+ private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE)
+
+ fun check(text: String, what: String) {
+ var depth = 0
+ var quote: Char? = null
+ val outside = StringBuilder(text.length)
+ var i = 0
+ while (i < text.length) {
+ val c = text[i]
+ if (quote != null) {
+ if (c == quote) {
+ // A doubled quote is an escaped one inside the literal.
+ if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null
+ }
+ outside.append(' ')
+ } else {
+ when (c) {
+ '\'', '"' -> {
+ quote = c
+ outside.append(' ')
+ }
+ '(' -> {
+ depth++
+ outside.append(c)
+ }
+ ')' -> {
+ if (--depth < 0) refuse(what, "closes a parenthesis it did not open")
+ outside.append(c)
+ }
+ ';' -> refuse(what, "ends the statement")
+ '\u0000' -> refuse(what, "contains a NUL character")
+ else -> outside.append(c)
+ }
+ }
+ i++
+ }
+ if (quote != null) refuse(what, "leaves a quote open")
+ if (depth != 0) refuse(what, "leaves a parenthesis open")
+ val bare = outside.toString()
+ if ("--" in bare || "/*" in bare) refuse(what, "contains a comment")
+ FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") }
+ }
+
+ private fun refuse(what: String, why: String): Nothing =
+ throw ProviderRefusal(BatchFailure.SCOPE, "$what $why")
+}
+
+/**
+ * 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")
+ val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") }
+ return ProviderQuery(table, columns, where, args, 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")?.also { SqlFragments.check(it, "$where: 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..7706b0a1
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt
@@ -0,0 +1,549 @@
+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 memberships = membershipUpdates(ops)
+ val owners = Owners(ownershipRequests(ops, memberships))
+ return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) }
+ }
+
+ /**
+ * Membership rows among the data rows that updates by id write `data1`
+ * of without restating the mimetype: their `data1` names a group, which
+ * must be the account's like on insert.
+ */
+ private fun membershipUpdates(ops: List): Set {
+ val ids = ops.asSequence()
+ .filterIsInstance()
+ .filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) }
+ .mapNotNull { it.target.id }
+ .toSet()
+ if (ids.isEmpty()) return emptySet()
+ return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys
+ }
+
+ private fun writesUntypedData1(values: Map) =
+ MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null
+
+ private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set): PlannedOp = when (op) {
+ is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
+ is ProviderOp.Insert -> {
+ checkAccountColumns(i, op.table, op.values)
+ checkPhotoFile(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)
+ checkPhotoFile(i, op.table, op.values)
+ checkReferences(i, op.table, op.values, owners)
+ if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) {
+ checkMembershipUpdate(i, op, owners, memberships)
+ }
+ 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",
+ )
+ }
+ }
+ }
+
+ /**
+ * `data1` written by an update that doesn't restate the mimetype: on a
+ * membership row it names a group. Rows chosen by a selection could be
+ * memberships the lookup didn't see, so only updates by id may do it.
+ */
+ private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set) {
+ val id = op.target.id
+ ?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id")
+ if (id !in memberships) return
+ val group = op.values[DATA1]?.rowId()
+ ?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id")
+ when (owners.of(ProviderTable.GROUPS, group)) {
+ Owner.OURS -> Unit
+ Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account")
+ Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists")
+ }
+ }
+
+ /**
+ * A photo row's file id (`data14`) is the provider's: it points into the
+ * photo store, where another account's file id would let `readPhoto`
+ * read that account's photo. Photos are written as bytes (`data15`).
+ */
+ private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map) {
+ if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) {
+ throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only")
+ }
+ }
+
+ /** 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, memberships: Set): 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) }
+ if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, 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"
+ private const val MIMETYPE = "mimetype"
+ private const val DATA1 = "data1"
+ private const val DATA14 = "data14"
+
+ /** 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 mimetype of each data row that exists. */
+ fun dataMimetypes(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..fd5d938a
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt
@@ -0,0 +1,173 @@
+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 a mail type, for anything
+ * but a StateChange (an EmailPush), when `changed` is missing, unreadable or
+ * names no type (older relays), or when an account's entry is unreadable, so
+ * a contact change never ends in a mail notification. The push task's guard
+ * (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules.
+ */
+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 STATE_CHANGE_KIND = "jmap-state-change"
+
+ /**
+ * Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`,
+ * which new mail changes too. A subscription made by an older build
+ * listens to them until the launch-time resync replaces its types, and a
+ * server without `EmailDelivery` has only them. Keep in sync with
+ * MAIL_TYPES in src/lib/push-background-task.ts.
+ */
+ private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox")
+ 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)
+ // Only a StateChange can say it carries no mail.
+ var mail = data["kind"] != STATE_CHANGE_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 (types.any { it in MAIL_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/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt
new file mode 100644
index 00000000..37e6dc38
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt
@@ -0,0 +1,112 @@
+package com.anonymous.bulwarkmobile.sync
+
+import android.content.SyncResult
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * What a run's report (src/device-sync/types.ts `RunReport`) asks of the
+ * framework's SyncResult. Kept free of Android classes so JVM tests cover it;
+ * [SyncReports.apply] copies it onto the real SyncResult.
+ *
+ * SyncManager treats io errors as soft (retry with back-off), auth errors,
+ * database errors and too many deletions as hard (no retry until something
+ * changes), and `fullSyncRequested` as "schedule another sync now".
+ */
+data class SyncOutcome(
+ val ioErrors: Long = 0,
+ val authErrors: Long = 0,
+ val databaseError: Boolean = false,
+ val tooManyDeletions: Boolean = false,
+ val inserts: Long = 0,
+ val updates: Long = 0,
+ val deletes: Long = 0,
+ val entries: Long = 0,
+ val skipped: Long = 0,
+ /** Epoch seconds; 0 when unset. */
+ val delayUntil: Long = 0,
+ val fullSyncRequested: Boolean = false,
+)
+
+object SyncReports {
+ /**
+ * Translates a report. [reportJson] is null when the run ended without
+ * one ([endReason] says why: JS never answered, React failed to start, …),
+ * which is a soft error so the framework retries.
+ */
+ fun outcome(reportJson: String?, endReason: String?): SyncOutcome {
+ if (reportJson == null) return SyncOutcome(ioErrors = 1)
+ val report = try {
+ JSONObject(reportJson)
+ } catch (e: JSONException) {
+ return SyncOutcome(ioErrors = 1)
+ }
+ val stats = report.optJSONObject("stats")
+ val down = stats?.optJSONObject("downloaded")
+ val up = stats?.optJSONObject("uploaded")
+ fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0
+ val base = SyncOutcome(
+ inserts = count(down, "created") + count(up, "created"),
+ updates = count(down, "updated") + count(up, "updated"),
+ deletes = count(down, "deleted") + count(up, "deleted"),
+ entries = stats?.optLong("entries", 0) ?: 0,
+ skipped = stats?.optLong("skipped", 0) ?: 0,
+ delayUntil = report.optLong("delayUntil", 0),
+ fullSyncRequested = report.optBoolean("moreRecordsToGet", false),
+ )
+ // SyncManager retries a failed sync at once, without back-off, when it
+ // made progress (inserts, updates, or deletes without
+ // tooManyDeletions). A hard error must therefore report none, or an
+ // auth failure after some work would loop.
+ val hard = base.copy(inserts = 0, updates = 0, deletes = 0)
+ return when (report.optString("outcome")) {
+ "ok", "cancelled", "disabled" -> base
+ "auth" -> hard.copy(authErrors = 1)
+ "permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true)
+ "tooManyDeletions" -> hard.copy(
+ tooManyDeletions = true,
+ // Shown in the system's "too many deletions" notification.
+ deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0,
+ )
+ // "io", "internal" and anything a newer JS might send: retry, at
+ // once if some work got done, else with back-off.
+ else -> base.copy(ioErrors = 1)
+ }
+ }
+
+ /**
+ * One line for logcat: the report's outcome, its message and how many
+ * items failed. The reason a run failed is otherwise only in the report,
+ * and JS warnings from a headless run don't reach logcat.
+ */
+ fun summary(reportJson: String?): String {
+ if (reportJson == null) return "no report"
+ val report = try {
+ JSONObject(reportJson)
+ } catch (e: JSONException) {
+ return "unreadable report"
+ }
+ val parts = mutableListOf("outcome=" + report.optString("outcome", "?"))
+ val items = report.optJSONArray("itemErrors")?.length() ?: 0
+ if (items > 0) parts += "itemErrors=$items"
+ val message = report.optString("message", "")
+ if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE)
+ return parts.joinToString(" ")
+ }
+
+ private const val MAX_LOGGED_MESSAGE = 300
+
+ fun apply(outcome: SyncOutcome, result: SyncResult) {
+ result.stats.numIoExceptions += outcome.ioErrors
+ result.stats.numAuthExceptions += outcome.authErrors
+ result.stats.numInserts += outcome.inserts
+ result.stats.numUpdates += outcome.updates
+ result.stats.numDeletes += outcome.deletes
+ result.stats.numEntries += outcome.entries
+ result.stats.numSkippedEntries += outcome.skipped
+ if (outcome.databaseError) result.databaseError = true
+ if (outcome.tooManyDeletions) result.tooManyDeletions = true
+ if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil
+ if (outcome.fullSyncRequested) result.fullSyncRequested = true
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index f7c8e036..3e363054 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2,4 +2,6 @@
Bulwark Mobile
contain
false
+
+ Bulwark
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/authenticator.xml b/android/app/src/main/res/xml/authenticator.xml
new file mode 100644
index 00000000..ee861b32
--- /dev/null
+++ b/android/app/src/main/res/xml/authenticator.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/android/app/src/main/res/xml/contacts.xml b/android/app/src/main/res/xml/contacts.xml
new file mode 100644
index 00000000..1aff029f
--- /dev/null
+++ b/android/app/src/main/res/xml/contacts.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/sync_calendar.xml b/android/app/src/main/res/xml/sync_calendar.xml
new file mode 100644
index 00000000..9c5c8d15
--- /dev/null
+++ b/android/app/src/main/res/xml/sync_calendar.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/xml/sync_contacts.xml b/android/app/src/main/res/xml/sync_contacts.xml
new file mode 100644
index 00000000..25e25fbc
--- /dev/null
+++ b/android/app/src/main/res/xml/sync_contacts.xml
@@ -0,0 +1,9 @@
+
+
+
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..2d1f6930
--- /dev/null
+++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt
@@ -0,0 +1,562 @@
+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()
+ /** data id → mimetype */
+ val mimetypes = 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 dataMimetypes(ids: Set) = mimetypes.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 `a membership row's group is checked on updates by id too`() {
+ lookup.mimetypes[100] = GROUP_MEMBERSHIP_MIMETYPE
+ plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":10}}""")
+ refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") }
+ refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":12}}""") }
+ // data1 by a selection could reach memberships the lookup never saw.
+ refused(BatchFailure.SCOPE) {
+ plan(CONTACTS, """{"op":"update","table":"data","where":"raw_contact_id = ?","args":[1],"values":{"data1":11}}""")
+ }
+ // data1 of other kinds is plain data.
+ lookup.mimetypes[100] = "vnd.android.cursor.item/phone_v2"
+ plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""")
+ }
+
+ @Test
+ fun `photo file ids are never written`() {
+ val photo = """"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo""""
+ plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":null,"data15":{"b64":"AAAA"}}}""")
+ refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":5}}""") }
+ refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data14":5}}""") }
+ }
+
+ @Test
+ fun `a selection can't close the scope's parentheses or query elsewhere`() {
+ for (where in listOf("1) OR (1", "dirty = 1) OR (1 = 1", "(1", "1; DELETE FROM events", "1 -- x", "1 /* x */",
+ "_id IN (SELECT _id FROM events)", "1 UNION ALL x", "title = 'open")) {
+ val quoted = JSONObject.quote(where)
+ refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","where":$quoted,"values":{"dirty":1}}""") }
+ refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","where":$quoted}""") }
+ refused(BatchFailure.SCOPE) {
+ ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":$quoted}""")
+ }
+ }
+ refused(BatchFailure.SCOPE) {
+ ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id); DROP TABLE x; --"}""")
+ }
+ // Parentheses and keywords inside quoted literals are data.
+ plan(CALENDAR, """{"op":"update","table":"events","where":"title = '(x' OR title = 'select'","values":{"dirty":1}}""")
+ plan(CALENDAR, """{"op":"assert","table":"events","where":"(dirty = 1 OR deleted = 1) AND _sync_id LIKE '~pending/%'","expectCount":0}""")
+ ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id DESC"}""")
+ }
+
+ @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..db2d5b53
--- /dev/null
+++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt
@@ -0,0 +1,192 @@
+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 or Mailbox change is possible mail, as a subscription from an older build sends it`() {
+ for (changed in listOf(
+ """{"c":{"Email":"s1","Mailbox":"s2"}}""",
+ """{"c":{"Email":"s1"}}""",
+ """{"c":{"Mailbox":"s1"}}""",
+ )) {
+ val decision = PushRouting.decide(stateChange(changed), routes)
+ assertTrue("changed=$changed", decision.startMailTask)
+ assertTrue("changed=$changed", decision.syncs.isEmpty())
+ }
+ val mixed = PushRouting.decide(stateChange("""{"team":{"CalendarEvent":"e1"},"c":{"Email":"s1"}}"""), routes)
+ assertTrue(mixed.startMailTask)
+ assertEquals(setOf("alice-work" to calendar), mixed.syncs)
+ // Types that are neither mail nor device sync say nothing about mail.
+ val other = PushRouting.decide(stateChange("""{"c":{"Thread":"t1","ContactCard":"s1"}}"""), routes)
+ assertFalse(other.startMailTask)
+ assertEquals(setOf("alice@example.org" to contacts), other.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 `reads a push the same way as the push task`() {
+ // kind (null: none), changed (null: none) → whether the mail task starts. The
+ // same table as MAIL_CASES in src/lib/__tests__/push-background-task.test.ts,
+ // whose carriesNoMail must agree.
+ val state = "jmap-state-change"
+ val emailPush = "jmap-email-push"
+ val cases: List> = listOf(
+ // No readable map: as before device sync.
+ Triple(state, null, true),
+ Triple(state, "", true),
+ Triple(state, "not json", true),
+ Triple(state, "[1]", true),
+ Triple(state, "{}", true),
+ // A map that names no type is read like a missing one.
+ Triple(state, """{"c":{}}""", true),
+ Triple(state, """{"c":{},"team":{}}""", true),
+ // An account entry that can't be read may be mail.
+ Triple(state, """{"c":null}""", true),
+ Triple(state, """{"c":"garbled","team":{"ContactCard":"s"}}""", true),
+ Triple(state, """{"c":[],"team":{"ContactCard":"s"}}""", true),
+ Triple(state, """{"c":5}""", true),
+ // Mail types anywhere in the map.
+ Triple(state, """{"c":{"EmailDelivery":"s"}}""", true),
+ Triple(state, """{"c":{"Email":"s"}}""", true),
+ Triple(state, """{"c":{"Mailbox":"s"}}""", true),
+ Triple(state, """{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}""", true),
+ // Only a StateChange can say it carries no mail.
+ Triple(null, """{"c":{"ContactCard":"s"}}""", true),
+ Triple(emailPush, """{"c":{"EmailDelivery":"s"}}""", true),
+ Triple(emailPush, null, true),
+ // Contact and calendar changes, and types that say nothing about mail.
+ Triple(state, """{"c":{"ContactCard":"s1","AddressBook":"s2"}}""", false),
+ Triple(state, """{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}""", false),
+ Triple(state, """{"c":{"ContactCard":"s"},"team":{}}""", false),
+ Triple(state, """{"elsewhere":{"ContactCard":"s"}}""", false),
+ Triple(state, """{"c":{"Thread":"t1","ContactCard":"s1"}}""", false),
+ )
+ for ((kind, changed, mail) in cases) {
+ val data = buildMap {
+ if (kind != null) put("kind", kind)
+ put("accountLabel", "alice")
+ put("accountId", "c")
+ put("emailIds", "[]")
+ if (changed != null) put("changed", changed)
+ }
+ assertEquals("kind=$kind changed=$changed", mail, PushRouting.decide(data, routes).startMailTask)
+ }
+ }
+
+ @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/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt
new file mode 100644
index 00000000..3594a338
--- /dev/null
+++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt
@@ -0,0 +1,99 @@
+package com.anonymous.bulwarkmobile.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SyncReportsTest {
+ private fun report(outcome: String, extra: String = "") = """
+ {"v":1,"runId":"r","authority":"com.android.contacts","outcome":"$outcome",
+ "startedAt":0,"durationMs":5,"conflicts":0,"itemErrors":[],
+ "stats":{"downloaded":{"created":3,"updated":2,"deleted":1},
+ "uploaded":{"created":1,"updated":4,"deleted":2},"entries":13,"skipped":1}$extra}
+ """.trimIndent()
+
+ @Test
+ fun `a run without a report is a soft error`() {
+ assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome(null, "task finished"))
+ }
+
+ @Test
+ fun `an unreadable report is a soft error`() {
+ assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome("{not json", "reported"))
+ }
+
+ @Test
+ fun `ok sums both directions into the framework's stats`() {
+ assertEquals(
+ SyncOutcome(inserts = 4, updates = 6, deletes = 3, entries = 13, skipped = 1),
+ SyncReports.outcome(report("ok"), "reported"),
+ )
+ }
+
+ @Test
+ fun `io keeps the progress so the framework retries at once`() {
+ val outcome = SyncReports.outcome(report("io"), "reported")
+ assertEquals(1L, outcome.ioErrors)
+ assertEquals(4L, outcome.inserts)
+ }
+
+ @Test
+ fun `auth is hard and reports no progress, or the framework would retry it at once`() {
+ assertEquals(
+ SyncOutcome(authErrors = 1, entries = 13, skipped = 1),
+ SyncReports.outcome(report("auth"), "reported"),
+ )
+ }
+
+ @Test
+ fun `permission, unsupported and safety aborts are database errors without progress`() {
+ for (outcome in listOf("permission", "unsupported", "safetyAbort")) {
+ assertEquals(
+ outcome,
+ SyncOutcome(databaseError = true, entries = 13, skipped = 1),
+ SyncReports.outcome(report(outcome), "reported"),
+ )
+ }
+ }
+
+ @Test
+ fun `too many deletions reports the pending deletions for the system notification`() {
+ assertEquals(
+ SyncOutcome(tooManyDeletions = true, deletes = 42, entries = 13, skipped = 1),
+ SyncReports.outcome(report("tooManyDeletions", ""","tooManyDeletions":{"count":42,"threshold":20}"""), "reported"),
+ )
+ }
+
+ @Test
+ fun `delayUntil and moreRecordsToGet reach the framework`() {
+ val outcome = SyncReports.outcome(report("ok", ""","delayUntil":1790000000,"moreRecordsToGet":true"""), "reported")
+ assertEquals(1790000000L, outcome.delayUntil)
+ assertEquals(true, outcome.fullSyncRequested)
+ }
+
+ @Test
+ fun `cancelled and disabled runs are not errors`() {
+ for (outcome in listOf("cancelled", "disabled")) {
+ assertEquals(0L, SyncReports.outcome(report(outcome), "reported").ioErrors)
+ }
+ }
+
+ @Test
+ fun `an outcome this build does not know is retried`() {
+ assertEquals(1L, SyncReports.outcome(report("somethingNew"), "reported").ioErrors)
+ }
+
+ @Test
+ fun `the log summary names the outcome, failed items and the message`() {
+ assertEquals("outcome=ok", SyncReports.summary(report("ok")))
+ assertEquals(
+ "outcome=internal message=Invalid column x",
+ SyncReports.summary(report("internal", ",\"message\":\"Invalid column x\"")),
+ )
+ val withItems = report("ok").replace("\"itemErrors\":[]", "\"itemErrors\":[{\"ref\":\"a\"},{\"ref\":\"b\"}]")
+ assertEquals("outcome=ok itemErrors=2", SyncReports.summary(withItems))
+ assertEquals("no report", SyncReports.summary(null))
+ assertEquals("unreadable report", SyncReports.summary("{"))
+ val long = SyncReports.summary(report("io", ",\"message\":\"" + "m".repeat(400) + "\""))
+ assertEquals("outcome=io message=".length + 300, long.length)
+ }
+}
diff --git a/docs/audit-2026-09.md b/docs/audit-2026-09.md
index 0cfcdc48..2c0ccc98 100644
--- a/docs/audit-2026-09.md
+++ b/docs/audit-2026-09.md
@@ -1020,7 +1020,8 @@ ones moved to the N/A lists, and the wrong text corrected.
| #46 email preview shows CSS | Not reproduced: HTML-only mail with `