feat: merge contacts and calendar sync with the phone on Android #34

Brings feat/device-sync into main: on Android, each account's address
books and calendars can sync both ways with the phone's Contacts and
Calendar apps (Settings -> Contacts / Calendar -> Sync to this
device), through a "Bulwark" Android account with a contacts and a
calendar sync adapter.

- native side: the account, both sync adapters, provider access scoped
  to the account, push routing to the syncs, sync problem notices
- the sync engine in `src/device-sync`, run as a headless task
- the settings sections, sign-out and removal handling, translations
- design, limitations and verification in `docs/device-sync.md`
This commit is contained in:
Linus Rath
2026-09-27 12:14:37 +02:00
219 changed files with 47834 additions and 219 deletions
+10
View File
@@ -154,6 +154,11 @@ android {
versionCode appVersionCode.toInteger()
versionName appVersionName
// Android account type of device sync (#34), derived from the package
// name so a later rename needs no other change. The XML in res/xml and
// the Kotlin in sync/ read it as @string/device_sync_account_type.
resValue "string", "device_sync_account_type", "${applicationId}.account"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
@@ -280,6 +285,11 @@ dependencies {
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5")
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
// android.jar only stubs org.json, so the tests use the real one.
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+4 -1
View File
@@ -3,5 +3,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
</application>
</manifest>
@@ -0,0 +1,108 @@
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 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), `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 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}"
}
Log.i(TAG, "debug command: $result")
pending.resultData = result
pending.finish()
}
}
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) {
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
else -> which
}
companion object {
private const val TAG = "BulwarkDeviceSync"
}
}
+30
View File
@@ -6,6 +6,14 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -73,6 +81,28 @@
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
They run in the app's main process on purpose: the adapters start the sync engine as a
headless JS task in the same React Native runtime as the UI. -->
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
</service>
<service android:name=".sync.ContactsSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
</service>
<service android:name=".sync.CalendarSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
</service>
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
@@ -1,5 +1,6 @@
package com.anonymous.bulwarkmobile
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
@@ -12,6 +13,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkFcmModule(reactContext))
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -11,6 +11,7 @@ import android.os.Bundle
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
import com.facebook.react.bridge.Arguments
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() {
val data = message.data
// Contact and calendar changes become syncs of the Android accounts
// they feed (device sync, #34); only mail needs the push task.
val mail = DeviceSyncPushRouter.route(applicationContext, data)
// Hand off to JS via a headless task only when the app isn't already
// running in the foreground - HeadlessJsTaskContext throws if started
// while foreground. When the app is open, the main JS instance already
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
if (!isAppInForeground()) {
if (mail && !isAppInForeground()) {
startHeadlessTask(applicationContext, data)
}
@@ -11,16 +11,11 @@ import android.os.Bundle
import android.os.PersistableBundle
import android.os.SystemClock
import android.util.Log
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
@@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
override fun onStartJob(params: JobParameters): Boolean {
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
withReadyReactContext { startTask(it, config, params) }
// JobService callbacks run on the main thread, where HeadlessJs wants to be called.
HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
return true
}
@@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
tasks.addTaskEventListener(this)
taskContext = tasks
}
UiThreadUtil.runOnUiThread {
try {
val taskId = tasks.startTask(config)
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
} catch (e: IllegalStateException) {
HeadlessJs.startTask(
tasks,
config,
onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
onRefused = { e ->
// The app came to the foreground since the push arrived and
// syncs on its own there.
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
jobFinished(params, false)
}
}
}
/**
* Calls [onReady] once with a React context whose JS instance is up,
* starting React Native if the process was woken for this job.
*
* A context exists before its instance is ready, and a task started on
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
* not available" and waits for the timeout), which happened when several
* pushes arrived while the app was starting. So the listener goes in
* first and the ready check after it; whichever sees the ready instance
* first runs the task.
*/
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
val done = AtomicBoolean(false)
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
val app = application as ReactApplication
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
host.removeReactInstanceEventListener(this)
runOnce(context)
}
}
host.addReactInstanceEventListener(listener)
val current = host.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
host.removeReactInstanceEventListener(listener)
runOnce(current)
} else {
host.start()
}
} else {
@Suppress("DEPRECATION")
val manager = app.reactNativeHost.reactInstanceManager
val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
manager.removeReactInstanceEventListener(this)
runOnce(context)
}
}
manager.addReactInstanceEventListener(listener)
val current = manager.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
manager.removeReactInstanceEventListener(listener)
runOnce(current)
} else if (!manager.hasStartedCreatingInitialContext()) {
manager.createReactContextInBackground()
}
}
},
)
}
companion object {
@@ -1,6 +1,7 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
import com.facebook.react.bridge.Arguments
import org.json.JSONArray
import org.json.JSONObject
@@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() {
BulwarkMessagingService.ensureChannel(this)
val data = parsePayload(message.content) ?: return
if (!BulwarkMessagingService.isAppInForeground()) {
// Same routing as FCM: contact and calendar changes request device
// syncs, and only mail starts the push task.
val mail = DeviceSyncPushRouter.route(applicationContext, data)
if (mail && !BulwarkMessagingService.isAppInForeground()) {
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
}
@@ -0,0 +1,146 @@
package com.anonymous.bulwarkmobile
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.annotation.MainThread
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.interfaces.TaskInterface
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import java.util.concurrent.atomic.AtomicBoolean
/**
* Running a headless JS task without HeadlessJsTaskService: starting React
* Native when the process was woken without any UI, and starting the task on
* its context. Shared by the push job (BulwarkPushJobService) and the device
* sync adapters.
*/
object HeadlessJs {
private const val TAG = "HeadlessJs"
/** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
private const val MAX_WAIT_MS = 600_000L
private const val WATCH_MS = 1_000L
/**
* Calls [onReady] once with a React context whose JS instance is up,
* starting React Native if the process was woken for this work.
*
* A context exists before its instance is ready, and a task started on
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
* not available" and waits for the timeout), which happened when several
* pushes arrived while the app was starting. So the listener goes in
* first and the ready check after it; whichever sees the ready instance
* first runs the task.
*
* The wait ends once, and its listener goes with it: with a ready context,
* or without one when React fails to start (the start task faults) or
* after [MAX_WAIT_MS], when no caller can still be waiting. A context that
* comes up later runs nothing of it, so waits given up on don't pile up on
* the host with everything their [onReady] holds. (A caller that gives up
* sooner, like the sync adapter at its deadline, checks that in [onReady].)
*
* Call it on the main thread; [onReady] runs there too. MainApplication
* creates the ReactHost on first use without a lock (ExpoReactHostFactory),
* so a background caller racing MainActivity could create a second one,
* with a second JS runtime.
*
* Returns React Native's start task when it had to be started, so a caller
* can notice a start that fails (a debug build with no Metro to load the
* bundle from), or null when the context was already up.
*/
@MainThread
fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface<Void>? {
val reactApp = app as ReactApplication
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
host.addReactInstanceEventListener(wait)
val current = host.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
wait.onReactContextInitialized(current)
return null
}
return host.start().also { wait.watch(it) }
}
@Suppress("DEPRECATION")
val manager = reactApp.reactNativeHost.reactInstanceManager
val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
manager.addReactInstanceEventListener(wait)
val current = manager.currentReactContext
if (current != null && current.hasActiveReactInstance()) {
wait.onReactContextInitialized(current)
return null
}
if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
wait.watch(null)
return null
}
/** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
private class ContextWait(
private val onReady: (ReactContext) -> Unit,
private val removeListener: (ReactInstanceEventListener) -> Unit,
) : ReactInstanceEventListener {
private val ended = AtomicBoolean(false)
override fun onReactContextInitialized(context: ReactContext) {
if (end()) onReady(context)
}
/** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
fun watch(boot: TaskInterface<Void>?) {
val main = Handler(Looper.getMainLooper())
val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
main.postDelayed(object : Runnable {
override fun run() {
if (ended.get()) return
val faulted = boot?.isFaulted() == true
if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
main.postDelayed(this, WATCH_MS)
} else if (end()) {
val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
Log.w(TAG, "$why; no longer waiting")
}
}
}, WATCH_MS)
}
private fun end(): Boolean {
if (!ended.compareAndSet(false, true)) return false
removeListener(this)
return true
}
}
/**
* Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
* [onStarted] gets the task id in that same UI-thread turn, so before any
* finish callback (HeadlessJsTaskContext posts those to the UI thread).
* [onRefused] gets the refusal when the context is resumed and [config]
* isn't allowed in the foreground.
*/
fun startTask(
tasks: HeadlessJsTaskContext,
config: HeadlessJsTaskConfig,
onStarted: (taskId: Int) -> Unit,
onRefused: (IllegalStateException) -> Unit,
) {
UiThreadUtil.runOnUiThread {
val taskId = try {
tasks.startTask(config)
} catch (e: IllegalStateException) {
onRefused(e)
return@runOnUiThread
}
onStarted(taskId)
}
}
}
@@ -0,0 +1,79 @@
package com.anonymous.bulwarkmobile.sync
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import com.anonymous.bulwarkmobile.MainActivity
/**
* The authenticator of the "Bulwark" account type. The app holds the
* credentials, not the account: accounts are only ever created by the app,
* from Settings → Contacts or Calendar, for an account that is signed in.
*/
class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
/** Settings → Accounts → Add account opens the app's device sync settings instead. */
override fun addAccount(
response: AccountAuthenticatorResponse?,
accountType: String?,
authTokenType: String?,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle {
val intent = Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(Uri.parse(SETTINGS_URI))
return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
}
/** Removal from Android Settings is allowed; the app notices and turns device sync off. */
override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle =
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
override fun getAuthToken(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported()
override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle =
unsupported()
override fun updateCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupported()
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported()
override fun getAuthTokenLabel(authTokenType: String?): String? = null
override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array<out String>?): Bundle =
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
private fun unsupported() = Bundle().apply {
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app")
}
companion object {
/** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */
const val SETTINGS_URI = "bulwarkmobile://settings/contacts"
}
}
class BulwarkAuthenticatorService : Service() {
private val authenticator by lazy { BulwarkAuthenticator(this) }
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
}
@@ -0,0 +1,331 @@
package com.anonymous.bulwarkmobile.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ActivityNotFoundException
import android.content.ContentResolver
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import org.json.JSONObject
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
/**
* `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) {
private val accountsListener = OnAccountsUpdateListener {
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 }
}
override fun getName(): String = NAME
override fun initialize() {
super.initialize()
val manager = AccountManager.get(reactApplicationContext)
val handler = Handler(Looper.getMainLooper())
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType()))
} else {
manager.addOnAccountsUpdatedListener(accountsListener, handler, false)
}
} catch (e: RuntimeException) {
Log.w(TAG, "Account change listener not registered", e)
}
}
override fun invalidate() {
try {
AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener)
} catch (e: RuntimeException) {
// Not registered.
}
io.shutdown()
super.invalidate()
}
private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext)
// ── accounts ────────────────────────────────────────────────
@ReactMethod
fun getInfo(promise: Promise) {
promise.resolve(Arguments.createMap().apply {
putString("accountType", accountType())
putInt("sdkInt", Build.VERSION.SDK_INT)
})
}
@ReactMethod
fun listAccounts(promise: Promise) = settle(promise) {
Arguments.createArray().apply {
for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) {
pushMap(Arguments.createMap().apply {
putString("name", account.name)
putString("registryId", registryId)
})
}
}
}
@ReactMethod
fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) {
DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId)
}
@ReactMethod
fun removeAccount(name: String, promise: Promise) = settle(promise) {
DeviceSyncAccounts.remove(reactApplicationContext, name)
}
// ── sync settings ───────────────────────────────────────────
@ReactMethod
fun getSyncSettings(name: String, promise: Promise) = settle(promise) {
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
Arguments.createMap().apply {
putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically())
putMap("authorities", Arguments.createMap().apply {
for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority))
})
}
}
private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply {
putInt("syncable", ContentResolver.getIsSyncable(account, authority))
putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority))
val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L
putDouble("periodicSeconds", periodic.toDouble())
putBoolean("active", ContentResolver.isSyncActive(account, authority))
putBoolean("pending", ContentResolver.isSyncPending(account, authority))
}
@ReactMethod
fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) {
DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled)
null
}
@ReactMethod
fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) {
DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong())
null
}
@ReactMethod
fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) {
val options = JSONObject(optionsJson)
val flags = SyncRequestFlags(
manual = options.optBoolean("manual"),
expedited = options.optBoolean("expedited"),
upload = options.optBoolean("upload"),
overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"),
discardLocalDeletions = options.optBoolean("discardLocalDeletions"),
)
DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags)
null
}
/** The account's own sync screen where the platform has one, else the list of synced accounts. */
@ReactMethod
fun openAccountSettings(name: String, promise: Promise) = settle(promise) {
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
.putExtra(EXTRA_ACCOUNT, account)
// Newer Settings apps pass only these arguments on to the account's
// screen; without them it closes at once ("Account provided does
// not exist: null", Android 12L), and no exception tells us so.
.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) })
val overview = Intent(Settings.ACTION_SYNC_SETTINGS)
.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type))
val activity = reactApplicationContext.currentActivity
for (intent in listOf(perAccount, overview)) {
try {
if (activity != null) {
activity.startActivity(intent)
} else {
reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
return@settle null
} catch (e: ActivityNotFoundException) {
// Try the next one.
}
}
throw IllegalStateException("No settings screen for sync accounts")
}
// ── run handshake ───────────────────────────────────────────
@ReactMethod
fun finishRun(runId: String, reportJson: String, promise: Promise) {
val accepted = DeviceSyncRuns.finish(runId, reportJson)
if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it")
promise.resolve(accepted)
}
@ReactMethod
fun isRunCancelled(runId: String, promise: Promise) {
promise.resolve(DeviceSyncRuns.isCancelled(runId))
}
// ── 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) {}
@ReactMethod
fun removeListeners(count: Int) {}
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) {
promise.reject("bad_args", e.message, e)
} catch (e: Exception) {
promise.reject("failed", e.message, e)
}
}
companion object {
const val NAME = "BulwarkDeviceSync"
private const val TAG = "BulwarkDeviceSync"
const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged"
/** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
private const val EXTRA_ACCOUNT = "account"
/** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
@Volatile private var currentInstance: BulwarkDeviceSyncModule? = null
/** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */
fun emit(eventName: String, params: WritableMap?) {
val module = currentInstance ?: return
val ctx = module.reactApplicationContext
if (!ctx.hasActiveReactInstance()) return
ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
}
}
}
@@ -0,0 +1,144 @@
package com.anonymous.bulwarkmobile.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import com.anonymous.bulwarkmobile.R
/**
* The Android accounts of device sync (#34) and their sync settings. One
* account per app account that has device sync on; see docs/device-sync.md.
*
* Android's own sync settings are the source of truth for on/off: the
* Contacts and Calendar toggles in Settings → Accounts are
* `getSyncAutomatically`, and the app reads them back instead of keeping a
* copy.
*/
object DeviceSyncAccounts {
const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY
const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY
val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY)
/** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */
const val USER_DATA_REGISTRY_ID = "registryId"
/** `<applicationId>.account`, set in build.gradle so the package name is never hard-coded. */
fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type)
fun account(context: Context, name: String) = Account(name, accountType(context))
fun list(context: Context): List<Pair<Account, String?>> {
val manager = AccountManager.get(context)
return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) }
}
fun exists(context: Context, name: String): Boolean =
AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name }
/**
* 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 }
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" }
for (authority in AUTHORITIES) {
ContentResolver.setIsSyncable(account, authority, 1)
ContentResolver.setSyncAutomatically(account, authority, false)
}
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)
val account = account(context, name)
if (manager.getAccountsByType(account.type).none { it.name == name }) return false
return manager.removeAccountExplicitly(account)
}
fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) {
requireAuthority(authority)
val account = account(context, name)
ContentResolver.setIsSyncable(account, authority, 1)
ContentResolver.setSyncAutomatically(account, authority, enabled)
}
/** Replaces our periodic sync for the authority; 0 removes it. */
fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) {
requireAuthority(authority)
val account = account(context, name)
for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) {
ContentResolver.removePeriodicSync(account, authority, periodic.extras)
}
if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds)
}
/**
* 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())
}
fun requireAuthority(authority: String) {
require(authority in AUTHORITIES) { "Unknown authority $authority" }
}
}
/** [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,
val expedited: Boolean = false,
val upload: Boolean = false,
val overrideTooManyDeletions: Boolean = false,
val discardLocalDeletions: Boolean = false,
) {
fun toExtras(): Bundle = Bundle().apply {
if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true)
if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true)
if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true)
}
}
@@ -0,0 +1,213 @@
package com.anonymous.bulwarkmobile.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Application
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.annotation.MainThread
import com.anonymous.bulwarkmobile.HeadlessJs
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.TimeUnit
/**
* The sync adapter behind both authorities (#34). It does no syncing itself:
* it starts the BulwarkDeviceSync headless JS task, where the engine lives
* (src/device-sync), waits for its report and hands that to SyncManager. See
* docs/device-sync.md.
*
* onPerformSync runs on its own SyncAdapterThread and may block; React Native
* is started from the main thread (see HeadlessJs), which stays free.
* SyncManager holds a wake lock for as long as onPerformSync runs.
*/
class DeviceSyncAdapter(context: Context) :
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) {
private val main = Handler(Looper.getMainLooper())
/**
* 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,
authority: String,
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")
syncResult.databaseError = true
return
}
val startedAt = SystemClock.elapsedRealtime()
// A sync scheduled as an expedited job (Android 12+) gets 3 minutes
// from JobScheduler instead of 10; stay inside either.
val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false)
val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS
val run = DeviceSyncRuns.open(account.name, authority)
main.post { startRun(run, account, registryId, authority, extras, deadline) }
try {
while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) {
val boot = run.boot
if (boot != null && boot.isFaulted()) {
run.end("React Native did not start: ${boot.getError()?.message}")
} else if (SystemClock.elapsedRealtime() >= deadline) {
run.end("no report before the deadline")
}
}
val outcome = SyncReports.outcome(run.report, run.endReason)
SyncReports.apply(outcome, syncResult)
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
"${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome")
} catch (e: InterruptedException) {
// onSyncCanceled interrupts this thread. JS sees the run as
// cancelled at its next checkpoint (isRunCancelled).
run.end("cancelled")
Log.i(TAG, "Sync ${run.runId} of $authority cancelled")
Thread.currentThread().interrupt()
} finally {
DeviceSyncRuns.close(run)
// After startRun, which was posted first, so a listener it added is removed.
main.post { run.detach() }
}
// 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
private fun startRun(
run: DeviceSyncRuns.Run,
account: Account,
registryId: String,
authority: String,
extras: Bundle,
deadline: Long,
) {
if (run.cancelled) return
try {
run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext ->
if (run.cancelled) return@withReadyReactContext
if (!reactContext.hasActiveReactInstance()) {
run.end("the React Native instance is gone")
return@withReadyReactContext
}
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
run.attach(tasks, object : HeadlessJsTaskEventListener {
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
// JS settled without a report, timed out, or the context was reloaded.
override fun onHeadlessJsTaskFinish(taskId: Int) {
if (taskId == run.taskId) run.end("task finished")
}
})
val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS)
val data = Arguments.createMap().apply {
putString("runId", run.runId)
putString("accountName", account.name)
putString("registryId", registryId)
putString("authority", authority)
putMap("extras", runExtras(extras))
putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble())
}
HeadlessJs.startTask(
tasks,
HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true),
onStarted = { run.taskId = it },
onRefused = { run.end("refused: ${it.message}") },
)
}
} catch (e: RuntimeException) {
run.end("could not start: ${e.message}")
}
}
private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply {
fun flag(key: String, name: String) {
if (extras.getBoolean(key, false)) putBoolean(name, true)
}
flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual")
flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload")
flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited")
flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff")
flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions")
flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions")
}
companion object {
private const val TAG = "BulwarkDeviceSync"
const val TASK_KEY = "BulwarkDeviceSync"
/** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */
private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job"
private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10
private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3
private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline
private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout
private const val MIN_TASK_MS = 30_000L
private const val POLL_MS = 500L
}
}
/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */
class ContactsSyncService : Service() {
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
companion object {
@Volatile private var adapter: DeviceSyncAdapter? = null
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
}
}
}
/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */
class CalendarSyncService : Service() {
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
companion object {
@Volatile private var adapter: DeviceSyncAdapter? = null
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
}
}
}
@@ -0,0 +1,125 @@
package com.anonymous.bulwarkmobile.sync
import com.facebook.react.interfaces.TaskInterface
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.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 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)
/** 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. */
@Volatile var taskId = NO_TASK
@Volatile private var tasks: HeadlessJsTaskContext? = null
@Volatile private var listener: HeadlessJsTaskEventListener? = null
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) {
this.tasks = tasks
this.listener = listener
tasks.addTaskEventListener(listener)
}
fun detach() {
val tasks = tasks ?: return
listener?.let { tasks.removeTaskEventListener(it) }
this.tasks = null
this.listener = null
}
}
private val runs = ConcurrentHashMap<String, Run>()
fun open(accountName: String, authority: String): Run =
Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it }
/**
* Forgets the run: a late finishRun is then 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. 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
/** 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
}
/**
* 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,376 @@
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 dataMimetypes(ids: Set<Long>): Map<Long, String> {
val mimetypes = HashMap<Long, String>()
forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c ->
if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1)
}
return mimetypes
}
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,466 @@
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"
/**
* Checks a selection or sort order before the account's scope is ANDed onto
* it as `(text) AND (scope)`: its parentheses must balance outside quoted
* literals, or `1) OR (1` would close the scope's own parenthesis. It may
* not end or comment out the statement, nor query other tables. The engine's
* selections are constants with `?` arguments; this guards against bugs.
*/
object SqlFragments {
private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE)
fun check(text: String, what: String) {
var depth = 0
var quote: Char? = null
val outside = StringBuilder(text.length)
var i = 0
while (i < text.length) {
val c = text[i]
if (quote != null) {
if (c == quote) {
// A doubled quote is an escaped one inside the literal.
if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null
}
outside.append(' ')
} else {
when (c) {
'\'', '"' -> {
quote = c
outside.append(' ')
}
'(' -> {
depth++
outside.append(c)
}
')' -> {
if (--depth < 0) refuse(what, "closes a parenthesis it did not open")
outside.append(c)
}
';' -> refuse(what, "ends the statement")
'\u0000' -> refuse(what, "contains a NUL character")
else -> outside.append(c)
}
}
i++
}
if (quote != null) refuse(what, "leaves a quote open")
if (depth != 0) refuse(what, "leaves a parenthesis open")
val bare = outside.toString()
if ("--" in bare || "/*" in bare) refuse(what, "contains a comment")
FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") }
}
private fun refuse(what: String, why: String): Nothing =
throw ProviderRefusal(BatchFailure.SCOPE, "$what $why")
}
/**
* 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")
val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") }
return ProviderQuery(table, columns, where, args, 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")?.also { SqlFragments.check(it, "$where: 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,549 @@
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 memberships = membershipUpdates(ops)
val owners = Owners(ownershipRequests(ops, memberships))
return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) }
}
/**
* Membership rows among the data rows that updates by id write `data1`
* of without restating the mimetype: their `data1` names a group, which
* must be the account's like on insert.
*/
private fun membershipUpdates(ops: List<ProviderOp>): Set<Long> {
val ids = ops.asSequence()
.filterIsInstance<ProviderOp.Update>()
.filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) }
.mapNotNull { it.target.id }
.toSet()
if (ids.isEmpty()) return emptySet()
return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys
}
private fun writesUntypedData1(values: Map<String, Cell>) =
MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null
private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set<Long>): PlannedOp = when (op) {
is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
is ProviderOp.Insert -> {
checkAccountColumns(i, op.table, op.values)
checkPhotoFile(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)
checkPhotoFile(i, op.table, op.values)
checkReferences(i, op.table, op.values, owners)
if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) {
checkMembershipUpdate(i, op, owners, memberships)
}
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",
)
}
}
}
/**
* `data1` written by an update that doesn't restate the mimetype: on a
* membership row it names a group. Rows chosen by a selection could be
* memberships the lookup didn't see, so only updates by id may do it.
*/
private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set<Long>) {
val id = op.target.id
?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id")
if (id !in memberships) return
val group = op.values[DATA1]?.rowId()
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id")
when (owners.of(ProviderTable.GROUPS, group)) {
Owner.OURS -> Unit
Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account")
Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists")
}
}
/**
* A photo row's file id (`data14`) is the provider's: it points into the
* photo store, where another account's file id would let `readPhoto`
* read that account's photo. Photos are written as bytes (`data15`).
*/
private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map<String, Cell>) {
if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) {
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only")
}
}
/** 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>, memberships: Set<Long>): 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) }
if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, 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"
private const val MIMETYPE = "mimetype"
private const val DATA1 = "data1"
private const val DATA14 = "data14"
/** 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 mimetype of each data row that exists. */
fun dataMimetypes(ids: Set<Long>): Map<Long, String>
/** 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,173 @@
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 a mail type, for anything
* but a StateChange (an EmailPush), when `changed` is missing, unreadable or
* names no type (older relays), or when an account's entry is unreadable, so
* a contact change never ends in a mail notification. The push task's guard
* (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules.
*/
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 STATE_CHANGE_KIND = "jmap-state-change"
/**
* Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`,
* which new mail changes too. A subscription made by an older build
* listens to them until the launch-time resync replaces its types, and a
* server without `EmailDelivery` has only them. Keep in sync with
* MAIL_TYPES in src/lib/push-background-task.ts.
*/
private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox")
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)
// Only a StateChange can say it carries no mail.
var mail = data["kind"] != STATE_CHANGE_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 (types.any { it in MAIL_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,112 @@
package com.anonymous.bulwarkmobile.sync
import android.content.SyncResult
import org.json.JSONException
import org.json.JSONObject
/**
* What a run's report (src/device-sync/types.ts `RunReport`) asks of the
* framework's SyncResult. Kept free of Android classes so JVM tests cover it;
* [SyncReports.apply] copies it onto the real SyncResult.
*
* SyncManager treats io errors as soft (retry with back-off), auth errors,
* database errors and too many deletions as hard (no retry until something
* changes), and `fullSyncRequested` as "schedule another sync now".
*/
data class SyncOutcome(
val ioErrors: Long = 0,
val authErrors: Long = 0,
val databaseError: Boolean = false,
val tooManyDeletions: Boolean = false,
val inserts: Long = 0,
val updates: Long = 0,
val deletes: Long = 0,
val entries: Long = 0,
val skipped: Long = 0,
/** Epoch seconds; 0 when unset. */
val delayUntil: Long = 0,
val fullSyncRequested: Boolean = false,
)
object SyncReports {
/**
* Translates a report. [reportJson] is null when the run ended without
* one ([endReason] says why: JS never answered, React failed to start, …),
* which is a soft error so the framework retries.
*/
fun outcome(reportJson: String?, endReason: String?): SyncOutcome {
if (reportJson == null) return SyncOutcome(ioErrors = 1)
val report = try {
JSONObject(reportJson)
} catch (e: JSONException) {
return SyncOutcome(ioErrors = 1)
}
val stats = report.optJSONObject("stats")
val down = stats?.optJSONObject("downloaded")
val up = stats?.optJSONObject("uploaded")
fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0
val base = SyncOutcome(
inserts = count(down, "created") + count(up, "created"),
updates = count(down, "updated") + count(up, "updated"),
deletes = count(down, "deleted") + count(up, "deleted"),
entries = stats?.optLong("entries", 0) ?: 0,
skipped = stats?.optLong("skipped", 0) ?: 0,
delayUntil = report.optLong("delayUntil", 0),
fullSyncRequested = report.optBoolean("moreRecordsToGet", false),
)
// SyncManager retries a failed sync at once, without back-off, when it
// made progress (inserts, updates, or deletes without
// tooManyDeletions). A hard error must therefore report none, or an
// auth failure after some work would loop.
val hard = base.copy(inserts = 0, updates = 0, deletes = 0)
return when (report.optString("outcome")) {
"ok", "cancelled", "disabled" -> base
"auth" -> hard.copy(authErrors = 1)
"permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true)
"tooManyDeletions" -> hard.copy(
tooManyDeletions = true,
// Shown in the system's "too many deletions" notification.
deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0,
)
// "io", "internal" and anything a newer JS might send: retry, at
// once if some work got done, else with back-off.
else -> base.copy(ioErrors = 1)
}
}
/**
* One line for logcat: the report's outcome, its message and how many
* items failed. The reason a run failed is otherwise only in the report,
* and JS warnings from a headless run don't reach logcat.
*/
fun summary(reportJson: String?): String {
if (reportJson == null) return "no report"
val report = try {
JSONObject(reportJson)
} catch (e: JSONException) {
return "unreadable report"
}
val parts = mutableListOf("outcome=" + report.optString("outcome", "?"))
val items = report.optJSONArray("itemErrors")?.length() ?: 0
if (items > 0) parts += "itemErrors=$items"
val message = report.optString("message", "")
if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE)
return parts.joinToString(" ")
}
private const val MAX_LOGGED_MESSAGE = 300
fun apply(outcome: SyncOutcome, result: SyncResult) {
result.stats.numIoExceptions += outcome.ioErrors
result.stats.numAuthExceptions += outcome.authErrors
result.stats.numInserts += outcome.inserts
result.stats.numUpdates += outcome.updates
result.stats.numDeletes += outcome.deletes
result.stats.numEntries += outcome.entries
result.stats.numSkippedEntries += outcome.skipped
if (outcome.databaseError) result.databaseError = true
if (outcome.tooManyDeletions) result.tooManyDeletions = true
if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil
if (outcome.fullSyncRequested) result.fullSyncRequested = true
}
}
@@ -2,4 +2,6 @@
<string name="app_name">Bulwark Mobile</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<!-- Account type label of device sync (#34), shown in Settings → Accounts. -->
<string name="device_sync_account_label" translatable="false">Bulwark</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The "Bulwark" account type of device sync (#34); see docs/device-sync.md. -->
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:icon="@mipmap/ic_launcher"
android:smallIcon="@mipmap/ic_launcher"
android:label="@string/device_sync_account_label" />
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
How Contacts apps show and edit contacts of the Bulwark account (#34). The
EditSchema lists exactly the data kinds device sync maps to JSContact
(docs/device-sync.md, "Contacts mapping"); apps offer nothing else.
AOSP Contacts drops the whole account type when this schema breaks its rules
(BaseAccountType): every kind with a type column needs <Type> children and
the others must have none, name needs maxOccurs="1" and every supports*
attribute true (supportsDisplayName for older releases), and photo,
nickname, organization, note and group_membership need maxOccurs="1".
A Type's maxOccurs limits that type only; birthday gets none unless given.
-->
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android">
<EditSchema>
<DataKind kind="name" maxOccurs="1"
supportsDisplayName="true"
supportsPrefix="true"
supportsMiddleName="true"
supportsSuffix="true"
supportsPhoneticFamilyName="true"
supportsPhoneticMiddleName="true"
supportsPhoneticGivenName="true" />
<DataKind kind="photo" maxOccurs="1" />
<DataKind kind="phone">
<Type type="mobile" />
<Type type="home" />
<Type type="work" />
<Type type="main" />
<Type type="fax_work" />
<Type type="fax_home" />
<Type type="other_fax" />
<Type type="pager" />
<Type type="work_mobile" />
<Type type="work_pager" />
<Type type="tty_tdd" />
<Type type="other" />
<Type type="custom" />
</DataKind>
<DataKind kind="email">
<Type type="home" />
<Type type="work" />
<Type type="other" />
<Type type="mobile" />
<Type type="custom" />
</DataKind>
<DataKind kind="nickname" maxOccurs="1" />
<DataKind kind="postal" needsStructured="true">
<Type type="home" />
<Type type="work" />
<Type type="other" />
<Type type="custom" />
</DataKind>
<DataKind kind="organization" maxOccurs="1" />
<!-- No <Type> allowed: websites are always OTHER in the editor. -->
<DataKind kind="website" />
<DataKind kind="event" dateWithTime="false">
<Type type="birthday" maxOccurs="1" yearOptional="true" />
<Type type="anniversary" yearOptional="true" />
<Type type="other" yearOptional="true" />
<Type type="custom" yearOptional="true" />
</DataKind>
<DataKind kind="relationship">
<Type type="assistant" />
<Type type="brother" />
<Type type="child" />
<Type type="domestic_partner" />
<Type type="father" />
<Type type="friend" />
<Type type="manager" />
<Type type="mother" />
<Type type="parent" />
<Type type="partner" />
<Type type="referred_by" />
<Type type="relative" />
<Type type="sister" />
<Type type="spouse" />
<Type type="custom" />
</DataKind>
<DataKind kind="note" maxOccurs="1" />
<DataKind kind="group_membership" maxOccurs="1" />
</EditSchema>
</ContactsAccountType>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Calendar sync adapter of device sync (#34); see docs/device-sync.md. -->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:contentAuthority="com.android.calendar"
android:supportsUploading="true"
android:userVisible="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="false" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Contacts sync adapter of device sync (#34); see docs/device-sync.md. -->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/device_sync_account_type"
android:contentAuthority="com.android.contacts"
android:supportsUploading="true"
android:userVisible="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="false" />
@@ -0,0 +1,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,562 @@
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>()
/** data id → mimetype */
val mimetypes = HashMap<Long, String>()
/** 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 dataMimetypes(ids: Set<Long>) = mimetypes.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 `a membership row's group is checked on updates by id too`() {
lookup.mimetypes[100] = GROUP_MEMBERSHIP_MIMETYPE
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":10}}""")
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") }
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":12}}""") }
// data1 by a selection could reach memberships the lookup never saw.
refused(BatchFailure.SCOPE) {
plan(CONTACTS, """{"op":"update","table":"data","where":"raw_contact_id = ?","args":[1],"values":{"data1":11}}""")
}
// data1 of other kinds is plain data.
lookup.mimetypes[100] = "vnd.android.cursor.item/phone_v2"
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""")
}
@Test
fun `photo file ids are never written`() {
val photo = """"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo""""
plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":null,"data15":{"b64":"AAAA"}}}""")
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":5}}""") }
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data14":5}}""") }
}
@Test
fun `a selection can't close the scope's parentheses or query elsewhere`() {
for (where in listOf("1) OR (1", "dirty = 1) OR (1 = 1", "(1", "1; DELETE FROM events", "1 -- x", "1 /* x */",
"_id IN (SELECT _id FROM events)", "1 UNION ALL x", "title = 'open")) {
val quoted = JSONObject.quote(where)
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","where":$quoted,"values":{"dirty":1}}""") }
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","where":$quoted}""") }
refused(BatchFailure.SCOPE) {
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":$quoted}""")
}
}
refused(BatchFailure.SCOPE) {
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id); DROP TABLE x; --"}""")
}
// Parentheses and keywords inside quoted literals are data.
plan(CALENDAR, """{"op":"update","table":"events","where":"title = '(x' OR title = 'select'","values":{"dirty":1}}""")
plan(CALENDAR, """{"op":"assert","table":"events","where":"(dirty = 1 OR deleted = 1) AND _sync_id LIKE '~pending/%'","expectCount":0}""")
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id DESC"}""")
}
@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,192 @@
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 or Mailbox change is possible mail, as a subscription from an older build sends it`() {
for (changed in listOf(
"""{"c":{"Email":"s1","Mailbox":"s2"}}""",
"""{"c":{"Email":"s1"}}""",
"""{"c":{"Mailbox":"s1"}}""",
)) {
val decision = PushRouting.decide(stateChange(changed), routes)
assertTrue("changed=$changed", decision.startMailTask)
assertTrue("changed=$changed", decision.syncs.isEmpty())
}
val mixed = PushRouting.decide(stateChange("""{"team":{"CalendarEvent":"e1"},"c":{"Email":"s1"}}"""), routes)
assertTrue(mixed.startMailTask)
assertEquals(setOf("alice-work" to calendar), mixed.syncs)
// Types that are neither mail nor device sync say nothing about mail.
val other = PushRouting.decide(stateChange("""{"c":{"Thread":"t1","ContactCard":"s1"}}"""), routes)
assertFalse(other.startMailTask)
assertEquals(setOf("alice@example.org" to contacts), other.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 `reads a push the same way as the push task`() {
// kind (null: none), changed (null: none) → whether the mail task starts. The
// same table as MAIL_CASES in src/lib/__tests__/push-background-task.test.ts,
// whose carriesNoMail must agree.
val state = "jmap-state-change"
val emailPush = "jmap-email-push"
val cases: List<Triple<String?, String?, Boolean>> = listOf(
// No readable map: as before device sync.
Triple(state, null, true),
Triple(state, "", true),
Triple(state, "not json", true),
Triple(state, "[1]", true),
Triple(state, "{}", true),
// A map that names no type is read like a missing one.
Triple(state, """{"c":{}}""", true),
Triple(state, """{"c":{},"team":{}}""", true),
// An account entry that can't be read may be mail.
Triple(state, """{"c":null}""", true),
Triple(state, """{"c":"garbled","team":{"ContactCard":"s"}}""", true),
Triple(state, """{"c":[],"team":{"ContactCard":"s"}}""", true),
Triple(state, """{"c":5}""", true),
// Mail types anywhere in the map.
Triple(state, """{"c":{"EmailDelivery":"s"}}""", true),
Triple(state, """{"c":{"Email":"s"}}""", true),
Triple(state, """{"c":{"Mailbox":"s"}}""", true),
Triple(state, """{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}""", true),
// Only a StateChange can say it carries no mail.
Triple(null, """{"c":{"ContactCard":"s"}}""", true),
Triple(emailPush, """{"c":{"EmailDelivery":"s"}}""", true),
Triple(emailPush, null, true),
// Contact and calendar changes, and types that say nothing about mail.
Triple(state, """{"c":{"ContactCard":"s1","AddressBook":"s2"}}""", false),
Triple(state, """{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}""", false),
Triple(state, """{"c":{"ContactCard":"s"},"team":{}}""", false),
Triple(state, """{"elsewhere":{"ContactCard":"s"}}""", false),
Triple(state, """{"c":{"Thread":"t1","ContactCard":"s1"}}""", false),
)
for ((kind, changed, mail) in cases) {
val data = buildMap<String, String> {
if (kind != null) put("kind", kind)
put("accountLabel", "alice")
put("accountId", "c")
put("emailIds", "[]")
if (changed != null) put("changed", changed)
}
assertEquals("kind=$kind changed=$changed", mail, PushRouting.decide(data, routes).startMailTask)
}
}
@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))
}
}
@@ -0,0 +1,99 @@
package com.anonymous.bulwarkmobile.sync
import org.junit.Assert.assertEquals
import org.junit.Test
class SyncReportsTest {
private fun report(outcome: String, extra: String = "") = """
{"v":1,"runId":"r","authority":"com.android.contacts","outcome":"$outcome",
"startedAt":0,"durationMs":5,"conflicts":0,"itemErrors":[],
"stats":{"downloaded":{"created":3,"updated":2,"deleted":1},
"uploaded":{"created":1,"updated":4,"deleted":2},"entries":13,"skipped":1}$extra}
""".trimIndent()
@Test
fun `a run without a report is a soft error`() {
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome(null, "task finished"))
}
@Test
fun `an unreadable report is a soft error`() {
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome("{not json", "reported"))
}
@Test
fun `ok sums both directions into the framework's stats`() {
assertEquals(
SyncOutcome(inserts = 4, updates = 6, deletes = 3, entries = 13, skipped = 1),
SyncReports.outcome(report("ok"), "reported"),
)
}
@Test
fun `io keeps the progress so the framework retries at once`() {
val outcome = SyncReports.outcome(report("io"), "reported")
assertEquals(1L, outcome.ioErrors)
assertEquals(4L, outcome.inserts)
}
@Test
fun `auth is hard and reports no progress, or the framework would retry it at once`() {
assertEquals(
SyncOutcome(authErrors = 1, entries = 13, skipped = 1),
SyncReports.outcome(report("auth"), "reported"),
)
}
@Test
fun `permission, unsupported and safety aborts are database errors without progress`() {
for (outcome in listOf("permission", "unsupported", "safetyAbort")) {
assertEquals(
outcome,
SyncOutcome(databaseError = true, entries = 13, skipped = 1),
SyncReports.outcome(report(outcome), "reported"),
)
}
}
@Test
fun `too many deletions reports the pending deletions for the system notification`() {
assertEquals(
SyncOutcome(tooManyDeletions = true, deletes = 42, entries = 13, skipped = 1),
SyncReports.outcome(report("tooManyDeletions", ""","tooManyDeletions":{"count":42,"threshold":20}"""), "reported"),
)
}
@Test
fun `delayUntil and moreRecordsToGet reach the framework`() {
val outcome = SyncReports.outcome(report("ok", ""","delayUntil":1790000000,"moreRecordsToGet":true"""), "reported")
assertEquals(1790000000L, outcome.delayUntil)
assertEquals(true, outcome.fullSyncRequested)
}
@Test
fun `cancelled and disabled runs are not errors`() {
for (outcome in listOf("cancelled", "disabled")) {
assertEquals(0L, SyncReports.outcome(report(outcome), "reported").ioErrors)
}
}
@Test
fun `an outcome this build does not know is retried`() {
assertEquals(1L, SyncReports.outcome(report("somethingNew"), "reported").ioErrors)
}
@Test
fun `the log summary names the outcome, failed items and the message`() {
assertEquals("outcome=ok", SyncReports.summary(report("ok")))
assertEquals(
"outcome=internal message=Invalid column x",
SyncReports.summary(report("internal", ",\"message\":\"Invalid column x\"")),
)
val withItems = report("ok").replace("\"itemErrors\":[]", "\"itemErrors\":[{\"ref\":\"a\"},{\"ref\":\"b\"}]")
assertEquals("outcome=ok itemErrors=2", SyncReports.summary(withItems))
assertEquals("no report", SyncReports.summary(null))
assertEquals("unreadable report", SyncReports.summary("{"))
val long = SyncReports.summary(report("io", ",\"message\":\"" + "m".repeat(400) + "\""))
assertEquals("outcome=io message=".length + 300, long.length)
}
}