From d42574d254a2e43210cf23f1ce10eb27d15db79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:08:35 +0700 Subject: [PATCH 01/16] feat(android): harden offline shell runtime --- apps/android/README.md | 9 + apps/android/app/build.gradle.kts | 12 ++ .../1.json | 87 ++++++++ .../databreeze/android/MainActivityTest.kt | 15 ++ .../databreeze/android/RoomIsolationTest.kt | 42 ++++ .../com/databreeze/android/AndroidRuntime.kt | 43 ++++ .../android/DataBreezeApplication.kt | 12 +- .../com/databreeze/android/MainActivity.kt | 69 +++++- .../android/security/DeviceKeyStore.kt | 60 +++++- .../databreeze/android/storage/LocalStore.kt | 203 +++++++++++++++++- .../com/databreeze/android/sync/SyncPorts.kt | 140 ++++++++++-- .../android/AndroidRuntimeContractTest.kt | 52 +++++ .../com/databreeze/android/BoundaryTest.kt | 54 ++++- .../android/SyncSchedulerContractTest.kt | 21 ++ apps/android/gradle/libs.versions.toml | 3 + tools/repo-cli/test/android-shell.test.mjs | 110 ++++++++++ 16 files changed, 888 insertions(+), 44 deletions(-) create mode 100644 apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json create mode 100644 apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt create mode 100644 apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt create mode 100644 apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt create mode 100644 apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt create mode 100644 tools/repo-cli/test/android-shell.test.mjs diff --git a/apps/android/README.md b/apps/android/README.md index 050158b3..e040787f 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -16,4 +16,13 @@ Vietnamese is the default locale; `values-en` provides the complete English cata Room, WorkManager, Android Keystore, and sync behind ports so feature modules cannot access credentials, raw paths, or network clients directly. Backup rules exclude local queues, databases, and sensitive preferences. +The local queue is keyed by `(accountId, workspaceId, mutationId)` and every Room query requires the account/workspace +scope. WorkManager receives only bounded IDs, cursors, and revisions; it uses unique, network-constrained work with +exponential backoff and an injected worker factory. Accepted mutations are marked complete idempotently. Sign-out +cancels scoped work, clears that scope's local queue, and removes its device key. A Room instrumentation test covers +cross-account isolation, while the Compose smoke test recreates the activity to exercise durable draft recovery. + +Connected instrumentation requires an attached emulator or device. A clean checkout can still compile the suite with +`./gradlew :app:compileDebugAndroidTestKotlin`; the release gate must record the device-backed run separately. + The launcher mark is copied from the approved generated DataBreeze asset; it is not redrawn or recolored. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 47e29227..00eca58b 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("org.jetbrains.kotlin.kapt") } android { @@ -69,6 +70,7 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.kotlinx.coroutines.android) implementation(libs.bundles.contractRuntime) + kapt(libs.androidx.room.compiler) implementation(platform("androidx.compose:compose-bom:${libs.versions.composeBom.get()}")) implementation("androidx.compose.ui:ui") @@ -79,9 +81,19 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.room.testing) androidTestImplementation(platform("androidx.compose:compose-bom:${libs.versions.composeBom.get()}")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.androidx.room.testing) +} + +kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + } } diff --git a/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json new file mode 100644 index 00000000..cd036c6b --- /dev/null +++ b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json @@ -0,0 +1,87 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "9f7753bf8dd22ecd25b625ad3c7ecadc", + "entities": [ + { + "tableName": "sync_queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `workspaceId` TEXT NOT NULL, `mutationId` TEXT NOT NULL, `operationType` TEXT NOT NULL, `payloadHash` TEXT NOT NULL, `dependencyId` TEXT, `state` TEXT NOT NULL, `createdAtEpochMs` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `workspaceId`, `mutationId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workspaceId", + "columnName": "workspaceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mutationId", + "columnName": "mutationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadHash", + "columnName": "payloadHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dependencyId", + "columnName": "dependencyId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "createdAtEpochMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "workspaceId", + "mutationId" + ] + }, + "indices": [ + { + "name": "index_sync_queue_accountId_workspaceId_state", + "unique": false, + "columnNames": [ + "accountId", + "workspaceId", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_queue_accountId_workspaceId_state` ON `${TABLE_NAME}` (`accountId`, `workspaceId`, `state`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f7753bf8dd22ecd25b625ad3c7ecadc')" + ] + } +} \ No newline at end of file diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 8786cf2e..38e3668f 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt @@ -3,6 +3,8 @@ package com.databreeze.android import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test @@ -17,4 +19,17 @@ class MainActivityTest { fun homeScreenIsDisplayed() { composeRule.onNodeWithTag("home-screen").assertIsDisplayed() } + + @Test + fun queued_draft_is_visible_after_activity_recreation() { + composeRule.onNodeWithTag("capture-button").performClick() + composeRule.onNodeWithTag("save-button").performClick() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("draft-status").fetchSemanticsNodes().isNotEmpty() + } + + composeRule.activityRule.scenario.recreate() + composeRule.onNodeWithTag("capture-button").performClick() + composeRule.onNodeWithTag("draft-status").assertIsDisplayed() + } } diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt new file mode 100644 index 00000000..18f05a1f --- /dev/null +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt @@ -0,0 +1,42 @@ +package com.databreeze.android + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.databreeze.android.storage.DataBreezeDatabase +import com.databreeze.android.storage.SyncQueueEntity +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomIsolationTest { + @Test + fun queue_queries_are_account_and_workspace_scoped_and_idempotent() = runBlocking { + val database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + DataBreezeDatabase::class.java, + ).allowMainThreadQueries().build() + try { + val first = SyncQueueEntity( + accountId = "account-a", + workspaceId = "workspace-a", + mutationId = "mutation-1", + operationType = "capture.submit", + payloadHash = "sha256:${"a".repeat(64)}", + ) + database.syncQueue().enqueue(first) + database.syncQueue().enqueue(first) + database.syncQueue().enqueue(first.copy(accountId = "account-b")) + + assertEquals(listOf(first), database.syncQueue().snapshot("account-a", "workspace-a")) + assertEquals(1, database.syncQueue().snapshot("account-b", "workspace-a").size) + assertTrue(database.syncQueue().snapshot("account-a", "workspace-b").isEmpty()) + } finally { + database.close() + } + } +} diff --git a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt new file mode 100644 index 00000000..b6f42d3d --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -0,0 +1,43 @@ +package com.databreeze.android + +import android.content.Context +import com.databreeze.android.security.AndroidDeviceKeyStore +import com.databreeze.android.security.DeviceKeyStore +import com.databreeze.android.storage.LocalStorePort +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.RoomLocalStore +import com.databreeze.android.sync.DataBreezeWorkerFactory +import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.sync.SyncTransport +import com.databreeze.android.sync.UnconfiguredSyncTransport +import com.databreeze.android.sync.WorkManagerSyncScheduler + +/** Application-owned adapters. Feature packages receive ports, never Context or raw clients. */ +class AndroidRuntime private constructor( + val localStore: LocalStorePort, + val deviceKeyStore: DeviceKeyStore, + val syncTransport: SyncTransport, + val syncScheduler: SyncScheduler, + val workerFactory: DataBreezeWorkerFactory, +) { + /** Revocation/account switch clears local work and the device-bound key before returning. */ + suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) { + syncScheduler.cancel(scope) + localStore.clear(scope) + deviceKeyStore.delete(keyAlias) + } + + companion object { + fun create(context: Context): AndroidRuntime { + val localStore = RoomLocalStore.create(context.applicationContext) + val transport = UnconfiguredSyncTransport() + return AndroidRuntime( + localStore = localStore, + deviceKeyStore = AndroidDeviceKeyStore(), + syncTransport = transport, + syncScheduler = WorkManagerSyncScheduler(context.applicationContext), + workerFactory = DataBreezeWorkerFactory(localStore, transport), + ) + } + } +} diff --git a/apps/android/app/src/main/java/com/databreeze/android/DataBreezeApplication.kt b/apps/android/app/src/main/java/com/databreeze/android/DataBreezeApplication.kt index 5a07c2a4..a7eca462 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/DataBreezeApplication.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/DataBreezeApplication.kt @@ -1,6 +1,14 @@ package com.databreeze.android import android.app.Application +import androidx.work.Configuration -/** Composition root. Device keys and local stores are injected here in later slices. */ -class DataBreezeApplication : Application() +/** Composition root. WorkManager receives only the typed, scope-bound worker factory. */ +class DataBreezeApplication : Application(), Configuration.Provider { + val runtime: AndroidRuntime by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { AndroidRuntime.create(this) } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(runtime.workerFactory) + .build() +} diff --git a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt index 1f3b10ef..84f879b6 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt @@ -14,10 +14,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -25,17 +25,35 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore +import com.databreeze.android.storage.LocalStorePort +import com.databreeze.android.storage.SyncQueueEntity +import kotlinx.coroutines.launch + +private object AppRoutes { + const val HOME = "home" + const val CAPTURE = "capture" +} + +private val localScope = AccountWorkspaceScope("local-account", "local-workspace") +private const val DRAFT_MUTATION_ID = "capture-draft" +private const val DRAFT_DIGEST = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { DataBreezeApp() } + val application = application as DataBreezeApplication + setContent { DataBreezeApp(application.runtime.localStore, localScope) } } } @Composable @OptIn(ExperimentalMaterial3Api::class) -fun DataBreezeApp() { +fun DataBreezeApp( + localStore: LocalStorePort = remember { InMemoryLocalStore() }, + scope: AccountWorkspaceScope = localScope, +) { val navController = rememberNavController() DataBreezeTheme { Scaffold( @@ -43,13 +61,19 @@ fun DataBreezeApp() { ) { padding -> NavHost( navController = navController, - startDestination = "home", + startDestination = AppRoutes.HOME, modifier = Modifier.padding(padding), ) { - composable("home") { - HomeScreen(onCapture = { navController.navigate("capture") }) + composable(AppRoutes.HOME) { + HomeScreen(onCapture = { navController.navigate(AppRoutes.CAPTURE) }) + } + composable(AppRoutes.CAPTURE) { + CaptureScreen( + localStore = localStore, + scope = scope, + onBack = { navController.popBackStack() }, + ) } - composable("capture") { CaptureScreen(onBack = { navController.popBackStack() }) } } } } @@ -73,8 +97,14 @@ private fun HomeScreen(onCapture: () -> Unit) { } @Composable -private fun CaptureScreen(onBack: () -> Unit) { - var submitted by remember { mutableStateOf(false) } +private fun CaptureScreen( + localStore: LocalStorePort, + scope: AccountWorkspaceScope, + onBack: () -> Unit, +) { + val queue by localStore.observeQueue(scope).collectAsState(initial = emptyList()) + val coroutineScope = rememberCoroutineScope() + val submitted = queue.any { it.mutationId == DRAFT_MUTATION_ID } Column( modifier = Modifier .fillMaxSize() @@ -86,8 +116,25 @@ private fun CaptureScreen(onBack: () -> Unit) { Text( stringResource(if (submitted) R.string.capture_saved else R.string.capture_body), style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.testTag("draft-status"), ) - Button(onClick = { submitted = true }, modifier = Modifier.testTag("save-button")) { + Button( + onClick = { + coroutineScope.launch { + localStore.enqueue( + SyncQueueEntity( + accountId = scope.accountId, + workspaceId = scope.workspaceId, + mutationId = DRAFT_MUTATION_ID, + operationType = "capture.submit", + payloadHash = DRAFT_DIGEST, + createdAtEpochMs = System.currentTimeMillis(), + ), + ) + } + }, + modifier = Modifier.testTag("save-button"), + ) { Text(stringResource(R.string.capture_save)) } Button(onClick = onBack, modifier = Modifier.testTag("back-button")) { diff --git a/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt b/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt index 7912f1e5..ba2de352 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt @@ -3,20 +3,32 @@ package com.databreeze.android.security import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec -data class DeviceKeyHandle(val alias: String) +private val safeAlias = Regex("[a-z][a-z0-9._-]{0,63}") + +data class DeviceKeyHandle(val alias: String) { + init { + require(safeAlias.matches(alias)) { "device key alias is invalid" } + } +} interface DeviceKeyStore { fun getOrCreate(alias: String): DeviceKeyHandle fun contains(alias: String): Boolean + fun delete(alias: String): Boolean + fun keyFor(handle: DeviceKeyHandle): SecretKey } class AndroidDeviceKeyStore : DeviceKeyStore { private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } override fun getOrCreate(alias: String): DeviceKeyHandle { + validateAlias(alias) if (!contains(alias)) { val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") generator.init( @@ -34,9 +46,51 @@ class AndroidDeviceKeyStore : DeviceKeyStore { return DeviceKeyHandle(alias) } - override fun contains(alias: String): Boolean = keyStore.containsAlias(alias) + override fun contains(alias: String): Boolean = + safeAlias.matches(alias) && keyStore.containsAlias(alias) + + override fun delete(alias: String): Boolean { + validateAlias(alias) + if (!keyStore.containsAlias(alias)) return false + keyStore.deleteEntry(alias) + return true + } - fun keyFor(handle: DeviceKeyHandle): SecretKey = + override fun keyFor(handle: DeviceKeyHandle): SecretKey = (keyStore.getKey(handle.alias, null) as? SecretKey) ?: error("device key is unavailable") + + companion object { + fun validateAlias(alias: String) { + require(safeAlias.matches(alias)) { + "device key alias must start with a letter and contain only bounded safe characters" + } + } + } +} + +data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray) { + init { + require(iv.size == 12) { "GCM IV must be 96 bits" } + require(ciphertext.isNotEmpty()) { "ciphertext cannot be empty" } + } +} + +/** Encrypts local sensitive fields; callers persist only this envelope, never plaintext. */ +class DevicePayloadCipher(private val keyStore: DeviceKeyStore) { + private val random = SecureRandom() + + fun encrypt(handle: DeviceKeyHandle, plaintext: ByteArray): EncryptedPayload { + require(plaintext.isNotEmpty()) { "plaintext cannot be empty" } + val iv = ByteArray(12).also(random::nextBytes) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keyStore.keyFor(handle), GCMParameterSpec(128, iv)) + return EncryptedPayload(iv, cipher.doFinal(plaintext)) + } + + fun decrypt(handle: DeviceKeyHandle, payload: EncryptedPayload): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, keyStore.keyFor(handle), GCMParameterSpec(128, payload.iv)) + return cipher.doFinal(payload.ciphertext) + } } diff --git a/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt b/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt index 524f3242..5bb15901 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt @@ -1,34 +1,123 @@ package com.databreeze.android.storage +import android.content.Context import androidx.room.Dao import androidx.room.Database import androidx.room.Entity +import androidx.room.Index import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Room import androidx.room.RoomDatabase import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.security.MessageDigest -@Entity(tableName = "sync_queue") +private val safeOpaqueId = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}") +private val safeOperation = Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,7}") +private val sha256Digest = Regex("sha256:[0-9a-fA-F]{64}") + +/** The only scope that may be used to address Android-local state. */ +data class AccountWorkspaceScope( + val accountId: String, + val workspaceId: String, +) { + init { + require(safeOpaqueId.matches(accountId)) { "accountId must be a bounded opaque identifier" } + require(safeOpaqueId.matches(workspaceId)) { "workspaceId must be a bounded opaque identifier" } + } + + /** Bounded, deterministic, content-minimized key for WorkManager unique work. */ + val stableKey: String = "scope-${sha256("$accountId\u0000$workspaceId")}" +} + +private fun sha256(value: String): String = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + +@Entity( + tableName = "sync_queue", + primaryKeys = ["accountId", "workspaceId", "mutationId"], + indices = [Index(value = ["accountId", "workspaceId", "state"])], +) data class SyncQueueEntity( - @androidx.room.PrimaryKey val mutationId: String, + val accountId: String, val workspaceId: String, + val mutationId: String, val operationType: String, val payloadHash: String, - val dependencyId: String?, - val state: String = "queued", -) + val dependencyId: String? = null, + val state: String = QUEUED_STATE, + val createdAtEpochMs: Long = 0L, +) { + init { + AccountWorkspaceScope(accountId, workspaceId) + require(safeOpaqueId.matches(mutationId)) { "mutationId must be a bounded opaque identifier" } + require(safeOperation.matches(operationType)) { "operationType must be a bounded operation name" } + require(sha256Digest.matches(payloadHash)) { "payloadHash must be a sha256 digest" } + require(dependencyId == null || safeOpaqueId.matches(dependencyId)) { + "dependencyId must be a bounded opaque identifier" + } + require(state in setOf(QUEUED_STATE, IN_FLIGHT_STATE, COMPLETED_STATE)) { "state is not supported" } + require(createdAtEpochMs >= 0L) { "createdAtEpochMs cannot be negative" } + } + + companion object { + const val QUEUED_STATE = "queued" + const val IN_FLIGHT_STATE = "in-flight" + const val COMPLETED_STATE = "completed" + } +} @Dao interface SyncQueueDao { - @Query("SELECT * FROM sync_queue ORDER BY mutationId") - fun observe(): Flow> + @Query( + """ + SELECT * FROM sync_queue + WHERE accountId = :accountId AND workspaceId = :workspaceId + ORDER BY createdAtEpochMs ASC, mutationId ASC + """, + ) + fun observe(accountId: String, workspaceId: String): Flow> + + @Query( + """ + SELECT * FROM sync_queue + WHERE accountId = :accountId AND workspaceId = :workspaceId + ORDER BY createdAtEpochMs ASC, mutationId ASC + """, + ) + suspend fun snapshot(accountId: String, workspaceId: String): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun enqueue(item: SyncQueueEntity): Long + + @Query( + "DELETE FROM sync_queue WHERE accountId = :accountId AND workspaceId = :workspaceId AND mutationId = :mutationId", + ) + suspend fun delete(accountId: String, workspaceId: String, mutationId: String): Int + + @Query("DELETE FROM sync_queue WHERE accountId = :accountId AND workspaceId = :workspaceId") + suspend fun clear(accountId: String, workspaceId: String): Int - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun enqueue(item: SyncQueueEntity) + @Query( + "UPDATE sync_queue SET state = :completedState WHERE accountId = :accountId AND workspaceId = :workspaceId AND mutationId = :mutationId", + ) + suspend fun markCompleted( + accountId: String, + workspaceId: String, + mutationId: String, + completedState: String = SyncQueueEntity.COMPLETED_STATE, + ): Int } -@Database(entities = [SyncQueueEntity::class], version = 1, exportSchema = false) +@Database(entities = [SyncQueueEntity::class], version = 1, exportSchema = true) abstract class DataBreezeDatabase : RoomDatabase() { abstract fun syncQueue(): SyncQueueDao } @@ -36,5 +125,97 @@ abstract class DataBreezeDatabase : RoomDatabase() { /** Port used by feature modules; no Android context or database leaks into the domain. */ interface LocalStorePort { suspend fun enqueue(mutation: SyncQueueEntity) - fun observeQueue(): Flow> + fun observeQueue(scope: AccountWorkspaceScope): Flow> + suspend fun snapshotQueue(scope: AccountWorkspaceScope): List + suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean + suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean + suspend fun clear(scope: AccountWorkspaceScope) +} + +class RoomLocalStore private constructor(private val database: DataBreezeDatabase) : LocalStorePort { + private val dao: SyncQueueDao = database.syncQueue() + + override suspend fun enqueue(mutation: SyncQueueEntity) { + dao.enqueue(mutation) + } + + override fun observeQueue(scope: AccountWorkspaceScope): Flow> = + dao.observe(scope.accountId, scope.workspaceId) + + override suspend fun snapshotQueue(scope: AccountWorkspaceScope): List = + dao.snapshot(scope.accountId, scope.workspaceId) + + override suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean = + dao.delete(scope.accountId, scope.workspaceId, mutationId) == 1 + + override suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean = + dao.markCompleted(scope.accountId, scope.workspaceId, mutationId) == 1 + + override suspend fun clear(scope: AccountWorkspaceScope) { + dao.clear(scope.accountId, scope.workspaceId) + } + + fun close() = database.close() + + companion object { + fun create(context: Context): RoomLocalStore = + RoomLocalStore( + Room.databaseBuilder(context, DataBreezeDatabase::class.java, "databreeze-local.db") + .enableMultiInstanceInvalidation() + .build(), + ) + + fun from(database: DataBreezeDatabase): RoomLocalStore = RoomLocalStore(database) + } +} + +/** Deterministic fake used by JVM tests and by the no-network shell configuration. */ +class InMemoryLocalStore : LocalStorePort { + private val mutex = Mutex() + private val items = linkedMapOf() + private val updates = MutableStateFlow>(emptyList()) + + override suspend fun enqueue(mutation: SyncQueueEntity) { + mutex.withLock { + items.putIfAbsent(key(mutation.accountId, mutation.workspaceId, mutation.mutationId), mutation) + publish() + } + } + + override fun observeQueue(scope: AccountWorkspaceScope): Flow> = + updates.asStateFlow().map { values -> + values.filter { it.accountId == scope.accountId && it.workspaceId == scope.workspaceId } + } + + override suspend fun snapshotQueue(scope: AccountWorkspaceScope): List = mutex.withLock { + items.values.filter { it.accountId == scope.accountId && it.workspaceId == scope.workspaceId } + } + + override suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean = mutex.withLock { + val removed = items.remove(key(scope.accountId, scope.workspaceId, mutationId)) != null + publish() + removed + } + + override suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean = mutex.withLock { + val key = key(scope.accountId, scope.workspaceId, mutationId) + val current = items[key] ?: return@withLock false + items[key] = current.copy(state = SyncQueueEntity.COMPLETED_STATE) + publish() + true + } + + override suspend fun clear(scope: AccountWorkspaceScope) { + mutex.withLock { + items.keys.removeIf { it.startsWith("${scope.accountId}\u0000${scope.workspaceId}\u0000") } + publish() + } + } + + private fun publish() { + updates.value = items.values.toList() + } + + private fun key(accountId: String, workspaceId: String, mutationId: String): String = + "$accountId\u0000$workspaceId\u0000$mutationId" } diff --git a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt index b18abd18..af2d5f0e 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt @@ -1,44 +1,156 @@ package com.databreeze.android.sync import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.WorkerFactory import androidx.work.WorkerParameters +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.LocalStorePort +import com.databreeze.android.storage.SyncQueueEntity import com.databreeze.contracts.v1.Identifier +import java.util.concurrent.TimeUnit -private const val SYNC_WORKSPACE_ID = "workspace_id" +private const val ACCOUNT_ID = "account_id" +private const val WORKSPACE_ID = "workspace_id" +private const val CURSOR = "cursor" +private const val REVISION = "revision" -data class SyncRequest(val workspaceId: Identifier, val cursor: String?) +data class SyncWorkInput( + val scope: AccountWorkspaceScope, + val cursor: String? = null, + val revision: Long? = null, +) { + init { + require(cursor == null || cursor.length <= 512) { "cursor must be bounded" } + require(cursor?.contains('\n') != true) { "cursor cannot contain line breaks" } + require(revision == null || revision >= 0L) { "revision cannot be negative" } + } + + fun toData(): Data = Data.Builder() + .putString(ACCOUNT_ID, scope.accountId) + .putString(WORKSPACE_ID, scope.workspaceId) + .apply { cursor?.let { putString(CURSOR, it) } } + .apply { revision?.let { putLong(REVISION, it) } } + .build() + + companion object { + fun fromData(data: Data): SyncWorkInput { + require(data.keyValueMap.keys.all { it in setOf(ACCOUNT_ID, WORKSPACE_ID, CURSOR, REVISION) }) { + "sync work input contains an unsupported field" + } + val accountId = data.getString(ACCOUNT_ID) + ?: throw IllegalArgumentException("account_id is required") + val workspaceId = data.getString(WORKSPACE_ID) + ?: throw IllegalArgumentException("workspace_id is required") + val revision = if (data.keyValueMap.containsKey(REVISION)) data.getLong(REVISION, -1L) else null + return SyncWorkInput( + scope = AccountWorkspaceScope(accountId, workspaceId), + cursor = data.getString(CURSOR), + revision = revision, + ) + } + } +} + +data class SyncRequest(val scope: AccountWorkspaceScope, val cursor: String?) { + val accountId: Identifier get() = scope.accountId + val workspaceId: Identifier get() = scope.workspaceId +} + +sealed interface SyncTransportResult { + data object Accepted : SyncTransportResult + data object Retryable : SyncTransportResult + data class Rejected(val code: String) : SyncTransportResult +} interface SyncTransport { - suspend fun synchronize(request: SyncRequest): Result + suspend fun synchronize(request: SyncRequest, mutations: List): SyncTransportResult } interface SyncScheduler { - fun enqueue(workspaceId: Identifier) + fun enqueue(scope: AccountWorkspaceScope, cursor: String? = null, revision: Long? = null) + fun cancel(scope: AccountWorkspaceScope) + + companion object { + fun uniqueWorkName(scope: AccountWorkspaceScope): String = "sync-${scope.stableKey}" + } } class WorkManagerSyncScheduler(private val context: Context) : SyncScheduler { - override fun enqueue(workspaceId: Identifier) { - val request = OneTimeWorkRequestBuilder() - .setInputData(androidx.work.Data.Builder().putString(SYNC_WORKSPACE_ID, workspaceId).build()) + override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) { + val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(SyncWorkInput(scope, cursor, revision).toData()) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30L, TimeUnit.SECONDS) .build() WorkManager.getInstance(context).enqueueUniqueWork( - "sync-$workspaceId", + SyncScheduler.uniqueWorkName(scope), ExistingWorkPolicy.KEEP, request, ) } + + override fun cancel(scope: AccountWorkspaceScope) { + WorkManager.getInstance(context).cancelUniqueWork(SyncScheduler.uniqueWorkName(scope)) + } } -class SyncWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result { - val workspaceId = inputData.getString(SYNC_WORKSPACE_ID) - ?: return Result.failure() - // The transport is injected by the application layer in the next vertical slice. - return if (workspaceId.isNotBlank()) Result.success() else Result.failure() +class SyncWorker( + appContext: Context, + params: WorkerParameters, + private val store: LocalStorePort, + private val transport: SyncTransport, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): ListenableWorker.Result { + val input = try { + SyncWorkInput.fromData(inputData) + } catch (_: IllegalArgumentException) { + return Result.failure() + } + val mutations = store.snapshotQueue(input.scope) + .filter { it.state != SyncQueueEntity.COMPLETED_STATE } + .map { it.mutationId } + return when (val outcome = transport.synchronize(SyncRequest(input.scope, input.cursor), mutations)) { + SyncTransportResult.Accepted -> { + mutations.forEach { store.markCompleted(input.scope, it) } + Result.success() + } + SyncTransportResult.Retryable -> Result.retry() + is SyncTransportResult.Rejected -> Result.failure( + Data.Builder().putString("reason_code", outcome.code).build(), + ) + } } +} + +class DataBreezeWorkerFactory( + private val store: LocalStorePort, + private val transport: SyncTransport, +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker? = when (workerClassName) { + SyncWorker::class.qualifiedName -> SyncWorker(appContext, workerParameters, store, transport) + else -> null + } +} +class UnconfiguredSyncTransport : SyncTransport { + override suspend fun synchronize(request: SyncRequest, mutations: List): SyncTransportResult = + SyncTransportResult.Rejected("transport_not_configured") } diff --git a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt new file mode 100644 index 00000000..e1381485 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt @@ -0,0 +1,52 @@ +package com.databreeze.android + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.sync.SyncWorkInput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidRuntimeContractTest { + @Test + fun scope_rejects_path_like_or_unbounded_identifiers() { + assertThrows(IllegalArgumentException::class.java) { + AccountWorkspaceScope(accountId = "account/one", workspaceId = "workspace-1") + } + assertThrows(IllegalArgumentException::class.java) { + AccountWorkspaceScope(accountId = "a".repeat(129), workspaceId = "workspace-1") + } + } + + @Test + fun work_input_round_trips_only_opaque_ids_and_revisions() { + val input = SyncWorkInput( + scope = AccountWorkspaceScope("account-1", "workspace-1"), + cursor = "cursor-1", + revision = 4L, + ) + + val restored = SyncWorkInput.fromData(input.toData()) + + assertEquals(input, restored) + assertTrue(input.toData().keyValueMap.keys.all { it in setOf("account_id", "workspace_id", "cursor", "revision") }) + } + + @Test + fun work_input_rejects_missing_scope() { + assertThrows(IllegalArgumentException::class.java) { + SyncWorkInput.fromData(androidx.work.Data.EMPTY) + } + } + + @Test + fun work_input_rejects_source_content_fields() { + val data = androidx.work.Data.Builder() + .putString("account_id", "account-1") + .putString("workspace_id", "workspace-1") + .putString("source_bytes", "must-not-be-carried") + .build() + + assertThrows(IllegalArgumentException::class.java) { SyncWorkInput.fromData(data) } + } +} diff --git a/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt b/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt index 5c454e2b..0815bd7e 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt @@ -1,16 +1,23 @@ package com.databreeze.android import com.databreeze.android.security.AndroidDeviceKeyStore +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore import com.databreeze.android.storage.SyncQueueEntity import com.databreeze.android.sync.SyncRequest +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test class BoundaryTest { @Test fun syncRequestUsesOpaqueWorkspaceIdentity() { - val request = SyncRequest(workspaceId = "workspace-1", cursor = null) + val request = SyncRequest( + scope = AccountWorkspaceScope("account-1", "workspace-1"), + cursor = null, + ) assertEquals("workspace-1", request.workspaceId) assertEquals(null, request.cursor) } @@ -18,10 +25,11 @@ class BoundaryTest { @Test fun queuedMutationCarriesHashAndDependencyWithoutSourceBytes() { val mutation = SyncQueueEntity( - mutationId = "mutation-1", + accountId = "account-1", workspaceId = "workspace-1", + mutationId = "mutation-1", operationType = "capture.submit", - payloadHash = "sha256:abc", + payloadHash = "sha256:${"a".repeat(64)}", dependencyId = null, ) assertTrue(mutation.payloadHash.startsWith("sha256:")) @@ -31,5 +39,45 @@ class BoundaryTest { @Test fun keystorePortHasStableDeviceKeyHandleType() { assertEquals("AndroidDeviceKeyStore", AndroidDeviceKeyStore::class.simpleName) + assertThrows(IllegalArgumentException::class.java) { + AndroidDeviceKeyStore.validateAlias("../credential") + } + } + + @Test + fun scope_key_is_stable_and_content_free() { + val scope = AccountWorkspaceScope("account-1", "workspace-1") + assertEquals(70, scope.stableKey.length) + assertTrue(scope.stableKey.startsWith("scope-")) + } + + @Test + fun in_memory_store_cannot_cross_account_or_workspace_boundaries() = runBlocking { + val store = InMemoryLocalStore() + val first = AccountWorkspaceScope("account-1", "workspace-1") + val second = AccountWorkspaceScope("account-2", "workspace-1") + store.enqueue( + SyncQueueEntity( + accountId = first.accountId, + workspaceId = first.workspaceId, + mutationId = "mutation-1", + operationType = "capture.submit", + payloadHash = "sha256:${"b".repeat(64)}", + ), + ) + store.enqueue( + SyncQueueEntity( + accountId = second.accountId, + workspaceId = second.workspaceId, + mutationId = "mutation-1", + operationType = "capture.submit", + payloadHash = "sha256:${"c".repeat(64)}", + ), + ) + + assertEquals(1, store.snapshotQueue(first).size) + assertEquals("account-1", store.snapshotQueue(first).single().accountId) + store.clear(first) + assertEquals(1, store.snapshotQueue(second).size) } } diff --git a/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt new file mode 100644 index 00000000..123cfac6 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt @@ -0,0 +1,21 @@ +package com.databreeze.android + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.sync.WorkManagerSyncScheduler +import org.junit.Assert.assertEquals +import org.junit.Test + +class SyncSchedulerContractTest { + @Test + fun unique_work_name_is_stable_and_scope_bound() { + val scope = AccountWorkspaceScope("account-1", "workspace-1") + + assertEquals("sync-${scope.stableKey}", SyncScheduler.uniqueWorkName(scope)) + } + + @Test + fun scheduler_type_is_the_workmanager_adapter() { + assertEquals("WorkManagerSyncScheduler", WorkManagerSyncScheduler::class.simpleName) + } +} diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 779c7638..940ab0a4 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -25,6 +25,8 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } @@ -44,3 +46,4 @@ contractRuntime = ["jackson-module-kotlin", "networknt-json-schema"] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } diff --git a/tools/repo-cli/test/android-shell.test.mjs b/tools/repo-cli/test/android-shell.test.mjs new file mode 100644 index 00000000..89b502cf --- /dev/null +++ b/tools/repo-cli/test/android-shell.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const repositoryRoot = path.resolve(import.meta.dirname, '..', '..', '..'); +const androidRoot = path.join(repositoryRoot, 'apps', 'android'); +const read = (relativePath) => readFileSync(path.join(androidRoot, relativePath), 'utf8'); + +function stringNames(xml) { + return [...xml.matchAll(/ match[1]).sort(); +} + +test('Android shell is bounded to supported API levels and generated contracts', () => { + const build = read('app/build.gradle.kts'); + assert.match(build, /compileSdk\s*=\s*36/u); + assert.match(build, /minSdk\s*=\s*26/u); + assert.match(build, /targetSdk\s*=\s*35/u); + assert.match(build, /androidx\.room\.compiler/u); + assert.match(build, /androidx\.work\.runtime/u); + assert.match(build, /kotlin\.kapt/u); + assert.ok( + existsSync( + path.join( + repositoryRoot, + 'packages', + 'contracts', + 'generated', + 'kotlin', + 'src', + 'main', + 'kotlin', + ), + ), + ); + assert.ok( + existsSync( + path.join(repositoryRoot, 'packages', 'design-tokens', 'tokens', 'generated', 'android'), + ), + ); +}); + +test('Android manifest fails closed for network, backup, and exported-component boundaries', () => { + const manifest = read('app/src/main/AndroidManifest.xml'); + assert.match(manifest, /android:usesCleartextTraffic="false"/u); + assert.match(manifest, /android:networkSecurityConfig="@xml\/network_security_config"/u); + assert.match(manifest, /android:allowBackup="false"/u); + assert.doesNotMatch(manifest, /MANAGE_EXTERNAL_STORAGE/u); + assert.doesNotMatch(manifest, /android:exported="true"[\s\S]*service/u); + + const network = read('app/src/main/res/xml/network_security_config.xml'); + assert.match(network, /cleartextTrafficPermitted="false"/u); + const backup = read('app/src/main/res/xml/backup_rules.xml'); + const extraction = read('app/src/main/res/xml/data_extraction_rules.xml'); + for (const rules of [backup, extraction]) { + assert.match(rules, /domain="database"/u); + assert.match(rules, /domain="sharedpref"/u); + assert.match(rules, /domain="external"/u); + } +}); + +test('Vietnamese and English Android catalogs have identical complete keys', () => { + const vietnamese = stringNames(read('app/src/main/res/values/strings.xml')); + const english = stringNames(read('app/src/main/res/values-en/strings.xml')); + assert.deepEqual(vietnamese, english); + assert.ok(vietnamese.length >= 8); +}); + +test('Android shell has durable local state, injected workers, and process-death coverage', () => { + const localStore = read('app/src/main/java/com/databreeze/android/storage/LocalStore.kt'); + const sync = read('app/src/main/java/com/databreeze/android/sync/SyncPorts.kt'); + const app = read('app/src/main/java/com/databreeze/android/DataBreezeApplication.kt'); + assert.match(localStore, /@Database\(entities = \[SyncQueueEntity::class\]/u); + assert.match(localStore, /primaryKeys = \["accountId", "workspaceId", "mutationId"\]/u); + assert.match(sync, /ExistingWorkPolicy\.KEEP/u); + assert.match(sync, /DataBreezeWorkerFactory/u); + assert.match(sync, /setRequiredNetworkType\(NetworkType\.CONNECTED\)/u); + assert.match(app, /Configuration\.Provider/u); + assert.match(app, /setWorkerFactory\(runtime\.workerFactory\)/u); + assert.ok( + existsSync( + path.join( + androidRoot, + 'app', + 'src', + 'androidTest', + 'java', + 'com', + 'databreeze', + 'android', + 'RoomIsolationTest.kt', + ), + ), + ); + assert.ok( + existsSync( + path.join( + androidRoot, + 'app', + 'src', + 'androidTest', + 'java', + 'com', + 'databreeze', + 'android', + 'MainActivityTest.kt', + ), + ), + ); +}); From dcdc07cf392742ef1f571de6eac75c776cc9b0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:13:03 +0700 Subject: [PATCH 02/16] fix(android): restore capture after process recreation --- .../androidTest/java/com/databreeze/android/MainActivityTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 38e3668f..653657a4 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt @@ -29,7 +29,7 @@ class MainActivityTest { } composeRule.activityRule.scenario.recreate() - composeRule.onNodeWithTag("capture-button").performClick() + composeRule.onNodeWithTag("capture-screen").assertIsDisplayed() composeRule.onNodeWithTag("draft-status").assertIsDisplayed() } } From 634ae28fa43f6d86448b5ab997a2c627a099bfe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:13:54 +0700 Subject: [PATCH 03/16] docs(android): record foundation reconciliation evidence --- .../foundation-android-2026-08-02.md | 36 +++++++++++++++++++ docs/plans/execution-orchestration.json | 27 ++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 docs/operations/foundation-android-2026-08-02.md diff --git a/docs/operations/foundation-android-2026-08-02.md b/docs/operations/foundation-android-2026-08-02.md new file mode 100644 index 00000000..48cb71d8 --- /dev/null +++ b/docs/operations/foundation-android-2026-08-02.md @@ -0,0 +1,36 @@ +# Android Foundation Reconciliation + +**Evidence date:** 2026-08-02 + +**Source commit:** `dcdc07cf392742ef1f571de6eac75c776cc9b0ed` (implementation `d42574d` plus the process-recreation test correction `dcdc07c`) + +**Scope:** FND-002, the Android shell completion task in Plan 010. + +## Delivered boundaries + +- Room now stores the sync queue with a composite `(accountId, workspaceId, mutationId)` primary key, scoped queries, idempotent enqueue, completion receipts, clear-on-sign-out, and a checked-in schema snapshot. +- WorkManager receives only bounded account/workspace IDs, cursors, and revisions. Unique work is scope-hashed, network constrained, backoff-enabled, injected through `DataBreezeWorkerFactory`, and marks accepted mutations complete. +- The application composition root supplies the Room store, Android Keystore, WorkManager scheduler, and transport adapter. The default transport is explicitly unconfigured and cannot silently send data. +- Device aliases are bounded and validated. Sensitive local fields have an AES-GCM payload port backed by Android Keystore. Sign-out cancels scoped work, clears the scoped queue, and removes the device key. +- Compose navigation uses stable routes and derives draft status from durable local state. Recreating the activity restores the capture route and saved draft state. +- Manifest, cleartext policy, backup/data-extraction exclusions, generated Kotlin contracts/design tokens, API-level pins, and Vietnamese/English resource parity are repository-checked. + +## Verification evidence + +| Check | Result | +|---|---| +| `node --test tools/repo-cli/test/android-shell.test.mjs` | 4 passed | +| `apps/android/gradlew.bat :app:testDebugUnitTest --no-daemon` | passed | +| `apps/android/gradlew.bat :app:assembleDebug :app:compileDebugAndroidTestKotlin --no-daemon` | passed | +| `apps/android/gradlew.bat :app:connectedDebugAndroidTest --no-daemon` | passed on `Medium_Phone(AVD) - 17`; three instrumentation tests, including Room isolation and activity recreation | +| `git diff --check` | passed | + +The first instrumentation attempt exposed an incorrect expectation after Navigation restored the capture destination on recreation. The test was corrected in `dcdc07c`; the rerun passed all three tests. + +## Safety and rollback + +WorkManager payloads reject unknown fields such as source bytes. Room and in-memory tests prove that sibling accounts and workspaces cannot observe or clear each other's queue. Backup rules exclude databases, preferences, files, and external storage. The generated Room schema is evidence for future migration review. + +Reverting `d42574d` and `dcdc07c` removes the FND-002 runtime, schema, and evidence tests without touching server data or the canonical logo. After a rollback, run the Android unit/compile/instrumentation checks and `corepack pnpm repo:check` before accepting another foundation task. + +FND-002 does not promote any product requirement to `verified`; it closes the Android foundation task boundary only. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index e154ecb9..6b25a963 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -15,11 +15,11 @@ } }, "checkpoint": { - "observedAt": "2026-08-02T09:06:00Z", - "remoteDev": "86e72d8", - "remoteMain": "8ac8bca", - "lastFeaturePullRequest": 9, - "lastPromotionPullRequest": 8, + "observedAt": "2026-08-02T10:08:12Z", + "remoteDev": "ae2a4fc1c350e684fcbde7ed0a5e9a5a97038505", + "remoteMain": "d26e6be16ecadc07467b458b853eb8070940e846", + "lastFeaturePullRequest": 12, + "lastPromotionPullRequest": 11, "openPullRequestsObserved": 0, "note": "Historical observation only; every session must fetch and recompute current state." }, @@ -52,11 +52,11 @@ "post-ga-planned", "blocked" ], - "nextTaskId": "FND-002", + "nextTaskId": "FND-003", "taskState": { "FND-001": { "status": "verified", - "commit": "7c023e5d7f6cfd3a4a9b2c2c0bdf098e0d4bbba5", + "commit": "7c023e51583cb6e168cf11e693ecaf2d60a71f72", "evidence": [ "docs/operations/foundation-reconciliation-2026-08-02.md", "tools/repo-cli/test/foundation-reconciliation.test.mjs", @@ -64,6 +64,19 @@ "services/engine/scripts/run-engine.mjs" ], "note": "The task reconciles the foundation against merged dev; OpenTofu remains a hosted validation boundary and no requirement status was promoted." + }, + "FND-002": { + "status": "verified", + "commit": "dcdc07cf392742ef1f571de6eac75c776cc9b0ed", + "evidence": [ + "docs/operations/foundation-android-2026-08-02.md", + "apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt", + "apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt", + "apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt", + "apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt", + "tools/repo-cli/test/android-shell.test.mjs" + ], + "note": "Room, WorkManager, Keystore, bilingual resources, generated contracts/tokens, backup/network policy, account isolation, and process-recreation evidence are complete. No product requirement status was promoted." } }, "plans": [ From 0290e9b900194b91a6e49244e45b7c422985d611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:14:28 +0700 Subject: [PATCH 04/16] fix(android): format room schema artifact --- .../1.json | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json index cd036c6b..08912cb4 100644 --- a/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json +++ b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json @@ -58,21 +58,13 @@ ], "primaryKey": { "autoGenerate": false, - "columnNames": [ - "accountId", - "workspaceId", - "mutationId" - ] + "columnNames": ["accountId", "workspaceId", "mutationId"] }, "indices": [ { "name": "index_sync_queue_accountId_workspaceId_state", "unique": false, - "columnNames": [ - "accountId", - "workspaceId", - "state" - ], + "columnNames": ["accountId", "workspaceId", "state"], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_queue_accountId_workspaceId_state` ON `${TABLE_NAME}` (`accountId`, `workspaceId`, `state`)" } @@ -84,4 +76,4 @@ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f7753bf8dd22ecd25b625ad3c7ecadc')" ] } -} \ No newline at end of file +} From 6c275b0368ed80da19257256d9680dad9954f5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:16:17 +0700 Subject: [PATCH 05/16] test(orchestration): track android foundation checkpoint --- tools/repo-cli/test/execution-orchestration.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 9eb2f267..337eb53c 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -176,7 +176,7 @@ test('repository checker validates the committed orchestration package', () => { test('ledger records verified task evidence before advancing the next task', () => { const ledger = readJson('docs/plans/execution-orchestration.json'); - assert.equal(ledger.nextTaskId, 'FND-002'); + assert.equal(ledger.nextTaskId, 'FND-003'); assert.deepEqual(ledger.taskState?.['FND-001']?.status, 'verified'); assert.match(ledger.taskState?.['FND-001']?.commit ?? '', /^[0-9a-f]{40}$/u); assert.ok( @@ -184,6 +184,13 @@ test('ledger records verified task evidence before advancing the next task', () 'docs/operations/foundation-reconciliation-2026-08-02.md', ), ); + assert.deepEqual(ledger.taskState?.['FND-002']?.status, 'verified'); + assert.match(ledger.taskState?.['FND-002']?.commit ?? '', /^[0-9a-f]{40}$/u); + assert.ok( + ledger.taskState['FND-002'].evidence.includes( + 'docs/operations/foundation-android-2026-08-02.md', + ), + ); }); test('CodeRabbit promotion disposition records one review and rejected claims', () => { From 7ea16a4637a2dd03e9de56be200a54fb959d7004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:19:29 +0700 Subject: [PATCH 06/16] docs(android): add root verification evidence --- docs/operations/foundation-android-2026-08-02.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/operations/foundation-android-2026-08-02.md b/docs/operations/foundation-android-2026-08-02.md index 48cb71d8..442fc1f0 100644 --- a/docs/operations/foundation-android-2026-08-02.md +++ b/docs/operations/foundation-android-2026-08-02.md @@ -23,6 +23,9 @@ | `apps/android/gradlew.bat :app:testDebugUnitTest --no-daemon` | passed | | `apps/android/gradlew.bat :app:assembleDebug :app:compileDebugAndroidTestKotlin --no-daemon` | passed | | `apps/android/gradlew.bat :app:connectedDebugAndroidTest --no-daemon` | passed on `Medium_Phone(AVD) - 17`; three instrumentation tests, including Room isolation and activity recreation | +| `corepack pnpm repo:check` | passed; 49 repository tests and all 21 workspace test tasks | +| `corepack pnpm repo:build` | passed; all 12 build tasks | +| `corepack pnpm infra:check` | static AWS checks passed; OpenTofu is not installed, so non-applying fmt/validate were skipped | | `git diff --check` | passed | The first instrumentation attempt exposed an incorrect expectation after Navigation restored the capture destination on recreation. The test was corrected in `dcdc07c`; the rerun passed all three tests. From a42ecfdd65922edcc5290ddbbd7df0fce565d525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:54:16 +0700 Subject: [PATCH 07/16] fix(android): close promotion sync lifecycle gaps --- .../1.json | 4 +- apps/android/app/src/main/AndroidManifest.xml | 11 +++- .../com/databreeze/android/AndroidRuntime.kt | 10 ++- .../com/databreeze/android/MainActivity.kt | 13 +++- .../android/security/DeviceKeyStore.kt | 14 ++-- .../databreeze/android/storage/LocalStore.kt | 37 ++++++++--- .../com/databreeze/android/sync/SyncPorts.kt | 66 +++++++++++++++++-- 7 files changed, 131 insertions(+), 24 deletions(-) diff --git a/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json index 08912cb4..8a59b864 100644 --- a/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json +++ b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json @@ -68,9 +68,11 @@ "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_queue_accountId_workspaceId_state` ON `${TABLE_NAME}` (`accountId`, `workspaceId`, `state`)" } - ] + ], + "foreignKeys": [] } ], + "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f7753bf8dd22ecd25b625ad3c7ecadc')" diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index bcea0005..af049b68 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + + + + diff --git a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt index b6f42d3d..003172d4 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -7,6 +7,8 @@ import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.AccountWorkspaceScope import com.databreeze.android.storage.RoomLocalStore import com.databreeze.android.sync.DataBreezeWorkerFactory +import com.databreeze.android.sync.SharedPreferencesSyncRevocationGuard +import com.databreeze.android.sync.SyncRevocationGuard import com.databreeze.android.sync.SyncScheduler import com.databreeze.android.sync.SyncTransport import com.databreeze.android.sync.UnconfiguredSyncTransport @@ -18,10 +20,12 @@ class AndroidRuntime private constructor( val deviceKeyStore: DeviceKeyStore, val syncTransport: SyncTransport, val syncScheduler: SyncScheduler, + val syncRevocationGuard: SyncRevocationGuard, val workerFactory: DataBreezeWorkerFactory, ) { /** Revocation/account switch clears local work and the device-bound key before returning. */ suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) { + syncRevocationGuard.revoke(scope) syncScheduler.cancel(scope) localStore.clear(scope) deviceKeyStore.delete(keyAlias) @@ -31,12 +35,16 @@ class AndroidRuntime private constructor( fun create(context: Context): AndroidRuntime { val localStore = RoomLocalStore.create(context.applicationContext) val transport = UnconfiguredSyncTransport() + val revocationGuard = SharedPreferencesSyncRevocationGuard( + context.applicationContext.getSharedPreferences("databreeze-sync", Context.MODE_PRIVATE), + ) return AndroidRuntime( localStore = localStore, deviceKeyStore = AndroidDeviceKeyStore(), syncTransport = transport, syncScheduler = WorkManagerSyncScheduler(context.applicationContext), - workerFactory = DataBreezeWorkerFactory(localStore, transport), + syncRevocationGuard = revocationGuard, + workerFactory = DataBreezeWorkerFactory(localStore, transport, revocationGuard), ) } } diff --git a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt index 84f879b6..d3ba91d8 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt @@ -29,6 +29,7 @@ import com.databreeze.android.storage.AccountWorkspaceScope import com.databreeze.android.storage.InMemoryLocalStore import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.SyncQueueEntity +import com.databreeze.android.sync.SyncScheduler import kotlinx.coroutines.launch private object AppRoutes { @@ -44,7 +45,13 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val application = application as DataBreezeApplication - setContent { DataBreezeApp(application.runtime.localStore, localScope) } + setContent { + DataBreezeApp( + localStore = application.runtime.localStore, + scope = localScope, + syncScheduler = application.runtime.syncScheduler, + ) + } } } @@ -53,6 +60,7 @@ class MainActivity : ComponentActivity() { fun DataBreezeApp( localStore: LocalStorePort = remember { InMemoryLocalStore() }, scope: AccountWorkspaceScope = localScope, + syncScheduler: SyncScheduler? = null, ) { val navController = rememberNavController() DataBreezeTheme { @@ -71,6 +79,7 @@ fun DataBreezeApp( CaptureScreen( localStore = localStore, scope = scope, + syncScheduler = syncScheduler, onBack = { navController.popBackStack() }, ) } @@ -100,6 +109,7 @@ private fun HomeScreen(onCapture: () -> Unit) { private fun CaptureScreen( localStore: LocalStorePort, scope: AccountWorkspaceScope, + syncScheduler: SyncScheduler?, onBack: () -> Unit, ) { val queue by localStore.observeQueue(scope).collectAsState(initial = emptyList()) @@ -131,6 +141,7 @@ private fun CaptureScreen( createdAtEpochMs = System.currentTimeMillis(), ), ) + syncScheduler?.enqueue(scope) } }, modifier = Modifier.testTag("save-button"), diff --git a/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt b/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt index ba2de352..04c9f548 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/security/DeviceKeyStore.kt @@ -3,7 +3,6 @@ package com.databreeze.android.security import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore -import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -74,18 +73,21 @@ data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray) { require(iv.size == 12) { "GCM IV must be 96 bits" } require(ciphertext.isNotEmpty()) { "ciphertext cannot be empty" } } + + override fun equals(other: Any?): Boolean = + this === other || (other is EncryptedPayload && + iv.contentEquals(other.iv) && ciphertext.contentEquals(other.ciphertext)) + + override fun hashCode(): Int = 31 * iv.contentHashCode() + ciphertext.contentHashCode() } /** Encrypts local sensitive fields; callers persist only this envelope, never plaintext. */ class DevicePayloadCipher(private val keyStore: DeviceKeyStore) { - private val random = SecureRandom() - fun encrypt(handle: DeviceKeyHandle, plaintext: ByteArray): EncryptedPayload { require(plaintext.isNotEmpty()) { "plaintext cannot be empty" } - val iv = ByteArray(12).also(random::nextBytes) val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, keyStore.keyFor(handle), GCMParameterSpec(128, iv)) - return EncryptedPayload(iv, cipher.doFinal(plaintext)) + cipher.init(Cipher.ENCRYPT_MODE, keyStore.keyFor(handle)) + return EncryptedPayload(cipher.iv, cipher.doFinal(plaintext)) } fun decrypt(handle: DeviceKeyHandle, payload: EncryptedPayload): ByteArray { diff --git a/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt b/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt index 5bb15901..c3782b9b 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/storage/LocalStore.kt @@ -13,6 +13,7 @@ import androidx.room.RoomDatabase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -54,7 +55,7 @@ data class SyncQueueEntity( val payloadHash: String, val dependencyId: String? = null, val state: String = QUEUED_STATE, - val createdAtEpochMs: Long = 0L, + val createdAtEpochMs: Long, ) { init { AccountWorkspaceScope(accountId, workspaceId) @@ -107,14 +108,14 @@ interface SyncQueueDao { suspend fun clear(accountId: String, workspaceId: String): Int @Query( - "UPDATE sync_queue SET state = :completedState WHERE accountId = :accountId AND workspaceId = :workspaceId AND mutationId = :mutationId", + "UPDATE sync_queue SET state = '${SyncQueueEntity.COMPLETED_STATE}' WHERE accountId = :accountId AND workspaceId = :workspaceId AND mutationId = :mutationId", ) - suspend fun markCompleted( - accountId: String, - workspaceId: String, - mutationId: String, - completedState: String = SyncQueueEntity.COMPLETED_STATE, - ): Int + suspend fun markCompleted(accountId: String, workspaceId: String, mutationId: String): Int + + @Query( + "DELETE FROM sync_queue WHERE accountId = :accountId AND workspaceId = :workspaceId AND mutationId IN (:mutationIds)", + ) + suspend fun deleteBatch(accountId: String, workspaceId: String, mutationIds: List): Int } @Database(entities = [SyncQueueEntity::class], version = 1, exportSchema = true) @@ -128,6 +129,7 @@ interface LocalStorePort { fun observeQueue(scope: AccountWorkspaceScope): Flow> suspend fun snapshotQueue(scope: AccountWorkspaceScope): List suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean + suspend fun deleteBatch(scope: AccountWorkspaceScope, mutationIds: List): Int suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean suspend fun clear(scope: AccountWorkspaceScope) } @@ -148,6 +150,9 @@ class RoomLocalStore private constructor(private val database: DataBreezeDatabas override suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean = dao.delete(scope.accountId, scope.workspaceId, mutationId) == 1 + override suspend fun deleteBatch(scope: AccountWorkspaceScope, mutationIds: List): Int = + if (mutationIds.isEmpty()) 0 else dao.deleteBatch(scope.accountId, scope.workspaceId, mutationIds) + override suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean = dao.markCompleted(scope.accountId, scope.workspaceId, mutationId) == 1 @@ -185,10 +190,13 @@ class InMemoryLocalStore : LocalStorePort { override fun observeQueue(scope: AccountWorkspaceScope): Flow> = updates.asStateFlow().map { values -> values.filter { it.accountId == scope.accountId && it.workspaceId == scope.workspaceId } - } + .sortedWith(compareBy({ it.createdAtEpochMs }, { it.mutationId })) + }.distinctUntilChanged() override suspend fun snapshotQueue(scope: AccountWorkspaceScope): List = mutex.withLock { - items.values.filter { it.accountId == scope.accountId && it.workspaceId == scope.workspaceId } + items.values + .filter { it.accountId == scope.accountId && it.workspaceId == scope.workspaceId } + .sortedWith(compareBy({ it.createdAtEpochMs }, { it.mutationId })) } override suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean = mutex.withLock { @@ -197,6 +205,15 @@ class InMemoryLocalStore : LocalStorePort { removed } + override suspend fun deleteBatch(scope: AccountWorkspaceScope, mutationIds: List): Int = mutex.withLock { + var removed = 0 + mutationIds.forEach { mutationId -> + if (items.remove(key(scope.accountId, scope.workspaceId, mutationId)) != null) removed++ + } + if (removed > 0) publish() + removed + } + override suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean = mutex.withLock { val key = key(scope.accountId, scope.workspaceId, mutationId) val current = items[key] ?: return@withLock false diff --git a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt index af2d5f0e..a89649c9 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt @@ -1,6 +1,7 @@ package com.databreeze.android.sync import android.content.Context +import android.content.SharedPreferences import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker @@ -17,6 +18,8 @@ import com.databreeze.android.storage.AccountWorkspaceScope import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.SyncQueueEntity import com.databreeze.contracts.v1.Identifier +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.TimeUnit private const val ACCOUNT_ID = "account_id" @@ -76,6 +79,55 @@ interface SyncTransport { suspend fun synchronize(request: SyncRequest, mutations: List): SyncTransportResult } +/** + * Serializes an authorized transport operation with scope revocation. Revocation is persisted + * before waiting for an in-flight operation, so a queued worker cannot start after sign-out. + */ +interface SyncRevocationGuard { + suspend fun withPermit(scope: AccountWorkspaceScope, operation: suspend () -> T): T? + suspend fun revoke(scope: AccountWorkspaceScope) +} + +class SharedPreferencesSyncRevocationGuard( + private val preferences: SharedPreferences, +) : SyncRevocationGuard { + private val mutex = Mutex() + + override suspend fun withPermit( + scope: AccountWorkspaceScope, + operation: suspend () -> T, + ): T? = mutex.withLock { + if (preferences.getBoolean(key(scope), false)) null else operation() + } + + override suspend fun revoke(scope: AccountWorkspaceScope) { + check(preferences.edit().putBoolean(key(scope), true).commit()) { + "unable to persist sync revocation" + } + mutex.withLock { Unit } + } + + private fun key(scope: AccountWorkspaceScope): String = "revoked-${scope.stableKey}" +} + +/** Deterministic guard used by JVM tests and dependency-free shell configurations. */ +class InMemorySyncRevocationGuard : SyncRevocationGuard { + private val mutex = Mutex() + private val revoked = java.util.concurrent.ConcurrentHashMap.newKeySet() + + override suspend fun withPermit( + scope: AccountWorkspaceScope, + operation: suspend () -> T, + ): T? = mutex.withLock { + if (scope.stableKey in revoked) null else operation() + } + + override suspend fun revoke(scope: AccountWorkspaceScope) { + revoked += scope.stableKey + mutex.withLock { Unit } + } +} + interface SyncScheduler { fun enqueue(scope: AccountWorkspaceScope, cursor: String? = null, revision: Long? = null) fun cancel(scope: AccountWorkspaceScope) @@ -98,7 +150,7 @@ class WorkManagerSyncScheduler(private val context: Context) : SyncScheduler { .build() WorkManager.getInstance(context).enqueueUniqueWork( SyncScheduler.uniqueWorkName(scope), - ExistingWorkPolicy.KEEP, + ExistingWorkPolicy.APPEND_OR_REPLACE, request, ) } @@ -113,6 +165,7 @@ class SyncWorker( params: WorkerParameters, private val store: LocalStorePort, private val transport: SyncTransport, + private val revocationGuard: SyncRevocationGuard, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): ListenableWorker.Result { val input = try { @@ -123,9 +176,12 @@ class SyncWorker( val mutations = store.snapshotQueue(input.scope) .filter { it.state != SyncQueueEntity.COMPLETED_STATE } .map { it.mutationId } - return when (val outcome = transport.synchronize(SyncRequest(input.scope, input.cursor), mutations)) { + val outcome = revocationGuard.withPermit(input.scope) { + transport.synchronize(SyncRequest(input.scope, input.cursor), mutations) + } ?: return Result.failure(Data.Builder().putString("reason_code", "scope_revoked").build()) + return when (outcome) { SyncTransportResult.Accepted -> { - mutations.forEach { store.markCompleted(input.scope, it) } + store.deleteBatch(input.scope, mutations) Result.success() } SyncTransportResult.Retryable -> Result.retry() @@ -139,13 +195,15 @@ class SyncWorker( class DataBreezeWorkerFactory( private val store: LocalStorePort, private val transport: SyncTransport, + private val revocationGuard: SyncRevocationGuard, ) : WorkerFactory() { override fun createWorker( appContext: Context, workerClassName: String, workerParameters: WorkerParameters, ): ListenableWorker? = when (workerClassName) { - SyncWorker::class.qualifiedName -> SyncWorker(appContext, workerParameters, store, transport) + SyncWorker::class.qualifiedName -> + SyncWorker(appContext, workerParameters, store, transport, revocationGuard) else -> null } } From 2ed2b6441073e5585557c1ebc065e76769a6c8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:54:23 +0700 Subject: [PATCH 08/16] test(android): cover promotion review boundaries --- .../databreeze/android/MainActivityTest.kt | 8 ++++-- .../databreeze/android/RoomIsolationTest.kt | 9 ++++-- .../android/AndroidRuntimeContractTest.kt | 5 ++-- .../com/databreeze/android/BoundaryTest.kt | 15 ++++++++++ .../android/SyncSchedulerContractTest.kt | 28 +++++++++++++++++++ tools/repo-cli/test/android-shell.test.mjs | 15 ++++++---- .../test/execution-orchestration.test.mjs | 1 + 7 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 653657a4..14eb3eac 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule @@ -22,14 +24,16 @@ class MainActivityTest { @Test fun queued_draft_is_visible_after_activity_recreation() { + val savedText = composeRule.activity.getString(R.string.capture_saved) composeRule.onNodeWithTag("capture-button").performClick() composeRule.onNodeWithTag("save-button").performClick() composeRule.waitUntil(timeoutMillis = 5_000) { - composeRule.onAllNodesWithTag("draft-status").fetchSemanticsNodes().isNotEmpty() + composeRule.onAllNodesWithText(savedText).fetchSemanticsNodes().isNotEmpty() } + composeRule.onNodeWithText(savedText).assertIsDisplayed() composeRule.activityRule.scenario.recreate() composeRule.onNodeWithTag("capture-screen").assertIsDisplayed() - composeRule.onNodeWithTag("draft-status").assertIsDisplayed() + composeRule.onNodeWithText(savedText).assertIsDisplayed() } } diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt index 18f05a1f..b56c4541 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt @@ -8,7 +8,6 @@ import com.databreeze.android.storage.DataBreezeDatabase import com.databreeze.android.storage.SyncQueueEntity import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -27,14 +26,20 @@ class RoomIsolationTest { mutationId = "mutation-1", operationType = "capture.submit", payloadHash = "sha256:${"a".repeat(64)}", + createdAtEpochMs = 1L, ) + val firstWorkspaceB = first.copy(workspaceId = "workspace-b", createdAtEpochMs = 2L) database.syncQueue().enqueue(first) database.syncQueue().enqueue(first) + database.syncQueue().enqueue(firstWorkspaceB) database.syncQueue().enqueue(first.copy(accountId = "account-b")) assertEquals(listOf(first), database.syncQueue().snapshot("account-a", "workspace-a")) assertEquals(1, database.syncQueue().snapshot("account-b", "workspace-a").size) - assertTrue(database.syncQueue().snapshot("account-a", "workspace-b").isEmpty()) + assertEquals( + listOf(firstWorkspaceB), + database.syncQueue().snapshot("account-a", "workspace-b"), + ) } finally { database.close() } diff --git a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt index e1381485..24633af8 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt @@ -1,5 +1,6 @@ package com.databreeze.android +import androidx.work.Data import com.databreeze.android.storage.AccountWorkspaceScope import com.databreeze.android.sync.SyncWorkInput import org.junit.Assert.assertEquals @@ -35,13 +36,13 @@ class AndroidRuntimeContractTest { @Test fun work_input_rejects_missing_scope() { assertThrows(IllegalArgumentException::class.java) { - SyncWorkInput.fromData(androidx.work.Data.EMPTY) + SyncWorkInput.fromData(Data.EMPTY) } } @Test fun work_input_rejects_source_content_fields() { - val data = androidx.work.Data.Builder() + val data = Data.Builder() .putString("account_id", "account-1") .putString("workspace_id", "workspace-1") .putString("source_bytes", "must-not-be-carried") diff --git a/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt b/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt index 0815bd7e..7b7699b9 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/BoundaryTest.kt @@ -31,6 +31,7 @@ class BoundaryTest { operationType = "capture.submit", payloadHash = "sha256:${"a".repeat(64)}", dependencyId = null, + createdAtEpochMs = 1L, ) assertTrue(mutation.payloadHash.startsWith("sha256:")) assertEquals(null, mutation.dependencyId) @@ -56,6 +57,7 @@ class BoundaryTest { val store = InMemoryLocalStore() val first = AccountWorkspaceScope("account-1", "workspace-1") val second = AccountWorkspaceScope("account-2", "workspace-1") + val third = AccountWorkspaceScope("account-1", "workspace-2") store.enqueue( SyncQueueEntity( accountId = first.accountId, @@ -63,6 +65,7 @@ class BoundaryTest { mutationId = "mutation-1", operationType = "capture.submit", payloadHash = "sha256:${"b".repeat(64)}", + createdAtEpochMs = 1L, ), ) store.enqueue( @@ -72,6 +75,17 @@ class BoundaryTest { mutationId = "mutation-1", operationType = "capture.submit", payloadHash = "sha256:${"c".repeat(64)}", + createdAtEpochMs = 2L, + ), + ) + store.enqueue( + SyncQueueEntity( + accountId = third.accountId, + workspaceId = third.workspaceId, + mutationId = "mutation-1", + operationType = "capture.submit", + payloadHash = "sha256:${"d".repeat(64)}", + createdAtEpochMs = 3L, ), ) @@ -79,5 +93,6 @@ class BoundaryTest { assertEquals("account-1", store.snapshotQueue(first).single().accountId) store.clear(first) assertEquals(1, store.snapshotQueue(second).size) + assertEquals(1, store.snapshotQueue(third).size) } } diff --git a/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt index 123cfac6..4be3f868 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt @@ -2,8 +2,13 @@ package com.databreeze.android import com.databreeze.android.storage.AccountWorkspaceScope import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.sync.InMemorySyncRevocationGuard import com.databreeze.android.sync.WorkManagerSyncScheduler +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class SyncSchedulerContractTest { @@ -18,4 +23,27 @@ class SyncSchedulerContractTest { fun scheduler_type_is_the_workmanager_adapter() { assertEquals("WorkManagerSyncScheduler", WorkManagerSyncScheduler::class.simpleName) } + + @Test + fun revocation_blocks_a_worker_that_has_not_started_transport() = runBlocking { + val guard = InMemorySyncRevocationGuard() + val scope = AccountWorkspaceScope("account-1", "workspace-1") + val entered = CompletableDeferred() + val release = CompletableDeferred() + + val inFlight = async { + guard.withPermit(scope) { + entered.complete(Unit) + release.await() + "sent" + } + } + entered.await() + val revocation = async { guard.revoke(scope) } + release.complete(Unit) + + assertEquals("sent", inFlight.await()) + revocation.await() + assertNull(guard.withPermit(scope) { "must-not-send" }) + } } diff --git a/tools/repo-cli/test/android-shell.test.mjs b/tools/repo-cli/test/android-shell.test.mjs index 89b502cf..82c956a0 100644 --- a/tools/repo-cli/test/android-shell.test.mjs +++ b/tools/repo-cli/test/android-shell.test.mjs @@ -46,16 +46,21 @@ test('Android manifest fails closed for network, backup, and exported-component assert.match(manifest, /android:networkSecurityConfig="@xml\/network_security_config"/u); assert.match(manifest, /android:allowBackup="false"/u); assert.doesNotMatch(manifest, /MANAGE_EXTERNAL_STORAGE/u); - assert.doesNotMatch(manifest, /android:exported="true"[\s\S]*service/u); + assert.doesNotMatch(manifest, /]*\bandroid:exported\s*=\s*"true")[^>]*>/u); + assert.match(manifest, /android:name="androidx\.work\.WorkManagerInitializer"/u); + assert.match( + manifest, + /android:name="androidx\.work\.WorkManagerInitializer"[\s\S]*tools:node="remove"/u, + ); const network = read('app/src/main/res/xml/network_security_config.xml'); assert.match(network, /cleartextTrafficPermitted="false"/u); const backup = read('app/src/main/res/xml/backup_rules.xml'); const extraction = read('app/src/main/res/xml/data_extraction_rules.xml'); for (const rules of [backup, extraction]) { - assert.match(rules, /domain="database"/u); - assert.match(rules, /domain="sharedpref"/u); - assert.match(rules, /domain="external"/u); + for (const domain of ['database', 'sharedpref', 'external']) { + assert.match(rules, new RegExp(`]*\\bdomain="${domain}")[^>]*>`, 'u')); + } } }); @@ -72,7 +77,7 @@ test('Android shell has durable local state, injected workers, and process-death const app = read('app/src/main/java/com/databreeze/android/DataBreezeApplication.kt'); assert.match(localStore, /@Database\(entities = \[SyncQueueEntity::class\]/u); assert.match(localStore, /primaryKeys = \["accountId", "workspaceId", "mutationId"\]/u); - assert.match(sync, /ExistingWorkPolicy\.KEEP/u); + assert.match(sync, /ExistingWorkPolicy\.APPEND_OR_REPLACE/u); assert.match(sync, /DataBreezeWorkerFactory/u); assert.match(sync, /setRequiredNetworkType\(NetworkType\.CONNECTED\)/u); assert.match(app, /Configuration\.Provider/u); diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 337eb53c..cdef1148 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -177,6 +177,7 @@ test('repository checker validates the committed orchestration package', () => { test('ledger records verified task evidence before advancing the next task', () => { const ledger = readJson('docs/plans/execution-orchestration.json'); assert.equal(ledger.nextTaskId, 'FND-003'); + assert.equal(ledger.checkpoint.lastFeaturePullRequest, 13); assert.deepEqual(ledger.taskState?.['FND-001']?.status, 'verified'); assert.match(ledger.taskState?.['FND-001']?.commit ?? '', /^[0-9a-f]{40}$/u); assert.ok( From fe8a37657d6ccbccf5fc9da62251b072fe1fc500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 17:54:26 +0700 Subject: [PATCH 09/16] docs(operations): record promotion review disposition --- docs/operations/code-review-14-disposition.md | 37 +++++++++++++++++++ docs/plans/execution-orchestration.json | 4 +- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 docs/operations/code-review-14-disposition.md diff --git a/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md new file mode 100644 index 00000000..066d2792 --- /dev/null +++ b/docs/operations/code-review-14-disposition.md @@ -0,0 +1,37 @@ +# Promotion PR #14 CodeRabbit disposition + +Review run: `82d15fdf-1cc0-49ec-9b67-9683ccd2c814` +Pull request: `dev → main` (`#14`) +Review policy: one full CodeRabbit review; no manual rerun + +## Findings addressed + +The following claims were reproduced against the promotion diff and fixed on the focused +review branch: + +- `MainActivityTest` now asserts the actual saved copy before and after recreation. +- Room and in-memory isolation tests cover both account and workspace dimensions. +- Sign-out persists a scope revocation guard and serializes it with transport, so a queued + worker cannot begin transport after revocation. +- Capture saves enqueue scoped WorkManager work only after the durable queue write. +- `EncryptedPayload` uses content equality and Android Keystore supplies the randomized GCM IV. +- Queue timestamps are required at construction and local snapshots/fakes use the Room ordering. +- `APPEND_OR_REPLACE` preserves newer sync inputs instead of dropping them behind `KEEP`. +- Accepted mutations are removed in one scoped batch operation rather than accumulating completed rows. +- Default WorkManager initialization is removed so the application `WorkerFactory` is authoritative. +- XML, backup, extraction, and orchestration checkpoint assertions fail closed and record PR #13. +- Room schema output includes canonical empty `foreignKeys` and `views` arrays. +- The JVM WorkManager contract test imports `Data` directly. + +## Claims intentionally not applied + +- **Switch kapt to KSP/Room plugin:** a local trial with the version-catalog alias failed Gradle + configuration because the Kotlin kapt plugin was already on the classpath with an unknown + version. The existing raw plugin ID is the compatible configuration; a KSP migration is a + separate dependency/toolchain task, not a promotion fix. +- **Docstring coverage warning:** the check is advisory and its 80% threshold is not part of the + repository release gates. Adding broad generated documentation would expand this Android + foundation change without improving the reviewed behavior. + +All actionable findings were handled in focused commits. Hosted checks and the connected Android +tests remain required before merging the review-fix PR and the synchronized promotion PR. diff --git a/docs/plans/execution-orchestration.json b/docs/plans/execution-orchestration.json index 6b25a963..0dcad477 100644 --- a/docs/plans/execution-orchestration.json +++ b/docs/plans/execution-orchestration.json @@ -16,9 +16,9 @@ }, "checkpoint": { "observedAt": "2026-08-02T10:08:12Z", - "remoteDev": "ae2a4fc1c350e684fcbde7ed0a5e9a5a97038505", + "remoteDev": "4d415494240abbc610574132678007a623c405e4", "remoteMain": "d26e6be16ecadc07467b458b853eb8070940e846", - "lastFeaturePullRequest": 12, + "lastFeaturePullRequest": 13, "lastPromotionPullRequest": 11, "openPullRequestsObserved": 0, "note": "Historical observation only; every session must fetch and recompute current state." From 8e7ee3fae81ac3ead92787bffec7f2ac10844255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:19:56 +0700 Subject: [PATCH 10/16] fix(android): scope sync revocation lifecycle --- .../com/databreeze/android/AndroidRuntime.kt | 11 +++++++ .../com/databreeze/android/sync/SyncPorts.kt | 30 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt index 003172d4..77686bb6 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -2,6 +2,7 @@ package com.databreeze.android import android.content.Context import com.databreeze.android.security.AndroidDeviceKeyStore +import com.databreeze.android.security.DeviceKeyHandle import com.databreeze.android.security.DeviceKeyStore import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.AccountWorkspaceScope @@ -23,6 +24,16 @@ class AndroidRuntime private constructor( val syncRevocationGuard: SyncRevocationGuard, val workerFactory: DataBreezeWorkerFactory, ) { + /** + * Re-enables a scope only after authentication has succeeded and its device key is ready. + * The explicit call prevents a process restart from silently undoing sign-out revocation. + */ + suspend fun signIn(scope: AccountWorkspaceScope, keyAlias: String): DeviceKeyHandle { + val handle = deviceKeyStore.getOrCreate(keyAlias) + syncRevocationGuard.reactivate(scope) + return handle + } + /** Revocation/account switch clears local work and the device-bound key before returning. */ suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) { syncRevocationGuard.revoke(scope) diff --git a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt index a89649c9..f0d8f63c 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/sync/SyncPorts.kt @@ -86,17 +86,18 @@ interface SyncTransport { interface SyncRevocationGuard { suspend fun withPermit(scope: AccountWorkspaceScope, operation: suspend () -> T): T? suspend fun revoke(scope: AccountWorkspaceScope) + suspend fun reactivate(scope: AccountWorkspaceScope) } class SharedPreferencesSyncRevocationGuard( private val preferences: SharedPreferences, ) : SyncRevocationGuard { - private val mutex = Mutex() + private val mutexes = java.util.concurrent.ConcurrentHashMap() override suspend fun withPermit( scope: AccountWorkspaceScope, operation: suspend () -> T, - ): T? = mutex.withLock { + ): T? = mutexFor(scope).withLock { if (preferences.getBoolean(key(scope), false)) null else operation() } @@ -104,28 +105,45 @@ class SharedPreferencesSyncRevocationGuard( check(preferences.edit().putBoolean(key(scope), true).commit()) { "unable to persist sync revocation" } - mutex.withLock { Unit } + mutexFor(scope).withLock { Unit } + } + + override suspend fun reactivate(scope: AccountWorkspaceScope) { + mutexFor(scope).withLock { + check(preferences.edit().remove(key(scope)).commit()) { + "unable to persist sync reactivation" + } + } } private fun key(scope: AccountWorkspaceScope): String = "revoked-${scope.stableKey}" + private fun mutexFor(scope: AccountWorkspaceScope): Mutex = + mutexes.computeIfAbsent(scope.stableKey) { Mutex() } } /** Deterministic guard used by JVM tests and dependency-free shell configurations. */ class InMemorySyncRevocationGuard : SyncRevocationGuard { - private val mutex = Mutex() + private val mutexes = java.util.concurrent.ConcurrentHashMap() private val revoked = java.util.concurrent.ConcurrentHashMap.newKeySet() override suspend fun withPermit( scope: AccountWorkspaceScope, operation: suspend () -> T, - ): T? = mutex.withLock { + ): T? = mutexFor(scope).withLock { if (scope.stableKey in revoked) null else operation() } override suspend fun revoke(scope: AccountWorkspaceScope) { revoked += scope.stableKey - mutex.withLock { Unit } + mutexFor(scope).withLock { Unit } } + + override suspend fun reactivate(scope: AccountWorkspaceScope) { + mutexFor(scope).withLock { revoked.remove(scope.stableKey) } + } + + private fun mutexFor(scope: AccountWorkspaceScope): Mutex = + mutexes.computeIfAbsent(scope.stableKey) { Mutex() } } interface SyncScheduler { From c679cbba957582dde1edd892d22e59c9b16cc0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:20:00 +0700 Subject: [PATCH 11/16] test(android): verify reactivation and scope isolation --- .../android/SyncSchedulerContractTest.kt | 34 +++++++++++++++++++ docs/operations/code-review-14-disposition.md | 11 ++++++ tools/repo-cli/test/android-shell.test.mjs | 3 ++ 3 files changed, 48 insertions(+) diff --git a/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt index 4be3f868..f97d4508 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt @@ -7,6 +7,7 @@ import com.databreeze.android.sync.WorkManagerSyncScheduler import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -46,4 +47,37 @@ class SyncSchedulerContractTest { revocation.await() assertNull(guard.withPermit(scope) { "must-not-send" }) } + + @Test + fun reactivation_restores_sync_after_an_explicit_sign_in() = runBlocking { + val guard = InMemorySyncRevocationGuard() + val scope = AccountWorkspaceScope("account-1", "workspace-1") + + guard.revoke(scope) + assertNull(guard.withPermit(scope) { "blocked" }) + guard.reactivate(scope) + + assertEquals("allowed", guard.withPermit(scope) { "allowed" }) + } + + @Test + fun revocation_for_one_scope_does_not_wait_for_another_scope() = runBlocking { + val guard = InMemorySyncRevocationGuard() + val first = AccountWorkspaceScope("account-1", "workspace-1") + val second = AccountWorkspaceScope("account-2", "workspace-2") + val entered = CompletableDeferred() + val release = CompletableDeferred() + + val inFlight = async { + guard.withPermit(first) { + entered.complete(Unit) + release.await() + } + } + entered.await() + withTimeout(1_000) { guard.revoke(second) } + release.complete(Unit) + inFlight.await() + Unit + } } diff --git a/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md index 066d2792..dfeada9e 100644 --- a/docs/operations/code-review-14-disposition.md +++ b/docs/operations/code-review-14-disposition.md @@ -35,3 +35,14 @@ review branch: All actionable findings were handled in focused commits. Hosted checks and the connected Android tests remain required before merging the review-fix PR and the synchronized promotion PR. + +## Automatic incremental review after PR #15 synchronization + +GitHub automatically refreshed the existing review when PR #15 merged into `dev` (no new review +was requested). It identified two additional valid claims, both fixed in the follow-up branch: + +- Revocation locks are now keyed by `AccountWorkspaceScope`, so a long-running sync in one + workspace cannot stall sign-out in another. +- The guard now exposes `reactivate(scope)` and `AndroidRuntime.signIn` calls it only after the + device key is initialized, allowing an explicit sign-in/sign-out cycle without silently + re-enabling a revoked scope on process restart. diff --git a/tools/repo-cli/test/android-shell.test.mjs b/tools/repo-cli/test/android-shell.test.mjs index 82c956a0..ef38c6d5 100644 --- a/tools/repo-cli/test/android-shell.test.mjs +++ b/tools/repo-cli/test/android-shell.test.mjs @@ -75,13 +75,16 @@ test('Android shell has durable local state, injected workers, and process-death const localStore = read('app/src/main/java/com/databreeze/android/storage/LocalStore.kt'); const sync = read('app/src/main/java/com/databreeze/android/sync/SyncPorts.kt'); const app = read('app/src/main/java/com/databreeze/android/DataBreezeApplication.kt'); + const runtime = read('app/src/main/java/com/databreeze/android/AndroidRuntime.kt'); assert.match(localStore, /@Database\(entities = \[SyncQueueEntity::class\]/u); assert.match(localStore, /primaryKeys = \["accountId", "workspaceId", "mutationId"\]/u); assert.match(sync, /ExistingWorkPolicy\.APPEND_OR_REPLACE/u); + assert.match(sync, /suspend fun reactivate\(scope: AccountWorkspaceScope\)/u); assert.match(sync, /DataBreezeWorkerFactory/u); assert.match(sync, /setRequiredNetworkType\(NetworkType\.CONNECTED\)/u); assert.match(app, /Configuration\.Provider/u); assert.match(app, /setWorkerFactory\(runtime\.workerFactory\)/u); + assert.match(runtime, /suspend fun signIn\(scope: AccountWorkspaceScope/u); assert.ok( existsSync( path.join( From 4fb2d14192e39a147aeda7e3e75fd6ce6406e2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:51 +0700 Subject: [PATCH 12/16] fix(android): serialize account lifecycle operations --- .../com/databreeze/android/AndroidRuntime.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt index 77686bb6..e29cfdf6 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -14,9 +14,12 @@ import com.databreeze.android.sync.SyncScheduler import com.databreeze.android.sync.SyncTransport import com.databreeze.android.sync.UnconfiguredSyncTransport import com.databreeze.android.sync.WorkManagerSyncScheduler +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** Application-owned adapters. Feature packages receive ports, never Context or raw clients. */ -class AndroidRuntime private constructor( +class AndroidRuntime internal constructor( val localStore: LocalStorePort, val deviceKeyStore: DeviceKeyStore, val syncTransport: SyncTransport, @@ -24,23 +27,30 @@ class AndroidRuntime private constructor( val syncRevocationGuard: SyncRevocationGuard, val workerFactory: DataBreezeWorkerFactory, ) { + private val lifecycleMutexes = ConcurrentHashMap() + /** * Re-enables a scope only after authentication has succeeded and its device key is ready. * The explicit call prevents a process restart from silently undoing sign-out revocation. */ - suspend fun signIn(scope: AccountWorkspaceScope, keyAlias: String): DeviceKeyHandle { - val handle = deviceKeyStore.getOrCreate(keyAlias) - syncRevocationGuard.reactivate(scope) - return handle - } + suspend fun signIn(scope: AccountWorkspaceScope, keyAlias: String): DeviceKeyHandle = + lifecycleMutex(scope).withLock { + val handle = deviceKeyStore.getOrCreate(keyAlias) + syncRevocationGuard.reactivate(scope) + handle + } /** Revocation/account switch clears local work and the device-bound key before returning. */ - suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) { - syncRevocationGuard.revoke(scope) - syncScheduler.cancel(scope) - localStore.clear(scope) - deviceKeyStore.delete(keyAlias) - } + suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) = + lifecycleMutex(scope).withLock { + syncRevocationGuard.revoke(scope) + syncScheduler.cancel(scope) + localStore.clear(scope) + deviceKeyStore.delete(keyAlias) + } + + private fun lifecycleMutex(scope: AccountWorkspaceScope): Mutex = + lifecycleMutexes.computeIfAbsent(scope.stableKey) { Mutex() } companion object { fun create(context: Context): AndroidRuntime { From 5bc23a3620fd7aa6df2ec2d402dc87efa979fd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:54 +0700 Subject: [PATCH 13/16] test(android): cover sign-in sign-out race --- .../android/AndroidRuntimeLifecycleTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt diff --git a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt new file mode 100644 index 00000000..ba6b3ae7 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt @@ -0,0 +1,110 @@ +package com.databreeze.android + +import com.databreeze.android.security.DeviceKeyHandle +import com.databreeze.android.security.DeviceKeyStore +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore +import com.databreeze.android.sync.DataBreezeWorkerFactory +import com.databreeze.android.sync.SyncRevocationGuard +import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.sync.SyncTransport +import com.databreeze.android.sync.UnconfiguredSyncTransport +import java.util.Collections +import java.util.concurrent.CountDownLatch +import javax.crypto.SecretKey +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AndroidRuntimeLifecycleTest { + @Test + fun sign_in_and_sign_out_are_serialized_for_the_same_scope() = runBlocking { + val scope = AccountWorkspaceScope("account-1", "workspace-1") + val events = Collections.synchronizedList(mutableListOf()) + val keyStarted = CountDownLatch(1) + val releaseKeyCreation = CountDownLatch(1) + val revokeCalled = CompletableDeferred() + val keyStore = BlockingDeviceKeyStore(events, keyStarted, releaseKeyCreation) + val guard = RecordingRevocationGuard(events, revokeCalled) + val scheduler = RecordingScheduler(events) + val transport: SyncTransport = UnconfiguredSyncTransport() + val store = InMemoryLocalStore() + val runtime = AndroidRuntime( + localStore = store, + deviceKeyStore = keyStore, + syncTransport = transport, + syncScheduler = scheduler, + syncRevocationGuard = guard, + workerFactory = DataBreezeWorkerFactory(store, transport, guard), + ) + + val signIn = async(Dispatchers.Default) { runtime.signIn(scope, "device-key") } + keyStarted.await() + val signOut = async(Dispatchers.Default) { runtime.signOut(scope, "device-key") } + + assertNull(withTimeoutOrNull(200) { revokeCalled.await() }) + assertEquals(listOf("key-create"), events.toList()) + + releaseKeyCreation.countDown() + withTimeout(2_000) { + signIn.await() + signOut.await() + } + + assertEquals(listOf("key-create", "reactivate", "revoke", "cancel", "key-delete"), events.toList()) + } + + private class BlockingDeviceKeyStore( + private val events: MutableList, + private val keyStarted: CountDownLatch, + private val releaseKeyCreation: CountDownLatch, + ) : DeviceKeyStore { + override fun getOrCreate(alias: String): DeviceKeyHandle { + events += "key-create" + keyStarted.countDown() + releaseKeyCreation.await() + return DeviceKeyHandle(alias) + } + + override fun contains(alias: String): Boolean = false + + override fun delete(alias: String): Boolean { + events += "key-delete" + return true + } + + override fun keyFor(handle: DeviceKeyHandle): SecretKey = + throw UnsupportedOperationException("not used by lifecycle test") + } + + private class RecordingRevocationGuard( + private val events: MutableList, + val revokeCalled: CompletableDeferred, + ) : SyncRevocationGuard { + override suspend fun withPermit(scope: AccountWorkspaceScope, operation: suspend () -> T): T = + operation() + + override suspend fun revoke(scope: AccountWorkspaceScope) { + events += "revoke" + revokeCalled.complete(Unit) + } + + override suspend fun reactivate(scope: AccountWorkspaceScope) { + events += "reactivate" + } + } + + private class RecordingScheduler(private val events: MutableList) : SyncScheduler { + override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) = Unit + + override fun cancel(scope: AccountWorkspaceScope) { + events += "cancel" + } + } +} From 6c22085ee2a3e0000b6ed643ee03ccb1929376c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:57 +0700 Subject: [PATCH 14/16] docs(operations): record lifecycle review disposition --- docs/operations/code-review-14-disposition.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md index dfeada9e..11393a4d 100644 --- a/docs/operations/code-review-14-disposition.md +++ b/docs/operations/code-review-14-disposition.md @@ -46,3 +46,11 @@ was requested). It identified two additional valid claims, both fixed in the fol - The guard now exposes `reactivate(scope)` and `AndroidRuntime.signIn` calls it only after the device key is initialized, allowing an explicit sign-in/sign-out cycle without silently re-enabling a revoked scope on process restart. + +## Automatic incremental review after PR #16 synchronization + +GitHub automatically refreshed the existing review after PR #16 merged into `dev` (no new review +was requested). It identified one valid lifecycle race: sign-in and sign-out could interleave +between device-key initialization and revocation. `AndroidRuntime` now serializes the complete +sign-in/sign-out lifecycle per `AccountWorkspaceScope`, and a blocking JVM test proves sign-out +cannot revoke, cancel, or delete the key until sign-in has completed. From fc1e5abf880685d600241a7ca7634a3556ce1909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:47:41 +0700 Subject: [PATCH 15/16] test(android): make lifecycle contention deterministic --- .../com/databreeze/android/AndroidRuntimeLifecycleTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt index ba6b3ae7..050cbd1a 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt @@ -13,6 +13,7 @@ import java.util.Collections import java.util.concurrent.CountDownLatch import javax.crypto.SecretKey import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking @@ -46,7 +47,10 @@ class AndroidRuntimeLifecycleTest { val signIn = async(Dispatchers.Default) { runtime.signIn(scope, "device-key") } keyStarted.await() - val signOut = async(Dispatchers.Default) { runtime.signOut(scope, "device-key") } + val signOut = async( + context = Dispatchers.Default, + start = CoroutineStart.UNDISPATCHED, + ) { runtime.signOut(scope, "device-key") } assertNull(withTimeoutOrNull(200) { revokeCalled.await() }) assertEquals(listOf("key-create"), events.toList()) From a413386c0c7ccf0c4f85d652187b87afd5171e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:47:42 +0700 Subject: [PATCH 16/16] docs(operations): record test determinism review --- docs/operations/code-review-14-disposition.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md index 11393a4d..d4a66255 100644 --- a/docs/operations/code-review-14-disposition.md +++ b/docs/operations/code-review-14-disposition.md @@ -54,3 +54,10 @@ was requested). It identified one valid lifecycle race: sign-in and sign-out cou between device-key initialization and revocation. `AndroidRuntime` now serializes the complete sign-in/sign-out lifecycle per `AccountWorkspaceScope`, and a blocking JVM test proves sign-out cannot revoke, cancel, or delete the key until sign-in has completed. + +## Automatic incremental review after PR #17 synchronization + +GitHub automatically refreshed the existing review after PR #17 merged into `dev` (no new review +was requested). It identified a valid test determinism issue: the lifecycle test now starts +sign-out with `CoroutineStart.UNDISPATCHED` after key creation has blocked, guaranteeing that the +test actually contends on the per-scope mutex before releasing sign-in.