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/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/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 } } 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/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." 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(