mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+222
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user