mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: give device sync scoped access to the contacts and calendar providers #34
The engine plans its provider reads and writes in JS, but only native code can reach ContactsProvider and CalendarProvider. This adds that bridge to the BulwarkDeviceSync module, with the rest of what the engine and the app need from it: - `query`, `applyBatch`, `readSyncState` and `readPhoto` take the `ProviderQuery`/`ProviderOp[]` JSON of `src/device-sync/types.ts`, check it (`sync/ProviderOps.kt`) and run it for one Android account of our type (`sync/ProviderScope.kt`, `sync/ProviderIo.kt`). Every selection gets the account ANDed in, every row an op names by id or points to as a parent must be the account's, and every URI carries `caller_is_syncadapter`, so nothing the sync writes marks rows dirty or schedules an upload. The calls run one at a time on the module's own thread through an unstable provider client, so a long batch never stalls other module calls and a provider that dies fails the call instead of the app. - `showSyncProblem` and `clearSyncProblem` post and clear the one notification per account that needs the user, e.g. "Sign in again". - `setPushRoutes` stores which Android accounts each JMAP account feeds, for the push router in `sync/PushRouting.kt`; the push services start using it in a later commit. - A run now ends exactly once and refuses a report that comes after that. A sync requested while a run is open makes the run ask for another sync when it ends, because SyncManager drops such requests as duplicates. `onPerformSync` lets nothing escape any more: an exception there killed the whole process, UI included. - `ensureAccount` no longer hands an existing Android account to another app account (`conflict`): its rows and SyncState belong to the first. Payloads cross as JSON strings, which the bridge moves much faster than nested maps. `src/device-sync/native.ts` is the typed JS side. The fake provider follows the rules the emulator runs turned up: contacts `data1`..`data14` read back as text, events and calendars the sync adapter inserts have DIRTY NULL, attendee rows are addressed by id, an op by id expects one row, and a vanished parent fails with `assert` while another account's fails with `scope`. Checked: 54 JVM tests (`gradlew :app:testDebugUnitTest`), debug and release (R8) builds, `npm run typecheck` and `npm test` on this commit, and on the API 32 emulator through the debug receiver: inserts with back-references and group memberships, SyncState, photo writes and scaled reads, asserts and expected counts, both scope refusals, the 499-op yield boundary, `tooLarge` for a 1.3 MB batch, and the notification.
This commit is contained in:
+77
-27
@@ -3,47 +3,97 @@ package com.anonymous.bulwarkmobile.sync
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Debug builds only: lets adb set up device sync accounts 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`.
|
||||
* Debug builds only: lets adb set up device sync accounts and drive the
|
||||
* provider bridge without the settings UI or JS, for the device tests in
|
||||
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
|
||||
* which only the shell and the system hold. Syncs themselves are triggered
|
||||
* with `adb shell requestsync`.
|
||||
*
|
||||
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
|
||||
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
|
||||
* --es enable contacts,calendar
|
||||
*
|
||||
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
|
||||
* authority, optional --ez on false), `remove` (name) and `list`.
|
||||
* authority, optional --ez on false), `remove` (name), `list`, and the
|
||||
* provider calls `query` and `batch` (name, authority, and the
|
||||
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
|
||||
* the shell's quoting, or pushed to the app's external files directory and
|
||||
* named by `file` when it is too long for a command line), `syncstate`
|
||||
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
|
||||
* the sync-problem notification, `problem` (name, title, text, uri, optional
|
||||
* channel) and `clearproblem` (name). Results go to logcat (tag
|
||||
* BulwarkDeviceSync) and the broadcast's result data.
|
||||
*/
|
||||
class DeviceSyncDebugReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val 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"
|
||||
val pending = goAsync()
|
||||
// Provider calls block; keep them off the main thread.
|
||||
thread(name = "DeviceSyncDebugReceiver") {
|
||||
val result = try {
|
||||
run(context, intent)
|
||||
} catch (e: Exception) {
|
||||
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.i(TAG, "debug command: $result")
|
||||
pending.resultData = result
|
||||
pending.finish()
|
||||
}
|
||||
Log.i(TAG, "debug command: $result")
|
||||
resultData = result
|
||||
}
|
||||
|
||||
private fun run(context: Context, intent: Intent): String {
|
||||
val name = intent.getStringExtra("name")
|
||||
return when (intent.getStringExtra("cmd")) {
|
||||
"ensure" -> {
|
||||
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
|
||||
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
|
||||
}
|
||||
"created=$created"
|
||||
}
|
||||
"enable" -> {
|
||||
val on = intent.getBooleanExtra("on", true)
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
|
||||
"enabled=$on"
|
||||
}
|
||||
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
|
||||
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
|
||||
"query" -> io(context, intent) { it.query(json(context, intent)) }
|
||||
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
|
||||
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
|
||||
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
|
||||
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
|
||||
}
|
||||
"problem" -> {
|
||||
SyncProblemNotifier.show(
|
||||
context,
|
||||
name!!,
|
||||
intent.getStringExtra("title").orEmpty(),
|
||||
intent.getStringExtra("text").orEmpty(),
|
||||
intent.getStringExtra("uri").orEmpty(),
|
||||
intent.getStringExtra("channel"),
|
||||
)
|
||||
"shown"
|
||||
}
|
||||
"clearproblem" -> {
|
||||
SyncProblemNotifier.clear(context, name!!)
|
||||
"cleared"
|
||||
}
|
||||
else -> "unknown cmd"
|
||||
}
|
||||
}
|
||||
|
||||
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
|
||||
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
|
||||
|
||||
private fun json(context: Context, intent: Intent): String {
|
||||
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
|
||||
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun authority(which: String) = when (which) {
|
||||
|
||||
+104
-3
@@ -19,11 +19,15 @@ import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings and the
|
||||
* run handshake of device sync (#34). The contract is `DeviceSyncNativeModule`
|
||||
* in src/device-sync/types.ts; docs/device-sync.md explains it.
|
||||
* `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run
|
||||
* handshake, provider I/O, push routes and the sync-problem notification of
|
||||
* device sync (#34). The contract is `DeviceSyncNativeModule` in
|
||||
* src/device-sync/types.ts; docs/device-sync.md explains it.
|
||||
*/
|
||||
class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
@@ -32,6 +36,15 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
emit(ACCOUNTS_CHANGED_EVENT, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider I/O runs here, one call at a time: a batch can hold the
|
||||
* provider for seconds (it sleeps at a contended yield point), which would
|
||||
* stall every other native module call on React's native-modules thread.
|
||||
*/
|
||||
private val io: ExecutorService = Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this }
|
||||
}
|
||||
@@ -59,6 +72,7 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
} catch (e: RuntimeException) {
|
||||
// Not registered.
|
||||
}
|
||||
io.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
@@ -181,6 +195,91 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
promise.resolve(DeviceSyncRuns.isCancelled(runId))
|
||||
}
|
||||
|
||||
// ── provider I/O ────────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */
|
||||
@ReactMethod
|
||||
fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) }
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */
|
||||
@ReactMethod
|
||||
fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) {
|
||||
val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) }
|
||||
try {
|
||||
io.execute {
|
||||
val result = try {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) }
|
||||
} catch (e: Throwable) {
|
||||
failed(e)
|
||||
}
|
||||
promise.resolve(result)
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.resolve(failed(e))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() }
|
||||
}
|
||||
|
||||
/** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */
|
||||
@ReactMethod
|
||||
fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY)
|
||||
.use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) }
|
||||
}
|
||||
|
||||
/** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */
|
||||
private fun onIo(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
io.execute {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: Throwable) {
|
||||
promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.reject("provider", "Device sync is shutting down", e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── push routes and notifications ───────────────────────────
|
||||
|
||||
/** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */
|
||||
@ReactMethod
|
||||
fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts (or replaces) the account's sync-problem notification. [channelName]
|
||||
* names the notification channel in the app's language; null keeps English.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun showSyncProblem(
|
||||
accountName: String,
|
||||
title: String,
|
||||
text: String,
|
||||
uri: String,
|
||||
channelName: String?,
|
||||
promise: Promise,
|
||||
) = settle(promise) {
|
||||
SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) {
|
||||
SyncProblemNotifier.clear(reactApplicationContext, accountName)
|
||||
null
|
||||
}
|
||||
|
||||
// NativeEventEmitter bookkeeping.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
@@ -191,6 +290,8 @@ class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
private inline fun settle(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: AccountConflictException) {
|
||||
promise.reject("conflict", e.message, e)
|
||||
} catch (e: SecurityException) {
|
||||
promise.reject("permission", e.message, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
|
||||
@@ -36,18 +36,32 @@ object DeviceSyncAccounts {
|
||||
return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) }
|
||||
}
|
||||
|
||||
fun exists(context: Context, name: String): Boolean =
|
||||
AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name }
|
||||
|
||||
/**
|
||||
* Adds the account when it is missing 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.
|
||||
* Adds the account when it is missing. Both authorities start syncable
|
||||
* with automatic sync off; the app turns on the ones the user picked.
|
||||
* Returns true when the account was created, false when it exists for
|
||||
* [registryId] already. An account that belongs to another registry id
|
||||
* keeps it ([AccountConflictException]): its rows and SyncState were
|
||||
* written for that app account.
|
||||
*/
|
||||
fun ensure(context: Context, name: String, registryId: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
val exists = manager.getAccountsByType(account.type).any { it.name == name }
|
||||
if (exists) {
|
||||
manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId)
|
||||
return false
|
||||
val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null
|
||||
when (ensureAction(exists, current, registryId)) {
|
||||
EnsureAction.KEEP -> return false
|
||||
EnsureAction.ADOPT -> {
|
||||
manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId)
|
||||
return false
|
||||
}
|
||||
EnsureAction.CONFLICT -> throw AccountConflictException(
|
||||
"The Android account $name belongs to another app account ($current)",
|
||||
)
|
||||
EnsureAction.CREATE -> Unit
|
||||
}
|
||||
val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) }
|
||||
check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" }
|
||||
@@ -58,6 +72,16 @@ object DeviceSyncAccounts {
|
||||
return true
|
||||
}
|
||||
|
||||
enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT }
|
||||
|
||||
/** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */
|
||||
fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when {
|
||||
!exists -> EnsureAction.CREATE
|
||||
currentRegistryId == registryId -> EnsureAction.KEEP
|
||||
currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT
|
||||
else -> EnsureAction.CONFLICT
|
||||
}
|
||||
|
||||
/** Removes the account; the Contacts and Calendar providers drop all of its rows. */
|
||||
fun remove(context: Context, name: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
@@ -83,8 +107,14 @@ object DeviceSyncAccounts {
|
||||
if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a sync. SyncManager drops a request that matches a sync it is
|
||||
* running, so a run of that account and authority is also flagged to
|
||||
* ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]).
|
||||
*/
|
||||
fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) {
|
||||
requireAuthority(authority)
|
||||
DeviceSyncRuns.flagSyncAgain(name, authority)
|
||||
ContentResolver.requestSync(account(context, name), authority, flags.toExtras())
|
||||
}
|
||||
|
||||
@@ -93,6 +123,9 @@ object DeviceSyncAccounts {
|
||||
}
|
||||
}
|
||||
|
||||
/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */
|
||||
class AccountConflictException(message: String) : Exception(message)
|
||||
|
||||
/** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */
|
||||
data class SyncRequestFlags(
|
||||
val manual: Boolean = false,
|
||||
|
||||
@@ -40,6 +40,13 @@ class DeviceSyncAdapter(context: Context) :
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Anything thrown out of here is rethrown on the sync thread
|
||||
* (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI
|
||||
* included. So every failure becomes a result: a soft error SyncManager
|
||||
* retries with back-off, or a database error for a missing permission,
|
||||
* as the framework would report it.
|
||||
*/
|
||||
override fun onPerformSync(
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
@@ -47,6 +54,18 @@ class DeviceSyncAdapter(context: Context) :
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
) {
|
||||
try {
|
||||
performSync(account, extras, authority, syncResult)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Sync of $authority refused by a permission check", e)
|
||||
syncResult.databaseError = true
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Sync of $authority failed", t)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID)
|
||||
if (registryId.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Sync of $authority skipped: the account has no app account")
|
||||
@@ -69,9 +88,9 @@ class DeviceSyncAdapter(context: Context) :
|
||||
run.end("no report before the deadline")
|
||||
}
|
||||
}
|
||||
val outcome = SyncReports.outcome(run.report.get(), run.endReason.get())
|
||||
val outcome = SyncReports.outcome(run.report, run.endReason)
|
||||
SyncReports.apply(outcome, syncResult)
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason.get()}) after " +
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
|
||||
"${SystemClock.elapsedRealtime() - startedAt} ms: $outcome")
|
||||
} catch (e: InterruptedException) {
|
||||
// onSyncCanceled interrupts this thread. JS sees the run as
|
||||
@@ -84,6 +103,12 @@ class DeviceSyncAdapter(context: Context) :
|
||||
// After startRun, which was posted first, so a listener it added is removed.
|
||||
main.post { run.detach() }
|
||||
}
|
||||
// A change pushed while the run was open: SyncManager dropped its
|
||||
// request as a duplicate of this sync, so ask for the next one here.
|
||||
if (run.syncAgainRequested) {
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again")
|
||||
syncResult.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
|
||||
@@ -6,24 +6,34 @@ import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The device sync runs an adapter is waiting for, keyed by a random run id.
|
||||
*
|
||||
* A run ends 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.
|
||||
* A run ends exactly once, on the first of: JS calling finishRun, the
|
||||
* headless task ending (JS settled, timed out or was reloaded away), React
|
||||
* refusing or failing to start, the adapter's deadline, or a cancel. A report
|
||||
* that arrives after that is refused whole rather than half-applied. Headless
|
||||
* task ids restart at 1 with every React context, so they never identify a
|
||||
* run; the run id does.
|
||||
*/
|
||||
object DeviceSyncRuns {
|
||||
const val NO_TASK = -1
|
||||
|
||||
enum class State { WAITING, ENDED }
|
||||
|
||||
class Run(val runId: String, val accountName: String, val authority: String) {
|
||||
private val state = AtomicReference(State.WAITING)
|
||||
private val syncAgain = AtomicBoolean(false)
|
||||
val done = CountDownLatch(1)
|
||||
val report = AtomicReference<String?>(null)
|
||||
val endReason = AtomicReference<String?>(null)
|
||||
|
||||
/** Written before [done] opens, read after it: the latch orders them. */
|
||||
@Volatile var report: String? = null
|
||||
private set
|
||||
@Volatile var endReason: String? = null
|
||||
private set
|
||||
@Volatile var cancelled = false
|
||||
@Volatile var boot: TaskInterface<Void>? = null
|
||||
/** Written and compared on the UI thread only. */
|
||||
@@ -31,10 +41,24 @@ object DeviceSyncRuns {
|
||||
@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)
|
||||
val isWaiting: Boolean get() = state.get() == State.WAITING
|
||||
|
||||
/** True once a sync was requested for this account and authority while the run was open. */
|
||||
val syncAgainRequested: Boolean get() = syncAgain.get()
|
||||
|
||||
/** Releases the waiting adapter. Only the first end counts; returns whether this was it. */
|
||||
fun end(reason: String): Boolean = end(reason, null)
|
||||
|
||||
internal fun end(reason: String, reportJson: String?): Boolean {
|
||||
if (!state.compareAndSet(State.WAITING, State.ENDED)) return false
|
||||
report = reportJson
|
||||
endReason = reason
|
||||
done.countDown()
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun requestSyncAgain() {
|
||||
syncAgain.set(true)
|
||||
}
|
||||
|
||||
fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) {
|
||||
@@ -56,27 +80,46 @@ object DeviceSyncRuns {
|
||||
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. */
|
||||
/**
|
||||
* Forgets the run: a late finishRun is then refused and isCancelled
|
||||
* answers true. Read [Run.syncAgainRequested] after this, so a request
|
||||
* can no longer slip in between reading the flag and closing.
|
||||
*/
|
||||
fun close(run: Run) {
|
||||
run.end("closed")
|
||||
run.cancelled = true
|
||||
runs.remove(run.runId)
|
||||
}
|
||||
|
||||
/** From JS. 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
|
||||
}
|
||||
/** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */
|
||||
fun finish(runId: String, reportJson: String): Boolean =
|
||||
runs[runId]?.end("reported", reportJson) ?: false
|
||||
|
||||
/** Unknown runs count as cancelled: the adapter has stopped waiting for them. */
|
||||
fun isCancelled(runId: String): Boolean = runs[runId]?.cancelled ?: true
|
||||
/** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */
|
||||
fun isCancelled(runId: String): Boolean {
|
||||
val run = runs[runId] ?: return true
|
||||
return run.cancelled || !run.isWaiting
|
||||
}
|
||||
|
||||
fun cancel(runId: String) {
|
||||
runs[runId]?.cancelled = true
|
||||
}
|
||||
|
||||
/** Runs currently waited for, newest last; for status and the push router's "sync again" flag. */
|
||||
fun active(): List<Run> = runs.values.toList()
|
||||
/**
|
||||
* Flags the open runs of [accountName] and [authority] to request another
|
||||
* sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a
|
||||
* `requestSync` that matches a sync it is running, so a change pushed
|
||||
* mid-run would otherwise wait for the next trigger. Returns whether a
|
||||
* run was flagged.
|
||||
*/
|
||||
fun flagSyncAgain(accountName: String, authority: String): Boolean {
|
||||
var flagged = false
|
||||
for (run in runs.values) {
|
||||
if (run.accountName == accountName && run.authority == authority) {
|
||||
run.requestSyncAgain()
|
||||
flagged = true
|
||||
}
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.TransactionTooLargeException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.SyncStateContract
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Runs device sync's provider calls for one Android account of our type and
|
||||
* one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of
|
||||
* `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this
|
||||
* class only builds the calls and reads the answers.
|
||||
*
|
||||
* Every URI carries `caller_is_syncadapter=true` and the account
|
||||
* (CalendarProvider accepts no other query parameter), so writes neither set
|
||||
* DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by
|
||||
* the account. The scope lookups are the exception: they read ids and owners
|
||||
* of rows of any account.
|
||||
*
|
||||
* The client is unstable: a provider process that dies fails the call instead
|
||||
* of taking this process (and the UI) with it. Call from a background thread;
|
||||
* close when done.
|
||||
*/
|
||||
class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val account: AccountRef
|
||||
private val client: ContentProviderClient
|
||||
|
||||
init {
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority")
|
||||
}
|
||||
account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext))
|
||||
client = appContext.contentResolver.acquireUnstableContentProviderClient(authority)
|
||||
?: throw IllegalStateException("No content provider for $authority")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ── the bridge calls ────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */
|
||||
fun query(queryJson: String): String {
|
||||
val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson))
|
||||
val writer = ProviderRowsWriter(plan.columns)
|
||||
val projection = plan.columns.toTypedArray()
|
||||
for (selection in plan.selections) {
|
||||
query(uri(plan.table), projection, selection, plan.orderBy).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
writer.beginRow()
|
||||
for (i in projection.indices) {
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i))
|
||||
Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i))
|
||||
Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i))
|
||||
// Blobs (photo thumbnails) never cross the bridge; readPhoto reads them.
|
||||
else -> writer.nullCell()
|
||||
}
|
||||
}
|
||||
writer.endRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.finish()
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */
|
||||
fun applyBatch(opsJson: String): String = try {
|
||||
val ops = ProviderOpParser.parseBatch(authority, opsJson)
|
||||
// Rows written for an account that is gone would outlive it: the
|
||||
// providers purge an account's rows only when accounts change.
|
||||
if (!DeviceSyncAccounts.exists(appContext, account.name)) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists")
|
||||
}
|
||||
if (ops.isEmpty()) {
|
||||
ProviderResults.ok(emptyList())
|
||||
} else {
|
||||
val planned = ScopePlanner(account, this).planBatch(ops)
|
||||
val results = client.applyBatch(ArrayList(planned.map(::build)))
|
||||
ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) })
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** The account's SyncState blob as text, or null when there is none. */
|
||||
fun readSyncState(): String? =
|
||||
SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type))
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* The raw contact's display photo, or its thumbnail when it has none,
|
||||
* scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null
|
||||
* when the raw contact has no photo.
|
||||
*/
|
||||
fun readPhoto(rawContactId: Long, maxPx: Int): String? {
|
||||
require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" }
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account")
|
||||
}
|
||||
var fileId: Long? = null
|
||||
var thumbnail: ByteArray? = null
|
||||
val photoRow = Selection(
|
||||
"raw_contact_id = ? AND mimetype = ?",
|
||||
listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE),
|
||||
)
|
||||
query(
|
||||
uri(ProviderTable.DATA),
|
||||
arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO),
|
||||
photoRow,
|
||||
"${ContactsContract.Data.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
if (!cursor.isNull(0)) fileId = cursor.getLong(0)
|
||||
if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1)
|
||||
}
|
||||
}
|
||||
val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null
|
||||
val jpeg = scaledJpeg(bytes, maxPx) ?: return null
|
||||
return JSONObject()
|
||||
.put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP))
|
||||
.put("fileId", fileId ?: JSONObject.NULL)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun readDisplayPhoto(rawContactId: Long): ByteArray? {
|
||||
val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon()
|
||||
.appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY)
|
||||
.scoped()
|
||||
.build()
|
||||
return try {
|
||||
client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } }
|
||||
} catch (e: FileNotFoundException) {
|
||||
// No display photo stored (small photos only have the thumbnail).
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||
val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx)
|
||||
val scaled = if (width == decoded.width && height == decoded.height) {
|
||||
decoded
|
||||
} else {
|
||||
Bitmap.createScaledBitmap(decoded, width, height, true)
|
||||
}
|
||||
return ByteArrayOutputStream().use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScopeLookup ─────────────────────────────────────────────
|
||||
|
||||
override fun ownCalendarIds(): Set<Long> {
|
||||
val ids = HashSet<Long>()
|
||||
val selection = Selection(
|
||||
"${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?",
|
||||
listOf(account.name, account.type),
|
||||
)
|
||||
query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c ->
|
||||
while (c.moveToNext()) ids += c.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> {
|
||||
val owners = HashMap<Long, AccountRef>()
|
||||
forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c ->
|
||||
owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty())
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.DATA, "raw_contact_id", ids)
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.EVENTS, "calendar_id", ids)
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(table, "event_id", ids)
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> {
|
||||
val events = HashSet<Long>()
|
||||
query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c ->
|
||||
while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Row id → [column] for the rows of [ids] that exist, in any account. */
|
||||
private fun idPairs(table: ProviderTable, column: String, ids: Set<Long>): Map<Long, Long> {
|
||||
val pairs = HashMap<Long, Long>()
|
||||
forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c ->
|
||||
if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
private fun forEachRow(
|
||||
table: ProviderTable,
|
||||
projection: Array<String>,
|
||||
idColumn: String,
|
||||
ids: Set<Long>,
|
||||
read: (Cursor) -> Unit,
|
||||
) {
|
||||
for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) {
|
||||
val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList())
|
||||
query(uri(table, scoped = false), projection, selection, null).use { c ->
|
||||
while (c.moveToNext()) read(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun query(uri: Uri, projection: Array<String>, selection: Selection, orderBy: String?): Cursor =
|
||||
client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy)
|
||||
?: throw IllegalStateException("$authority returned no cursor for $uri")
|
||||
|
||||
// ── operations ──────────────────────────────────────────────
|
||||
|
||||
private fun build(op: PlannedOp): ContentProviderOperation = when (op) {
|
||||
is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) }
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Update -> ContentProviderOperation
|
||||
.newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.apply {
|
||||
op.values?.let { values ->
|
||||
withValues(ContentValues().apply { for ((column, text) in values) put(column, text) })
|
||||
}
|
||||
}
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
// An insert that replaces the account's row; never a yield point.
|
||||
is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation(
|
||||
syncStateUri(),
|
||||
Account(account.name, account.type),
|
||||
op.value.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply {
|
||||
// An item URI takes no selection at all (CalendarProvider refuses one).
|
||||
if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply {
|
||||
if (count != null) withExpectedCount(count)
|
||||
}
|
||||
|
||||
private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) {
|
||||
// Settings inserts answer with the account's URI, which has no id.
|
||||
is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull())
|
||||
is PlannedOp.SetSyncState -> OpOutcome()
|
||||
else -> OpOutcome(count = count)
|
||||
}
|
||||
|
||||
private fun contentValues(values: Map<String, Cell>) = ContentValues(values.size).apply {
|
||||
for ((column, cell) in values) {
|
||||
when (cell) {
|
||||
is Cell.Text -> put(column, cell.value)
|
||||
is Cell.Integer -> put(column, cell.value)
|
||||
is Cell.Real -> put(column, cell.value)
|
||||
is Cell.Blob -> put(column, decodeBlob(column, cell.base64))
|
||||
Cell.Null -> putNull(column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBlob(column: String, base64: String): ByteArray = try {
|
||||
Base64.decode(base64, Base64.DEFAULT)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64")
|
||||
}
|
||||
|
||||
// ── URIs ────────────────────────────────────────────────────
|
||||
|
||||
private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri {
|
||||
val base = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI
|
||||
ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI
|
||||
ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI
|
||||
ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI
|
||||
ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI
|
||||
ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI
|
||||
ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI
|
||||
ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI
|
||||
ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI
|
||||
ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI
|
||||
}
|
||||
val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon()
|
||||
return if (scoped) builder.scoped().build() else builder.syncAdapter().build()
|
||||
}
|
||||
|
||||
private fun syncStateUri(): Uri = when (authority) {
|
||||
DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI
|
||||
else -> CalendarContract.SyncState.CONTENT_URI
|
||||
}.buildUpon().scoped().build()
|
||||
|
||||
private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true")
|
||||
|
||||
private fun Uri.Builder.scoped() = syncAdapter()
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type)
|
||||
|
||||
companion object {
|
||||
private const val ID = "_id"
|
||||
private const val JPEG_QUALITY = 85
|
||||
|
||||
/** Same parameter name in ContactsContract and CalendarContract. */
|
||||
private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER
|
||||
|
||||
/** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */
|
||||
fun failureOf(e: Throwable): BatchFailure {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
when (t) {
|
||||
is ProviderRefusal -> return t.reason
|
||||
is TransactionTooLargeException -> return BatchFailure.TOO_LARGE
|
||||
is SecurityException -> return BatchFailure.PERMISSION
|
||||
is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message)
|
||||
}
|
||||
t = t.cause?.takeIf { it !== t }
|
||||
}
|
||||
return BatchFailure.PROVIDER
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The provider operations of device sync (#34) as the engine sends them: the
|
||||
* `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed
|
||||
* into a validated model. [ScopePlanner] turns the model into provider calls
|
||||
* scoped to one account and [ProviderIo] runs them. Nothing here touches
|
||||
* Android types, so JVM tests cover all of it.
|
||||
*/
|
||||
|
||||
/** `BatchFailure` of types.ts: why a batch was refused or failed. */
|
||||
enum class BatchFailure(val wire: String) {
|
||||
ASSERT("assert"),
|
||||
TOO_LARGE("tooLarge"),
|
||||
SCOPE("scope"),
|
||||
PERMISSION("permission"),
|
||||
PROVIDER("provider"),
|
||||
}
|
||||
|
||||
/** A query or batch refused before it reached the provider. */
|
||||
class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message)
|
||||
|
||||
/** How the rows of a table are tied to an account; see [ScopePlanner]. */
|
||||
enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS }
|
||||
|
||||
/** The tables the engine may touch, per authority. Any other table is refused. */
|
||||
enum class ProviderTable(
|
||||
val wire: String,
|
||||
val authority: String,
|
||||
val scope: TableScope,
|
||||
/** The table whose row each row here belongs to, named by [parentColumn]. */
|
||||
val parent: ProviderTable? = null,
|
||||
val parentColumn: String? = null,
|
||||
/**
|
||||
* How a selection names the row id; null when rows have none (Settings
|
||||
* rows are keyed by their account). CalendarProvider queries attendees
|
||||
* joined with events and calendars, where a bare `_id` is ambiguous.
|
||||
*/
|
||||
val idColumn: String? = "_id",
|
||||
) {
|
||||
RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"),
|
||||
GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null),
|
||||
CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"),
|
||||
ATTENDEES(
|
||||
"attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id",
|
||||
),
|
||||
REMINDERS(
|
||||
"reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id",
|
||||
),
|
||||
EXTENDED_PROPERTIES(
|
||||
"extended_properties",
|
||||
DeviceSyncAccounts.CALENDAR_AUTHORITY,
|
||||
TableScope.EVENT_IDS,
|
||||
EVENTS,
|
||||
"event_id",
|
||||
"ExtendedProperties._id",
|
||||
),
|
||||
COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
;
|
||||
|
||||
/** An insert returns the new row id, which later ops can back-reference. */
|
||||
val insertReturnsId: Boolean get() = idColumn != null
|
||||
|
||||
companion object {
|
||||
fun of(authority: String, wire: String): ProviderTable =
|
||||
entries.firstOrNull { it.wire == wire && it.authority == authority }
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell as it crosses the bridge: text, a number, null, or (written to
|
||||
* `data.data15` only) photo bytes as base64. Numbers that are integers become
|
||||
* [Integer], so an assert compares `5` with the provider's `5`, not `5.0`.
|
||||
*/
|
||||
sealed interface Cell {
|
||||
data class Text(val value: String) : Cell
|
||||
data class Integer(val value: Long) : Cell
|
||||
data class Real(val value: Double) : Cell
|
||||
data class Blob(val base64: String) : Cell
|
||||
data object Null : Cell
|
||||
|
||||
/** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */
|
||||
fun text(): String? = when (this) {
|
||||
is Text -> value
|
||||
is Integer -> value.toString()
|
||||
is Real -> value.toString()
|
||||
is Blob -> throw IllegalStateException("Blobs have no text form")
|
||||
Null -> null
|
||||
}
|
||||
|
||||
/** The row id this cell names, if it names one (`12` or `"12"`). */
|
||||
fun rowId(): Long? = when (this) {
|
||||
is Integer -> value
|
||||
is Text -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */
|
||||
data class RowTarget(val id: Long?, val where: String?, val args: List<String>)
|
||||
|
||||
data class ProviderQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val where: String?,
|
||||
val args: List<String>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One `ProviderOp` of types.ts, validated. */
|
||||
sealed interface ProviderOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
/** Column → index of an earlier insert of the batch whose new row id fills it. */
|
||||
val refs: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>?,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp
|
||||
}
|
||||
|
||||
const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership"
|
||||
|
||||
/**
|
||||
* The table a written column points into when the row it names must belong to
|
||||
* the account as well: the parent of a row, the master of an exception event,
|
||||
* the group of a membership row.
|
||||
*/
|
||||
internal fun referencedTable(table: ProviderTable, column: String, values: Map<String, Cell>): ProviderTable? = when {
|
||||
column == table.parentColumn -> table.parent
|
||||
table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS
|
||||
table == ProviderTable.DATA && column == "data1" &&
|
||||
values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS
|
||||
else -> null
|
||||
}
|
||||
|
||||
object ProviderOpParser {
|
||||
/**
|
||||
* ContactsProvider throws when its op counter reaches this. It counts an
|
||||
* op before resetting at a yield point, so at most 499 ops may follow a
|
||||
* yield point up to and including the next one.
|
||||
*/
|
||||
const val CONTACTS_OPS_PER_YIELD_POINT = 500
|
||||
|
||||
/** JS numbers beyond this are not exact integers any more. */
|
||||
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0
|
||||
|
||||
fun parseQuery(authority: String, json: String): ProviderQuery {
|
||||
val o = parseObject(json, "query")
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array")
|
||||
val columns = (0 until rawColumns.length()).map { i ->
|
||||
rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string")
|
||||
}
|
||||
if (columns.isEmpty()) throw invalid("query needs at least one column")
|
||||
val (where, args) = o.whereArgs("query")
|
||||
return ProviderQuery(table, columns, where, args, o.optionalString("orderBy"))
|
||||
}
|
||||
|
||||
fun parseBatch(authority: String, json: String): List<ProviderOp> {
|
||||
val array = try {
|
||||
JSONArray(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("ops are not a JSON array: ${e.message}")
|
||||
}
|
||||
val ops = (0 until array.length()).map { i ->
|
||||
val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object")
|
||||
parseOp(authority, i, o)
|
||||
}
|
||||
checkSyncState(ops)
|
||||
checkBackReferences(ops)
|
||||
if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops)
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp {
|
||||
val where = "op $index"
|
||||
val kind = o.opt("op") as? String ?: throw invalid("$where has no op")
|
||||
val yieldAllowed = o.optBoolean("yieldAllowed", false)
|
||||
if (kind == "syncState") {
|
||||
val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value")
|
||||
return ProviderOp.SetSyncState(value, yieldAllowed)
|
||||
}
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
return when (kind) {
|
||||
"insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed)
|
||||
"update" -> {
|
||||
val values = o.writeValues(table, where)
|
||||
if (values.isEmpty()) throw invalid("$where: an update needs values")
|
||||
ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed)
|
||||
}
|
||||
"delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed)
|
||||
"assert" -> {
|
||||
val target = o.target(table, where)
|
||||
val values = o.assertValues(where)
|
||||
val expectCount = o.expectCount(where)
|
||||
if (values.isNullOrEmpty() && expectCount == null && target.id == null) {
|
||||
throw invalid("$where: an assert needs values, an expected count or an id")
|
||||
}
|
||||
ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed)
|
||||
}
|
||||
else -> throw invalid("$where: unknown op $kind")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state describes the rows of its batch, so it must commit with them:
|
||||
* as the last op, and not at a yield point, where the provider may commit
|
||||
* everything before it on its own.
|
||||
*/
|
||||
private fun checkSyncState(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.SetSyncState) return@forEachIndexed
|
||||
if (i != ops.lastIndex) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch")
|
||||
}
|
||||
if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBackReferences(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.Insert) return@forEachIndexed
|
||||
for ((column, ref) in op.refs) {
|
||||
val target = ops.getOrNull(ref)
|
||||
if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) {
|
||||
throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id")
|
||||
}
|
||||
val expected = referencedTable(op.table, column, op.values)
|
||||
if (expected != null && target.table != expected) {
|
||||
throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}",
|
||||
)
|
||||
}
|
||||
}
|
||||
val parentColumn = op.table.parentColumn ?: return@forEachIndexed
|
||||
val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null
|
||||
if (!named) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkYieldPoints(ops: List<ProviderOp>) {
|
||||
var count = 0
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (++count >= CONTACTS_OPS_PER_YIELD_POINT) {
|
||||
throw invalid(
|
||||
"op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " +
|
||||
"(ContactsProvider refuses the batch)",
|
||||
)
|
||||
}
|
||||
if (i > 0 && op.yieldAllowed) count = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────
|
||||
|
||||
private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message)
|
||||
|
||||
private fun parseObject(json: String, what: String): JSONObject = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("$what is not a JSON object: ${e.message}")
|
||||
}
|
||||
|
||||
private fun JSONObject.requireString(key: String): String =
|
||||
opt(key) as? String ?: throw invalid("$key must be a string")
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) {
|
||||
null, JSONObject.NULL -> null
|
||||
is String -> v.takeIf { it.isNotBlank() }
|
||||
else -> throw invalid("$key must be a string")
|
||||
}
|
||||
|
||||
private fun JSONObject.target(table: ProviderTable, where: String): RowTarget {
|
||||
val id = when (val raw = opt("id")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 }
|
||||
?: throw invalid("$where: id must be a row id")
|
||||
}
|
||||
if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id")
|
||||
val (sql, args) = whereArgs(where)
|
||||
return RowTarget(id, sql, args)
|
||||
}
|
||||
|
||||
private fun JSONObject.whereArgs(where: String): Pair<String?, List<String>> {
|
||||
val sql = optionalString("where")
|
||||
val args = when (val raw = opt("args")) {
|
||||
null, JSONObject.NULL -> emptyList()
|
||||
is JSONArray -> (0 until raw.length()).map { i ->
|
||||
scalar(raw.opt(i), "$where: args[$i]").text()
|
||||
?: throw invalid("$where: args[$i] must be a string or a number")
|
||||
}
|
||||
else -> throw invalid("$where: args must be an array")
|
||||
}
|
||||
if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where")
|
||||
return sql to args
|
||||
}
|
||||
|
||||
private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||
?: throw invalid("$where: expectCount must be a count")
|
||||
}
|
||||
|
||||
private fun JSONObject.refs(where: String): Map<String, Int> {
|
||||
val raw = opt("refs") ?: return emptyMap()
|
||||
if (raw == JSONObject.NULL) return emptyMap()
|
||||
if (raw !is JSONObject) throw invalid("$where: refs must be an object")
|
||||
val out = LinkedHashMap<String, Int>()
|
||||
for (column in raw.keys()) {
|
||||
val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }
|
||||
?: throw invalid("$where: refs.$column must be an op index")
|
||||
out[column] = index.toInt()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.writeValues(table: ProviderTable, where: String): Map<String, Cell> {
|
||||
val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) {
|
||||
val v = raw.opt(column)
|
||||
out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.assertValues(where: String): Map<String, Cell>? {
|
||||
val raw = opt("values") ?: return null
|
||||
if (raw == JSONObject.NULL) return null
|
||||
if (raw !is JSONObject) throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column")
|
||||
return out
|
||||
}
|
||||
|
||||
/** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */
|
||||
private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob {
|
||||
if (table != ProviderTable.DATA || column != "data15") {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column")
|
||||
}
|
||||
val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }")
|
||||
return Cell.Blob(base64)
|
||||
}
|
||||
|
||||
private fun scalar(v: Any?, what: String): Cell = when (v) {
|
||||
null, JSONObject.NULL -> Cell.Null
|
||||
is String -> Cell.Text(v)
|
||||
is Boolean -> Cell.Integer(if (v) 1 else 0)
|
||||
is Number -> numberCell(v)
|
||||
else -> throw invalid("$what must be text, a number or null")
|
||||
}
|
||||
|
||||
/** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */
|
||||
internal fun numberCell(n: Number): Cell = when (n) {
|
||||
is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong())
|
||||
is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble())
|
||||
is BigDecimal -> try {
|
||||
Cell.Integer(n.longValueExact())
|
||||
} catch (e: ArithmeticException) {
|
||||
Cell.Real(n.toDouble())
|
||||
}
|
||||
else -> {
|
||||
val d = n.toDouble()
|
||||
if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Plans provider calls for one account: every selection gets the account
|
||||
* condition ANDed in, and every row an operation names by id, or points to
|
||||
* from a written column, is checked to belong to the account first
|
||||
* (docs/device-sync.md, "Native module API", Scoping).
|
||||
*
|
||||
* How a table is tied to the account ([TableScope]):
|
||||
* - raw contacts, groups, settings, calendars and colors carry it in their
|
||||
* `account_name`/`account_type` columns;
|
||||
* - contacts data rows are filtered by ContactsProvider itself through the
|
||||
* `account_name`/`account_type` URI parameters. Their account columns are
|
||||
* never selected on: Android 17 drops them from the Data view for apps
|
||||
* targeting more than API 36;
|
||||
* - events get `calendar_id IN (…)` of the account's calendars, not the
|
||||
* account columns: CalendarProvider updates events on the Events table,
|
||||
* which has none;
|
||||
* - attendees, reminders and extended properties get `event_id IN (…)` of the
|
||||
* account's events among those the operation touches. CalendarProvider
|
||||
* applies no account filter to them.
|
||||
*
|
||||
* Selections never use subqueries: the providers may compile them strictly.
|
||||
*/
|
||||
class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) {
|
||||
private val calendarIds: Set<Long> by lazy { lookup.ownCalendarIds() }
|
||||
|
||||
fun planQuery(query: ProviderQuery): PlannedQuery {
|
||||
val target = RowTarget(null, query.where, query.args)
|
||||
val selections = if (query.table.scope == TableScope.EVENT_IDS) {
|
||||
// Mostly a handful of events (the engine reads children per chunk
|
||||
// of events); a query over all of them is split so no selection
|
||||
// outgrows a Binder transaction. Rows are then ordered per chunk.
|
||||
ownEventsMatching(query.table, target)
|
||||
.sorted()
|
||||
.chunked(ID_LIST_CHUNK)
|
||||
.map { chunk -> selection(query.table, target, chunk) }
|
||||
} else {
|
||||
listOf(selection(query.table, target, null))
|
||||
}
|
||||
return PlannedQuery(query.table, query.columns, selections, query.orderBy)
|
||||
}
|
||||
|
||||
fun planBatch(ops: List<ProviderOp>): List<PlannedOp> {
|
||||
val owners = Owners(ownershipRequests(ops))
|
||||
return ops.mapIndexed { i, op -> plan(i, op, owners) }
|
||||
}
|
||||
|
||||
private fun plan(i: Int, op: ProviderOp, owners: Owners): PlannedOp = when (op) {
|
||||
is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
|
||||
is ProviderOp.Insert -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values - op.refs.keys, owners)
|
||||
val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values
|
||||
PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Update -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values, owners)
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
if (op.table == ProviderTable.EXTENDED_PROPERTIES) {
|
||||
// CalendarProvider updates extended properties only through
|
||||
// their item URI, which takes no selection: the id check above
|
||||
// is the scope.
|
||||
val id = op.target.id
|
||||
if (id == null || op.target.where != null) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id")
|
||||
}
|
||||
PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed)
|
||||
} else {
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
is ProviderOp.Delete -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Assert -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
val values = op.values?.mapValues { (_, cell) -> cell.text() }
|
||||
PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */
|
||||
private fun expectedCount(target: RowTarget, expectCount: Int?): Int? =
|
||||
expectCount ?: if (target.id != null) 1 else null
|
||||
|
||||
private fun accountCells() = mapOf(
|
||||
ACCOUNT_NAME to Cell.Text(account.name),
|
||||
ACCOUNT_TYPE to Cell.Text(account.type),
|
||||
)
|
||||
|
||||
/**
|
||||
* Account columns may only restate the account, on the tables that have
|
||||
* them; a data row, event or attendee follows its parent's account.
|
||||
*/
|
||||
private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) {
|
||||
val cell = values[column] ?: continue
|
||||
val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) {
|
||||
ACCOUNT_NAME -> cell == Cell.Text(account.name)
|
||||
ACCOUNT_TYPE -> cell == Cell.Text(account.type)
|
||||
else -> cell == Cell.Null
|
||||
}
|
||||
if (!restatesAccount) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents, masters and groups a written column names must be rows of the
|
||||
* account that still exist. (Back-references were checked by the parser:
|
||||
* they name inserts of the same batch.)
|
||||
*/
|
||||
private fun checkReferences(i: Int, table: ProviderTable, values: Map<String, Cell>, owners: Owners) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
if (cell == Cell.Null) {
|
||||
if (column == table.parentColumn) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val id = cell.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id")
|
||||
when (owners.of(referenced, id)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id of another account",
|
||||
)
|
||||
// Deleted meanwhile, as far as the engine can tell: re-read and re-plan.
|
||||
Owner.MISSING -> throw ProviderRefusal(
|
||||
BatchFailure.ASSERT,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row named by id must not belong to another account; a missing one fails on the expected count instead. */
|
||||
private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) {
|
||||
val id = target.id ?: return
|
||||
if (owners.of(table, id) == Owner.FOREIGN) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account")
|
||||
}
|
||||
}
|
||||
|
||||
/** For attendees, reminders and extended properties: the account's events the op may touch. */
|
||||
private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection<Long>? {
|
||||
if (table.scope != TableScope.EVENT_IDS) return null
|
||||
val id = target.id ?: return ownEventsMatching(table, target)
|
||||
return listOfNotNull(owners.eventOf(table, id))
|
||||
}
|
||||
|
||||
private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set<Long> {
|
||||
val candidates = lookup.childEventIdsMatching(table, target.where, target.args)
|
||||
if (candidates.isEmpty()) return emptySet()
|
||||
return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys
|
||||
}
|
||||
|
||||
private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection<Long>?): Selection {
|
||||
val parts = ArrayList<String>(3)
|
||||
val args = ArrayList<String>()
|
||||
target.id?.let {
|
||||
parts += "${table.idColumn} = ?"
|
||||
args += it.toString()
|
||||
}
|
||||
target.where?.let {
|
||||
parts += it
|
||||
args += target.args
|
||||
}
|
||||
when (table.scope) {
|
||||
TableScope.ACCOUNT_COLUMNS -> {
|
||||
parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?"
|
||||
args += account.name
|
||||
args += account.type
|
||||
}
|
||||
TableScope.URI_PARAMETERS -> Unit
|
||||
TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds)
|
||||
TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty())
|
||||
}
|
||||
return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args)
|
||||
}
|
||||
|
||||
/** Every row id the batch names, by table: op targets and referenced rows. */
|
||||
private fun ownershipRequests(ops: List<ProviderOp>): Map<ProviderTable, Set<Long>> {
|
||||
val requests = HashMap<ProviderTable, MutableSet<Long>>()
|
||||
fun request(table: ProviderTable, id: Long) {
|
||||
requests.getOrPut(table) { HashSet() } += id
|
||||
}
|
||||
fun requestReferences(table: ProviderTable, values: Map<String, Cell>) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
cell.rowId()?.let { request(referenced, it) }
|
||||
}
|
||||
}
|
||||
for (op in ops) {
|
||||
when (op) {
|
||||
is ProviderOp.Insert -> requestReferences(op.table, op.values)
|
||||
is ProviderOp.Update -> {
|
||||
requestReferences(op.table, op.values)
|
||||
op.target.id?.let { request(op.table, it) }
|
||||
}
|
||||
is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.SetSyncState -> Unit
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
private enum class Owner { OURS, FOREIGN, MISSING }
|
||||
|
||||
/** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */
|
||||
private inner class Owners(requests: Map<ProviderTable, Set<Long>>) {
|
||||
private val owners = HashMap<ProviderTable, Map<Long, Owner>>()
|
||||
private val childEvents = HashMap<ProviderTable, Map<Long, Long>>()
|
||||
|
||||
init {
|
||||
// Children lead to events, data rows to raw contacts; both are
|
||||
// added to their parent's lookup.
|
||||
val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty())
|
||||
for (table in CHILD_TABLES) {
|
||||
val ids = requests[table] ?: continue
|
||||
val events = lookup.childEvents(table, ids)
|
||||
childEvents[table] = events
|
||||
eventIds += events.values
|
||||
}
|
||||
val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty())
|
||||
val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty()
|
||||
rawContactIds += dataParents.values
|
||||
|
||||
if (eventIds.isNotEmpty()) {
|
||||
owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds)
|
||||
.mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
for ((table, events) in childEvents) {
|
||||
// A child whose event is gone is an orphan, not ours.
|
||||
owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) }
|
||||
}
|
||||
val roots = HashMap<ProviderTable, Set<Long>>()
|
||||
for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it }
|
||||
if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds
|
||||
for ((table, ids) in roots) {
|
||||
owners[table] = lookup.rootAccounts(table, ids)
|
||||
.mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
if (dataParents.isNotEmpty()) {
|
||||
owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) ->
|
||||
ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownerOrForeign(table: ProviderTable, id: Long): Owner =
|
||||
owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN
|
||||
|
||||
fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING
|
||||
|
||||
/** The event of a child row, when both exist and are the account's. */
|
||||
fun eventOf(table: ProviderTable, id: Long): Long? =
|
||||
childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_NAME = "account_name"
|
||||
const val ACCOUNT_TYPE = "account_type"
|
||||
const val DATA_SET = "data_set"
|
||||
|
||||
/** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */
|
||||
const val ID_LIST_CHUNK = 500
|
||||
|
||||
private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS }
|
||||
private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS }
|
||||
|
||||
/** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */
|
||||
fun idList(column: String, ids: Collection<Long>): String =
|
||||
if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")")
|
||||
}
|
||||
}
|
||||
|
||||
/** An Android account of our type: the scope of every provider call. */
|
||||
data class AccountRef(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* What [ScopePlanner] needs to know about rows beyond the operations
|
||||
* themselves. [ProviderIo] answers with provider queries: except for
|
||||
* [ownCalendarIds], these read rows of any account, but only their ids and
|
||||
* owners, to tell rows of another account from rows that are gone.
|
||||
*/
|
||||
interface ScopeLookup {
|
||||
/** `_id`s of the account's calendars. */
|
||||
fun ownCalendarIds(): Set<Long>
|
||||
|
||||
/** The account of each row of a root table that exists. */
|
||||
fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef>
|
||||
|
||||
/** The raw contact of each data row that exists. */
|
||||
fun dataRawContacts(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The calendar of each event that exists. */
|
||||
fun eventCalendars(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The event of each attendee, reminder or extended property that exists. */
|
||||
fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The events of the rows of a child table that match [where]. */
|
||||
fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long>
|
||||
}
|
||||
|
||||
/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */
|
||||
data class Selection(val sql: String?, val args: List<String>) {
|
||||
companion object {
|
||||
val NONE = Selection(null, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */
|
||||
data class PlannedQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val selections: List<Selection>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */
|
||||
sealed interface PlannedOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
val backReferences: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [itemId] set: an update through the row's own URI, without a selection. */
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val itemId: Long?,
|
||||
val values: Map<String, Cell>,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [values] compared as text, as ContentProviderOperation does. */
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val values: Map<String, String?>?,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class SetSyncState(val value: String) : PlannedOp {
|
||||
override val yieldAllowed: Boolean get() = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The result of one op, as `OpResult` of types.ts. */
|
||||
data class OpOutcome(val id: Long? = null, val count: Int? = null)
|
||||
|
||||
object ProviderResults {
|
||||
fun ok(results: List<OpOutcome>): String {
|
||||
val array = JSONArray()
|
||||
for (result in results) {
|
||||
array.put(JSONObject().apply {
|
||||
result.id?.let { put("id", it) }
|
||||
result.count?.let { put("count", it) }
|
||||
})
|
||||
}
|
||||
return JSONObject().put("ok", true).put("results", array).toString()
|
||||
}
|
||||
|
||||
fun failed(reason: BatchFailure, message: String?): String = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("reason", reason.wire)
|
||||
.put("message", message ?: reason.wire)
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* An OperationApplicationException is an assert that did not hold or an
|
||||
* expected count that did not match (`assert`), except when an insert got
|
||||
* no row back or ContactsProvider refused the batch's yield points.
|
||||
*/
|
||||
fun failureOfOperationApplication(message: String?): BatchFailure = when {
|
||||
message == null -> BatchFailure.ASSERT
|
||||
message.startsWith("Insert into ") -> BatchFailure.PROVIDER
|
||||
message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER
|
||||
else -> BatchFailure.ASSERT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results as `ProviderRows` JSON, built by hand: a chunk of contacts
|
||||
* can carry megabytes of shadows, and this is the hot path of every run.
|
||||
*/
|
||||
class ProviderRowsWriter(columns: List<String>) {
|
||||
private val out = StringBuilder(4096)
|
||||
private var rows = 0
|
||||
private var cells = 0
|
||||
|
||||
init {
|
||||
out.append("{\"columns\":[")
|
||||
columns.forEachIndexed { i, column ->
|
||||
if (i > 0) out.append(',')
|
||||
appendString(column)
|
||||
}
|
||||
out.append("],\"rows\":[")
|
||||
}
|
||||
|
||||
fun beginRow() {
|
||||
if (rows++ > 0) out.append(',')
|
||||
out.append('[')
|
||||
cells = 0
|
||||
}
|
||||
|
||||
fun endRow() {
|
||||
out.append(']')
|
||||
}
|
||||
|
||||
fun nullCell() = cell { out.append("null") }
|
||||
|
||||
fun longCell(value: Long) = cell { out.append(value) }
|
||||
|
||||
fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") }
|
||||
|
||||
fun textCell(value: String) = cell { appendString(value) }
|
||||
|
||||
fun finish(): String = out.append("]}").toString()
|
||||
|
||||
private inline fun cell(write: () -> Unit) {
|
||||
if (cells++ > 0) out.append(',')
|
||||
write()
|
||||
}
|
||||
|
||||
private fun appendString(s: String) {
|
||||
out.append('"')
|
||||
for (c in s) {
|
||||
when {
|
||||
c == '"' -> out.append("\\\"")
|
||||
c == '\\' -> out.append("\\\\")
|
||||
c == '\n' -> out.append("\\n")
|
||||
c == '\r' -> out.append("\\r")
|
||||
c == '\t' -> out.append("\\t")
|
||||
c < ' ' || c.code == 0x2028 || c.code == 0x2029 ->
|
||||
out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0'))
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
out.append('"')
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */
|
||||
object PhotoScaling {
|
||||
/** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */
|
||||
fun sampleSize(width: Int, height: Int, maxPx: Int): Int {
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
var sample = 1
|
||||
val longer = maxOf(width, height)
|
||||
while (longer / (sample * 2) >= maxPx) sample *= 2
|
||||
return sample
|
||||
}
|
||||
|
||||
fun targetSize(width: Int, height: Int, maxPx: Int): Pair<Int, Int> {
|
||||
val longer = maxOf(width, height)
|
||||
if (longer <= maxPx) return width to height
|
||||
val scale = maxPx.toDouble() / longer
|
||||
return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a push asks for (#34): syncs of the Android accounts that contacts and
|
||||
* calendar changes feed, and whether the mail push task should run at all.
|
||||
*
|
||||
* The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data
|
||||
* key `changed`: JMAP account id → type → state; only its first account is
|
||||
* repeated as `accountId`), so every account key is looked at. The mail task
|
||||
* runs only for mail: when some account's map has `EmailDelivery`, for an
|
||||
* EmailPush, or when `changed` is missing or unreadable (older relays), so a
|
||||
* contact change never ends in a mail notification.
|
||||
*/
|
||||
object PushRouting {
|
||||
/** An Android account and the authorities it syncs, fed by one JMAP account. */
|
||||
data class Route(val accountName: String, val authorities: Set<String>)
|
||||
|
||||
data class Decision(
|
||||
/** (Android account name, authority) pairs to request a sync for. */
|
||||
val syncs: Set<Pair<String, String>>,
|
||||
val startMailTask: Boolean,
|
||||
)
|
||||
|
||||
private const val EMAIL_PUSH_KIND = "jmap-email-push"
|
||||
private const val EMAIL_DELIVERY = "EmailDelivery"
|
||||
private val CONTACT_TYPES = setOf("ContactCard", "AddressBook")
|
||||
private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar")
|
||||
|
||||
fun decide(data: Map<String, String>, routes: Map<String, List<Route>>): Decision {
|
||||
val changed = data["changed"]?.let(::parseChanged)
|
||||
var mail = data["kind"] == EMAIL_PUSH_KIND || changed == null
|
||||
val syncs = LinkedHashSet<Pair<String, String>>()
|
||||
for ((jmapAccountId, types) in changed.orEmpty()) {
|
||||
if (types == null) {
|
||||
// One account's entry is unreadable: it may be mail.
|
||||
mail = true
|
||||
continue
|
||||
}
|
||||
if (EMAIL_DELIVERY in types) mail = true
|
||||
val contacts = types.any { it in CONTACT_TYPES }
|
||||
val calendar = types.any { it in CALENDAR_TYPES }
|
||||
for (route in routes[jmapAccountId].orEmpty()) {
|
||||
if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
}
|
||||
if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
}
|
||||
}
|
||||
}
|
||||
return Decision(syncs, mail)
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP account id → its changed types (null for an entry that is not an
|
||||
* object). Null for a map that is unreadable or names no type at all,
|
||||
* which the relay sends as `{}` when it had no map.
|
||||
*/
|
||||
private fun parseChanged(json: String): Map<String, Set<String>?>? {
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
return null
|
||||
}
|
||||
val out = LinkedHashMap<String, Set<String>?>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val types = o.opt(jmapAccountId) as? JSONObject
|
||||
out[jmapAccountId] = types?.keys()?.asSequence()?.toSet()
|
||||
}
|
||||
return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it.
|
||||
* [strict] refuses anything malformed (IllegalArgumentException); otherwise
|
||||
* malformed entries are skipped, so a bad route never stops mail.
|
||||
*/
|
||||
fun parseRoutes(json: String?, strict: Boolean = false): Map<String, List<Route>> {
|
||||
if (json.isNullOrEmpty()) return emptyMap()
|
||||
fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message")
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
if (strict) bad("not a JSON object") else return emptyMap()
|
||||
}
|
||||
val out = LinkedHashMap<String, List<Route>>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val entries = o.opt(jmapAccountId) as? JSONArray
|
||||
?: if (strict) bad("$jmapAccountId must map to an array") else continue
|
||||
val routes = ArrayList<Route>()
|
||||
for (i in 0 until entries.length()) {
|
||||
val entry = entries.opt(i) as? JSONObject
|
||||
val name = entry?.opt("accountName") as? String
|
||||
val authorities = entry?.opt("authorities") as? JSONArray
|
||||
if (name.isNullOrEmpty() || authorities == null) {
|
||||
if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue
|
||||
}
|
||||
val known = (0 until authorities.length()).mapNotNull { j ->
|
||||
val authority = authorities.opt(j) as? String
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
}
|
||||
routes += Route(name, known.toSet())
|
||||
}
|
||||
out[jmapAccountId] = routes
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [PushRouting] to incoming pushes (BulwarkMessagingService,
|
||||
* BulwarkUnifiedPushService) with the routes JS stored through
|
||||
* `setPushRoutes`.
|
||||
*/
|
||||
object DeviceSyncPushRouter {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
private const val PREFS = "bulwark_device_sync"
|
||||
private const val KEY_ROUTES = "pushRoutes"
|
||||
|
||||
/** Validates and stores the routes; they must survive the process, pushes arrive without JS. */
|
||||
fun saveRoutes(context: Context, json: String) {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ROUTES, json)
|
||||
.commit()
|
||||
check(saved) { "Push routes could not be saved" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the syncs the push asks for, with constant (empty) extras so
|
||||
* repeated pushes coalesce into one pending sync. Returns whether the mail
|
||||
* push task should run; any failure here says yes, so mail never depends
|
||||
* on device sync.
|
||||
*/
|
||||
fun route(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val routes = PushRouting.parseRoutes(
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null),
|
||||
)
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
for ((accountName, authority) in decision.syncs) {
|
||||
try {
|
||||
DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags())
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Push: no sync requested for $authority", e)
|
||||
}
|
||||
}
|
||||
decision.startMailTask
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push not routed", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The one notification per Android account that device sync needs the user
|
||||
* for (#34), e.g. "Sign in again" after the server rejected the credentials.
|
||||
* The engine posts it through `showSyncProblem`; the app clears it once the
|
||||
* problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the
|
||||
* app.
|
||||
*/
|
||||
object SyncProblemNotifier {
|
||||
const val CHANNEL_ID = "bulwark_sync"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Sync problems"
|
||||
private const val TAG_PREFIX = "bulwark-sync:"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val DEEP_LINK_SCHEME = "bulwarkmobile"
|
||||
|
||||
/**
|
||||
* Creates the channel, or renames it: JS passes the name in the app's
|
||||
* language. Nothing urgent, so default importance and no vibration.
|
||||
*/
|
||||
fun ensureChannel(context: Context, name: String?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { enableVibration(false) }
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) {
|
||||
val link = Uri.parse(uri)
|
||||
require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" }
|
||||
ensureChannel(context, channelName)
|
||||
val tag = TAG_PREFIX + accountName
|
||||
// Explicit, so no other app can answer the tap.
|
||||
val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
tag.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
// Runs repeat the same problem until it is solved; say it once.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun clear(context: Context, accountName: String) {
|
||||
context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncAccounts.EnsureAction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncAccountsTest {
|
||||
@Test
|
||||
fun `ensureAccount creates, keeps, adopts or refuses`() {
|
||||
assertEquals(EnsureAction.CREATE, DeviceSyncAccounts.ensureAction(false, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.KEEP, DeviceSyncAccounts.ensureAction(true, "alice@example.org@mail", "alice@example.org@mail"))
|
||||
// An account whose setup never wrote a registry id.
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, "", "alice@example.org@mail"))
|
||||
// Its rows and SyncState belong to another app account: never rewritten.
|
||||
assertEquals(EnsureAction.CONFLICT, DeviceSyncAccounts.ensureAction(true, "alice@example.org@old", "alice@example.org@mail"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncRunsTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
@Test
|
||||
fun `a report is accepted once, while the adapter waits`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"io\"}"))
|
||||
assertFalse(run.end("no report before the deadline"))
|
||||
assertEquals(0L, run.done.count)
|
||||
assertEquals("{\"outcome\":\"ok\"}", run.report)
|
||||
assertEquals("reported", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report after the run ended is refused whole`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(run.end("no report before the deadline"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertNull(run.report)
|
||||
assertEquals("no report before the deadline", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runs nobody waits for, or that ended, count as cancelled`() {
|
||||
assertTrue(DeviceSyncRuns.isCancelled("unknown"))
|
||||
val run = DeviceSyncRuns.open("alice", calendar)
|
||||
assertFalse(DeviceSyncRuns.isCancelled(run.runId))
|
||||
run.end("task finished")
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
DeviceSyncRuns.close(run)
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sync requested during a run flags that run only, and the flag outlives the close`() {
|
||||
val mine = DeviceSyncRuns.open("alice", contacts)
|
||||
val otherAuthority = DeviceSyncRuns.open("alice", calendar)
|
||||
val otherAccount = DeviceSyncRuns.open("bob", contacts)
|
||||
assertTrue(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(mine)
|
||||
assertTrue(mine.syncAgainRequested)
|
||||
assertFalse(otherAuthority.syncAgainRequested)
|
||||
assertFalse(otherAccount.syncAgainRequested)
|
||||
// Closed runs are not flagged any more.
|
||||
assertFalse(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(otherAuthority)
|
||||
DeviceSyncRuns.close(otherAccount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
private const val CONTACTS = "com.android.contacts"
|
||||
private const val CALENDAR = "com.android.calendar"
|
||||
private val ME = AccountRef("alice@example.org", "com.anonymous.bulwarkmobile.account")
|
||||
private val OTHER = AccountRef("bob@gmail.com", "com.google")
|
||||
|
||||
/** The provider as far as the planner asks: who owns which row; [me] is the account the lookup is bound to. */
|
||||
private class FakeLookup(private val me: AccountRef = ME) : ScopeLookup {
|
||||
val rawContacts = HashMap<Long, AccountRef>()
|
||||
val groups = HashMap<Long, AccountRef>()
|
||||
val calendars = HashMap<Long, AccountRef>()
|
||||
val colors = HashMap<Long, AccountRef>()
|
||||
/** data id → raw contact */
|
||||
val data = HashMap<Long, Long>()
|
||||
/** event id → calendar */
|
||||
val events = HashMap<Long, Long>()
|
||||
/** child table → child id → event */
|
||||
val children = HashMap<ProviderTable, HashMap<Long, Long>>()
|
||||
/** (child table, where) → events of the matching rows; a null where matches every row. */
|
||||
val matching = HashMap<Pair<ProviderTable, String?>, Set<Long>>()
|
||||
|
||||
override fun ownCalendarIds() = calendars.filterValues { it == me }.keys
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> rawContacts
|
||||
ProviderTable.GROUPS -> groups
|
||||
ProviderTable.CALENDARS -> calendars
|
||||
ProviderTable.COLORS -> colors
|
||||
else -> throw AssertionError("no root lookup for $table")
|
||||
}.filterKeys { it in ids }
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>) = data.filterKeys { it in ids }
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>) = events.filterKeys { it in ids }
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
children[table].orEmpty().filterKeys { it in ids }
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> =
|
||||
matching[table to where] ?: if (where == null) children[table].orEmpty().values.toSet() else emptySet()
|
||||
|
||||
fun child(table: ProviderTable, id: Long, event: Long) {
|
||||
children.getOrPut(table) { HashMap() }[id] = event
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderOpsTest {
|
||||
private val lookup = FakeLookup().apply {
|
||||
rawContacts[1] = ME
|
||||
rawContacts[2] = OTHER
|
||||
groups[10] = ME
|
||||
groups[11] = OTHER
|
||||
data[100] = 1
|
||||
data[101] = 2
|
||||
calendars[20] = ME
|
||||
calendars[21] = ME
|
||||
calendars[22] = OTHER
|
||||
colors[40] = ME
|
||||
events[30] = 20
|
||||
events[31] = 21
|
||||
events[32] = 22
|
||||
child(ProviderTable.ATTENDEES, 50, 30)
|
||||
child(ProviderTable.ATTENDEES, 51, 32)
|
||||
child(ProviderTable.REMINDERS, 60, 31)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 70, 30)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 71, 32)
|
||||
}
|
||||
private val planner = ScopePlanner(ME, lookup)
|
||||
|
||||
private fun ops(vararg op: String) = "[${op.joinToString(",")}]"
|
||||
|
||||
private fun plan(authority: String, vararg op: String) =
|
||||
planner.planBatch(ProviderOpParser.parseBatch(authority, ops(*op)))
|
||||
|
||||
private fun refused(reason: BatchFailure, block: () -> Unit): String {
|
||||
try {
|
||||
block()
|
||||
} catch (e: ProviderRefusal) {
|
||||
assertEquals(e.message, reason, e.reason)
|
||||
return e.message.orEmpty()
|
||||
}
|
||||
fail("expected a $reason refusal")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
// ── tables and parsing ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `tables are enumerated per authority and anything else is out of scope`() {
|
||||
assertEquals(ProviderTable.EXTENDED_PROPERTIES, ProviderTable.of(CALENDAR, "extended_properties"))
|
||||
assertEquals(ProviderTable.SETTINGS, ProviderTable.of(CONTACTS, "settings"))
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "events") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CALENDAR, "raw_contacts") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "contacts") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"instances","where":"1"}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers keep integers exact whatever org_json parsed them into`() {
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5.0))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(BigDecimal("5.0")))
|
||||
assertEquals(Cell.Integer(1L shl 60), ProviderOpParser.numberCell(BigInteger.ONE.shiftLeft(60)))
|
||||
assertEquals(Cell.Integer(1_700_000_000_000), ProviderOpParser.numberCell(1_700_000_000_000L))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(BigDecimal("1.5")))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(1.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `values keep text, numbers, booleans as 0 and 1, and nulls`() {
|
||||
val insert = ProviderOpParser.parseBatch(
|
||||
CONTACTS,
|
||||
ops("""{"op":"insert","table":"raw_contacts","values":{"sync1":"a","dirty":0,"starred":true,"sync2":null}}"""),
|
||||
).single() as ProviderOp.Insert
|
||||
assertEquals(
|
||||
mapOf("sync1" to Cell.Text("a"), "dirty" to Cell.Integer(0), "starred" to Cell.Integer(1), "sync2" to Cell.Null),
|
||||
insert.values,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed ops are provider failures`() {
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, "{}") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"upsert","table":"groups","values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"assert","table":"groups","where":"dirty = 1"}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"settings","id":1}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","args":["x"]}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","id":1.5}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","where":"a = ?","args":[null]}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bytes are only accepted for data_data15`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo","data15":{"b64":"AAEC"}}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Blob("AAEC"), planned.values["data15"])
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"raw_contacts","id":1,"values":{"sync2":{"b64":"AAEC"}}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data1":{"b64":"AA"}}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data15":{"bytes":"AA"}}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── batch rules ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a syncState op must be the last op and not a yield point`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"groups","id":10,"values":{"sync2":"x"},"yieldAllowed":true}""",
|
||||
"""{"op":"syncState","value":"{\"v\":1}"}""",
|
||||
)
|
||||
assertEquals(PlannedOp.SetSyncState("{\"v\":1}"), planned.last())
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"syncState","value":"{}"}""", """{"op":"delete","table":"groups","id":10}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"syncState","value":"{}","yieldAllowed":true}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletes(count: Int, yieldAt: Set<Int> = emptySet()) = (0 until count).joinToString(",", "[", "]") { i ->
|
||||
"""{"op":"delete","table":"groups","where":"sync1 = ?","args":["$i"]${if (i in yieldAt) ""","yieldAllowed":true""" else ""}}"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contacts batches stop at 499 ops between yield points, counted as ContactsProvider does`() {
|
||||
assertEquals(499, ProviderOpParser.parseBatch(CONTACTS, deletes(499)).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500)) }
|
||||
// The yielding op still counts toward the ops before it.
|
||||
assertEquals(998, ProviderOpParser.parseBatch(CONTACTS, deletes(998, setOf(498))).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(999, setOf(498))) }
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(600, setOf(499))) }
|
||||
// A yield on the first op is ignored, as the provider ignores it.
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500, setOf(0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar batches have no yield point limit`() {
|
||||
val many = (0 until 600).joinToString(",", "[", "]") { """{"op":"delete","table":"colors","where":"color_index = '$it'"}""" }
|
||||
assertEquals(600, ProviderOpParser.parseBatch(CALENDAR, many).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back-references name earlier inserts, and parents name their parent table`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"vnd.android.cursor.item/name","data1":"A"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
assertEquals(mapOf("raw_contact_id" to 0), (planned[1] as PlannedOp.Insert).backReferences)
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
// Settings rows have no id to refer to.
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"settings","values":{"ungrouped_visible":1}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"groups","values":{"title":"g"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":null,"attendeeEmail":"a@b"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── scope: inserts ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root inserts are written into the account, whatever the op says`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1","account_name":"alice@example.org"}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Text(ME.name), planned.values["account_name"])
|
||||
assertEquals(Cell.Text(ME.type), planned.values["account_type"])
|
||||
val calendar = plan(CALENDAR, """{"op":"insert","table":"calendars","values":{"name":"c"}}""").single()
|
||||
assertEquals(Cell.Text(ME.type), (calendar as PlannedOp.Insert).values["account_type"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account columns never move a row out of the account`() {
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"raw_contacts","values":{"account_name":"bob@gmail.com"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{"account_type":"com.google"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"groups","values":{"title":"g","data_set":"plugin"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inserts must name a parent of the account that still exists`() {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x"}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"1","mimetype":"x"}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":2,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":9,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"me","mimetype":"x"}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":21,"dtstart":0}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":22,"dtstart":0}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":20,"original_id":32}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":31,"minutes":10}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":32,"minutes":10}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":99}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership row may only name a group of the account`() {
|
||||
val membership = """"mimetype":"vnd.android.cursor.item/group_membership""""
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":10}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"group_sourceid":"c/g"}}""")
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":11}}""")
|
||||
}
|
||||
// data1 of other kinds is plain data.
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x","data1":11}}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a row to another parent is checked like an insert`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":22}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"raw_contact_id":null}}""") }
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":21}}""")
|
||||
}
|
||||
|
||||
// ── scope: selections ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root tables are selected by their account columns`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"raw_contacts","where":"dirty = ? AND deleted = 0","args":[1],"values":{"dirty":0}}""",
|
||||
).single() as PlannedOp.Update
|
||||
assertEquals(
|
||||
Selection("(dirty = ? AND deleted = 0) AND (account_name = ? AND account_type = ?)", listOf("1", ME.name, ME.type)),
|
||||
op.selection,
|
||||
)
|
||||
assertNull(op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an op by id selects that row of the account and expects exactly one row`() {
|
||||
val op = plan(CONTACTS, """{"op":"delete","table":"groups","id":10}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(_id = ?) AND (account_name = ? AND account_type = ?)", listOf("10", ME.name, ME.type)), op.selection)
|
||||
assertEquals(1, op.expectedCount)
|
||||
val counted = plan(CONTACTS, """{"op":"assert","table":"raw_contacts","id":1,"expectCount":0}""").single()
|
||||
assertEquals(0, (counted as PlannedOp.Assert).expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an id of another account is refused, a vanished one is left to the expected count`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":2}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":101,"values":{"data1":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"assert","table":"events","id":32,"values":{"dirty":0}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","id":51}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"calendars","id":22,"values":{"name":"x"}}""") }
|
||||
val gone = plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":9}""").single() as PlannedOp.Delete
|
||||
assertEquals(1, gone.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data rows rely on the URI parameters and never select on the Data view's account columns`() {
|
||||
val op = plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":"x"}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?)", listOf("100")), op.selection)
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"],"where":"raw_contact_id IN (1)"}"""))
|
||||
assertEquals(listOf(Selection("(raw_contact_id IN (1))", emptyList())), query.selections)
|
||||
val all = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection.NONE), all.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events are selected by the account's calendars`() {
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id","title"],"where":"dirty = 1","orderBy":"_id"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(dirty = 1) AND (calendar_id IN (20,21))", emptyList())), query.selections)
|
||||
assertEquals("_id", query.orderBy)
|
||||
val update = plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"dirty":0}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?) AND (calendar_id IN (20,21))", listOf("30")), update.selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account without calendars selects no events`() {
|
||||
val carol = AccountRef("carol", ME.type)
|
||||
val empty = ScopePlanner(carol, FakeLookup(carol).apply { calendars.putAll(lookup.calendars) })
|
||||
val query = empty.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection("(0)", emptyList())), query.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `children of events are selected by the account's events among those they touch`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id IN (30,32)"] = setOf(30, 32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["attendeeEmail"],"where":"event_id IN (30,32)"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(event_id IN (30,32)) AND (event_id IN (30))", emptyList())), query.selections)
|
||||
|
||||
val byId = plan(CALENDAR, """{"op":"delete","table":"attendees","id":50}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Attendees._id = ?) AND (event_id IN (30))", listOf("50")), byId.selection)
|
||||
assertEquals(1, byId.expectedCount)
|
||||
|
||||
val gone = plan(CALENDAR, """{"op":"delete","table":"reminders","id":69}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Reminders._id = ?) AND (0)", listOf("69")), gone.selection)
|
||||
|
||||
lookup.matching[ProviderTable.REMINDERS to "event_id = ?"] = setOf(31)
|
||||
val byWhere = plan(CALENDAR, """{"op":"delete","table":"reminders","where":"event_id = ?","args":[31]}""").single()
|
||||
assertEquals(Selection("(event_id = ?) AND (event_id IN (31))", listOf("31")), (byWhere as PlannedOp.Delete).selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a child query that touches none of the account's events reads nothing`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id = 32"] = setOf(32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":"event_id = 32"}"""),
|
||||
)
|
||||
assertTrue(query.selections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long event lists are split into chunks`() {
|
||||
val many = (1000L until 1000L + 1200).toSet()
|
||||
for (id in many) lookup.events[id] = 20
|
||||
lookup.matching[ProviderTable.REMINDERS to null] = many
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"reminders","columns":["minutes"]}"""))
|
||||
assertEquals(3, query.selections.size)
|
||||
assertTrue(query.selections.all { it.sql!!.startsWith("(event_id IN (") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extended properties are updated through their item URI, so only by id`() {
|
||||
val op = plan(CALENDAR, """{"op":"update","table":"extended_properties","id":70,"values":{"value":"v"}}""").single()
|
||||
assertEquals(PlannedOp.Update(ProviderTable.EXTENDED_PROPERTIES, Selection.NONE, 70, mapOf("value" to Cell.Text("v")), 1, false), op)
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","id":71,"values":{"value":"v"}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","where":"name = 'x'","values":{"value":"v"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asserts compare text and keep nulls`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"assert","table":"raw_contacts","id":1,"values":{"version":3,"dirty":0,"sync3":null,"sourceid":"c/1"}}""",
|
||||
).single() as PlannedOp.Assert
|
||||
assertEquals(mapOf("version" to "3", "dirty" to "0", "sync3" to null, "sourceid" to "c/1"), op.values)
|
||||
assertEquals(1, op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings are written by account`() {
|
||||
val insert = plan(CONTACTS, """{"op":"insert","table":"settings","values":{"ungrouped_visible":1,"should_sync":1}}""").single()
|
||||
assertEquals(Cell.Text(ME.name), (insert as PlannedOp.Insert).values["account_name"])
|
||||
val update = plan(CONTACTS, """{"op":"update","table":"settings","values":{"ungrouped_visible":1}}""").single()
|
||||
assertEquals(
|
||||
Selection("(account_name = ? AND account_type = ?)", listOf(ME.name, ME.type)),
|
||||
(update as PlannedOp.Update).selection,
|
||||
)
|
||||
}
|
||||
|
||||
// ── results ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `batch results are BatchResult JSON`() {
|
||||
val ok = JSONObject(ProviderResults.ok(listOf(OpOutcome(id = 7), OpOutcome(count = 1), OpOutcome())))
|
||||
assertEquals(true, ok.getBoolean("ok"))
|
||||
val results = ok.getJSONArray("results")
|
||||
assertEquals(7L, results.getJSONObject(0).getLong("id"))
|
||||
assertEquals(1, results.getJSONObject(1).getInt("count"))
|
||||
assertEquals(0, results.getJSONObject(2).length())
|
||||
val failed = JSONObject(ProviderResults.failed(BatchFailure.TOO_LARGE, "TransactionTooLargeException"))
|
||||
assertEquals(false, failed.getBoolean("ok"))
|
||||
assertEquals("tooLarge", failed.getString("reason"))
|
||||
assertEquals("TransactionTooLargeException", failed.getString("message"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an OperationApplicationException is an assert unless an insert got no row`() {
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Found value 4 when expected 3 for column version"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Expected 1 rows but actual 0"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("wrong number of rows: 0"))
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Insert into content://com.android.contacts/settings returned no result"),
|
||||
)
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Too many content provider operations between yield points."),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query rows are ProviderRows JSON with escaped text`() {
|
||||
val writer = ProviderRowsWriter(listOf("_id", "title", "x"))
|
||||
writer.beginRow()
|
||||
writer.longCell(9_007_199_254_740_991)
|
||||
writer.textCell("a \"quoted\"\\ line\nbreak\u0001 and \u2028")
|
||||
writer.nullCell()
|
||||
writer.endRow()
|
||||
writer.beginRow()
|
||||
writer.longCell(2)
|
||||
writer.doubleCell(1.5)
|
||||
writer.doubleCell(Double.NaN)
|
||||
writer.endRow()
|
||||
val parsed = JSONObject(writer.finish())
|
||||
assertEquals(JSONArray(listOf("_id", "title", "x")).toString(), parsed.getJSONArray("columns").toString())
|
||||
val rows = parsed.getJSONArray("rows")
|
||||
assertEquals(9_007_199_254_740_991L, rows.getJSONArray(0).getLong(0))
|
||||
assertEquals("a \"quoted\"\\ line\nbreak\u0001 and \u2028", rows.getJSONArray(0).getString(1))
|
||||
assertTrue(rows.getJSONArray(0).isNull(2))
|
||||
assertEquals(1.5, rows.getJSONArray(1).getDouble(1), 0.0)
|
||||
assertTrue(rows.getJSONArray(1).isNull(2))
|
||||
assertEquals("{\"columns\":[],\"rows\":[]}", ProviderRowsWriter(emptyList()).finish())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos decode close to the target size and scale their longer side down to it`() {
|
||||
assertEquals(1, PhotoScaling.sampleSize(720, 720, 512))
|
||||
assertEquals(4, PhotoScaling.sampleSize(4000, 3000, 512))
|
||||
assertEquals(512 to 384, PhotoScaling.targetSize(1000, 750, 512))
|
||||
assertEquals(96 to 96, PhotoScaling.targetSize(96, 96, 512))
|
||||
assertEquals(1 to 512, PhotoScaling.targetSize(3, 4000, 512))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PushRoutingTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
private val routes = PushRouting.parseRoutes(
|
||||
"""
|
||||
{
|
||||
"c": [{ "accountName": "alice@example.org", "authorities": ["$contacts", "$calendar"] }],
|
||||
"team": [
|
||||
{ "accountName": "alice@example.org", "authorities": ["$contacts"] },
|
||||
{ "accountName": "alice-work", "authorities": ["$calendar"] }
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
strict = true,
|
||||
)
|
||||
|
||||
private fun stateChange(changed: String) = mapOf(
|
||||
"kind" to "jmap-state-change",
|
||||
"accountLabel" to "alice",
|
||||
"accountId" to "c",
|
||||
"emailIds" to "[]",
|
||||
"changed" to changed,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a contact change requests a contacts sync and no mail task`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":{"ContactCard":"s1"}}"""), routes)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar types request calendar syncs of the routed accounts only`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"team":{"Calendar":"s1","CalendarEvent":"s2"}}"""), routes)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every account key is routed, not only the one repeated as accountId`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"c":{"AddressBook":"s1"},"team":{"ContactCard":"s2","CalendarEvent":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
// alice@example.org's contacts sync is requested once.
|
||||
assertEquals(setOf("alice@example.org" to contacts, "alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mail anywhere in the map starts the mail task, next to the syncs`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"team":{"CalendarEvent":"s1"},"c":{"EmailDelivery":"s2","Email":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email push always starts the mail task`() {
|
||||
val data = mapOf("kind" to "jmap-email-push", "accountId" to "c", "changed" to """{"c":{"EmailDelivery":"s"}}""")
|
||||
assertTrue(PushRouting.decide(data, routes).startMailTask)
|
||||
assertTrue(PushRouting.decide(mapOf("kind" to "jmap-email-push"), routes).startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payloads without a readable map are legacy mail pushes`() {
|
||||
for (changed in listOf(null, "", "{}", "not json", "[1]", """{"c":{}}""")) {
|
||||
val data = stateChange(changed ?: "").let { if (changed == null) it - "changed" else it }
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
assertTrue("changed=$changed", decision.startMailTask)
|
||||
assertTrue("changed=$changed", decision.syncs.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable account entry may be mail`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":"garbled","team":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts without routes are ignored`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"elsewhere":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.syncs.isEmpty())
|
||||
assertFalse(decision.startMailTask)
|
||||
assertTrue(PushRouting.decide(stateChange("""{"c":{"ContactCard":"s"}}"""), emptyMap()).syncs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored routes are validated strictly and read leniently`() {
|
||||
val bad = listOf(
|
||||
"[]",
|
||||
"""{"c":{}}""",
|
||||
"""{"c":[{"authorities":["$contacts"]}]}""",
|
||||
"""{"c":[{"accountName":"a","authorities":["com.example.tasks"]}]}""",
|
||||
)
|
||||
for (json in bad) {
|
||||
try {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
throw AssertionError("accepted $json")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes("[]"))
|
||||
assertEquals(
|
||||
mapOf("c" to listOf(PushRouting.Route("a", setOf(contacts)))),
|
||||
PushRouting.parseRoutes("""{"c":[{"accountName":"a","authorities":["$contacts","com.example.tasks"]},{"x":1}]}"""),
|
||||
)
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes(null))
|
||||
}
|
||||
}
|
||||
+49
-12
@@ -1018,39 +1018,76 @@ nested maps. Every provider call is scoped to one account of our type.
|
||||
| `openAccountSettings(name)` | the account's sync screen (`android.settings.ACCOUNT_SYNC_SETTINGS` with the Account), else `SYNC_SETTINGS` filtered to our type |
|
||||
| `finishRun(runId, reportJson)` / `isRunCancelled(runId)` | the run handshake |
|
||||
| `setPushRoutes(json)` | `{ jmapAccountId: [{ accountName, authorities }] }` for the push router, in SharedPreferences |
|
||||
| `showSyncProblem(name, title, text, uri, channelName)` / `clearSyncProblem(name)` | the account's one sync-problem notification; a tap opens `uri` (`bulwarkmobile://` only) in the app; `channelName` names the channel in the app's language |
|
||||
| `query(name, authority, queryJson)` | `ProviderQuery` → `ProviderRows` |
|
||||
| `applyBatch(name, authority, opsJson)` | `ProviderOp[]` → `BatchResult`; failures resolve as `{ok: false, reason}` |
|
||||
| `readSyncState(name, authority)` | the SyncState blob as text |
|
||||
| `readPhoto(name, rawContactId, maxPx)` | the display photo (or thumbnail), scaled, as JPEG base64 + file id |
|
||||
|
||||
Provider calls run one at a time on a thread of the module's own (a batch
|
||||
can hold the provider for seconds at a contended yield point), through an
|
||||
unstable provider client, so a provider process that dies fails the call
|
||||
instead of this process. `query`, `readSyncState` and `readPhoto` reject with
|
||||
code `scope`, `permission` or `provider`.
|
||||
|
||||
Events: `BulwarkDeviceSync:accountsChanged` (an account of our type was added
|
||||
or removed; JS re-lists). Events without a JS listener are dropped, so they are
|
||||
hints; the UI re-reads state on mount and on foreground.
|
||||
|
||||
Scoping, enforced in Kotlin for every op:
|
||||
Scoping, enforced in Kotlin for every op
|
||||
(`android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt`):
|
||||
|
||||
- URIs always carry `caller_is_syncadapter=true`, `account_name`,
|
||||
`account_type` (CalendarProvider accepts no other query parameters).
|
||||
- Queries and updates/deletes by `where` get the account condition ANDed in:
|
||||
`account_name/account_type` columns for raw_contacts, groups, settings,
|
||||
calendars, events, colors; for data rows, `raw_contact_id IN` our raw
|
||||
contacts; for attendees, reminders and extended properties, `event_id IN`
|
||||
our events (resolved first; the providers may compile selections strictly,
|
||||
so no subqueries). Ops by `id` verify the row belongs to the account first
|
||||
and expect exactly one row.
|
||||
- `account_name`/`account_type` columns for raw_contacts, groups,
|
||||
settings, calendars and colors;
|
||||
- data rows are filtered by ContactsProvider itself through the account
|
||||
URI parameters (their account columns are never selected on: Android 17
|
||||
drops them from the Data view for apps targeting API 37);
|
||||
- events get `calendar_id IN` the account's calendars (CalendarProvider
|
||||
updates events on the Events table, which has no account columns);
|
||||
- attendees, reminders and extended properties get `event_id IN` the
|
||||
account's events among those the op touches (resolved first; the
|
||||
providers may compile selections strictly, so no subqueries).
|
||||
- Ops by `id` verify the row belongs to the account first (`scope`
|
||||
otherwise) and expect exactly one row unless they set `expectCount`, so a
|
||||
row that vanished fails with `assert`. `id` and `where` together address
|
||||
the id row if it matches. On attendees, reminders and extended properties
|
||||
the id column is table-qualified (`Attendees._id`), since CalendarProvider
|
||||
reads attendees through a join where a bare `_id` is ambiguous; the engine
|
||||
therefore never writes `_id` into a `where` there. Extended properties are
|
||||
updated through their item URI, so by `id` only.
|
||||
- Inserts into data/attendees/reminders/extended_properties/events must name a
|
||||
parent of the account (a back-reference to an insert of the same batch, or
|
||||
an id that is verified).
|
||||
parent (a back-reference to an insert of the same batch, or an id); an
|
||||
insert or update naming a parent, exception master (`original_id`) or group
|
||||
(`data1` of a membership row) checks it: gone fails with `assert` (re-read
|
||||
and re-plan), another account's with `scope`. A back-reference must point
|
||||
to an earlier insert of the right table; settings inserts return no id.
|
||||
- `account_name`/`account_type`/`data_set` may only be written to restate the
|
||||
account, on the tables that have them (`scope` otherwise).
|
||||
- Tables are an enumerated set per authority; anything else is refused
|
||||
(`scope`).
|
||||
- Blobs (`{ b64 }`) are only accepted for `data.data15` (photos).
|
||||
- A `syncState` op must be the last op of its batch and not `yieldAllowed`,
|
||||
so a provider yield never commits the state ahead of the rows (refused as
|
||||
`scope` otherwise).
|
||||
- Batches: at most 499 ops between yield points (ContactsProvider throws at
|
||||
500), about 300 KB per call; `TransactionTooLargeException` resolves as
|
||||
- Batches: at most 499 ops up to and including each yield point
|
||||
(ContactsProvider counts an op before resetting at a yield point and throws
|
||||
at 500), about 300 KB per call; `TransactionTooLargeException` resolves as
|
||||
`tooLarge` so JS splits the batch. `OperationApplicationException` resolves
|
||||
as `assert`, `SecurityException` as `permission`.
|
||||
as `assert` (except the provider's own "too many operations" and failed
|
||||
inserts, `provider`), `SecurityException` as `permission`. The whole batch
|
||||
is also refused (`scope`) once the Android account is gone, so no rows
|
||||
outlive it.
|
||||
|
||||
What the providers do with values, which the planners account for:
|
||||
|
||||
- contacts `data1`..`data14` are TEXT columns: a number written there reads
|
||||
back as a string, so mappers normalise types before comparing;
|
||||
- `Events.dirty` and `Calendars.dirty` are NULL after a sync-adapter insert:
|
||||
planners write `dirty = 0` on every insert and select dirty rows with
|
||||
`dirty = 1`.
|
||||
|
||||
## App integration
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('FakeDeviceProviders: contacts', () => {
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: theirs.rawId, [Data.MIMETYPE]: MimeType.NOTE } },
|
||||
]);
|
||||
expect(orphan).toMatchObject({ ok: false, reason: 'scope' });
|
||||
await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toThrow(/scope/);
|
||||
await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toMatchObject({ code: 'scope' });
|
||||
});
|
||||
|
||||
it('never sets DIRTY for the sync adapter but does for app edits, and only apps soft-delete', async () => {
|
||||
@@ -162,7 +162,8 @@ describe('FakeDeviceProviders: contacts', () => {
|
||||
expect(photo?.jpegBase64).toBe('AAAA');
|
||||
expect(photo?.fileId).toEqual(expect.any(Number));
|
||||
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
|
||||
expect(rows.rows[0]).toEqual([photo!.fileId, null]);
|
||||
// data14 is a TEXT column: the file id reads back as text.
|
||||
expect(rows.rows[0]).toEqual([String(photo!.fileId), null]);
|
||||
});
|
||||
|
||||
it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => {
|
||||
@@ -268,13 +269,13 @@ describe('FakeDeviceProviders: calendar', () => {
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
const masterId = res.results[0].id!;
|
||||
const ex = fake.user.exceptionViaUri(masterId, 86_400_000, { [Events.SELF_ATTENDEE_STATUS]: 1 });
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBe(0);
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
|
||||
expect(fake.row('events', ex)).toMatchObject({ [Events.DIRTY]: 1, [Events._SYNC_ID]: null, [Events.UID_2445]: 'u1', [Events.ORIGINAL_SYNC_ID]: 'c/e1' });
|
||||
expect(fake.rows('attendees', 'event_id = ?', [ex])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
|
||||
const split = fake.user.splitViaUri(masterId, 5 * 86_400_000, '20260101T000000Z', {});
|
||||
expect(fake.row('events', split)![Events._SYNC_ID]).toBe('c/e1');
|
||||
expect(fake.row('events', masterId)![Events.RRULE]).toContain('UNTIL=20260101T000000Z');
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBe(0);
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,3 +292,179 @@ describe('AOSP format checks', () => {
|
||||
expect(aospRruleValid('COUNT=2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FakeDeviceProviders: the native bridge rules', () => {
|
||||
async function calendarWithEvent(fake: FakeDeviceProviders, account = ME) {
|
||||
const port = fake.port(account, CALENDAR_AUTHORITY);
|
||||
const res = await port.applyBatch([
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'calendars',
|
||||
values: {
|
||||
[Calendars.NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_COLOR]: -16776961,
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: 700,
|
||||
[Calendars.OWNER_ACCOUNT]: account,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'events',
|
||||
values: { [Events.DTSTART]: 0, [Events.DTEND]: 3_600_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 },
|
||||
refs: { [Events.CALENDAR_ID]: 0 },
|
||||
},
|
||||
{ op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: OTHER }, refs: { event_id: 1 } },
|
||||
{ op: 'insert', table: 'extended_properties', values: { name: 'x', value: '1' }, refs: { event_id: 1 } },
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
return {
|
||||
port,
|
||||
calendarId: res.results[0].id!,
|
||||
eventId: res.results[1].id!,
|
||||
attendeeId: res.results[2].id!,
|
||||
propertyId: res.results[3].id!,
|
||||
};
|
||||
}
|
||||
|
||||
it('stores numbers written to data1..data14 as text', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const res = await port.applyBatch([
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'data',
|
||||
values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHONE, [Data.DATA1]: '+1 555', [Data.DATA2]: 2 },
|
||||
},
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
const rows = await port.query({
|
||||
table: 'data',
|
||||
columns: [Data.DATA2, Data.RAW_CONTACT_ID],
|
||||
where: `${Data.MIMETYPE} = ?`,
|
||||
args: [MimeType.PHONE],
|
||||
});
|
||||
expect(rows.rows).toEqual([['2', rawId]]);
|
||||
// Asserts compare as text, so a number still matches.
|
||||
const ok = await port.applyBatch([{ op: 'assert', table: 'data', id: res.results[0].id!, values: { [Data.DATA2]: 2 } }]);
|
||||
expect(ok.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves DIRTY NULL on events and calendars the sync adapter inserts', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, calendarId, eventId } = await calendarWithEvent(fake);
|
||||
expect(fake.row('calendars', calendarId)![Calendars.DIRTY]).toBeNull();
|
||||
expect(fake.row('events', eventId)![Events.DIRTY]).toBeNull();
|
||||
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 0' })).rows).toEqual([]);
|
||||
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 1' })).rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('expects exactly the id row unless told otherwise, and applies where to it', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const gone = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000 }]);
|
||||
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const tolerated = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000, expectCount: 0 }]);
|
||||
expect(tolerated).toEqual({ ok: true, results: [{ count: 0 }] });
|
||||
const mismatch = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 1`, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(mismatch).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const matched = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 0`, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(matched).toEqual({ ok: true, results: [{ count: 1 }] });
|
||||
});
|
||||
|
||||
it('refuses _id in where on calendar child tables, and updates extended properties by id only', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, eventId, attendeeId, propertyId } = await calendarWithEvent(fake);
|
||||
await expect(
|
||||
port.query({ table: 'attendees', columns: ['event_id'], where: '_id = ?', args: [attendeeId] }),
|
||||
).rejects.toMatchObject({ code: 'provider' });
|
||||
const byWhere = await port.applyBatch([{ op: 'delete', table: 'attendees', where: '_id = ?', args: [attendeeId] }]);
|
||||
expect(byWhere).toMatchObject({ ok: false, reason: 'provider' });
|
||||
const byId = await port.applyBatch([{ op: 'delete', table: 'attendees', id: attendeeId }]);
|
||||
expect(byId).toEqual({ ok: true, results: [{ count: 1 }] });
|
||||
const propertiesByWhere = await port.applyBatch([
|
||||
{ op: 'update', table: 'extended_properties', where: 'event_id = ?', args: [eventId], values: { value: '2' } },
|
||||
]);
|
||||
expect(propertiesByWhere).toMatchObject({ ok: false, reason: 'provider' });
|
||||
const propertiesById = await port.applyBatch([
|
||||
{ op: 'update', table: 'extended_properties', id: propertyId, values: { value: '2' } },
|
||||
]);
|
||||
expect(propertiesById.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('fails with assert for a parent that is gone and with scope for one of another account', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const mine = await calendarWithEvent(fake);
|
||||
const theirs = await calendarWithEvent(fake, OTHER);
|
||||
const gone = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId + 1000, minutes: 10 } },
|
||||
]);
|
||||
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const foreign = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId, minutes: 10 } },
|
||||
]);
|
||||
expect(foreign).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const unnamed = await mine.port.applyBatch([{ op: 'insert', table: 'reminders', values: { minutes: 10 } }]);
|
||||
expect(unnamed).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const moveAway = await mine.port.applyBatch([
|
||||
{ op: 'update', table: 'events', id: mine.eventId, values: { [Events.CALENDAR_ID]: theirs.calendarId } },
|
||||
]);
|
||||
expect(moveAway).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const wrongRef = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'colors', values: { color_type: 0, color_index: '1', color: 1 } },
|
||||
{ op: 'insert', table: 'reminders', values: { minutes: 10 }, refs: { event_id: 0 } },
|
||||
]);
|
||||
expect(wrongRef).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const leaves = await mine.port.applyBatch([
|
||||
{ op: 'update', table: 'calendars', id: mine.calendarId, values: { [Calendars.ACCOUNT_NAME]: OTHER } },
|
||||
]);
|
||||
expect(leaves).toMatchObject({ ok: false, reason: 'scope' });
|
||||
});
|
||||
|
||||
it('wants syncState last and never at a yield point', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const early = await port.applyBatch([
|
||||
{ op: 'syncState', value: '{}' },
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(early).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const yielding = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
{ op: 'syncState', value: '{}', yieldAllowed: true },
|
||||
]);
|
||||
expect(yielding).toMatchObject({ ok: false, reason: 'scope' });
|
||||
expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns no id for a settings insert, which cannot be addressed by id either', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const port = fake.port(ME, CONTACTS_AUTHORITY);
|
||||
const res = await port.applyBatch([{ op: 'insert', table: 'settings', values: { ungrouped_visible: 1 } }]);
|
||||
expect(res).toEqual({ ok: true, results: [{}] });
|
||||
const byId = await port.applyBatch([{ op: 'delete', table: 'settings', id: 1 }]);
|
||||
expect(byId).toMatchObject({ ok: false, reason: 'provider' });
|
||||
});
|
||||
|
||||
it('counts the op at a yield point before resetting, like ContactsProvider', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const update = (yieldAllowed = false): ProviderOp => ({
|
||||
op: 'update',
|
||||
table: 'raw_contacts',
|
||||
id: rawId,
|
||||
values: { [RawContacts.SYNC2]: 'y' },
|
||||
yieldAllowed,
|
||||
});
|
||||
// 499 ops, then a yield point: fine.
|
||||
const fits = [...Array.from({ length: 498 }, () => update()), update(true), ...Array.from({ length: 10 }, () => update())];
|
||||
expect((await port.applyBatch(fits)).ok).toBe(true);
|
||||
// The yield point is the 500th op since the start: refused.
|
||||
const late = [...Array.from({ length: 499 }, () => update()), update(true)];
|
||||
expect(await port.applyBatch(late)).toMatchObject({ ok: false, reason: 'provider' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* "Device data model", and the AOSP facts quoted there):
|
||||
*
|
||||
* - Sync-adapter writes never set DIRTY; app writes do (data rows, events and
|
||||
* their attendees/reminders). The provider never clears DIRTY.
|
||||
* their attendees/reminders). The provider never clears DIRTY. Events and
|
||||
* calendars the sync adapter inserts have DIRTY NULL unless it writes one.
|
||||
* - Contacts `data1`..`data14` are TEXT columns: numbers are stored, and read
|
||||
* back, as their text.
|
||||
* - RawContacts.VERSION goes up on every data-row insert, update or delete,
|
||||
* whoever makes it, and on a DELETED change; never for DIRTY, SOURCE_ID or
|
||||
* SYNC1-4. Groups bump VERSION on every update.
|
||||
@@ -23,6 +26,15 @@
|
||||
* `P<n>D`, while `PT0S`-style all-day durations, NULL STATUS updates,
|
||||
* SELF_ATTENDEE_STATUS updates and RRULEs with unknown parts fail the batch.
|
||||
* - Scoping: rows of other accounts are invisible and untouchable.
|
||||
* - The native bridge's rules (android/.../sync/ProviderOps.kt and
|
||||
* ProviderScope.kt): an op with `id` expects exactly one row unless it says
|
||||
* otherwise, and applies `where` too; attendees, reminders and extended
|
||||
* properties refuse `_id` in `where` (ambiguous in CalendarProvider's
|
||||
* join), and extended properties are updated by `id` only; a named parent,
|
||||
* exception master or group that is gone fails with `assert`, one of
|
||||
* another account with `scope`; `syncState` must be the batch's last op
|
||||
* and no yield point; settings inserts return no id. Query and photo
|
||||
* errors carry the native rejection `code`.
|
||||
*
|
||||
* Pure TypeScript, for vitest only.
|
||||
*/
|
||||
@@ -106,8 +118,93 @@ class FakeProviderError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A rejected query or photo read, with the `code` the native module rejects with. */
|
||||
function rejection(code: BatchFailure, message: string): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
const isEmpty = (v: Cell | undefined) => v === null || v === undefined || v === '';
|
||||
|
||||
/** The parent row each row of a table belongs to. */
|
||||
const PARENTS: Partial<Record<ProviderTable, { column: string; table: ProviderTable }>> = {
|
||||
data: { column: Data.RAW_CONTACT_ID, table: 'raw_contacts' },
|
||||
events: { column: Events.CALENDAR_ID, table: 'calendars' },
|
||||
attendees: { column: Attendees.EVENT_ID, table: 'events' },
|
||||
reminders: { column: Reminders.EVENT_ID, table: 'events' },
|
||||
extended_properties: { column: ExtendedProperties.EVENT_ID, table: 'events' },
|
||||
};
|
||||
|
||||
/** Tables read through CalendarProvider's join, where a bare `_id` is ambiguous. */
|
||||
const JOINED_CHILD_TABLES: ReadonlySet<ProviderTable> = new Set(['attendees', 'reminders', 'extended_properties']);
|
||||
|
||||
/** Tables that carry the account in `account_name`/`account_type`. */
|
||||
const ACCOUNT_TABLES: ReadonlySet<ProviderTable> = new Set(['raw_contacts', 'groups', 'settings', 'calendars', 'colors']);
|
||||
|
||||
/**
|
||||
* The table a written column points into when the row it names must be the
|
||||
* account's too (ProviderOps.kt `referencedTable`): a parent, an exception's
|
||||
* master, a membership's group.
|
||||
*/
|
||||
function referencedTable(table: ProviderTable, column: string, values: WriteRow): ProviderTable | null {
|
||||
if (PARENTS[table]?.column === column) return PARENTS[table].table;
|
||||
if (table === 'events' && column === Events.ORIGINAL_ID) return 'events';
|
||||
if (table === 'data' && column === Data.DATA1 && values[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP) return 'groups';
|
||||
return null;
|
||||
}
|
||||
|
||||
const TEXT_DATA_COLUMNS: ReadonlySet<string> = new Set(numbered('data', 1, 14));
|
||||
|
||||
/** ContactsProvider's `data1`..`data14` have TEXT affinity: SQLite stores numbers as their text. */
|
||||
function textifyData(row: StoredRow): void {
|
||||
for (const column of TEXT_DATA_COLUMNS) {
|
||||
const v = row[column];
|
||||
if (typeof v === 'number') row[column] = String(v);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the native side refuses before touching the provider (ProviderOps.kt
|
||||
* `parseBatch`): a misplaced syncState, bad back-references, inserts that
|
||||
* name no parent, and contacts batches without enough yield points.
|
||||
*/
|
||||
function refuseBatch(authority: Authority, ops: ProviderOp[]): { reason: BatchFailure; message: string } | null {
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
const op = ops[i];
|
||||
if (op.op !== 'syncState') continue;
|
||||
if (i !== ops.length - 1) return { reason: 'scope', message: `op ${i}: syncState must be the last op of its batch` };
|
||||
if (op.yieldAllowed) return { reason: 'scope', message: `op ${i}: syncState must not be a yield point` };
|
||||
}
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
const op = ops[i];
|
||||
if (op.op !== 'insert') continue;
|
||||
for (const [column, ref] of Object.entries(op.refs ?? {})) {
|
||||
const target = ops[ref];
|
||||
if (!Number.isInteger(ref) || ref < 0 || ref >= i || target?.op !== 'insert' || target.table === 'settings') {
|
||||
return { reason: 'provider', message: `op ${i}: ${column} refers to op ${ref}, which is not an earlier insert with a row id` };
|
||||
}
|
||||
const expected = referencedTable(op.table, column, op.values);
|
||||
if (expected && target.table !== expected) {
|
||||
return { reason: 'scope', message: `op ${i}: ${column} must refer to a ${expected} insert, not ${target.table}` };
|
||||
}
|
||||
}
|
||||
const parent = PARENTS[op.table];
|
||||
if (parent && !(parent.column in (op.refs ?? {})) && (op.values[parent.column] ?? null) === null) {
|
||||
return { reason: 'scope', message: `op ${i}: a ${op.table} insert must name its ${parent.column}` };
|
||||
}
|
||||
}
|
||||
if (authority === CONTACTS_AUTHORITY) {
|
||||
// ContactsProvider counts an op before resetting at a yield point.
|
||||
let sinceYield = 0;
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
if (++sinceYield >= 500) {
|
||||
return { reason: 'provider', message: 'Too many content provider operations between yield points' };
|
||||
}
|
||||
if (i > 0 && ops[i].yieldAllowed) sinceYield = 0;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** AOSP calendarcommon2 Duration.parse: `[+-]P` then `<digits><W|D|H|M|S>`, `T` ignored. */
|
||||
export function aospDurationValid(value: string): boolean {
|
||||
return /^[+-]?P(T?\d+[WDHMS])*T?$/.test(value) && /\d/.test(value);
|
||||
@@ -408,15 +505,10 @@ export class FakeDeviceProviders {
|
||||
record(false);
|
||||
return { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' };
|
||||
}
|
||||
if (authority === CONTACTS_AUTHORITY) {
|
||||
let sinceYield = 0;
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
if (i > 0 && ops[i].yieldAllowed) sinceYield = 0;
|
||||
if (++sinceYield >= 500) {
|
||||
record(false);
|
||||
return { ok: false, reason: 'provider', message: 'Too many content provider operations between yield points' };
|
||||
}
|
||||
}
|
||||
const refusal = refuseBatch(authority, ops);
|
||||
if (refusal) {
|
||||
record(false);
|
||||
return { ok: false, ...refusal };
|
||||
}
|
||||
for (const hook of this.beforeBatch.splice(0)) hook();
|
||||
|
||||
@@ -469,6 +561,13 @@ function checkColumns(table: ProviderTable, columns: Iterable<string>, write: bo
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite on CalendarProvider's attendee join: "ambiguous column name: _id". Address those rows with `id`. */
|
||||
function checkNoBareId(table: ProviderTable, where: string | undefined): void {
|
||||
if (JOINED_CHILD_TABLES.has(table) && whereColumns(where).includes('_id')) {
|
||||
throw new FakeProviderError('provider', `ambiguous column name: _id (address ${table} rows with id)`);
|
||||
}
|
||||
}
|
||||
|
||||
class FakePort implements ProviderPort {
|
||||
constructor(
|
||||
private readonly fake: FakeDeviceProviders,
|
||||
@@ -478,12 +577,13 @@ class FakePort implements ProviderPort {
|
||||
|
||||
async query(q: ProviderQuery): Promise<ProviderRows> {
|
||||
if (!tablesFor(this.authority).includes(q.table)) {
|
||||
throw new Error(`scope: table ${q.table} is not part of ${this.authority}`);
|
||||
throw rejection('scope', `table ${q.table} is not part of ${this.authority}`);
|
||||
}
|
||||
try {
|
||||
checkColumns(q.table, [...q.columns, ...whereColumns(q.where)], false);
|
||||
checkNoBareId(q.table, q.where);
|
||||
} catch (e) {
|
||||
throw new Error((e as Error).message);
|
||||
throw rejection('provider', (e as Error).message);
|
||||
}
|
||||
const pred = compileWhere(q.where, q.args);
|
||||
const order = compileOrderBy(q.orderBy);
|
||||
@@ -507,7 +607,9 @@ class FakePort implements ProviderPort {
|
||||
|
||||
async readPhoto(rawContactId: number, _maxPx: number): Promise<PhotoData | null> {
|
||||
const rc = this.fake.table('raw_contacts').get(rawContactId);
|
||||
if (!rc || !this.fake.owned('raw_contacts', rc, this.accountName)) throw new Error('scope: raw contact of another account');
|
||||
if (!rc || !this.fake.owned('raw_contacts', rc, this.accountName)) {
|
||||
throw rejection('scope', `Raw contact ${rawContactId} is not part of the account`);
|
||||
}
|
||||
for (const row of this.fake.table('data').values()) {
|
||||
if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) {
|
||||
const fileId = row[Data.DATA14] === null ? null : Number(row[Data.DATA14]);
|
||||
@@ -545,22 +647,70 @@ class BatchApplier {
|
||||
if (!tablesFor(this.authority).includes(table)) this.scopeError(`table ${table} is not part of ${this.authority}`);
|
||||
}
|
||||
|
||||
/** The rows an op addresses: the `id` row if it also matches `where`, or every row of the account matching `where`. */
|
||||
private target(table: ProviderTable, id: number | undefined, where: string | undefined, args: Array<string | number> | undefined): number[] {
|
||||
if (id !== undefined && table === 'settings') throw new FakeProviderError('provider', 'settings rows have no id');
|
||||
checkColumns(table, whereColumns(where), false);
|
||||
checkNoBareId(table, where);
|
||||
const pred = compileWhere(where, args);
|
||||
const rows = this.fake.table(table);
|
||||
if (id !== undefined) {
|
||||
const row = rows.get(id);
|
||||
if (!row) return [];
|
||||
if (!this.fake.owned(table, row, this.account)) this.scopeError(`${table} ${id} belongs to another account`);
|
||||
return [id];
|
||||
return pred((c) => this.fake.readCell(table, row, c)) ? [id] : [];
|
||||
}
|
||||
checkColumns(table, whereColumns(where), false);
|
||||
const pred = compileWhere(where, args);
|
||||
return [...rows]
|
||||
.filter(([, row]) => this.fake.owned(table, row, this.account))
|
||||
.filter(([, row]) => pred((c) => this.fake.readCell(table, row, c)))
|
||||
.map(([rowId]) => rowId);
|
||||
}
|
||||
|
||||
/** An op that names one row by id expects exactly that row unless it says otherwise. */
|
||||
private checkCount(ids: number[], id: number | undefined, expectCount: number | undefined): void {
|
||||
const expected = expectCount ?? (id !== undefined ? 1 : undefined);
|
||||
if (expected !== undefined && ids.length !== expected) {
|
||||
throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}, expected ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Account columns may only restate the account, and only on the tables that have them. */
|
||||
private checkAccountColumns(table: ProviderTable, values: WriteRow): void {
|
||||
for (const column of ['account_name', 'account_type', 'data_set']) {
|
||||
if (!(column in values)) continue;
|
||||
const cell = values[column];
|
||||
const restates =
|
||||
ACCOUNT_TABLES.has(table) &&
|
||||
(column === 'account_name' ? cell === this.account : column === 'account_type' ? cell === this.fake.accountType : cell === null);
|
||||
if (!restates) this.scopeError(`${table}.${column} would leave the account`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents, masters and groups a written column names must be rows of the
|
||||
* account that still exist: a gone one fails with `assert` (the engine
|
||||
* re-reads), one of another account with `scope`. `skip`: columns filled by
|
||||
* back-references, checked before the batch ran.
|
||||
*/
|
||||
private checkReferences(table: ProviderTable, values: WriteRow, skip: ReadonlySet<string> = new Set()): void {
|
||||
for (const [column, cell] of Object.entries(values)) {
|
||||
if (skip.has(column)) continue;
|
||||
const referenced = referencedTable(table, column, values);
|
||||
if (!referenced) continue;
|
||||
if (cell === null) {
|
||||
if (PARENTS[table]?.column === column) this.scopeError(`${table}.${column} must name a ${referenced} row`);
|
||||
continue;
|
||||
}
|
||||
const id = typeof cell === 'number' ? cell : typeof cell === 'string' && /^\d+$/.test(cell) ? Number(cell) : NaN;
|
||||
if (!Number.isInteger(id)) throw new FakeProviderError('provider', `${table}.${column} must be a row id`);
|
||||
const row = this.fake.table(referenced).get(id);
|
||||
if (!row) throw new FakeProviderError('assert', `${table}.${column} names ${referenced} ${id}, which no longer exists`);
|
||||
if (!this.fake.owned(referenced, row, this.account)) {
|
||||
this.scopeError(`${table}.${column} names ${referenced} ${id} of another account`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private values(table: ProviderTable, values: WriteRow): StoredRow {
|
||||
checkColumns(table, Object.keys(values), true);
|
||||
const out: StoredRow = {};
|
||||
@@ -603,30 +753,21 @@ class BatchApplier {
|
||||
}
|
||||
|
||||
private insert(table: ProviderTable, values: WriteRow, refs: Record<string, number> | undefined, index: number): OpResult {
|
||||
this.checkAccountColumns(table, values);
|
||||
this.checkReferences(table, values, new Set(Object.keys(refs ?? {})));
|
||||
const row = this.values(table, values);
|
||||
// Back-references win over values, as ContentProviderOperation applies them last.
|
||||
for (const [column, ref] of Object.entries(refs ?? {})) {
|
||||
if (ref >= index || this.results[ref]?.id === undefined) throw new FakeProviderError('provider', `bad back-reference ${ref}`);
|
||||
row[column] = this.results[ref].id!;
|
||||
}
|
||||
const id = this.fake.allocId();
|
||||
const account = { account_name: this.account, account_type: this.fake.accountType };
|
||||
const parentOwned = (parentTable: ProviderTable, parentId: Cell) => {
|
||||
const parent = this.fake.table(parentTable).get(Number(parentId));
|
||||
if (!parent || !this.fake.owned(parentTable, parent, this.account)) {
|
||||
this.scopeError(`${table} insert names ${parentTable} ${String(parentId)} outside the account`);
|
||||
}
|
||||
return parent;
|
||||
};
|
||||
const accountMismatch = (r: StoredRow) =>
|
||||
(r.account_name !== undefined && r.account_name !== this.account) ||
|
||||
(r.account_type !== undefined && r.account_type !== this.fake.accountType);
|
||||
switch (table) {
|
||||
case 'raw_contacts':
|
||||
if (accountMismatch(row)) this.scopeError('raw contact of another account');
|
||||
Object.assign(row, { ...defaults(RAW_CONTACT_DEFAULTS), ...row, ...account });
|
||||
break;
|
||||
case 'groups':
|
||||
if (accountMismatch(row)) this.scopeError('group of another account');
|
||||
Object.assign(row, { ...defaults(GROUP_DEFAULTS), ...row, ...account });
|
||||
break;
|
||||
case 'settings': {
|
||||
@@ -634,21 +775,22 @@ class BatchApplier {
|
||||
if (s.account_name === this.account && s.account_type === this.fake.accountType) this.fake.table('settings').delete(sid);
|
||||
}
|
||||
Object.assign(row, { [ContactsSettings.UNGROUPED_VISIBLE]: 0, [ContactsSettings.SHOULD_SYNC]: 1, ...row, ...account });
|
||||
break;
|
||||
this.fake.table(table).set(id, row);
|
||||
// Keyed by the account: the insert answers without a row id.
|
||||
return {};
|
||||
}
|
||||
case 'data': {
|
||||
parentOwned('raw_contacts', row[Data.RAW_CONTACT_ID]);
|
||||
row[Data.RAW_CONTACT_ID] = Number(row[Data.RAW_CONTACT_ID]);
|
||||
if (isEmpty(row[Data.MIMETYPE])) throw new FakeProviderError('provider', 'data row without mimetype');
|
||||
row[Data.DATA_VERSION] = 0;
|
||||
if (row[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP) this.resolveMembership(row);
|
||||
this.processPhoto(row);
|
||||
this.fake.normalizeData(row);
|
||||
textifyData(row);
|
||||
this.insertedDataFor.add(Number(row[Data.RAW_CONTACT_ID]));
|
||||
break;
|
||||
}
|
||||
case 'calendars': {
|
||||
if (accountMismatch(row)) this.scopeError('calendar of another account');
|
||||
for (const c of [Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CALENDAR_ACCESS_LEVEL, Calendars.OWNER_ACCOUNT]) {
|
||||
if (isEmpty(row[c])) throw new FakeProviderError('provider', `calendar insert without ${c}`);
|
||||
}
|
||||
@@ -656,8 +798,9 @@ class BatchApplier {
|
||||
break;
|
||||
}
|
||||
case 'events': {
|
||||
const calendar = parentOwned('calendars', row[Events.CALENDAR_ID])!;
|
||||
row[Events.CALENDAR_ID] = Number(row[Events.CALENDAR_ID]);
|
||||
const calendarId = Number(row[Events.CALENDAR_ID]);
|
||||
row[Events.CALENDAR_ID] = calendarId;
|
||||
const calendar = this.fake.table('calendars').get(calendarId)!;
|
||||
Object.assign(row, { ...defaults(EVENT_DEFAULTS), ...row });
|
||||
this.fake.fixEvent(row, true);
|
||||
if (isEmpty(row[Events.ORGANIZER])) row[Events.ORGANIZER] = calendar[Calendars.OWNER_ACCOUNT];
|
||||
@@ -676,11 +819,9 @@ class BatchApplier {
|
||||
case 'attendees':
|
||||
case 'reminders':
|
||||
case 'extended_properties':
|
||||
parentOwned('events', row.event_id);
|
||||
row.event_id = Number(row.event_id);
|
||||
break;
|
||||
case 'colors':
|
||||
if (accountMismatch(row)) this.scopeError('color of another account');
|
||||
Object.assign(row, { ...row, ...account });
|
||||
break;
|
||||
}
|
||||
@@ -733,10 +874,14 @@ class BatchApplier {
|
||||
values: WriteRow,
|
||||
expectCount: number | undefined,
|
||||
): OpResult {
|
||||
const ids = this.target(table, id, where, args);
|
||||
if (expectCount !== undefined && ids.length !== expectCount) {
|
||||
throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`);
|
||||
this.checkAccountColumns(table, values);
|
||||
this.checkReferences(table, values);
|
||||
if (table === 'extended_properties' && (id === undefined || where !== undefined)) {
|
||||
// CalendarProvider updates them only through the item URI, which takes no selection.
|
||||
throw new FakeProviderError('provider', 'extended properties can only be updated by id');
|
||||
}
|
||||
const ids = this.target(table, id, where, args);
|
||||
this.checkCount(ids, id, expectCount);
|
||||
const patch = this.values(table, values);
|
||||
for (const rowId of ids) {
|
||||
const row = this.fake.table(table).get(rowId)!;
|
||||
@@ -757,6 +902,7 @@ class BatchApplier {
|
||||
row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1;
|
||||
this.processPhoto(row);
|
||||
this.fake.normalizeData(row);
|
||||
textifyData(row);
|
||||
this.fake.bumpVersion(Number(row[Data.RAW_CONTACT_ID]));
|
||||
break;
|
||||
case 'events':
|
||||
@@ -781,9 +927,7 @@ class BatchApplier {
|
||||
expectCount: number | undefined,
|
||||
): OpResult {
|
||||
const ids = this.target(table, id, where, args);
|
||||
if (expectCount !== undefined && ids.length !== expectCount) {
|
||||
throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`);
|
||||
}
|
||||
this.checkCount(ids, id, expectCount);
|
||||
for (const rowId of ids) {
|
||||
const row = this.fake.table(table).get(rowId);
|
||||
if (!row) continue;
|
||||
@@ -802,9 +946,7 @@ class BatchApplier {
|
||||
expectCount: number | undefined,
|
||||
): OpResult {
|
||||
const ids = this.target(table, id, where, args);
|
||||
if (expectCount !== undefined && ids.length !== expectCount) {
|
||||
throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`);
|
||||
}
|
||||
this.checkCount(ids, id, expectCount);
|
||||
if (values) {
|
||||
checkColumns(table, Object.keys(values), false);
|
||||
for (const rowId of ids) {
|
||||
@@ -859,11 +1001,12 @@ const CALENDAR_DEFAULTS: StoredRow = {
|
||||
[Calendars.MAX_REMINDERS]: 5,
|
||||
[Calendars.CAN_ORGANIZER_RESPOND]: 1,
|
||||
[Calendars.CAN_MODIFY_TIME_ZONE]: 1,
|
||||
[Calendars.DIRTY]: 0,
|
||||
// No column default: NULL until someone writes it.
|
||||
[Calendars.DIRTY]: null,
|
||||
};
|
||||
|
||||
const EVENT_DEFAULTS: StoredRow = {
|
||||
[Events.DIRTY]: 0,
|
||||
[Events.DIRTY]: null,
|
||||
[Events.DELETED]: 0,
|
||||
[Events.AVAILABILITY]: 0,
|
||||
[Events.ACCESS_LEVEL]: 0,
|
||||
@@ -909,6 +1052,7 @@ export class FakeUser {
|
||||
const id = this.fake.allocId();
|
||||
const row: StoredRow = { ...values, [Data.RAW_CONTACT_ID]: rawContactId, [Data.DATA_VERSION]: 0 };
|
||||
this.fake.normalizeData(row);
|
||||
textifyData(row);
|
||||
this.fake.table('data').set(id, row);
|
||||
this.fake.bumpVersion(rawContactId);
|
||||
this.dirtyContact(rawContactId);
|
||||
@@ -922,6 +1066,7 @@ export class FakeUser {
|
||||
Object.assign(row, values);
|
||||
row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1;
|
||||
this.fake.normalizeData(row);
|
||||
textifyData(row);
|
||||
const rcId = Number(row[Data.RAW_CONTACT_ID]);
|
||||
this.fake.bumpVersion(rcId);
|
||||
this.dirtyContact(rcId);
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import {
|
||||
clearSyncProblem,
|
||||
createProviderPort,
|
||||
ensureAndroidAccount,
|
||||
finishRun,
|
||||
getDeviceSyncModule,
|
||||
getSyncSettings,
|
||||
isDeviceSyncAvailable,
|
||||
isRunCancelled,
|
||||
listAndroidAccounts,
|
||||
onAccountsChanged,
|
||||
openAccountSettings,
|
||||
removeAndroidAccount,
|
||||
requestSync,
|
||||
setPeriodicSync,
|
||||
setPushRoutes,
|
||||
setSyncEnabled,
|
||||
showSyncProblem,
|
||||
} from '../native';
|
||||
import {
|
||||
ACCOUNTS_CHANGED_EVENT,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type AccountSyncSettings,
|
||||
type Authority,
|
||||
type ProviderOp,
|
||||
type RunReport,
|
||||
} from '../types';
|
||||
|
||||
const modules = NativeModules as Record<string, unknown>;
|
||||
const ACCOUNT = 'alice@example.org';
|
||||
|
||||
const SETTINGS: AccountSyncSettings = {
|
||||
masterAutomatic: true,
|
||||
authorities: {
|
||||
[CONTACTS_AUTHORITY]: { syncable: 1, automatic: true, periodicSeconds: 3600, active: false, pending: false },
|
||||
[CALENDAR_AUTHORITY]: { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Installs a BulwarkDeviceSync module whose methods resolve like the Kotlin one. */
|
||||
function installModule(overrides: Record<string, unknown> = {}) {
|
||||
const module = {
|
||||
getInfo: vi.fn(async () => ({ accountType: 'com.anonymous.bulwarkmobile.account', sdkInt: 34 })),
|
||||
listAccounts: vi.fn(async () => [{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]),
|
||||
ensureAccount: vi.fn(async () => true),
|
||||
removeAccount: vi.fn(async () => true),
|
||||
getSyncSettings: vi.fn(async () => SETTINGS),
|
||||
setSyncEnabled: vi.fn(async () => undefined),
|
||||
setPeriodicSync: vi.fn(async () => undefined),
|
||||
requestSync: vi.fn(async () => undefined),
|
||||
openAccountSettings: vi.fn(async () => undefined),
|
||||
finishRun: vi.fn(async () => true),
|
||||
isRunCancelled: vi.fn(async () => false),
|
||||
setPushRoutes: vi.fn(async () => undefined),
|
||||
query: vi.fn(async () => JSON.stringify({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] })),
|
||||
applyBatch: vi.fn(async () => JSON.stringify({ ok: true, results: [{ id: 5 }, { count: 1 }] })),
|
||||
readSyncState: vi.fn(async (): Promise<string | null> => null),
|
||||
readPhoto: vi.fn(async (): Promise<string | null> => null),
|
||||
showSyncProblem: vi.fn(async () => undefined),
|
||||
clearSyncProblem: vi.fn(async () => undefined),
|
||||
addListener: vi.fn(),
|
||||
removeListeners: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
modules.BulwarkDeviceSync = module;
|
||||
return module;
|
||||
}
|
||||
|
||||
const TWO_OPS: ProviderOp[] = [
|
||||
{ op: 'insert', table: 'raw_contacts', values: { sourceid: null } },
|
||||
{ op: 'update', table: 'data', id: 7, values: { data1: 'x' } },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
delete modules.BulwarkDeviceSync;
|
||||
(Platform as { OS: string }).OS = 'android';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('module lookup', () => {
|
||||
it('reads NativeModules on every call instead of caching the module', () => {
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
const first = installModule();
|
||||
expect(getDeviceSyncModule()).toBe(first);
|
||||
const second = installModule();
|
||||
expect(getDeviceSyncModule()).toBe(second);
|
||||
delete modules.BulwarkDeviceSync;
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
});
|
||||
|
||||
it('is off on iOS even if a module of that name exists', () => {
|
||||
installModule();
|
||||
(Platform as { OS: string }).OS = 'ios';
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
expect(isDeviceSyncAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('is available on Android with the module', () => {
|
||||
expect(isDeviceSyncAvailable()).toBe(false);
|
||||
installModule();
|
||||
expect(isDeviceSyncAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects everything, and subscribes nothing, without the module', async () => {
|
||||
const calls: Array<() => Promise<unknown>> = [
|
||||
() => listAndroidAccounts(),
|
||||
() => ensureAndroidAccount(ACCOUNT, 'r'),
|
||||
() => removeAndroidAccount(ACCOUNT),
|
||||
() => getSyncSettings(ACCOUNT),
|
||||
() => setSyncEnabled(ACCOUNT, CONTACTS_AUTHORITY, true),
|
||||
() => setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 3600),
|
||||
() => requestSync(ACCOUNT, CONTACTS_AUTHORITY),
|
||||
() => openAccountSettings(ACCOUNT),
|
||||
() => finishRun('run', {} as RunReport),
|
||||
() => isRunCancelled('run'),
|
||||
() => setPushRoutes({}),
|
||||
() => showSyncProblem(ACCOUNT, 't', 'x', 'bulwarkmobile://login'),
|
||||
() => clearSyncProblem(ACCOUNT),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).query({ table: 'raw_contacts', columns: ['_id'] }),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch([]),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readSyncState(),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readPhoto(1, 512),
|
||||
];
|
||||
for (const call of calls) {
|
||||
await expect(call()).rejects.toMatchObject({ code: 'unavailable' });
|
||||
}
|
||||
const unsubscribe = onAccountsChanged(() => undefined);
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProviderPort', () => {
|
||||
it('is bound to its account and authority', () => {
|
||||
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
expect(port.accountName).toBe(ACCOUNT);
|
||||
expect(port.authority).toBe(CALENDAR_AUTHORITY);
|
||||
});
|
||||
|
||||
it('sends a query as JSON and parses the rows', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
const rows = await port.query({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] });
|
||||
expect(rows).toEqual({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] });
|
||||
expect(module.query).toHaveBeenCalledWith(
|
||||
ACCOUNT,
|
||||
CONTACTS_AUTHORITY,
|
||||
JSON.stringify({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on query replies that are not ProviderRows', async () => {
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
const q = { table: 'raw_contacts' as const, columns: ['_id'] };
|
||||
for (const reply of [
|
||||
'not json',
|
||||
JSON.stringify({ columns: ['_id'] }),
|
||||
JSON.stringify({ columns: ['_id'], rows: [[1, 2]] }),
|
||||
JSON.stringify({ columns: ['_id'], rows: [[{ b64: 'AA' }]] }),
|
||||
JSON.stringify({ columns: [7], rows: [] }),
|
||||
42,
|
||||
]) {
|
||||
installModule({ query: vi.fn(async () => reply) });
|
||||
await expect(port.query(q)).rejects.toThrow(/BulwarkDeviceSync\.query/);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes native rejections through with their code', async () => {
|
||||
const scope = Object.assign(new Error('Table instances is not part of com.android.calendar'), { code: 'scope' });
|
||||
installModule({ query: vi.fn(async () => Promise.reject(scope)) });
|
||||
await expect(
|
||||
createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).query({ table: 'events', columns: ['_id'] }),
|
||||
).rejects.toBe(scope);
|
||||
});
|
||||
|
||||
it('sends ops as JSON and returns the batch result', async () => {
|
||||
const module = installModule();
|
||||
const result = await createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS);
|
||||
expect(result).toEqual({ ok: true, results: [{ id: 5 }, { count: 1 }] });
|
||||
expect(module.applyBatch).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, JSON.stringify(TWO_OPS));
|
||||
});
|
||||
|
||||
it('returns failed batches as results', async () => {
|
||||
installModule({
|
||||
applyBatch: vi.fn(async () => JSON.stringify({ ok: false, reason: 'assert', message: 'Expected 1 rows but actual 0' })),
|
||||
});
|
||||
await expect(createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: 'assert',
|
||||
message: 'Expected 1 rows but actual 0',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on batch replies that are not BatchResults', async () => {
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
for (const reply of [
|
||||
JSON.stringify({ ok: false, reason: 'weird', message: 'x' }),
|
||||
JSON.stringify({ ok: true, results: [{ id: 'five' }, {}] }),
|
||||
JSON.stringify({ ok: true, results: [{ id: 5 }] }),
|
||||
JSON.stringify({ ok: true }),
|
||||
'[]',
|
||||
]) {
|
||||
installModule({ applyBatch: vi.fn(async () => reply) });
|
||||
await expect(port.applyBatch(TWO_OPS)).rejects.toThrow(/BulwarkDeviceSync\.applyBatch/);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the SyncState as text', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
await expect(port.readSyncState()).resolves.toBeNull();
|
||||
module.readSyncState.mockResolvedValueOnce('{"v":1}');
|
||||
await expect(port.readSyncState()).resolves.toBe('{"v":1}');
|
||||
expect(module.readSyncState).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
});
|
||||
|
||||
it('reads photos of contacts only', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
await expect(port.readPhoto(3, 512)).resolves.toBeNull();
|
||||
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/', fileId: 12 }));
|
||||
await expect(port.readPhoto(3, 512)).resolves.toEqual({ jpegBase64: '/9j/', fileId: 12 });
|
||||
expect(module.readPhoto).toHaveBeenLastCalledWith(ACCOUNT, 3, 512);
|
||||
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/' }));
|
||||
await expect(port.readPhoto(3, 512)).rejects.toThrow(/readPhoto/);
|
||||
await expect(createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).readPhoto(3, 512)).rejects.toThrow(/contacts/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accounts and sync settings', () => {
|
||||
it('passes account calls through', async () => {
|
||||
const module = installModule();
|
||||
await expect(listAndroidAccounts()).resolves.toEqual([{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]);
|
||||
await expect(ensureAndroidAccount(ACCOUNT, 'reg')).resolves.toBe(true);
|
||||
expect(module.ensureAccount).toHaveBeenCalledWith(ACCOUNT, 'reg');
|
||||
await expect(removeAndroidAccount(ACCOUNT)).resolves.toBe(true);
|
||||
expect(module.removeAccount).toHaveBeenCalledWith(ACCOUNT);
|
||||
await expect(getSyncSettings(ACCOUNT)).resolves.toEqual(SETTINGS);
|
||||
await setSyncEnabled(ACCOUNT, CALENDAR_AUTHORITY, false);
|
||||
expect(module.setSyncEnabled).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY, false);
|
||||
await openAccountSettings(ACCOUNT);
|
||||
expect(module.openAccountSettings).toHaveBeenCalledWith(ACCOUNT);
|
||||
});
|
||||
|
||||
it('lets a registry id conflict reject with its code', async () => {
|
||||
const conflict = Object.assign(new Error('belongs to another app account'), { code: 'conflict' });
|
||||
installModule({ ensureAccount: vi.fn(async () => Promise.reject(conflict)) });
|
||||
await expect(ensureAndroidAccount(ACCOUNT, 'other')).rejects.toMatchObject({ code: 'conflict' });
|
||||
});
|
||||
|
||||
it('sends whole seconds for periodic syncs and refuses nonsense', async () => {
|
||||
const module = installModule();
|
||||
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 899.6);
|
||||
expect(module.setPeriodicSync).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 900);
|
||||
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 0);
|
||||
expect(module.setPeriodicSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 0);
|
||||
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, -1)).rejects.toBeInstanceOf(RangeError);
|
||||
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, Number.NaN)).rejects.toBeInstanceOf(RangeError);
|
||||
});
|
||||
|
||||
it('sends sync request options as JSON', async () => {
|
||||
const module = installModule();
|
||||
await requestSync(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
expect(module.requestSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, '{}');
|
||||
await requestSync(ACCOUNT, CALENDAR_AUTHORITY, { manual: true, expedited: true });
|
||||
expect(module.requestSync).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
CALENDAR_AUTHORITY,
|
||||
JSON.stringify({ manual: true, expedited: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run handshake', () => {
|
||||
it('sends the report as JSON and returns whether it was accepted', async () => {
|
||||
const module = installModule({ finishRun: vi.fn(async () => false) });
|
||||
const report = { v: 1, runId: 'r1', authority: CONTACTS_AUTHORITY, outcome: 'ok' } as RunReport;
|
||||
await expect(finishRun('r1', report)).resolves.toBe(false);
|
||||
expect(module.finishRun).toHaveBeenCalledWith('r1', JSON.stringify(report));
|
||||
});
|
||||
|
||||
it('asks whether a run was cancelled', async () => {
|
||||
const module = installModule({ isRunCancelled: vi.fn(async () => true) });
|
||||
await expect(isRunCancelled('r1')).resolves.toBe(true);
|
||||
expect(module.isRunCancelled).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('push routes and notifications', () => {
|
||||
it('stores push routes as JSON', async () => {
|
||||
const module = installModule();
|
||||
const routes: Record<string, Array<{ accountName: string; authorities: Authority[] }>> = {
|
||||
c: [{ accountName: ACCOUNT, authorities: [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY] }],
|
||||
};
|
||||
await setPushRoutes(routes);
|
||||
expect(module.setPushRoutes).toHaveBeenCalledWith(JSON.stringify(routes));
|
||||
});
|
||||
|
||||
it('posts and clears the sync problem notification', async () => {
|
||||
const module = installModule();
|
||||
await showSyncProblem(ACCOUNT, 'Sign in again', 'Contacts stopped syncing', 'bulwarkmobile://login');
|
||||
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
'Sign in again',
|
||||
'Contacts stopped syncing',
|
||||
'bulwarkmobile://login',
|
||||
null,
|
||||
);
|
||||
await showSyncProblem(ACCOUNT, 'Erneut anmelden', 'Kontakte', 'bulwarkmobile://login', 'Synchronisierungsprobleme');
|
||||
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
'Erneut anmelden',
|
||||
'Kontakte',
|
||||
'bulwarkmobile://login',
|
||||
'Synchronisierungsprobleme',
|
||||
);
|
||||
await clearSyncProblem(ACCOUNT);
|
||||
expect(module.clearSyncProblem).toHaveBeenCalledWith(ACCOUNT);
|
||||
});
|
||||
|
||||
it('listens for account changes until unsubscribed', () => {
|
||||
installModule();
|
||||
const remove = vi.fn();
|
||||
let handler: (() => void) | undefined;
|
||||
const addListener = vi
|
||||
.spyOn(NativeEventEmitter.prototype, 'addListener')
|
||||
.mockImplementation(((event: string, h: () => void) => {
|
||||
expect(event).toBe(ACCOUNTS_CHANGED_EVENT);
|
||||
handler = h;
|
||||
return { remove };
|
||||
}) as never);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = onAccountsChanged(listener);
|
||||
expect(addListener).toHaveBeenCalledTimes(1);
|
||||
handler?.();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Typed wrapper around `NativeModules.BulwarkDeviceSync` (#34): the one module
|
||||
* of device sync that talks to React Native's bridge. The raw contract is
|
||||
* `DeviceSyncNativeModule` in ./types; docs/device-sync.md ("Native module
|
||||
* API") explains it.
|
||||
*
|
||||
* The native module is looked up on every call and never cached, so a dev
|
||||
* reload or an APK built before device sync never leaves a stale reference.
|
||||
* Everything except `getDeviceSyncModule` and `isDeviceSyncAvailable`
|
||||
* rejects when the module is missing (iOS, or an old APK), and
|
||||
* `onAccountsChanged` then returns an unsubscribe that does nothing.
|
||||
*
|
||||
* Provider data crosses the bridge as JSON strings; replies are checked here,
|
||||
* so a malformed one fails loudly instead of reaching the engine.
|
||||
*/
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import {
|
||||
ACCOUNTS_CHANGED_EVENT,
|
||||
CONTACTS_AUTHORITY,
|
||||
type AccountSyncSettings,
|
||||
type AndroidAccount,
|
||||
type Authority,
|
||||
type BatchFailure,
|
||||
type BatchResult,
|
||||
type Cell,
|
||||
type DeviceSyncNativeModule,
|
||||
type OpResult,
|
||||
type PhotoData,
|
||||
type ProviderPort,
|
||||
type ProviderRows,
|
||||
type RequestSyncOptions,
|
||||
type RunReport,
|
||||
} from './types';
|
||||
|
||||
export function getDeviceSyncModule(): DeviceSyncNativeModule | null {
|
||||
if (Platform.OS !== 'android') return null;
|
||||
return (
|
||||
((NativeModules as Record<string, unknown>).BulwarkDeviceSync as DeviceSyncNativeModule | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Android with a build that carries the native module. */
|
||||
export function isDeviceSyncAvailable(): boolean {
|
||||
return getDeviceSyncModule() !== null;
|
||||
}
|
||||
|
||||
function requireModule(): DeviceSyncNativeModule {
|
||||
const module = getDeviceSyncModule();
|
||||
if (!module) {
|
||||
throw Object.assign(new Error('Device sync is not available in this build'), { code: 'unavailable' });
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
// ─── Provider ──────────────────────────────────────────
|
||||
|
||||
/** The provider bound to one Android account of our type and one authority. */
|
||||
export function createProviderPort(accountName: string, authority: Authority): ProviderPort {
|
||||
return {
|
||||
accountName,
|
||||
authority,
|
||||
async query(q) {
|
||||
const json = await requireModule().query(accountName, authority, JSON.stringify(q));
|
||||
return toRows(parseReply(json, 'query'));
|
||||
},
|
||||
async applyBatch(ops) {
|
||||
const json = await requireModule().applyBatch(accountName, authority, JSON.stringify(ops));
|
||||
const result = toBatchResult(parseReply(json, 'applyBatch'));
|
||||
if (result.ok && result.results.length !== ops.length) {
|
||||
throw malformed('applyBatch', `${result.results.length} results for ${ops.length} ops`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async readSyncState() {
|
||||
const state: unknown = await requireModule().readSyncState(accountName, authority);
|
||||
if (state === null || state === undefined) return null;
|
||||
if (typeof state !== 'string') throw malformed('readSyncState', 'not text');
|
||||
return state;
|
||||
},
|
||||
async readPhoto(rawContactId, maxPx) {
|
||||
if (authority !== CONTACTS_AUTHORITY) throw new Error('readPhoto reads contacts: this port is for calendars');
|
||||
const json: unknown = await requireModule().readPhoto(accountName, rawContactId, maxPx);
|
||||
return json === null || json === undefined ? null : toPhoto(parseReply(json, 'readPhoto'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function malformed(method: string, detail: string): Error {
|
||||
return new Error(`BulwarkDeviceSync.${method} replied with malformed data: ${detail}`);
|
||||
}
|
||||
|
||||
function parseReply(json: unknown, method: string): unknown {
|
||||
if (typeof json !== 'string') throw malformed(method, `a ${typeof json}, not JSON text`);
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
throw malformed(method, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
const isCell = (v: unknown): v is Cell => v === null || typeof v === 'string' || typeof v === 'number';
|
||||
const isOptionalNumber = (v: unknown) => v === undefined || typeof v === 'number';
|
||||
|
||||
function toRows(value: unknown): ProviderRows {
|
||||
if (!isObject(value) || !Array.isArray(value.columns) || !Array.isArray(value.rows)) {
|
||||
throw malformed('query', 'no columns and rows');
|
||||
}
|
||||
const { columns, rows } = value;
|
||||
if (!columns.every((c) => typeof c === 'string')) throw malformed('query', 'a column name is not text');
|
||||
for (const row of rows) {
|
||||
if (!Array.isArray(row) || row.length !== columns.length || !row.every(isCell)) {
|
||||
throw malformed('query', 'a row does not match its columns');
|
||||
}
|
||||
}
|
||||
return { columns: columns as string[], rows: rows as Cell[][] };
|
||||
}
|
||||
|
||||
const BATCH_FAILURES: readonly BatchFailure[] = ['assert', 'tooLarge', 'scope', 'permission', 'provider'];
|
||||
|
||||
function toBatchResult(value: unknown): BatchResult {
|
||||
if (isObject(value) && value.ok === true && Array.isArray(value.results)) {
|
||||
const results: OpResult[] = value.results.map((r) => {
|
||||
if (!isObject(r) || !isOptionalNumber(r.id) || !isOptionalNumber(r.count)) {
|
||||
throw malformed('applyBatch', 'an op result is not { id?, count? }');
|
||||
}
|
||||
return r as OpResult;
|
||||
});
|
||||
return { ok: true, results };
|
||||
}
|
||||
if (
|
||||
isObject(value) &&
|
||||
value.ok === false &&
|
||||
BATCH_FAILURES.includes(value.reason as BatchFailure) &&
|
||||
typeof value.message === 'string'
|
||||
) {
|
||||
return { ok: false, reason: value.reason as BatchFailure, message: value.message };
|
||||
}
|
||||
throw malformed('applyBatch', 'not a BatchResult');
|
||||
}
|
||||
|
||||
function toPhoto(value: unknown): PhotoData {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
typeof value.jpegBase64 !== 'string' ||
|
||||
!(value.fileId === null || typeof value.fileId === 'number')
|
||||
) {
|
||||
throw malformed('readPhoto', 'not { jpegBase64, fileId }');
|
||||
}
|
||||
return { jpegBase64: value.jpegBase64, fileId: value.fileId };
|
||||
}
|
||||
|
||||
// ─── Accounts and sync settings ────────────────────────
|
||||
|
||||
export async function listAndroidAccounts(): Promise<AndroidAccount[]> {
|
||||
return requireModule().listAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the Android account for an app account. Resolves true when it was
|
||||
* created, false when it exists for this registry id already; rejects with
|
||||
* code `conflict` when an account of that name belongs to another one.
|
||||
*/
|
||||
export async function ensureAndroidAccount(name: string, registryId: string): Promise<boolean> {
|
||||
return requireModule().ensureAccount(name, registryId);
|
||||
}
|
||||
|
||||
/** Removes the Android account; the providers drop all of its rows. */
|
||||
export async function removeAndroidAccount(name: string): Promise<boolean> {
|
||||
return requireModule().removeAccount(name);
|
||||
}
|
||||
|
||||
export async function getSyncSettings(name: string): Promise<AccountSyncSettings> {
|
||||
return requireModule().getSyncSettings(name);
|
||||
}
|
||||
|
||||
export async function setSyncEnabled(name: string, authority: Authority, enabled: boolean): Promise<void> {
|
||||
await requireModule().setSyncEnabled(name, authority, enabled);
|
||||
}
|
||||
|
||||
/** Replaces the periodic sync; 0 removes it. Android raises anything shorter than 15 minutes to that. */
|
||||
export async function setPeriodicSync(name: string, authority: Authority, seconds: number): Promise<void> {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) throw new RangeError(`Invalid sync interval: ${seconds}`);
|
||||
await requireModule().setPeriodicSync(name, authority, Math.round(seconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks SyncManager for a sync. Repeated requests with the same options
|
||||
* coalesce; one that arrives while the same sync runs makes that run ask for
|
||||
* another one when it ends.
|
||||
*/
|
||||
export async function requestSync(name: string, authority: Authority, options: RequestSyncOptions = {}): Promise<void> {
|
||||
await requireModule().requestSync(name, authority, JSON.stringify(options));
|
||||
}
|
||||
|
||||
/** The account's sync screen in Android Settings (or the list of synced accounts). */
|
||||
export async function openAccountSettings(name: string): Promise<void> {
|
||||
await requireModule().openAccountSettings(name);
|
||||
}
|
||||
|
||||
// ─── The run handshake ─────────────────────────────────
|
||||
|
||||
/** Hands the report to the waiting adapter; false when the run already ended (deadline, cancel) or is unknown. */
|
||||
export async function finishRun(runId: string, report: RunReport): Promise<boolean> {
|
||||
return requireModule().finishRun(runId, JSON.stringify(report));
|
||||
}
|
||||
|
||||
/** True once the adapter stopped waiting for the run (cancelled, out of time, or unknown). */
|
||||
export async function isRunCancelled(runId: string): Promise<boolean> {
|
||||
return requireModule().isRunCancelled(runId);
|
||||
}
|
||||
|
||||
// ─── Push routes and notifications ─────────────────────
|
||||
|
||||
/**
|
||||
* Tells the native push router which Android accounts each JMAP account feeds,
|
||||
* so a pushed contact or calendar change requests their syncs without JS.
|
||||
* Replaces the previous routes.
|
||||
*/
|
||||
export async function setPushRoutes(
|
||||
routes: Record<string, Array<{ accountName: string; authorities: Authority[] }>>,
|
||||
): Promise<void> {
|
||||
await requireModule().setPushRoutes(JSON.stringify(routes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts (or replaces) the account's "sync problem" notification; tapping it
|
||||
* opens `uri`, a `bulwarkmobile://` deep link. `channelName` names the
|
||||
* notification channel in the app's language (English when omitted).
|
||||
*/
|
||||
export async function showSyncProblem(
|
||||
accountName: string,
|
||||
title: string,
|
||||
text: string,
|
||||
uri: string,
|
||||
channelName?: string,
|
||||
): Promise<void> {
|
||||
await requireModule().showSyncProblem(accountName, title, text, uri, channelName ?? null);
|
||||
}
|
||||
|
||||
export async function clearSyncProblem(accountName: string): Promise<void> {
|
||||
await requireModule().clearSyncProblem(accountName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `listener` when an account of our type is added or removed (also in
|
||||
* Android Settings). A hint only: events without a JS listener are dropped,
|
||||
* so re-read the accounts on mount and on foreground too.
|
||||
*/
|
||||
export function onAccountsChanged(listener: () => void): () => void {
|
||||
const module = getDeviceSyncModule();
|
||||
if (!module) return () => undefined;
|
||||
const subscription = new NativeEventEmitter(module).addListener(ACCOUNTS_CHANGED_EVENT, () => listener());
|
||||
return () => subscription.remove();
|
||||
}
|
||||
@@ -74,19 +74,38 @@ export interface ProviderRows {
|
||||
|
||||
/**
|
||||
* One provider operation. `id` addresses one row of our account by `_id`;
|
||||
* `where`/`args` address a set (always ANDed with the account scope).
|
||||
* `where`/`args` address a set (always ANDed with the account scope). With
|
||||
* both, the op addresses the id row if it matches `where`. An op with `id`
|
||||
* expects exactly one row unless `expectCount` says otherwise, so a row that
|
||||
* vanished fails the batch with `assert`.
|
||||
*
|
||||
* - `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.
|
||||
* or an event and its attendees. A column in both `refs` and `values` takes
|
||||
* the back-reference. Settings rows return no id and cannot be referenced.
|
||||
* - `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.
|
||||
* - `syncState` replaces this account's SyncState blob. It must be the last
|
||||
* op of its batch and never a yield point (refused with `scope`).
|
||||
* - `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).
|
||||
* commit everything before it and let other writers in. Contacts batches
|
||||
* must mark one at least every 400 ops (ContactsProvider refuses a batch
|
||||
* with 500 ops up to and including a yield point).
|
||||
*
|
||||
* What the real providers do with the cells (the fake provider copies it):
|
||||
* - contacts `data1`..`data14` are TEXT columns, so a number written there
|
||||
* reads back as a string (`2` → `"2"`);
|
||||
* - `Events.dirty` and `Calendars.dirty` are NULL, not 0, after an insert by
|
||||
* the sync adapter: write `dirty: 0` explicitly, and select dirty rows with
|
||||
* `dirty = 1`;
|
||||
* - attendees, reminders and extended properties are read through a join,
|
||||
* where `_id` is ambiguous: address one of their rows with `id`, never
|
||||
* with `_id` in `where`. Extended properties are updated by `id` only.
|
||||
* - an insert (or an update) naming a parent row, exception master or group
|
||||
* that no longer exists fails with `assert`, so the engine re-reads; one of
|
||||
* another account fails with `scope`.
|
||||
*/
|
||||
export type ProviderOp =
|
||||
| { op: 'insert'; table: ProviderTable; values: WriteRow; refs?: Record<string, number>; yieldAllowed?: boolean }
|
||||
@@ -305,7 +324,12 @@ 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. */
|
||||
/**
|
||||
* Adds the account if missing. Resolves true when it was created, false
|
||||
* when it exists for `registryId` already (one left without a registryId
|
||||
* is adopted); rejects with code `conflict` when it belongs to another
|
||||
* registry id, whose rows and SyncState it holds.
|
||||
*/
|
||||
ensureAccount(name: string, registryId: string): Promise<boolean>;
|
||||
/** Removes the account; the providers drop its rows. */
|
||||
removeAccount(name: string): Promise<boolean>;
|
||||
@@ -328,7 +352,17 @@ export interface DeviceSyncNativeModule {
|
||||
* accounts with a contact or calendar type requests those syncs.
|
||||
*/
|
||||
setPushRoutes(routesJson: string): Promise<void>;
|
||||
/**
|
||||
* Posts (or replaces) the account's one sync-problem notification; a tap
|
||||
* opens `uri`, which must be a `bulwarkmobile://` link. `channelName` names
|
||||
* the notification channel in the app's language; null keeps English.
|
||||
*/
|
||||
showSyncProblem(accountName: string, title: string, text: string, uri: string, channelName: string | null): Promise<void>;
|
||||
clearSyncProblem(accountName: string): Promise<void>;
|
||||
|
||||
// Provider I/O runs on one native thread, a call at a time. `query`,
|
||||
// `readSyncState` and `readPhoto` reject with code `scope`, `permission` or
|
||||
// `provider` (as `BatchFailure`).
|
||||
/** `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). */
|
||||
|
||||
Reference in New Issue
Block a user