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..8a59b864 --- /dev/null +++ b/apps/android/app/schemas/com.databreeze.android.storage.DataBreezeDatabase/1.json @@ -0,0 +1,81 @@ +{ + "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`)" + } + ], + "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/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 8786cf2e..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 @@ -3,6 +3,10 @@ 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.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test @@ -17,4 +21,19 @@ class MainActivityTest { fun homeScreenIsDisplayed() { composeRule.onNodeWithTag("home-screen").assertIsDisplayed() } + + @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.onAllNodesWithText(savedText).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(savedText).assertIsDisplayed() + + composeRule.activityRule.scenario.recreate() + composeRule.onNodeWithTag("capture-screen").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 new file mode 100644 index 00000000..b56c4541 --- /dev/null +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/RoomIsolationTest.kt @@ -0,0 +1,47 @@ +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.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)}", + 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) + assertEquals( + listOf(firstWorkspaceB), + database.syncQueue().snapshot("account-a", "workspace-b"), + ) + } finally { + database.close() + } + } +} 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 new file mode 100644 index 00000000..e29cfdf6 --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -0,0 +1,72 @@ +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 +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 +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 internal constructor( + val localStore: LocalStorePort, + val deviceKeyStore: DeviceKeyStore, + val syncTransport: SyncTransport, + val syncScheduler: SyncScheduler, + 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 = + 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) = + 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 { + 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), + syncRevocationGuard = revocationGuard, + workerFactory = DataBreezeWorkerFactory(localStore, transport, revocationGuard), + ) + } + } +} 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..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 @@ -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,43 @@ 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 com.databreeze.android.sync.SyncScheduler +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( + localStore = application.runtime.localStore, + scope = localScope, + syncScheduler = application.runtime.syncScheduler, + ) + } } } @Composable @OptIn(ExperimentalMaterial3Api::class) -fun DataBreezeApp() { +fun DataBreezeApp( + localStore: LocalStorePort = remember { InMemoryLocalStore() }, + scope: AccountWorkspaceScope = localScope, + syncScheduler: SyncScheduler? = null, +) { val navController = rememberNavController() DataBreezeTheme { Scaffold( @@ -43,13 +69,20 @@ 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, + syncScheduler = syncScheduler, + onBack = { navController.popBackStack() }, + ) } - composable("capture") { CaptureScreen(onBack = { navController.popBackStack() }) } } } } @@ -73,8 +106,15 @@ private fun HomeScreen(onCapture: () -> Unit) { } @Composable -private fun CaptureScreen(onBack: () -> Unit) { - var submitted by remember { mutableStateOf(false) } +private fun CaptureScreen( + localStore: LocalStorePort, + scope: AccountWorkspaceScope, + syncScheduler: SyncScheduler?, + 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 +126,26 @@ 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(), + ), + ) + syncScheduler?.enqueue(scope) + } + }, + 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..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,20 +3,31 @@ package com.databreeze.android.security import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore +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 +45,54 @@ 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" } + } + + 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) { + fun encrypt(handle: DeviceKeyHandle, plaintext: ByteArray): EncryptedPayload { + require(plaintext.isNotEmpty()) { "plaintext cannot be empty" } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keyStore.keyFor(handle)) + return EncryptedPayload(cipher.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..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 @@ -1,34 +1,124 @@ 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.distinctUntilChanged +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, +) { + 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> - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun enqueue(item: SyncQueueEntity) + @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 + + @Query( + "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): 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 = false) +@Database(entities = [SyncQueueEntity::class], version = 1, exportSchema = true) abstract class DataBreezeDatabase : RoomDatabase() { abstract fun syncQueue(): SyncQueueDao } @@ -36,5 +126,113 @@ 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 deleteBatch(scope: AccountWorkspaceScope, mutationIds: List): Int + 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 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 + + 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 } + .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 } + .sortedWith(compareBy({ it.createdAtEpochMs }, { it.mutationId })) + } + + 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 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 + 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..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 @@ -1,44 +1,232 @@ 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 +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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 +} + +/** + * 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) + suspend fun reactivate(scope: AccountWorkspaceScope) +} + +class SharedPreferencesSyncRevocationGuard( + private val preferences: SharedPreferences, +) : SyncRevocationGuard { + private val mutexes = java.util.concurrent.ConcurrentHashMap() + + override suspend fun withPermit( + scope: AccountWorkspaceScope, + operation: suspend () -> T, + ): T? = mutexFor(scope).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" + } + 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 mutexes = java.util.concurrent.ConcurrentHashMap() + private val revoked = java.util.concurrent.ConcurrentHashMap.newKeySet() + + override suspend fun withPermit( + scope: AccountWorkspaceScope, + operation: suspend () -> T, + ): T? = mutexFor(scope).withLock { + if (scope.stableKey in revoked) null else operation() + } + + override suspend fun revoke(scope: AccountWorkspaceScope) { + revoked += scope.stableKey + 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 { - 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", - ExistingWorkPolicy.KEEP, + SyncScheduler.uniqueWorkName(scope), + ExistingWorkPolicy.APPEND_OR_REPLACE, 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, + private val revocationGuard: SyncRevocationGuard, +) : 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 } + 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 -> { + store.deleteBatch(input.scope, mutations) + 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, + 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, revocationGuard) + 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..24633af8 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeContractTest.kt @@ -0,0 +1,53 @@ +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 +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(Data.EMPTY) + } + } + + @Test + fun work_input_rejects_source_content_fields() { + val data = 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/AndroidRuntimeLifecycleTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt new file mode 100644 index 00000000..050cbd1a --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt @@ -0,0 +1,114 @@ +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.CoroutineStart +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( + context = Dispatchers.Default, + start = CoroutineStart.UNDISPATCHED, + ) { 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" + } + } +} 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..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 @@ -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,11 +25,13 @@ 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, + createdAtEpochMs = 1L, ) assertTrue(mutation.payloadHash.startsWith("sha256:")) assertEquals(null, mutation.dependencyId) @@ -31,5 +40,59 @@ 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") + val third = AccountWorkspaceScope("account-1", "workspace-2") + store.enqueue( + SyncQueueEntity( + accountId = first.accountId, + workspaceId = first.workspaceId, + mutationId = "mutation-1", + operationType = "capture.submit", + payloadHash = "sha256:${"b".repeat(64)}", + createdAtEpochMs = 1L, + ), + ) + store.enqueue( + SyncQueueEntity( + accountId = second.accountId, + workspaceId = second.workspaceId, + 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, + ), + ) + + assertEquals(1, store.snapshotQueue(first).size) + 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 new file mode 100644 index 00000000..f97d4508 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/SyncSchedulerContractTest.kt @@ -0,0 +1,83 @@ +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 kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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) + } + + @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" }) + } + + @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/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/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md new file mode 100644 index 00000000..d4a66255 --- /dev/null +++ b/docs/operations/code-review-14-disposition.md @@ -0,0 +1,63 @@ +# 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. + +## 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. + +## 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. + +## 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. 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..442fc1f0 --- /dev/null +++ b/docs/operations/foundation-android-2026-08-02.md @@ -0,0 +1,39 @@ +# 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 | +| `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. + +## 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..0dcad477 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": "4d415494240abbc610574132678007a623c405e4", + "remoteMain": "d26e6be16ecadc07467b458b853eb8070940e846", + "lastFeaturePullRequest": 13, + "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": [ 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..ef38c6d5 --- /dev/null +++ b/tools/repo-cli/test/android-shell.test.mjs @@ -0,0 +1,118 @@ +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, /]*\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]) { + for (const domain of ['database', 'sharedpref', 'external']) { + assert.match(rules, new RegExp(`]*\\bdomain="${domain}")[^>]*>`, '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'); + 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( + 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', + ), + ), + ); +}); diff --git a/tools/repo-cli/test/execution-orchestration.test.mjs b/tools/repo-cli/test/execution-orchestration.test.mjs index 9eb2f267..cdef1148 100644 --- a/tools/repo-cli/test/execution-orchestration.test.mjs +++ b/tools/repo-cli/test/execution-orchestration.test.mjs @@ -176,7 +176,8 @@ 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.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( @@ -184,6 +185,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', () => {