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;
+}