Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,43 @@ 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,
val syncScheduler: SyncScheduler,
val syncRevocationGuard: SyncRevocationGuard,
val workerFactory: DataBreezeWorkerFactory,
) {
private val lifecycleMutexes = ConcurrentHashMap<String, Mutex>()

/**
* 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>())
val keyStarted = CountDownLatch(1)
val releaseKeyCreation = CountDownLatch(1)
val revokeCalled = CompletableDeferred<Unit>()
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<String>,
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<String>,
val revokeCalled: CompletableDeferred<Unit>,
) : SyncRevocationGuard {
override suspend fun <T> 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<String>) : SyncScheduler {
override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) = Unit

override fun cancel(scope: AccountWorkspaceScope) {
events += "cancel"
}
}
}
8 changes: 8 additions & 0 deletions docs/operations/code-review-14-disposition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading