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 `