From 4fb2d14192e39a147aeda7e3e75fd6ce6406e2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:51 +0700 Subject: [PATCH 1/3] fix(android): serialize account lifecycle operations --- .../com/databreeze/android/AndroidRuntime.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt index 77686bb6..e29cfdf6 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/AndroidRuntime.kt @@ -14,9 +14,12 @@ import com.databreeze.android.sync.SyncScheduler import com.databreeze.android.sync.SyncTransport import com.databreeze.android.sync.UnconfiguredSyncTransport import com.databreeze.android.sync.WorkManagerSyncScheduler +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** Application-owned adapters. Feature packages receive ports, never Context or raw clients. */ -class AndroidRuntime private constructor( +class AndroidRuntime internal constructor( val localStore: LocalStorePort, val deviceKeyStore: DeviceKeyStore, val syncTransport: SyncTransport, @@ -24,23 +27,30 @@ class AndroidRuntime private constructor( val syncRevocationGuard: SyncRevocationGuard, val workerFactory: DataBreezeWorkerFactory, ) { + private val lifecycleMutexes = ConcurrentHashMap() + /** * Re-enables a scope only after authentication has succeeded and its device key is ready. * The explicit call prevents a process restart from silently undoing sign-out revocation. */ - suspend fun signIn(scope: AccountWorkspaceScope, keyAlias: String): DeviceKeyHandle { - val handle = deviceKeyStore.getOrCreate(keyAlias) - syncRevocationGuard.reactivate(scope) - return handle - } + suspend fun signIn(scope: AccountWorkspaceScope, keyAlias: String): DeviceKeyHandle = + lifecycleMutex(scope).withLock { + val handle = deviceKeyStore.getOrCreate(keyAlias) + syncRevocationGuard.reactivate(scope) + handle + } /** Revocation/account switch clears local work and the device-bound key before returning. */ - suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) { - syncRevocationGuard.revoke(scope) - syncScheduler.cancel(scope) - localStore.clear(scope) - deviceKeyStore.delete(keyAlias) - } + suspend fun signOut(scope: AccountWorkspaceScope, keyAlias: String) = + lifecycleMutex(scope).withLock { + syncRevocationGuard.revoke(scope) + syncScheduler.cancel(scope) + localStore.clear(scope) + deviceKeyStore.delete(keyAlias) + } + + private fun lifecycleMutex(scope: AccountWorkspaceScope): Mutex = + lifecycleMutexes.computeIfAbsent(scope.stableKey) { Mutex() } companion object { fun create(context: Context): AndroidRuntime { From 5bc23a3620fd7aa6df2ec2d402dc87efa979fd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:54 +0700 Subject: [PATCH 2/3] test(android): cover sign-in sign-out race --- .../android/AndroidRuntimeLifecycleTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt diff --git a/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt new file mode 100644 index 00000000..ba6b3ae7 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/AndroidRuntimeLifecycleTest.kt @@ -0,0 +1,110 @@ +package com.databreeze.android + +import com.databreeze.android.security.DeviceKeyHandle +import com.databreeze.android.security.DeviceKeyStore +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore +import com.databreeze.android.sync.DataBreezeWorkerFactory +import com.databreeze.android.sync.SyncRevocationGuard +import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.sync.SyncTransport +import com.databreeze.android.sync.UnconfiguredSyncTransport +import java.util.Collections +import java.util.concurrent.CountDownLatch +import javax.crypto.SecretKey +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AndroidRuntimeLifecycleTest { + @Test + fun sign_in_and_sign_out_are_serialized_for_the_same_scope() = runBlocking { + val scope = AccountWorkspaceScope("account-1", "workspace-1") + val events = Collections.synchronizedList(mutableListOf()) + val keyStarted = CountDownLatch(1) + val releaseKeyCreation = CountDownLatch(1) + val revokeCalled = CompletableDeferred() + val keyStore = BlockingDeviceKeyStore(events, keyStarted, releaseKeyCreation) + val guard = RecordingRevocationGuard(events, revokeCalled) + val scheduler = RecordingScheduler(events) + val transport: SyncTransport = UnconfiguredSyncTransport() + val store = InMemoryLocalStore() + val runtime = AndroidRuntime( + localStore = store, + deviceKeyStore = keyStore, + syncTransport = transport, + syncScheduler = scheduler, + syncRevocationGuard = guard, + workerFactory = DataBreezeWorkerFactory(store, transport, guard), + ) + + val signIn = async(Dispatchers.Default) { runtime.signIn(scope, "device-key") } + keyStarted.await() + val signOut = async(Dispatchers.Default) { runtime.signOut(scope, "device-key") } + + assertNull(withTimeoutOrNull(200) { revokeCalled.await() }) + assertEquals(listOf("key-create"), events.toList()) + + releaseKeyCreation.countDown() + withTimeout(2_000) { + signIn.await() + signOut.await() + } + + assertEquals(listOf("key-create", "reactivate", "revoke", "cancel", "key-delete"), events.toList()) + } + + private class BlockingDeviceKeyStore( + private val events: MutableList, + private val keyStarted: CountDownLatch, + private val releaseKeyCreation: CountDownLatch, + ) : DeviceKeyStore { + override fun getOrCreate(alias: String): DeviceKeyHandle { + events += "key-create" + keyStarted.countDown() + releaseKeyCreation.await() + return DeviceKeyHandle(alias) + } + + override fun contains(alias: String): Boolean = false + + override fun delete(alias: String): Boolean { + events += "key-delete" + return true + } + + override fun keyFor(handle: DeviceKeyHandle): SecretKey = + throw UnsupportedOperationException("not used by lifecycle test") + } + + private class RecordingRevocationGuard( + private val events: MutableList, + val revokeCalled: CompletableDeferred, + ) : SyncRevocationGuard { + override suspend fun withPermit(scope: AccountWorkspaceScope, operation: suspend () -> T): T = + operation() + + override suspend fun revoke(scope: AccountWorkspaceScope) { + events += "revoke" + revokeCalled.complete(Unit) + } + + override suspend fun reactivate(scope: AccountWorkspaceScope) { + events += "reactivate" + } + } + + private class RecordingScheduler(private val events: MutableList) : SyncScheduler { + override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) = Unit + + override fun cancel(scope: AccountWorkspaceScope) { + events += "cancel" + } + } +} From 6c22085ee2a3e0000b6ed643ee03ccb1929376c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 2 Aug 2026 18:38:57 +0700 Subject: [PATCH 3/3] docs(operations): record lifecycle review disposition --- docs/operations/code-review-14-disposition.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/operations/code-review-14-disposition.md b/docs/operations/code-review-14-disposition.md index dfeada9e..11393a4d 100644 --- a/docs/operations/code-review-14-disposition.md +++ b/docs/operations/code-review-14-disposition.md @@ -46,3 +46,11 @@ was requested). It identified two additional valid claims, both fixed in the fol - The guard now exposes `reactivate(scope)` and `AndroidRuntime.signIn` calls it only after the device key is initialized, allowing an explicit sign-in/sign-out cycle without silently re-enabling a revoked scope on process restart. + +## Automatic incremental review after PR #16 synchronization + +GitHub automatically refreshed the existing review after PR #16 merged into `dev` (no new review +was requested). It identified one valid lifecycle race: sign-in and sign-out could interleave +between device-key initialization and revocation. `AndroidRuntime` now serializes the complete +sign-in/sign-out lifecycle per `AccountWorkspaceScope`, and a blocking JVM test proves sign-out +cannot revoke, cancel, or delete the key until sign-in has completed.