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 @@ -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')"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
Expand Down
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
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
)
}
}
}

Expand All @@ -53,6 +60,7 @@ class MainActivity : ComponentActivity() {
fun DataBreezeApp(
localStore: LocalStorePort = remember { InMemoryLocalStore() },
scope: AccountWorkspaceScope = localScope,
syncScheduler: SyncScheduler? = null,
) {
val navController = rememberNavController()
DataBreezeTheme {
Expand All @@ -71,6 +79,7 @@ fun DataBreezeApp(
CaptureScreen(
localStore = localStore,
scope = scope,
syncScheduler = syncScheduler,
onBack = { navController.popBackStack() },
)
}
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -131,6 +141,7 @@ private fun CaptureScreen(
createdAtEpochMs = System.currentTimeMillis(),
),
)
syncScheduler?.enqueue(scope)
}
},
modifier = Modifier.testTag("save-button"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String>): Int
}

@Database(entities = [SyncQueueEntity::class], version = 1, exportSchema = true)
Expand All @@ -128,6 +129,7 @@ interface LocalStorePort {
fun observeQueue(scope: AccountWorkspaceScope): Flow<List<SyncQueueEntity>>
suspend fun snapshotQueue(scope: AccountWorkspaceScope): List<SyncQueueEntity>
suspend fun delete(scope: AccountWorkspaceScope, mutationId: String): Boolean
suspend fun deleteBatch(scope: AccountWorkspaceScope, mutationIds: List<String>): Int
suspend fun markCompleted(scope: AccountWorkspaceScope, mutationId: String): Boolean
suspend fun clear(scope: AccountWorkspaceScope)
}
Expand All @@ -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<String>): 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

Expand Down Expand Up @@ -185,10 +190,13 @@ class InMemoryLocalStore : LocalStorePort {
override fun observeQueue(scope: AccountWorkspaceScope): Flow<List<SyncQueueEntity>> =
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<SyncQueueEntity> = 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 {
Expand All @@ -197,6 +205,15 @@ class InMemoryLocalStore : LocalStorePort {
removed
}

override suspend fun deleteBatch(scope: AccountWorkspaceScope, mutationIds: List<String>): 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
Expand Down
Loading
Loading