feat: register a bulwark account with android's contacts and calendar sync #34

Android syncs the contacts and calendars of other services through
accounts and sync adapters. This adds the Android side of device sync:
a "Bulwark" account type (`${applicationId}.account`, set in
build.gradle), an authenticator that sends Settings → Add account to
the app instead of asking for credentials, and one sync adapter each
for the contacts and the calendar provider.

An adapter does no syncing itself. It starts the BulwarkDeviceSync
headless JS task from the main thread, through the helper the push job
uses, waits for the task's report and hands it to SyncManager as the
SyncResult. The engine that will write the report runs in JS, so the
existing client stays the only owner of token refresh, mTLS and
session discovery; for now the task only reports that device sync is
off. Nothing in the app creates an account yet, so the feature stays
unreachable until its settings land. Debug builds get an adb hook for
the device tests (DeviceSyncDebugReceiver, shell only).

Hard errors are reported without progress: SyncManager retries a
failed sync at once when it made progress, which would loop an auth
failure.

Checked on the API 34 and API 32 emulators with the debug build, from
logcat and `dumpsys content`:
- app in the foreground: report after 319-334 ms
- app in the background: 320-327 ms
- process killed: SyncManager starts it for the sync service, React
  Native starts headless, and the report arrives after 16.2 s (API 34)
  and 8.5 s (API 32), most of it the debug bundle download from Metro
- an io report is retried with back-off and an auth report is not
  retried; too many deletions shows the system's "Attempted to delete
  too many Contacts." notification
- with Metro unreachable the adapter gives up after 2 s instead of
  waiting for its deadline
- `gradlew :app:testDebugUnitTest`: 10 JVM tests for the report mapping
This commit is contained in:
Linus Rath
2026-09-27 01:53:40 +02:00
parent 1f7acaf08a
commit ed2525e5cb
20 changed files with 1434 additions and 1 deletions
+10
View File
@@ -154,6 +154,11 @@ android {
versionCode appVersionCode.toInteger() versionCode appVersionCode.toInteger()
versionName appVersionName 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'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
signingConfigs { signingConfigs {
@@ -280,6 +285,11 @@ dependencies {
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read. // Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5") 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 isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+4 -1
View File
@@ -3,5 +3,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" /> <application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
</application>
</manifest> </manifest>
@@ -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"
}
}
+30
View File
@@ -6,6 +6,14 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
@@ -66,6 +74,28 @@
</intent-filter> </intent-filter>
</service> </service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/> <meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
They run in the app's main process on purpose: the adapters start the sync engine as a
headless JS task in the same React Native runtime as the UI. -->
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
</service>
<service android:name=".sync.ContactsSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
</service>
<service android:name=".sync.CalendarSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
</service>
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand --> <!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/> <service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label"> <receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
@@ -1,5 +1,6 @@
package com.anonymous.bulwarkmobile package com.anonymous.bulwarkmobile
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
@@ -12,6 +13,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkFcmModule(reactContext)) add(BulwarkFcmModule(reactContext))
add(BulwarkUnifiedPushModule(reactContext)) add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext)) add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
// The bridge registers React Native's own module under this name. // The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext)) add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -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<out String>?,
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<out String>?): 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
}
@@ -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)
}
}
}
@@ -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"
/** `<applicationId>.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<Pair<Account, String?>> {
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)
}
}
@@ -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 }
}
}
}
@@ -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<String?>(null)
val endReason = AtomicReference<String?>(null)
@Volatile var cancelled = false
@Volatile var boot: TaskInterface<Void>? = 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<String, Run>()
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<Run> = runs.values.toList()
}
@@ -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
}
}
@@ -2,4 +2,6 @@
<string name="app_name">Bulwark Mobile</string> <string name="app_name">Bulwark Mobile</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<!-- Account type label of device sync (#34), shown in Settings → Accounts. -->
<string name="device_sync_account_label" translatable="false">Bulwark</string>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The "Bulwark" account type of device sync (#34); see docs/device-sync.md. -->
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:icon="@mipmap/ic_launcher"
android:smallIcon="@mipmap/ic_launcher"
android:label="@string/device_sync_account_label" />
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
How Contacts apps show and edit contacts of the Bulwark account (#34). The
EditSchema lists exactly the data kinds device sync maps to JSContact
(docs/device-sync.md, "Contacts mapping"); apps offer nothing else.
-->
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android">
<EditSchema>
<DataKind kind="name" maxOccurs="1"
supportsDisplayName="true"
supportsPrefix="true"
supportsMiddleName="true"
supportsSuffix="true"
supportsPhoneticFamilyName="true"
supportsPhoneticMiddleName="true"
supportsPhoneticGivenName="true" />
<DataKind kind="photo" maxOccurs="1" />
<DataKind kind="phone" />
<DataKind kind="email" />
<DataKind kind="nickname" maxOccurs="1" />
<DataKind kind="postal" needsStructured="true" />
<DataKind kind="organization" maxOccurs="1" />
<DataKind kind="website" />
<DataKind kind="event" dateWithTime="false" />
<DataKind kind="relationship" />
<DataKind kind="note" maxOccurs="1" />
<DataKind kind="group_membership" maxOccurs="1" />
</EditSchema>
</ContactsAccountType>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Calendar sync adapter of device sync (#34); see docs/device-sync.md. -->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:contentAuthority="com.android.calendar"
android:supportsUploading="true"
android:userVisible="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="false" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Contacts sync adapter of device sync (#34); see docs/device-sync.md. -->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:contentAuthority="com.android.contacts"
android:supportsUploading="true"
android:userVisible="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="false" />
@@ -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)
}
}
+8
View File
@@ -15,6 +15,14 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]); 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. // Home-screen widgets (Android): every widget event runs this headless task.
registerWidgetTaskHandler(widgetTaskHandler); registerWidgetTaskHandler(widgetTaskHandler);
+41
View File
@@ -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<void> {
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.
}
}
+369
View File
@@ -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<string, Cell>;
export type WriteRow = Record<string, WriteCell>;
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<string | number>;
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<string, number>; yieldAllowed?: boolean }
| {
op: 'update';
table: ProviderTable;
id?: number;
where?: string;
args?: Array<string | number>;
values: WriteRow;
expectCount?: number;
yieldAllowed?: boolean;
}
| {
op: 'delete';
table: ProviderTable;
id?: number;
where?: string;
args?: Array<string | number>;
expectCount?: number;
yieldAllowed?: boolean;
}
| {
op: 'assert';
table: ProviderTable;
id?: number;
where?: string;
args?: Array<string | number>;
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<ProviderRows>;
applyBatch(ops: ProviderOp[]): Promise<BatchResult>;
readSyncState(): Promise<string | null>;
/** Contacts only: the raw contact's display photo (or thumbnail), or null. */
readPhoto(rawContactId: number, maxPx: number): Promise<PhotoData | null>;
}
// ─── 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<Authority, AuthoritySyncSettings>;
}
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<AndroidAccount[]>;
/** Adds the account if missing and (re)writes its registryId. Resolves true when it was created. */
ensureAccount(name: string, registryId: string): Promise<boolean>;
/** Removes the account; the providers drop its rows. */
removeAccount(name: string): Promise<boolean>;
getSyncSettings(name: string): Promise<AccountSyncSettings>;
/** Syncable = 1 plus `setSyncAutomatically(enabled)` for the authority. */
setSyncEnabled(name: string, authority: Authority, enabled: boolean): Promise<void>;
/** Replaces our periodic sync; 0 removes it. */
setPeriodicSync(name: string, authority: Authority, seconds: number): Promise<void>;
requestSync(name: string, authority: Authority, optionsJson: string): Promise<void>;
/** Opens the account's sync screen in Android Settings. */
openAccountSettings(name: string): Promise<void>;
/** Hands the report to the waiting adapter. Resolves false for an unknown or finished run. */
finishRun(runId: string, reportJson: string): Promise<boolean>;
isRunCancelled(runId: string): Promise<boolean>;
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. */
query(name: string, authority: Authority, queryJson: string): Promise<string>;
/** `ProviderOp[]` JSON in, `BatchResult` JSON out (failures resolve, they do not reject). */
applyBatch(name: string, authority: Authority, opsJson: string): Promise<string>;
readSyncState(name: string, authority: Authority): Promise<string | null>;
/** `PhotoData` JSON, or null when the raw contact has no photo. */
readPhoto(name: string, rawContactId: number, maxPx: number): Promise<string | null>;
// NativeEventEmitter bookkeeping.
addListener(eventName: string): void;
removeListeners(count: number): void;
}
// ─── App-side preferences the engine reads ─────────────
/** Collections are keyed `<jmapAccountId>/<collectionId>` 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<CollectionKey, boolean>;
calendarSelection: Record<CollectionKey, boolean>;
/** 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;
}