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..b08f283b --- /dev/null +++ b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt @@ -0,0 +1,58 @@ +package com.anonymous.bulwarkmobile.sync + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * Debug builds only: lets adb set up device sync accounts without the settings + * UI, for the device tests in docs/device-sync.md ("Testing"). Protected by + * android.permission.DUMP, which only the shell and the system hold. Syncs + * themselves are triggered with `adb shell requestsync`. + * + * adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \ + * --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \ + * --es enable contacts,calendar + * + * Commands: `ensure` (name, registry, optional enable), `enable` (name, + * authority, optional --ez on false), `remove` (name) and `list`. + */ +class DeviceSyncDebugReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val result = try { + val name = intent.getStringExtra("name") + when (intent.getStringExtra("cmd")) { + "ensure" -> { + val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!) + for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) { + DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true) + } + "created=$created" + } + "enable" -> { + val on = intent.getBooleanExtra("on", true) + DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on) + "enabled=$on" + } + "remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}" + "list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" } + else -> "unknown cmd" + } + } catch (e: Exception) { + "error: ${e.javaClass.simpleName}: ${e.message}" + } + Log.i(TAG, "debug command: $result") + resultData = result + } + + 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 1c2731e4..3d6513ce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,14 @@ + + + + + + + + @@ -66,6 +74,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/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..e60da9f2 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt @@ -0,0 +1,222 @@ +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.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 + +/** + * `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings and the + * run handshake of device sync (#34). The contract is `DeviceSyncNativeModule` + * in src/device-sync/types.ts; docs/device-sync.md explains it. + */ +class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + private val accountsListener = OnAccountsUpdateListener { + emit(ACCOUNTS_CHANGED_EVENT, null) + } + + 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. + } + 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) + 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)) + } + + // 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: 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" + + @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..2bbbcf86 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt @@ -0,0 +1,111 @@ +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) } + } + + /** + * Adds the account when it is missing and (re)writes its registry id. + * Both authorities start syncable with automatic sync off; the app turns + * on the ones the user picked. Returns true when the account was created. + */ + fun ensure(context: Context, name: String, registryId: String): Boolean { + val manager = AccountManager.get(context) + val account = account(context, name) + val exists = manager.getAccountsByType(account.type).any { it.name == name } + if (exists) { + manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId) + return false + } + val 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 + } + + /** 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) + } + + fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) { + requireAuthority(authority) + ContentResolver.requestSync(account(context, name), authority, flags.toExtras()) + } + + fun requireAuthority(authority: String) { + require(authority in AUTHORITIES) { "Unknown authority $authority" } + } +} + +/** 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..6bf83444 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt @@ -0,0 +1,188 @@ +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()) + + override fun onPerformSync( + account: Account, + extras: Bundle, + authority: String, + provider: ContentProviderClient, + 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.get(), run.endReason.get()) + SyncReports.apply(outcome, syncResult) + Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason.get()}) after " + + "${SystemClock.elapsedRealtime() - startedAt} ms: $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() } + } + } + + @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..cbe67561 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt @@ -0,0 +1,82 @@ +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.AtomicReference + +/** + * The device sync runs an adapter is waiting for, keyed by a random run id. + * + * A run ends on the first of: JS calling finishRun, the headless task ending + * (JS settled, timed out or was reloaded away), React refusing or failing to + * start, the adapter's deadline, or a cancel. Headless task ids restart at 1 + * with every React context, so they never identify a run; the run id does, + * and a report for a run nobody waits for any more is ignored. + */ +object DeviceSyncRuns { + const val NO_TASK = -1 + + class Run(val runId: String, val accountName: String, val authority: String) { + val done = CountDownLatch(1) + val report = AtomicReference(null) + val endReason = AtomicReference(null) + @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 + + /** Releases the waiting adapter. Idempotent: the first reason wins. */ + fun end(reason: String) { + endReason.compareAndSet(null, reason) + done.countDown() + } + + 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 ignored and isCancelled answers true. */ + fun close(run: Run) { + run.cancelled = true + runs.remove(run.runId) + } + + /** From JS. Returns false when nothing waits for this run (stale, duplicate or finished). */ + fun finish(runId: String, reportJson: String): Boolean { + val run = runs[runId] ?: return false + if (!run.report.compareAndSet(null, reportJson)) return false + run.end("reported") + return true + } + + /** Unknown runs count as cancelled: the adapter has stopped waiting for them. */ + fun isCancelled(runId: String): Boolean = runs[runId]?.cancelled ?: true + + fun cancel(runId: String) { + runs[runId]?.cancelled = true + } + + /** Runs currently waited for, newest last; for status and the push router's "sync again" flag. */ + fun active(): List = runs.values.toList() +} 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..280523ef --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt @@ -0,0 +1,90 @@ +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) + } + } + + 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..d6247191 --- /dev/null +++ b/android/app/src/main/res/xml/contacts.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + 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/SyncReportsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt new file mode 100644 index 00000000..273e53b8 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt @@ -0,0 +1,84 @@ +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) + } +} diff --git a/index.ts b/index.ts index e1ae8026..5dfad1b2 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,14 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]); }); +// Device sync (Android, #34): the contacts and calendar sync adapters run the +// sync engine through this task, headless or next to the UI. The engine is +// required on first use so an app start does not load it. +AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import('./src/device-sync/types').RunPayload) => { + const { runDeviceSyncTask } = require('./src/device-sync/task') as typeof import('./src/device-sync/task'); + await runDeviceSyncTask(data); +}); + // Home-screen widgets (Android): every widget event runs this headless task. registerWidgetTaskHandler(widgetTaskHandler); diff --git a/src/device-sync/task.ts b/src/device-sync/task.ts new file mode 100644 index 00000000..95e473d7 --- /dev/null +++ b/src/device-sync/task.ts @@ -0,0 +1,41 @@ +/** + * Entry of the BulwarkDeviceSync headless task, which the contacts and + * calendar sync adapters start for every sync (index.ts registers it). See + * docs/device-sync.md. + * + * The run must always end with `finishRun`, called before the task's promise + * settles: JS timers stop once the task is over, and a sync adapter waiting + * for a report that never comes holds its sync until the deadline. + * + * The sync engine is not wired in yet, so a run only reports that device + * sync is off; nothing in the app can turn it on at this point. + */ +import { NativeModules } from 'react-native'; +import type { DeviceSyncNativeModule, RunPayload, RunReport } from './types'; + +export async function runDeviceSyncTask(payload: RunPayload): Promise { + const native = NativeModules.BulwarkDeviceSync as DeviceSyncNativeModule | undefined; + if (!native || !payload?.runId) return; + const report: RunReport = { + v: 1, + runId: payload.runId, + authority: payload.authority, + outcome: 'disabled', + message: 'Device sync is not available in this build', + startedAt: Date.now(), + durationMs: 0, + stats: { + downloaded: { created: 0, updated: 0, deleted: 0 }, + uploaded: { created: 0, updated: 0, deleted: 0 }, + entries: 0, + skipped: 0, + }, + conflicts: 0, + itemErrors: [], + }; + try { + await native.finishRun(payload.runId, JSON.stringify(report)); + } catch { + // The adapter ends the run on its own when the task finishes. + } +} diff --git a/src/device-sync/types.ts b/src/device-sync/types.ts new file mode 100644 index 00000000..8bf1b899 --- /dev/null +++ b/src/device-sync/types.ts @@ -0,0 +1,369 @@ +/** + * Contracts of Android device sync (#34): the native bridge, the provider row + * and operation model, the headless payload and the run report. The design is + * in docs/device-sync.md; this file is its machine-checked half. + * + * Pure types and constants. Nothing here, nor in the mappers and merge rules + * built on it, may import react-native: vitest runs them in node. + */ + +// ─── Authorities and the headless task ───────────────── + +export const CONTACTS_AUTHORITY = 'com.android.contacts'; +export const CALENDAR_AUTHORITY = 'com.android.calendar'; +export type Authority = typeof CONTACTS_AUTHORITY | typeof CALENDAR_AUTHORITY; +export const AUTHORITIES: readonly Authority[] = [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY]; + +/** The headless JS task the sync adapters start (registered in index.ts). */ +export const DEVICE_SYNC_TASK = 'BulwarkDeviceSync'; + +/** Event the native module emits when accounts of our type change. */ +export const ACCOUNTS_CHANGED_EVENT = 'BulwarkDeviceSync:accountsChanged'; + +// ─── Provider rows ───────────────────────────────────── + +/** Tables the native side lets the engine touch, per authority. */ +export type ContactsTable = 'raw_contacts' | 'data' | 'groups' | 'settings'; +export type CalendarTable = + | 'calendars' + | 'events' + | 'attendees' + | 'reminders' + | 'extended_properties' + | 'colors'; +export type ProviderTable = ContactsTable | CalendarTable; + +export const CONTACTS_TABLES: readonly ContactsTable[] = ['raw_contacts', 'data', 'groups', 'settings']; +export const CALENDAR_TABLES: readonly CalendarTable[] = [ + 'calendars', + 'events', + 'attendees', + 'reminders', + 'extended_properties', + 'colors', +]; + +/** + * A cell as it crosses the bridge: text, a number (booleans are 0/1, times are + * epoch milliseconds, ids fit a double) or null. Blob columns are written as + * `{ b64 }` (the photo bytes) and never read back through `query`. + */ +export type Cell = string | number | null; +export type BlobCell = { b64: string }; +export type WriteCell = Cell | BlobCell; +export type Row = Record; +export type WriteRow = Record; + +export interface ProviderQuery { + table: ProviderTable; + columns: string[]; + /** + * SQL WHERE clause with `?` placeholders. The native side ANDs the account + * scope onto it, so it never needs (and must not rely on) account columns. + */ + where?: string; + args?: Array; + orderBy?: string; +} + +/** Query results, column-major names and row-major cells. */ +export interface ProviderRows { + columns: string[]; + rows: Cell[][]; +} + +/** + * One provider operation. `id` addresses one row of our account by `_id`; + * `where`/`args` address a set (always ANDed with the account scope). + * + * - `insert.refs` maps a column to the index of an earlier `insert` in the + * same batch whose new row id fills it (ContentProviderOperation + * back-references), so one batch can create a raw contact and its data rows, + * or an event and its attendees. + * - `assert` fails the whole batch unless the addressed rows hold `values` + * (compared as text, as ContentProviderOperation does) and, when given, + * their number is `expectCount`. + * - `syncState` replaces this account's SyncState blob. + * - `yieldAllowed` marks the first op of an item group: the provider may + * commit everything before it and let other writers in. Batches must mark + * one at least every 400 ops (ContactsProvider refuses 500 without one). + */ +export type ProviderOp = + | { op: 'insert'; table: ProviderTable; values: WriteRow; refs?: Record; yieldAllowed?: boolean } + | { + op: 'update'; + table: ProviderTable; + id?: number; + where?: string; + args?: Array; + values: WriteRow; + expectCount?: number; + yieldAllowed?: boolean; + } + | { + op: 'delete'; + table: ProviderTable; + id?: number; + where?: string; + args?: Array; + expectCount?: number; + yieldAllowed?: boolean; + } + | { + op: 'assert'; + table: ProviderTable; + id?: number; + where?: string; + args?: Array; + values?: Row; + expectCount?: number; + yieldAllowed?: boolean; + } + | { op: 'syncState'; value: string; yieldAllowed?: boolean }; + +export interface OpResult { + /** New row id for an insert. */ + id?: number; + /** Rows touched by an update or delete. */ + count?: number; +} + +/** + * Why a batch failed. Nothing of a failed batch is applied except item groups + * the provider committed at yield points it actually took, which it does only + * under contention; callers must therefore re-read before retrying. + * - `assert`: an assert op did not hold (a concurrent edit), or an + * `expectCount` did not match. + * - `tooLarge`: the batch exceeded the Binder transaction limit; split it. + * - `scope`: the native side refused an op outside our account (an engine bug). + * - `permission`: the runtime permission for the authority is gone. + * - `provider`: anything else the provider threw. + */ +export type BatchFailure = 'assert' | 'tooLarge' | 'scope' | 'permission' | 'provider'; + +export type BatchResult = + | { ok: true; results: OpResult[] } + | { ok: false; reason: BatchFailure; message: string }; + +export interface PhotoData { + /** JPEG, downscaled so its longer side is at most the requested size. */ + jpegBase64: string; + /** `Photo.PHOTO_FILE_ID` of the display photo it was read from, if any. */ + fileId: number | null; +} + +/** + * The provider, bound to one Android account and one authority. Implemented + * by src/device-sync/native.ts on the device and by the in-memory fake in + * tests. All calls are scoped: rows of other accounts are invisible. + */ +export interface ProviderPort { + readonly accountName: string; + readonly authority: Authority; + query(q: ProviderQuery): Promise; + applyBatch(ops: ProviderOp[]): Promise; + readSyncState(): Promise; + /** Contacts only: the raw contact's display photo (or thumbnail), or null. */ + readPhoto(rawContactId: number, maxPx: number): Promise; +} + +// ─── Accounts and sync settings ──────────────────────── + +export interface AndroidAccount { + /** Account name as Android shows it (the login, or the registry id on a clash). */ + name: string; + /** `AccountEntry.id` of the app account it belongs to, from the account's userData. */ + registryId: string | null; +} + +export interface AuthoritySyncSettings { + /** `ContentResolver.getIsSyncable`: 1 yes, 0 no, -1 unknown. */ + syncable: number; + /** `getSyncAutomatically`: the toggle in Settings → Accounts. */ + automatic: boolean; + /** Period of our periodic sync in seconds, or 0 when there is none. */ + periodicSeconds: number; + active: boolean; + pending: boolean; +} + +export interface AccountSyncSettings { + /** `getMasterSyncAutomatically`: "Auto-sync data" for the whole device. */ + masterAutomatic: boolean; + authorities: Record; +} + +export interface RequestSyncOptions { + manual?: boolean; + expedited?: boolean; + upload?: boolean; + overrideTooManyDeletions?: boolean; + discardLocalDeletions?: boolean; +} + +// ─── The headless run ────────────────────────────────── + +/** SYNC_EXTRAS_* flags of the request that started a run. */ +export interface RunExtras { + manual?: boolean; + upload?: boolean; + expedited?: boolean; + ignoreBackoff?: boolean; + overrideTooManyDeletions?: boolean; + discardLocalDeletions?: boolean; +} + +/** Data of the BulwarkDeviceSync headless task, as Kotlin sends it. */ +export interface RunPayload { + runId: string; + accountName: string; + registryId: string; + authority: Authority; + extras: RunExtras; + /** Epoch ms after which Kotlin stops waiting; the run must have finished. */ + deadline: number; +} + +export type RunOutcome = + /** Everything was synced; per-item errors may still be listed. */ + | 'ok' + /** Network or server trouble: the framework retries with back-off. */ + | 'io' + /** Credentials rejected even after rebuilding the client: sign in again. */ + | 'auth' + /** The runtime permission for the authority is missing. */ + | 'permission' + /** The server does not offer the capability for this account. */ + | 'unsupported' + /** Local deletions above the threshold; waiting for the user's decision. */ + | 'tooManyDeletions' + /** A full reconcile found an empty server but a full device, and stopped. */ + | 'safetyAbort' + /** Cancelled by the framework, or out of time with work left. */ + | 'cancelled' + /** Sync is off for this account/authority in the app or in Android. */ + | 'disabled' + /** An exception the engine did not expect. */ + | 'internal'; + +export interface SideCounts { + created: number; + updated: number; + deleted: number; +} + +export interface RunStats { + /** Server → device: objects applied to rows. */ + downloaded: SideCounts; + /** Device → server: objects created, patched, destroyed. */ + uploaded: SideCounts; + /** Objects looked at. */ + entries: number; + /** Objects left for later (poisoned, backed off, read-only reverts). */ + skipped: number; +} + +export interface ItemError { + /** `SOURCE_ID`/`_SYNC_ID` when known, else `row:<_id>`. */ + ref: string; + side: 'upload' | 'download'; + /** SetError type (`invalidProperties`, …) or an engine error code. */ + type: string; + description?: string; + /** Epoch ms before which the item is not retried unless it changes. */ + retryAt?: number; +} + +export interface RunReport { + v: 1; + runId: string; + authority: Authority; + outcome: RunOutcome; + message?: string; + startedAt: number; + durationMs: number; + stats: RunStats; + /** Units where both sides changed differently; the server won each. */ + conflicts: number; + itemErrors: ItemError[]; + /** Set with outcome `tooManyDeletions`. */ + tooManyDeletions?: { count: number; threshold: number }; + /** Epoch seconds: do not sync before (a 429's Retry-After). */ + delayUntil?: number; + /** The run stopped at its time budget with work left; run again soon. */ + moreRecordsToGet?: boolean; +} + +// ─── Native module (the raw bridge) ──────────────────── + +/** + * `NativeModules.BulwarkDeviceSync`. Large payloads cross as JSON strings, + * which the bridge moves far faster than nested maps. Every provider call is + * scoped to `accountName` of our account type by the native side. + */ +export interface DeviceSyncNativeModule { + getInfo(): Promise<{ accountType: string; sdkInt: number }>; + + listAccounts(): Promise; + /** Adds the account if missing and (re)writes its registryId. Resolves true when it was created. */ + ensureAccount(name: string, registryId: string): Promise; + /** Removes the account; the providers drop its rows. */ + removeAccount(name: string): Promise; + + getSyncSettings(name: string): Promise; + /** Syncable = 1 plus `setSyncAutomatically(enabled)` for the authority. */ + setSyncEnabled(name: string, authority: Authority, enabled: boolean): Promise; + /** Replaces our periodic sync; 0 removes it. */ + setPeriodicSync(name: string, authority: Authority, seconds: number): Promise; + requestSync(name: string, authority: Authority, optionsJson: string): Promise; + /** Opens the account's sync screen in Android Settings. */ + openAccountSettings(name: string): Promise; + + /** Hands the report to the waiting adapter. Resolves false for an unknown or finished run. */ + finishRun(runId: string, reportJson: string): Promise; + isRunCancelled(runId: string): Promise; + + /** `ProviderQuery` JSON in, `ProviderRows` JSON out. */ + query(name: string, authority: Authority, queryJson: string): Promise; + /** `ProviderOp[]` JSON in, `BatchResult` JSON out (failures resolve, they do not reject). */ + applyBatch(name: string, authority: Authority, opsJson: string): Promise; + readSyncState(name: string, authority: Authority): Promise; + /** `PhotoData` JSON, or null when the raw contact has no photo. */ + readPhoto(name: string, rawContactId: number, maxPx: number): Promise; + + // NativeEventEmitter bookkeeping. + addListener(eventName: string): void; + removeListeners(count: number): void; +} + +// ─── App-side preferences the engine reads ───────────── + +/** Collections are keyed `/` everywhere. */ +export type CollectionKey = string; + +export type ReminderOwner = 'device' | 'bulwark'; + +/** + * What the engine needs from the app's device-sync store for one registry + * account. Selections list explicit choices only; a collection the user never + * touched follows the default rule: collections of the personal account on, + * shared ones off. + */ +export interface AccountSyncPrefs { + contactsSelection: Record; + calendarSelection: Record; + /** Where contacts created on the device go; unset means the server's default book. */ + newContactsAddressBook?: CollectionKey; + reminderOwner: ReminderOwner; +} + +/** Last run per authority, as the settings UI shows it. */ +export interface RunStatus { + at: number; + outcome: RunOutcome; + message?: string; + durationMs: number; + conflicts: number; + itemErrors: number; + stats: RunStats; +}