Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d42574d
feat(android): harden offline shell runtime
BeforeLights Aug 2, 2026
dcdc07c
fix(android): restore capture after process recreation
BeforeLights Aug 2, 2026
634ae28
docs(android): record foundation reconciliation evidence
BeforeLights Aug 2, 2026
0290e9b
fix(android): format room schema artifact
BeforeLights Aug 2, 2026
6c275b0
test(orchestration): track android foundation checkpoint
BeforeLights Aug 2, 2026
7ea16a4
docs(android): add root verification evidence
BeforeLights Aug 2, 2026
4d41549
feat: close Android foundation gaps (#13)
BeforeLights Aug 2, 2026
a42ecfd
fix(android): close promotion sync lifecycle gaps
BeforeLights Aug 2, 2026
2ed2b64
test(android): cover promotion review boundaries
BeforeLights Aug 2, 2026
fe8a376
docs(operations): record promotion review disposition
BeforeLights Aug 2, 2026
55b4c66
Merge pull request #15 from DatabreezeService/fix/promotion-14-review
BeforeLights Aug 2, 2026
8e7ee3f
fix(android): scope sync revocation lifecycle
BeforeLights Aug 2, 2026
c679cbb
test(android): verify reactivation and scope isolation
BeforeLights Aug 2, 2026
4692492
Merge pull request #16 from DatabreezeService/fix/promotion-14-followup
BeforeLights Aug 2, 2026
4fb2d14
fix(android): serialize account lifecycle operations
BeforeLights Aug 2, 2026
5bc23a3
test(android): cover sign-in sign-out race
BeforeLights Aug 2, 2026
6c22085
docs(operations): record lifecycle review disposition
BeforeLights Aug 2, 2026
001dc16
Merge pull request #17 from DatabreezeService/fix/android-lifecycle
BeforeLights Aug 2, 2026
fc1e5ab
test(android): make lifecycle contention deterministic
BeforeLights Aug 2, 2026
a413386
docs(operations): record test determinism review
BeforeLights Aug 2, 2026
92b3e9a
Merge pull request #18 from DatabreezeService/fix/android-lifecycle-t…
BeforeLights Aug 2, 2026
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
9 changes: 9 additions & 0 deletions apps/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Context>(),
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()
}
}
}
11 changes: 10 additions & 1 deletion apps/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />

<application
Expand All @@ -13,6 +13,15 @@
android:supportsRtl="true"
android:theme="@style/Theme.DataBreeze"
android:usesCleartextTraffic="false">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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 =
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),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading