diff --git a/CLAUDE.md b/CLAUDE.md index 519db4a02..b1a619268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ Password / Biometric ↓ derive / unlock RootKek ───────────────────── never persisted ↓ unwrap - ARK (Account Root Key) ────── in-memory only (Session); wrapped in account_registry.pb + ARK (Account Root Key) ────── Session (in-memory); wrapped in account_registry.pb; + optionally escrowed in backup_ark_data.pb (see Backup Escrow) ↓ unwrap (one per vault) VaultKey ──────────────────── wrapped in VaultEntity.keyInformation (Room) ↓ unwrap (one per item) @@ -92,6 +93,30 @@ Password / Biometric - **AAD** (`itemId + vaultId`) — bound to every ciphertext; prevents transplant attacks. - **Rust FFI** (`de.davis.keygo.rust`) implements all wrap/unwrap/derive operations. +## Backup Escrow + +A scheduled backup runs with no user present, so it cannot reach the ARK the normal way. Scheduling +one therefore escrows a second copy of the ARK, and the export passphrase alongside it, under +Keystore aliases that deliberately do **not** require user authentication (`KeyId.BackupArkKey`, +`KeyId.BackupPassphraseKey`; see `BackupArkUnlocker`). + +This is the one place the "ARK is never readable without authenticating" rule is relaxed, so it +carries its own rules: + +- The escrow exists **only while a job is scheduled**. `CleanupBackupResourcesUseCase` releases it + the moment no live job remains, and `reconcile()` sweeps up jobs the scheduler dropped without a + run. `BackupWorker.MAX_ATTEMPTS` bounds retries so a deferring job cannot hold it open forever. +- `reconcile()` runs once per process start (`BackupEscrowReconciler`, an eager Koin singleton), not + on entry to the backup screen. A dropped job produces no run to clean up after it, so the trigger + must not depend on the user navigating anywhere; process start bounds the escrow's stale lifetime + to a single process. Do not move it back behind a UI event. +- Both aliases set `setUnlockedDeviceRequired(true)` on API 28+. On API 26-27 that constraint does + not exist, so on those levels the escrow is readable whenever the process runs. +- A passphrase-sealed scheduled backup is not stronger than an ARK-sealed one: both keys sit under + the same auth-free policy. +- Do not widen the escrow's lifetime, its auth policy, or the set of callers that can read it + without explicit instruction. + ## Sensitive Areas - **Migration** — preserve backward compat, smallest safe change diff --git a/README.md b/README.md index cb7c600fe..3a70e7601 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## KeyGo v2 — Your Secure Digital Vault +## KeyGo v2 - Your Secure Digital Vault > [!CAUTION] > This branch (v2) is under active development. For the stable v1 release, switch to @@ -8,5 +8,5 @@ For further information, please see the [v1 branch](https://github.com/OffRange/ ### License -Licensed under the GNU General Public License v3.0. This means KeyGo is free software — you can +Licensed under the GNU General Public License v3.0. This means KeyGo is free software: you can redistribute and/or modify it under GPLv3 terms. For full details, see the [LICENSE](LICENSE) file. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db1e5028e..6aad6c6e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + implementation(libs.koin.androidx.workmanager) implementation(libs.aboutlibraries.compose.m3) @@ -116,6 +117,7 @@ dependencies { implementation(projects.feature.creditCard) implementation(projects.feature.autofill) implementation(projects.feature.settings) + implementation(projects.feature.backup) implementation(projects.migrationCreateAccess) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt index 91906837b..e16a44ba7 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt @@ -3,6 +3,7 @@ package de.davis.keygo.app import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger +import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.core.annotation.KoinApplication import org.koin.plugin.module.dsl.startKoin @@ -14,6 +15,7 @@ class KeyGoApplication : Application() { startKoin { androidLogger() androidContext(this@KeyGoApplication) + workManagerFactory() } } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt index 746f43746..0552f4cd1 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/MainActivity.kt @@ -40,6 +40,8 @@ import de.davis.keygo.dashboard.presentation.DetailType import de.davis.keygo.dashboard.presentation.dashboardGraph import de.davis.keygo.feature.auth.presentation.AuthRoute import de.davis.keygo.feature.auth.presentation.authGraph +import de.davis.keygo.feature.backup.presentation.BackupHubRoute +import de.davis.keygo.feature.backup.presentation.backupGraph import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute import de.davis.keygo.feature.settings.presentation.settingsGraph import de.davis.keygo.item.dialog.SelectItemContent @@ -165,6 +167,7 @@ private fun App() { settingsGraph( onOpenChangePassword = { navController.navigate(ChangePasswordRoute) }, onShowLibraries = { navController.navigate(RouteDestination.Libraries) }, + onOpenBackup = { navController.navigate(BackupHubRoute) }, onUp = { navController.navigateUp() }, ) @@ -185,6 +188,11 @@ private fun App() { ) } } + + backupGraph( + navigateToDestination = navController::navigate, + navigateUp = { navController.navigateUp() }, + ) } } } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt index 9386f7ddd..8812b82fe 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt @@ -5,6 +5,7 @@ import androidx.datastore.dataStore import de.davis.keygo.core.identity.data.local.model.ProtoAccountState import de.davis.keygo.core.identity.di.annotation.AccountRegistryQualifier import de.davis.keygo.core.security.di.CoreSecurityModule +import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt index fe1ef4f5e..d632b1a3e 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt @@ -76,7 +76,7 @@ class CreateAccessUseCase( // Persist the account before the vault: the vault is encrypted under the account's // ARK, so a vault row without a recoverable account is dead weight. If the vault - // write fails after this, the half-state is recoverable on retry — `set` overwrites. + // write fails after this, the half-state is recoverable on retry, since `set` overwrites. accountRepository.set( Account( id = accountHolder.account.id, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt index d5bdf5004..9f783ea29 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt @@ -7,6 +7,7 @@ import androidx.room.Upsert import de.davis.keygo.core.item.data.local.entity.CreditCardEntity import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import kotlinx.coroutines.flow.Flow @Dao @@ -22,4 +23,8 @@ internal interface CreditCardDao { @Transaction @Query("SELECT * FROM credit_card WHERE id = :id") suspend fun getById(id: ItemId): CreditCardProjection? -} \ No newline at end of file + + @Transaction + @Query("SELECT * FROM credit_card WHERE id IN (SELECT id FROM item WHERE vault_id = :vaultId)") + suspend fun getByVault(vaultId: VaultId): List +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt index 916d822ce..0c9e18378 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.Query import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo +import de.davis.keygo.core.item.domain.alias.ItemId @Dao internal interface PasskeyDao { @@ -15,6 +16,9 @@ internal interface PasskeyDao { @Query("SELECT * FROM passkey WHERE credential_id = :credentialId") suspend fun getPasskey(credentialId: ByteArray): PasskeyEntity? + @Query("SELECT * FROM passkey WHERE login_id = :loginId") + suspend fun getPasskeysForLogin(loginId: ItemId): List + @Query("SELECT EXISTS (SELECT 1 FROM passkey WHERE credential_id IN (:credentialIds))") suspend fun doesCredentialIdsExist(credentialIds: Set): Boolean diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/credential/PasswordEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/credential/PasswordEntity.kt index b45839a7c..e1f2ba56e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/credential/PasswordEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/credential/PasswordEntity.kt @@ -10,7 +10,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.PasswordScore -// The primary key is shared with ItemEntity — one ItemId identifies both the base item row and +// The primary key is shared with ItemEntity: one ItemId identifies both the base item row and // this password row. This models the "is-a" relationship at the DB level (joined-table // inheritance): there is no separate password-specific ID. @Entity( diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt index 184d15dcc..561af0684 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt @@ -4,10 +4,12 @@ import androidx.room.withTransaction import de.davis.keygo.core.item.data.local.dao.CreditCardDao import de.davis.keygo.core.item.data.local.dao.ItemDao import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection import de.davis.keygo.core.item.data.mapper.toCreditCardEntity import de.davis.keygo.core.item.data.mapper.toData import de.davis.keygo.core.item.data.mapper.toDomain import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.util.Result @@ -40,4 +42,7 @@ internal class CreditCardRepositoryImpl( override suspend fun getCreditCardById(itemId: ItemId): CreditCard? = creditCardDao.getById(itemId)?.toDomain() -} \ No newline at end of file + + override suspend fun getCreditCardsByVault(vaultId: VaultId): List = + creditCardDao.getByVault(vaultId).map(CreditCardProjection::toDomain) +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasskeyRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasskeyRepositoryImpl.kt index 91e231589..67ca856f6 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasskeyRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasskeyRepositoryImpl.kt @@ -1,9 +1,11 @@ package de.davis.keygo.core.item.data.repository import de.davis.keygo.core.item.data.local.dao.PasskeyDao +import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo import de.davis.keygo.core.item.data.mapper.toData import de.davis.keygo.core.item.data.mapper.toDomain +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasskeyMetadata import de.davis.keygo.core.item.domain.repository.PasskeyRepository @@ -26,4 +28,7 @@ internal class PasskeyRepositoryImpl( override suspend fun getPasskey(credentialId: ByteArray): Passkey? = passkeyDao.getPasskey(credentialId)?.toDomain() + + override suspend fun getPasskeysByLogin(loginId: ItemId): List = + passkeyDao.getPasskeysForLogin(loginId).map(PasskeyEntity::toDomain) } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt new file mode 100644 index 000000000..9747c52ae --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.room.withTransaction +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.domain.TransactionRunner +import org.koin.core.annotation.Single + +@Single +internal class RoomTransactionRunner( + private val database: ItemDatabase, +) : TransactionRunner { + override suspend fun runInTransaction(block: suspend () -> R): R = + database.withTransaction { block() } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt new file mode 100644 index 000000000..8ba9acb9f --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.core.item.domain + +/** + * Runs a block of persistence work as a single atomic unit: either every write inside [block] + * commits, or none of them do. If the calling coroutine is cancelled or [block] throws, the + * transaction rolls back. + * + * Nested calls join the outermost transaction rather than opening a new one, so callers can safely + * compose operations that already run their own transactions internally. + */ +interface TransactionRunner { + suspend fun runInTransaction(block: suspend () -> R): R +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt index 6443199e0..e3ef029a1 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt @@ -1,6 +1,7 @@ package de.davis.keygo.core.item.domain.repository import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.util.Result import kotlinx.coroutines.flow.Flow @@ -10,4 +11,5 @@ interface CreditCardRepository { fun observeCreditCardById(itemId: ItemId): Flow suspend fun getCreditCardById(itemId: ItemId): CreditCard? + suspend fun getCreditCardsByVault(vaultId: VaultId): List } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasskeyRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasskeyRepository.kt index 5dde5ede2..165b128fd 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasskeyRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasskeyRepository.kt @@ -1,5 +1,6 @@ package de.davis.keygo.core.item.domain.repository +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasskeyMetadata @@ -11,4 +12,6 @@ interface PasskeyRepository { suspend fun getPasskeysForRP(rpId: String): List suspend fun getPasskey(credentialId: ByteArray): Passkey? + + suspend fun getPasskeysByLogin(loginId: ItemId): List } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconPicker.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconPicker.kt new file mode 100644 index 000000000..7434b04d9 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconPicker.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.core.item.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.Vault + +/** + * Every [Vault.Icon] as a toggle grid, one of which is [selected]. + * + * Lives here rather than in a feature module because more than one flow creates vaults, and the + * picker has to look and behave the same in all of them. Callers supply their own heading. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun VaultIconPicker( + selected: Vault.Icon, + onSelect: (Vault.Icon) -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Vault.Icon.entries.forEach { icon -> + FilledTonalIconToggleButton( + checked = selected == icon, + onCheckedChange = { onSelect(icon) }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize()), + shapes = IconButtonDefaults.toggleableShapes(), + ) { + Icon( + imageVector = icon.toImageVector(), + contentDescription = null, + ) + } + } + } +} + +@Preview +@Composable +private fun VaultIconPickerPreview() { + MaterialTheme { + Surface { + VaultIconPicker(selected = Vault.Icon.Work, onSelect = {}) + } + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt index 97b4ab59c..69cc97104 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt @@ -42,7 +42,7 @@ class ItemMapperTest { keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), ) - // Item.toData() → ItemEntity + // Item.toData() -> ItemEntity @Test fun `Login toData maps id and vaultId`() { @@ -80,7 +80,7 @@ class ItemMapperTest { assertFalse((testLogin(pinned = false) as Item).toData().pinned) } - // LightweightItem.toDomain() → LiteItem.Concrete + // LightweightItem.toDomain() -> LiteItem.Concrete @Test fun `LightweightItem toDomain copies all fields`() { @@ -100,7 +100,7 @@ class ItemMapperTest { assertTrue(lite.pinned) } - // LightweightItemSearchResult.toDomain() → LiteItemSearchResult + // LightweightItemSearchResult.toDomain() -> LiteItemSearchResult @Test fun `LightweightItemSearchResult toDomain copies all fields`() { @@ -126,7 +126,7 @@ class ItemMapperTest { assertFalse(result.pinned) } - // MovableItemPojo.toDomain() → MovableItem + // MovableItemPojo.toDomain() -> MovableItem @Test fun `MovableItemPojo toDomain maps id`() { diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt index 1320b7ed4..1717bc56a 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt @@ -1,6 +1,7 @@ package de.davis.keygo.core.item import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.util.Result @@ -50,4 +51,7 @@ class FakeCreditCardRepository : CreditCardRepository { store.map { it[itemId] } override suspend fun getCreditCardById(itemId: ItemId): CreditCard? = store.value[itemId] + + override suspend fun getCreditCardsByVault(vaultId: VaultId): List = + store.value.values.filter { it.vaultId == vaultId } } diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt index 483ce566c..9a0d798b9 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasskeyRepository.kt @@ -1,5 +1,6 @@ package de.davis.keygo.core.item +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasskeyMetadata import de.davis.keygo.core.item.domain.repository.PasskeyRepository @@ -23,4 +24,7 @@ class FakePasskeyRepository : PasskeyRepository { override suspend fun getPasskey(credentialId: ByteArray): Passkey? = store.firstOrNull { it.credentialId.contentEquals(credentialId) } + + override suspend fun getPasskeysByLogin(loginId: ItemId): List = + store.filter { it.loginId == loginId } } diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt new file mode 100644 index 000000000..fa75d66c6 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.TransactionRunner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * In-memory [TransactionRunner] for tests: it executes [block] without a real transaction and + * records how many times a transaction was opened. + * + * - [enteredCount] is the number of times [runInTransaction] was invoked. Assert it equals 1 to + * verify a caller wraps all of its writes in a single atomic unit rather than one per item. + * - Nested calls increment the count like any other; the fakes it wraps do not model rollback, so + * tests using it verify wrapping structure, not the rollback guarantee (that is Room's, covered by + * the real [de.davis.keygo.core.item.domain.TransactionRunner] implementation). + * - [block] runs in a *separate coroutine*, mirroring Room's `withTransaction`, which dispatches + * onto its own transaction thread. This is not incidental: a pass-through fake that ran [block] + * inline let a `Flow` invariant violation reach production, because callers emitting progress + * from inside a transaction were emitting across a coroutine boundary and only the real Room + * implementation exposed it. Keep the context switch. + */ +class FakeTransactionRunner : TransactionRunner { + + var enteredCount = 0 + private set + + override suspend fun runInTransaction(block: suspend () -> R): R { + enteredCount++ + return withContext(Dispatchers.Default) { block() } + } +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt index d444da5cc..af35339a2 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/KeyStoreManagerImpl.kt @@ -33,10 +33,9 @@ internal class KeyStoreManagerImpl( iv: ByteArray?, ): Cipher { val alias = keyId.id - val key = when (keyStore.containsAlias(alias)) { true -> keyStore.getKey(alias, null) - else -> createKeyFor(alias) + else -> createKeyFor(keyId) } val cipher = Cipher.getInstance("$ALGORITHM/$BLOCK_MODE/$PADDING_MODE") @@ -56,15 +55,21 @@ internal class KeyStoreManagerImpl( return cipher } - private fun createKeyFor(alias: String): SecretKey { + override fun deleteKey(keyId: KeyId) { + if (keyStore.containsAlias(keyId.id)) keyStore.deleteEntry(keyId.id) + } + + private fun createKeyFor(keyId: KeyId): SecretKey { val spec = KeyGenParameterSpec.Builder( - alias, + keyId.id, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ).apply { setBlockModes(BLOCK_MODE) setEncryptionPaddings(PADDING_MODE) - setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setUnlockedDeviceRequired(true) + + setUserAuthenticationRequired(keyId.needsAuthentication) setInvalidatedByBiometricEnrollment(true) setRandomizedEncryptionRequired(true) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt new file mode 100644 index 000000000..20f5fb7a6 --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.core.security.data.crypto + +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory +import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.wrap.KeyWrapper +import org.koin.core.annotation.Single + +@Single +internal class CryptographicScopeProviderFactoryImpl( + private val itemRepository: ItemRepository, + private val itemManager: ItemManager, + private val keyWrapper: KeyWrapper, +) : CryptographicScopeProviderFactory { + + override fun forSession(session: Session): CryptographicScopeProvider = + CryptographicScopeProviderImpl(session, itemRepository, itemManager, keyWrapper) +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/KeyStoreManager.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/KeyStoreManager.kt index 3a8c22bd9..dbdbea6bd 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/KeyStoreManager.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/KeyStoreManager.kt @@ -11,4 +11,6 @@ interface KeyStoreManager { cryptographicMode: CryptographicMode, iv: ByteArray? = null ): Cipher + + fun deleteKey(keyId: KeyId) } \ No newline at end of file diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CipherExt.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CipherExt.kt new file mode 100644 index 000000000..73b2fd0cb --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CipherExt.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.core.security.domain.crypto + +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.crypto.Cipher + +suspend fun Cipher.suspendDoFinal(input: ByteArray): Result = + withContext(Dispatchers.Default) { + runCatching { + doFinal(input) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it) } + ) + } \ No newline at end of file diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt new file mode 100644 index 000000000..5dc7d0cda --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.security.domain.crypto + +import de.davis.keygo.core.security.domain.Session + +/** + * Builds a [CryptographicScopeProvider] bound to a specific [Session]. The default binding uses the + * app-wide session; backup uses this to run against a recovered ARK without mutating global state. + */ +fun interface CryptographicScopeProviderFactory { + fun forSession(session: Session): CryptographicScopeProvider +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt index e39388c07..dce9578b1 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt @@ -1,8 +1,23 @@ package de.davis.keygo.core.security.domain.model -data class KeyId(val id: String) { +/** + * Every Android Keystore alias this app owns. + * + * An enum rather than a constructible type: [id] and [needsAuthentication] are only meaningful as a + * pair, and [needsAuthentication] is read once, at key creation. A caller free to name an existing + * alias under a weaker policy could otherwise mint an auth-free key where an auth-bound one was + * intended, so the set of aliases is closed here by construction. + */ +enum class KeyId(val id: String, val needsAuthentication: Boolean) { + /** Wraps the account's ARK for biometric unlock; this key can only be used once unlocked via biometrics */ + BiometricVaultKek("biometric_vault_kek", true), - companion object { - val BiometricVaultKek = KeyId("biometric_vault_kek") - } -} \ No newline at end of file + /** Wraps the escrowed backup passphrase; auth-free so a scheduled backup can run unattended. */ + BackupPassphraseKey("backup_passphrase_key", false), + + /** + * Wraps the escrowed ARK copy for decrypting data for backups; auth-free for the same reason + * as [BackupPassphraseKey]. + */ + BackupArkKey("backup_ark_key", false), +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt index 0ef5ca516..5b4ab3fa9 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt @@ -26,7 +26,7 @@ class ItemWithCryptoScopeUseCase( ): Result { val item = fetch(itemId) ?: return Result.Failure(CryptoScopeError.IdNotFound) - return handleItem(item, block) + return withItem(item, block) } suspend fun observe( @@ -34,11 +34,11 @@ class ItemWithCryptoScopeUseCase( source: (ItemId) -> Flow, block: suspend CryptographicScope.(I) -> R, ): Flow> = source(itemId).map { item -> - item?.let { handleItem(it, block) } + item?.let { withItem(it, block) } ?: Result.Failure(CryptoScopeError.IdNotFound) } - private suspend fun handleItem( + suspend fun withItem( item: I, block: suspend CryptographicScope.(I) -> R, ): Result { diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt index 672585cbe..21de887f8 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt @@ -100,6 +100,23 @@ class ItemWithCryptoScopeUseCaseTest { assertIs(failure.error) } + @Test + fun `withItem runs block on an already fetched item and returns success`() = runTest { + val result = useCase.withItem(card(newItemId())) { it.name } + + assertTrue(result.isSuccess()) + assertEquals("Test Card", result.getOrNull()) + } + + @Test + fun `withItem returns IdNotFound when vault key is missing`() = runTest { + val result = useCase.withItem(card(newItemId()).copy(vaultId = newVaultId())) { it.name } + + assertTrue(result.isFailure()) + val failure = assertIs>(result) + assertIs(failure.error) + } + @Test fun `observe emits success carrying the block result`() = runTest { val id = newItemId() diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt index 043c5f381..3195cb1d9 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt @@ -10,7 +10,7 @@ import de.davisalessandro.keygo.rust.KeyWrapperInterface /** * Constructs the production [CryptographicScopeProvider] backed by the supplied fakes. * - * Use when a test depends on the AAD-binding semantics — ciphertext bound to + * Use when a test depends on the AAD-binding semantics: ciphertext bound to * (vaultId, itemId, label), item keys wrapped under the vault key. For tests * that only need a deterministic round-trip with no AAD enforcement, * [FakeCryptographicScopeProvider] is simpler. diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt index ac759b7e2..aa3635085 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -10,6 +10,7 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result +import java.util.Collections import kotlin.coroutines.CoroutineContext import kotlin.experimental.xor @@ -28,7 +29,9 @@ class FakeCryptographicScopeProvider( ) : CallHistory } - val callHistory = mutableListOf() + // Synchronized: BackupCollector calls encrypt/decrypt concurrently across items, and this + // list is written from whichever real thread each call lands on. + val callHistory: MutableList = Collections.synchronizedList(mutableListOf()) val encryptCalls get() = callHistory.filterIsInstance() val rewrapCalls diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProviderFactory.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProviderFactory.kt new file mode 100644 index 000000000..d0b599082 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProviderFactory.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory + +class FakeCryptographicScopeProviderFactory( + private val provider: CryptographicScopeProvider, +) : CryptographicScopeProviderFactory { + + var lastSession: Session? = null + private set + + override fun forSession(session: Session): CryptographicScopeProvider { + lastSession = session + return provider + } +} diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManager.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManager.kt new file mode 100644 index 000000000..203b41ee1 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManager.kt @@ -0,0 +1,46 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Software AES-256/GCM stand-in for AndroidKeyStore. Keys are generated per alias and kept + * in-memory, so wrap/unwrap round-trips deterministically in JVM unit tests. Set [deviceLocked] + * to simulate a key gated by setUnlockedDeviceRequired(true) being used while the device is locked. + */ +class FakeKeyStoreManager( + var deviceLocked: Boolean = false, +) : KeyStoreManager { + + val keys = mutableMapOf() + + override fun getOrCreateCipherFor( + keyId: KeyId, + cryptographicMode: CryptographicMode, + iv: ByteArray?, + ): Cipher { + if (deviceLocked) + throw IllegalStateException("device locked") + + val key = keys.getOrPut(keyId) { + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val mode = when (cryptographicMode) { + CryptographicMode.Encrypt, CryptographicMode.Wrap -> Cipher.ENCRYPT_MODE + CryptographicMode.Decrypt, CryptographicMode.Unwrap -> Cipher.DECRYPT_MODE + } + if (iv != null) cipher.init(mode, key, GCMParameterSpec(128, iv)) + else cipher.init(mode, key) + return cipher + } + + override fun deleteKey(keyId: KeyId) { + keys.remove(keyId) + } +} diff --git a/core/util/build.gradle.kts b/core/util/build.gradle.kts index 25e0c8f35..c7775fa8f 100644 --- a/core/util/build.gradle.kts +++ b/core/util/build.gradle.kts @@ -13,6 +13,9 @@ android { dependencies { implementation(libs.okhttp) + api(libs.androidx.datastore) + api(libs.google.protobuf.kotlin.lite) + testImplementation(libs.okhttp.jvm) testFixturesImplementation(libs.kotlin.test) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/DefaultProtoSerializer.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/data/serializer/DefaultProtoSerializer.kt similarity index 84% rename from core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/DefaultProtoSerializer.kt rename to core/util/src/main/kotlin/de/davis/keygo/core/util/data/serializer/DefaultProtoSerializer.kt index ff7dfa95f..a61c81c2c 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/DefaultProtoSerializer.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/data/serializer/DefaultProtoSerializer.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.core.identity.di +package de.davis.keygo.core.util.data.serializer import androidx.datastore.core.Serializer import com.google.protobuf.MessageLite @@ -6,7 +6,7 @@ import com.google.protobuf.Parser import java.io.InputStream import java.io.OutputStream -internal class DefaultProtoSerializer( +class DefaultProtoSerializer( private val defaultInstance: T, private val parser: Parser ) : Serializer { @@ -20,4 +20,4 @@ internal class DefaultProtoSerializer( override suspend fun writeTo(t: T, output: OutputStream) { t.writeTo(output) } -} \ No newline at end of file +} diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt index 878c0c713..0da5a5a34 100644 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt @@ -52,7 +52,7 @@ class SortUseCase { } private fun compareNumeric(a: String, b: String): Int { - // Fast path: different lengths means different magnitudes — no parsing + // Fast path: different lengths mean different magnitudes, so no parsing if (a.length != b.length) return a.length - b.length // Same length: lexicographic order == numeric order for digit-only strings return a.compareTo(b) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/snackbar/ObserveSnackbarEvents.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/snackbar/ObserveSnackbarEvents.kt index 69ce95ddc..9d9642755 100644 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/snackbar/ObserveSnackbarEvents.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/snackbar/ObserveSnackbarEvents.kt @@ -40,12 +40,12 @@ fun SnackbarHandler( } } - // One-shot messages (errors) — fire and forget, not replayed after rotation + // One-shot messages (errors): fire and forget, not replayed after rotation ObserveAsEvents(snackbarManager.oneShotEvents, snackbarManager) { msg -> show(msg) } - // Sticky messages (undo actions) — replayed after rotation via StateFlow + // Sticky messages (undo actions): replayed after rotation via StateFlow LaunchedEffect(snackbarManager) { snackbarManager.stickyMessage.filterNotNull().collect { msg -> show(msg) diff --git a/feature/autofill/src/main/res/values/strings.xml b/feature/autofill/src/main/res/values/strings.xml index 09da58b9d..c60abee2a 100644 --- a/feature/autofill/src/main/res/values/strings.xml +++ b/feature/autofill/src/main/res/values/strings.xml @@ -10,8 +10,8 @@ Suggest this item - “%1$s” will be suggested next time you sign in. This choice is stored on your device and can be changed on the item\'s detail screen. - “%1$s” will be suggested next time you sign in on %2$s. This choice is stored on your device and can be changed on the item\'s detail screen. + \"%1$s\" will be suggested next time you sign in. This choice is stored on your device and can be changed on the item\'s detail screen. + \"%1$s\" will be suggested next time you sign in on %2$s. This choice is stored on your device and can be changed on the item\'s detail screen. Not now diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/ClassifierTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/ClassifierTest.kt index 7b50de700..b12fda97d 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/ClassifierTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/ClassifierTest.kt @@ -19,7 +19,7 @@ class ClassifierTest { @Test fun `autofill hints take precedence over html attributes`() { // autofillHints = {"password"}, htmlAttributes = {"type":"email"}, tokens = {"username"} - // → Password (autofillHints win) + // -> Password (autofillHints win) val result = classify( autofillHints = setOf("password"), htmlAttributes = mapOf("type" to "email"), @@ -31,7 +31,7 @@ class ClassifierTest { @Test fun `html attributes take precedence over tokens`() { // autofillHints = {}, htmlAttributes = {"type":"email"}, tokens = {"username"} - // → EMail (htmlAttributes win over tokens) + // -> EMail (htmlAttributes win over tokens) val result = classify( autofillHints = emptySet(), htmlAttributes = mapOf("type" to "email"), @@ -43,7 +43,7 @@ class ClassifierTest { @Test fun `tokens are used when no hints or attributes match`() { // autofillHints = {}, htmlAttributes = {}, tokens = {"username"} - // → Username (tokens win over nothing) + // -> Username (tokens win over nothing) val result = classify( autofillHints = emptySet(), htmlAttributes = emptyMap(), @@ -230,7 +230,7 @@ class ClassifierTest { @Test fun `token emailaddress is not recognized due to boundary`() { - // "email" is followed by "a" (alphanumeric) — boundary check fails + // "email" is followed by "a" (alphanumeric), so the boundary check fails val result = classify(tokens = setOf("emailaddress")) assertEquals(FieldType.Undefined, result) } diff --git a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeSignatureInfoProvider.kt b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeSignatureInfoProvider.kt index 99491dcff..29e7ad681 100644 --- a/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeSignatureInfoProvider.kt +++ b/feature/autofill/src/testFixtures/kotlin/de/davis/keygo/core/feature/autofill/FakeSignatureInfoProvider.kt @@ -9,7 +9,7 @@ import de.davis.keygo.feature.autofill.domain.SignatureInfoProvider * Returns an empty set for any package name not in [signatures]. */ class FakeSignatureInfoProvider : SignatureInfoProvider { - // Configurable: packageName → set of signatures (default: empty = no signatures) + // Configurable: packageName -> set of signatures (default: empty = no signatures) var signatures: Map> = emptyMap() override fun getSignatureInfo(packageName: String): Set = diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts new file mode 100644 index 000000000..c235638aa --- /dev/null +++ b/feature/backup/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.keygo.android.compose) + alias(libs.plugins.keygo.android.protobuf) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "de.davis.keygo.feature.backup" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } + + testFixtures { + enable = true + } +} + +dependencies { + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.datastore) + implementation(libs.androidx.work) + + implementation(libs.koin.androidx.workmanager) + + implementation(projects.core.ui) + implementation(projects.core.util) + implementation(projects.core.item) + implementation(projects.core.security) + implementation(projects.rust) + implementation(projects.feature.item.core) + implementation(projects.feature.vault) + + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + testImplementation(libs.robolectric) + + testFixturesApi(projects.core.util) + testFixturesApi(projects.core.security) + testFixturesImplementation(libs.kotlinx.coroutines.core) + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } +} diff --git a/feature/backup/consumer-rules.pro b/feature/backup/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/backup/src/main/AndroidManifest.xml b/feature/backup/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b39179d06 --- /dev/null +++ b/feature/backup/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupArkKeyStoreImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupArkKeyStoreImpl.kt new file mode 100644 index 000000000..68c4a08ca --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupArkKeyStoreImpl.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.backup.data + +import androidx.datastore.core.DataStore +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupArkData +import de.davis.keygo.feature.backup.data.local.model.protoBackupArkData +import de.davis.keygo.feature.backup.di.annotation.BackupArkQualifier +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class BackupArkKeyStoreImpl( + @param:BackupArkQualifier + private val dataStore: DataStore, +) : BackupArkKeyStore { + + override suspend fun save(data: CryptographicData) { + dataStore.updateData { + protoBackupArkData { + ct = data.data.toByteString() + iv = data.iv.toByteString() + } + } + } + + override suspend fun load(): CryptographicData? { + val proto = dataStore.data.first() + if (proto.ct.isEmpty || proto.iv.isEmpty) return null + return CryptographicData(proto.ct.toByteArray(), proto.iv.toByteArray()) + } + + override suspend fun clear() { + dataStore.updateData { it.toBuilder().clearCt().clearIv().build() } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt new file mode 100644 index 000000000..d7481a5d0 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.backup.data + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.core.net.toUri +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +@Single +internal class BackupDestinationResolverImpl( + private val context: Context, +) : BackupDestinationResolver { + + override suspend fun resolve( + uri: BackupDestinationUri, + cachedName: String?, + ): BackupDestination = withContext(Dispatchers.IO) { + val parsed = uri.value.toUri() + + if (DocumentsContract.isTreeUri(parsed)) parsed.asFolderDestination(cachedName) + else parsed.asFileDestination(cachedName) + } + + // The provider is only ever asked for the one part it alone knows - a folder's name, or a + // file's. The provider label and the on-device path are derived locally, so they stay correct + // whether or not a grant is still behind the uri. + private fun Uri.asFolderDestination(cachedName: String?) = BackupDestination( + provider = providerLabel(), + displayPath = cachedName ?: treeDisplayPath(), + fileName = null, + ) + + private fun Uri.asFileDestination(cachedName: String?) = BackupDestination( + provider = providerLabel(), + displayPath = documentDisplayPath(), + fileName = cachedName ?: queryDisplayName(), + ) + + private fun Uri.providerLabel(): BackupDestination.Provider { + val authority = authority ?: return BackupDestination.Provider.Unknown + + if (authority == EXTERNAL_STORAGE) return BackupDestination.Provider.OnDevice + + val pm = context.packageManager + val info = pm.resolveContentProvider(authority, 0) + ?: return BackupDestination.Provider.ThirdParty(authority) + + val label = pm.getApplicationLabel(info.applicationInfo).toString() + return BackupDestination.Provider.ThirdParty(label) + } + + private fun Uri.treeDisplayPath(): String { + val docId = DocumentsContract.getTreeDocumentId(this) + return if (authority == EXTERNAL_STORAGE) externalStoragePath(docId) + else queryTreeDisplayName() ?: docId + } + + private fun Uri.documentDisplayPath(): String { + if (authority != EXTERNAL_STORAGE) + return (providerLabel() as? BackupDestination.Provider.ThirdParty)?.name ?: "" + + // Strip the file name: the card shows it separately via fileName. A + // third-party provider that doesn't answer the display-name query falls + // back to its app label (or "" for an unresolvable authority) - a cosmetic + // gap, never a crash, in an error path CreateDocument shouldn't reach. + val docId = DocumentsContract.getDocumentId(this) + val (volume, path) = docId.splitVolumeAndPath() + val root = if (volume == "primary") "Internal storage" else volume + val parent = path.substringBeforeLast('/', missingDelimiterValue = "") + return listOf(root, parent).filter { it.isNotBlank() }.joinToString("/") + } + + private fun externalStoragePath(docId: String): String { + val (volume, path) = docId.splitVolumeAndPath() + val root = if (volume == "primary") "Internal storage" else volume + return if (path.isBlank()) root else "$root/$path" + } + + private fun String.splitVolumeAndPath(): Pair = + split(":", limit = 2).let { it[0] to it.getOrElse(1) { "" } } + + private fun Uri.queryTreeDisplayName(): String? = + DocumentsContract.buildDocumentUriUsingTree( + this, + DocumentsContract.getTreeDocumentId(this), + ).queryDisplayName() + + // The grant behind a stored destination is not guaranteed to outlive the record that names it: + // it is handed back once no live job needs the folder any more, and the user can revoke it - or + // uninstall the provider - at any point. Every caller already degrades a missing name to the + // document id or the app label, so a provider that refuses to answer takes that same path + // instead of the whole resolve() throwing at whoever is rendering the destination. + private fun Uri.queryDisplayName(): String? = + try { + context.contentResolver.query( + this, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (_: SecurityException) { + null + } catch (_: IllegalArgumentException) { + null + } + + companion object { + private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSchedulerImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSchedulerImpl.kt new file mode 100644 index 000000000..b9f0a83fc --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSchedulerImpl.kt @@ -0,0 +1,89 @@ +package de.davis.keygo.feature.backup.data + +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.await +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration + +@Single +internal class BackupSchedulerImpl( + private val workManager: WorkManager, + private val backupJobRepository: BackupJobRepository, +) : BackupScheduler { + + private val constraints by lazy { + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build() + } + + override suspend fun scheduleRecurringBackup( + job: BackupJob, + interval: BackupInterval, + ): Result { + val repeat = when (interval.unit) { + IntervalUnit.Days -> interval.count.days + IntervalUnit.Weeks -> interval.count.days * 7 + }.toJavaDuration() + + val request = PeriodicWorkRequestBuilder(repeat) + .setConstraints(constraints) + .addTag(BackupWorker.TAG) + .addTag(BackupWorker.TAG_RECURRING) + .build() + + // Recurring is a singleton: store under a stable key so a later UPDATE (which preserves the + // existing work id, not request.id) never orphans the record. The worker resolves it via + // TAG_RECURRING. Persist before enqueue so the worker can never start ahead of its record. + return backupJobRepository.putJob(BackupWorker.RECURRING_WORK_ID, job) + .onSuccess { + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = BackupWorker.UNIQUE_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = request, + ).await() + } + } + + override suspend fun scheduleOneTimeBackup(job: BackupJob): Result { + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(BackupWorker.TAG) + .addTag(BackupWorker.TAG_ONE_TIME) + .build() + + return backupJobRepository.putJob(request.id.toString(), job).onSuccess { + workManager.enqueue(request).await() + } + } + + override fun cancel() { + workManager.cancelUniqueWork(BackupWorker.UNIQUE_WORK_NAME) + } + + override suspend fun outstandingWorkIds(): Set = + workManager.getWorkInfosByTagFlow(BackupWorker.TAG).first() + .filterNot { it.state.isFinished } + .mapTo(mutableSetOf()) { info -> + // Periodic work keeps one WorkManager id across runs, but its record lives under + // the stable recurring key. + if (BackupWorker.TAG_RECURRING in info.tags) BackupWorker.RECURRING_WORK_ID + else info.id.toString() + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt new file mode 100644 index 000000000..9c98fd429 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.backup.data + +import de.davis.keygo.core.security.domain.Session + +/** + * A read-only [Session] holding a recovered ARK for the duration of a single backup. It never + * mutates app-wide session state; [startSession] is unsupported and [endSession] is a no-op. + */ +internal class BackupSession(private val backupArk: ByteArray) : Session { + + override val ark: ByteArray get() = backupArk + + override fun startSession(ark: ByteArray) = + error("BackupSession is read-only") + + override fun endSession() = Unit +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/ContentResolverBackupFileStore.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/ContentResolverBackupFileStore.kt new file mode 100644 index 000000000..c9687fd67 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/ContentResolverBackupFileStore.kt @@ -0,0 +1,96 @@ +package de.davis.keygo.feature.backup.data + +import android.content.Context +import android.provider.DocumentsContract +import androidx.core.net.toUri +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +/** Runs a blocking SAF call off the caller's thread and folds it into a [Result]. */ +private suspend inline fun io(crossinline block: () -> T): Result = + withContext(Dispatchers.IO) { + runCatching { block() }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it) }, + ) + } + +@Single +internal class ContentResolverBackupFileStore( + private val context: Context, +) : BackupFileStore { + + override suspend fun read(uri: BackupDestinationUri): Result = io { + context.contentResolver.openInputStream(uri.value.toUri()) + ?.use { it.readBytes().decodeToString() } + ?: error("Unable to open input stream for ${uri.value}") + } + + override suspend fun writeNewDocument( + folder: BackupDestinationUri, + fileName: String, + mimeType: String, + text: String, + ): Result = io { + val tree = folder.value.toUri() + val directory = DocumentsContract.buildDocumentUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree), + ) + val document = DocumentsContract.createDocument( + context.contentResolver, + directory, + mimeType, + fileName, + ) ?: error("Unable to create document $fileName in ${folder.value}") + + context.contentResolver.openOutputStream(document) + ?.use { it.write(text.encodeToByteArray()) } + ?: error("Unable to open output stream for $document") + } + + override suspend fun listBackups( + folder: BackupDestinationUri, + baseName: String, + ): Result, Throwable> = io { + val tree = folder.value.toUri() + val children = DocumentsContract.buildChildDocumentsUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree), + ) + context.contentResolver.query( + children, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + ), + null, + null, + null, + )?.use { cursor -> + buildList { + while (cursor.moveToNext()) { + val documentId = cursor.getString(0) + val name = cursor.getString(1) ?: continue + if (!name.startsWith(baseName)) continue + val documentUri = + DocumentsContract.buildDocumentUriUsingTree(tree, documentId) + add(BackupEntry(BackupDestinationUri(documentUri.toString()), name)) + } + } + } ?: emptyList() + } + + override suspend fun delete(uri: BackupDestinationUri): Result = io { + val deleted = DocumentsContract.deleteDocument( + context.contentResolver, + uri.value.toUri(), + ) + if (!deleted) error("Unable to delete ${uri.value}") + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/PersistableUriManagerImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/PersistableUriManagerImpl.kt new file mode 100644 index 000000000..9298142c2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/PersistableUriManagerImpl.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.backup.data + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import de.davis.keygo.feature.backup.domain.PersistableUriManager +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import org.koin.core.annotation.Single + +@Single +class PersistableUriManagerImpl( + private val context: Context +) : PersistableUriManager { + + override fun takePersistableUriPermission(uri: BackupDestinationUri) { + context.contentResolver.takePersistableUriPermission( + uri.value.toUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + override fun releasePersistableUriPermission(uri: BackupDestinationUri) { + context.contentResolver.releasePersistableUriPermission( + uri.value.toUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt new file mode 100644 index 000000000..9bd2454b7 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.feature.backup.data.mapper + +import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJob +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobKt +import de.davis.keygo.feature.backup.data.local.model.protoBackupJob +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal fun ProtoBackupJob.toDomain(): BackupJob { + val fileFormat = FileFormat.valueOf(format) + return BackupJob( + uri = BackupDestinationUri(uri), + format = fileFormat, + wrappedPassphrase = if (hasPassphraseCt() && hasPassphraseIv()) + CryptographicData( + data = passphraseCt.toByteArray(), + iv = passphraseIv.toByteArray(), + ) + else null, + encryption = if (fileFormat.encrypted) + (if (hasEncryption()) runCatching { EncryptionMethod.valueOf(encryption) }.getOrNull() else null) + ?: EncryptionMethod.Passphrase + else null, + csvPreset = if (fileFormat == FileFormat.CSV) + (if (hasCsvPreset()) runCatching { CsvPreset.valueOf(csvPreset) }.getOrNull() else null) + ?: CsvPreset.Browser + else null, + keepCount = if (hasKeepCount()) keepCount else null, + createdAt = createdAt, + finishedAt = if (hasFinishedAt()) finishedAt else null, + lastResult = backupResultFromProto( + resultName = if (hasLastResult()) lastResult else null, + errorName = if (hasLastError()) lastError else null, + ), + cancelled = this.cancelled, + destinationName = if (hasDestinationName()) destinationName else null, + ) +} + +internal fun BackupJob.toProto() = protoBackupJob { + uri = this@toProto.uri.value + format = this@toProto.format.name + wrappedPassphrase?.let { + passphraseCt = it.data.toByteString() + passphraseIv = it.iv.toByteString() + } + this@toProto.encryption?.let { encryption = it.name } + this@toProto.csvPreset?.let { csvPreset = it.name } + this@toProto.keepCount?.let { keepCount = it } + createdAt = this@toProto.createdAt + this@toProto.finishedAt?.let { finishedAt = it } + this@toProto.lastResult?.let { writeResult(it) } + cancelled = this@toProto.cancelled + this@toProto.destinationName?.let { destinationName = it } +} + +// Encodes a result into the proto's two independent fields. The recurring record is reused every +// run, so a stale reason must not outlive its run - clear it when the new result carries none. +internal fun ProtoBackupJobKt.Dsl.writeResult(result: BackupResult) { + lastResult = result.protoResultName + val error = result.protoErrorName + if (error != null) lastError = error + else clearLastError() +} + +// The proto persists the outcome as two independent strings (result + reason). Keep that layout so +// no DataStore migration is needed, but resolve them into a single sealed BackupResult in the domain. +private const val PROTO_RESULT_SUCCESS = "Success" +private const val PROTO_RESULT_FAILURE = "Failure" + +internal val BackupResult.protoResultName: String + get() = when (this) { + BackupResult.Success -> PROTO_RESULT_SUCCESS + is BackupResult.Failure -> PROTO_RESULT_FAILURE + } + +internal val BackupResult.protoErrorName: String? + get() = (this as? BackupResult.Failure)?.reason?.name + +private fun backupResultFromProto(resultName: String?, errorName: String?): BackupResult? = + when (resultName) { + PROTO_RESULT_SUCCESS -> BackupResult.Success + PROTO_RESULT_FAILURE -> BackupResult.Failure( + errorName?.let { runCatching { BackupFailureReason.valueOf(it) }.getOrNull() }, + ) + + else -> null + } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapper.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapper.kt new file mode 100644 index 000000000..57fff9702 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapper.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.feature.backup.data.mapper + +import androidx.work.Data +import androidx.work.WorkInfo +import androidx.work.workDataOf +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.worker.BackupWorker + +internal const val PROGRESS_KEY_PHASE = "phase" +internal const val PROGRESS_KEY_PROCESSED = "processed" +internal const val PROGRESS_KEY_TOTAL = "total" + +internal const val PROGRESS_PHASE_RUNNING = "running" +internal const val PROGRESS_PHASE_WRITING = "writing" + +internal fun WorkInfo.toStatus() = BackupWorkStatus( + id = id.toString(), + kind = toKind(tags), + state = toState( + state = state, + progress = toProgress( + phase = progress.getString(PROGRESS_KEY_PHASE), + processed = progress.getInt(PROGRESS_KEY_PROCESSED, 0), + total = progress.getInt(PROGRESS_KEY_TOTAL, 0), + ), + ), +) + +internal fun toState( + state: WorkInfo.State, + progress: ExportProgress.InFlight? = null, +): DispatchedBackup.State = when (state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> DispatchedBackup.State.Enqueued + WorkInfo.State.RUNNING -> DispatchedBackup.State.Running(progress) + WorkInfo.State.SUCCEEDED -> DispatchedBackup.State.Succeeded + WorkInfo.State.FAILED -> DispatchedBackup.State.Failed + WorkInfo.State.CANCELLED -> DispatchedBackup.State.Cancelled +} + +internal fun toKind(tags: Set): DispatchedBackup.Kind = + if (BackupWorker.TAG_RECURRING in tags) DispatchedBackup.Kind.Recurring + else DispatchedBackup.Kind.OneTime + +internal fun toProgress(phase: String?, processed: Int, total: Int): ExportProgress.InFlight? = + when (phase) { + PROGRESS_PHASE_WRITING -> ExportProgress.Writing + PROGRESS_PHASE_RUNNING -> + if (total > 0) ExportProgress.Running(processed, total) else null + + else -> null + } + +internal fun ExportProgress.InFlight.toProgressData(): Data = when (this) { + is ExportProgress.Running -> workDataOf( + PROGRESS_KEY_PHASE to PROGRESS_PHASE_RUNNING, + PROGRESS_KEY_PROCESSED to processed, + PROGRESS_KEY_TOTAL to total, + ) + + ExportProgress.Writing -> workDataOf(PROGRESS_KEY_PHASE to PROGRESS_PHASE_WRITING) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt new file mode 100644 index 000000000..c71d0daf4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt @@ -0,0 +1,96 @@ +package de.davis.keygo.feature.backup.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJob +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobKt +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobs +import de.davis.keygo.feature.backup.data.local.model.copy +import de.davis.keygo.feature.backup.data.mapper.toDomain +import de.davis.keygo.feature.backup.data.mapper.toProto +import de.davis.keygo.feature.backup.data.mapper.writeResult +import de.davis.keygo.feature.backup.di.annotation.BackupJobsQualifier +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class BackupJobRepositoryImpl( + @param:BackupJobsQualifier + private val dataStore: DataStore, +) : BackupJobRepository { + + override suspend fun getJob(workId: WorkId): BackupJob? = + dataStore.data.map { it.jobsMap[workId]?.toDomain() }.firstOrNull() + + override suspend fun getJobs(): Map = + dataStore.data.first().jobsMap.mapValues { (_, proto) -> proto.toDomain() } + + override fun observeJobs(): Flow> = + dataStore.data.map { it.jobsMap.values.map { proto -> proto.toDomain() } } + + override suspend fun putJob(workId: WorkId, job: BackupJob): Result = runCatching { + dataStore.updateData { current -> + current.copy { + jobs[workId] = job.copy(createdAt = System.currentTimeMillis()).toProto() + } + } + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(Unit) }, + ) + + override suspend fun markFinished(workId: WorkId, result: BackupResult, finishedAt: Long) = + updateJob(workId) { + this.finishedAt = finishedAt + writeResult(result) + } + + override suspend fun markCancelled(workId: WorkId, cancelledAt: Long) = updateJob(workId) { + cancelled = true + finishedAt = cancelledAt + } + + // Clearing credentials must not prune: the record is still live and its retention position has + // not changed. + override suspend fun clearPassphrase(workId: WorkId) = updateJob(workId, prune = false) { + clearPassphraseCt() + clearPassphraseIv() + } + + /** Applies [edit] to the record under [workId]. A missing record is a no-op. */ + private suspend fun updateJob( + workId: WorkId, + prune: Boolean = true, + edit: ProtoBackupJobKt.Dsl.() -> Unit, + ) { + dataStore.updateData { current -> + val existing = current.jobsMap[workId] ?: return@updateData current + val updated = existing.copy(edit) + if (prune) current.upsertAndPrune(workId, updated) + else current.copy { jobs[workId] = updated } + } + } + + // Writes [job] under [workId], then drops finished one-time records beyond the retention cap so the + // store cannot grow without bound as WorkManager silently prunes its own history. + private fun ProtoBackupJobs.upsertAndPrune( + workId: WorkId, + job: ProtoBackupJob + ): ProtoBackupJobs { + val merged = jobsMap + (workId to job) + val keep = retainedJobKeys( + merged.mapValues { (_, j) -> if (j.hasFinishedAt()) j.finishedAt else null }, + ) + return copy { + jobs.clear() + jobs.putAll(merged.filterKeys { it in keep }) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt new file mode 100644 index 000000000..5b3154922 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.backup.data.repository + +import de.davis.keygo.feature.backup.worker.BackupWorker + +// WorkManager eventually drops its own finished work entries, but this DataStore does not, and every +// one-time backup persists a permanent entry keyed by its work id. Bound that growth: the recurring +// record and any job that has not finished yet are retained unconditionally; among finished one-time +// jobs only the most recent [max] survive, ordered by when they finished. +internal const val MAX_FINISHED_ONE_TIME_JOBS = 10 + +internal fun retainedJobKeys( + finishedAtByKey: Map, + max: Int = MAX_FINISHED_ONE_TIME_JOBS, + recurringKey: String = BackupWorker.RECURRING_WORK_ID, +): Set { + val (cappable, alwaysRetained) = finishedAtByKey.entries.partition { (key, finishedAt) -> + key != recurringKey && finishedAt != null + } + val survivors = cappable + .sortedByDescending { it.value ?: 0L } + .take(max) + .map { it.key } + return alwaysRetained.mapTo(mutableSetOf()) { it.key }.apply { addAll(survivors) } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt new file mode 100644 index 000000000..cb04940b0 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.data.repository + +import androidx.work.WorkManager +import de.davis.keygo.feature.backup.data.mapper.toStatus +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.repository.DispatchedBackupRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +import java.util.UUID + +@Single +internal class DispatchedBackupRepositoryImpl( + private val workManager: WorkManager, +) : DispatchedBackupRepository { + + override fun observe(): Flow> = + workManager.getWorkInfosByTagFlow(BackupWorker.TAG) + .map { infos -> infos.map { it.toStatus() } } + + override suspend fun cancel(id: String) { + workManager.cancelWorkById(UUID.fromString(id)) + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt new file mode 100644 index 000000000..95ad98c46 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt @@ -0,0 +1,50 @@ +package de.davis.keygo.feature.backup.di + +import android.content.Context +import androidx.datastore.dataStore +import androidx.work.WorkManager +import de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupArkData +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobs +import de.davis.keygo.feature.backup.di.annotation.BackupArkQualifier +import de.davis.keygo.feature.backup.di.annotation.BackupJobsQualifier +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.backup") +object FeatureBackupModule { + + private val Context.backupJobsDataStore by dataStore( + "backup_jobs.pb", + DefaultProtoSerializer( + defaultInstance = ProtoBackupJobs.getDefaultInstance(), + parser = ProtoBackupJobs.parser() + ) + ) + + @Single + @BackupJobsQualifier + internal fun provideBackupJobsDataStore(context: Context) = + context.backupJobsDataStore + + private val Context.backupArkDataStore by dataStore( + "backup_ark_data.pb", + DefaultProtoSerializer( + defaultInstance = ProtoBackupArkData.getDefaultInstance(), + parser = ProtoBackupArkData.parser(), + ), + ) + + @Single + @BackupArkQualifier + internal fun provideBackupArkDataStore(context: Context) = + context.backupArkDataStore + + @Single + internal fun provideWorkManager(context: Context): WorkManager = + WorkManager.getInstance(context) +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupArkQualifier.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupArkQualifier.kt new file mode 100644 index 000000000..50c3d1017 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupArkQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.di.annotation + +import org.koin.core.annotation.Qualifier + +@Qualifier +annotation class BackupArkQualifier diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt new file mode 100644 index 000000000..3c1879822 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class BackupJobsQualifier diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt new file mode 100644 index 000000000..2ff8fdc21 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory +import de.davis.keygo.core.security.domain.crypto.suspendDoFinal +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.data.BackupSession +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import org.koin.core.annotation.Single + +/** + * Resolves the crypto scope for a backup. Prefers the live [Session]; when locked, silently recovers + * the ARK copy via the non-auth [KeyId.BackupArkKey] and binds the scope to a throwaway + * [BackupSession]. The global session is never touched. + */ +@Single +internal class BackupArkUnlocker( + private val session: Session, + private val keyStoreManager: KeyStoreManager, + private val arkKeyStore: BackupArkKeyStore, + private val scopeProviderFactory: CryptographicScopeProviderFactory, + private val vaultRepository: VaultRepository, +) { + + /** + * Runs [block] with the ARK for this backup. A recovered ARK is zeroed afterwards; a live + * [Session.ark] is the app's own session key and is left alone. + */ + suspend fun withArk(block: suspend (ByteArray) -> R): Result { + val live = session.arkOrNull() + if (live != null) return Result.Success(block(live)) + + return resultBinding { + val ark = recoverArk().bind() + try { + block(ark) + } finally { + ark.fill(0) + } + } + } + + /** Runs [block] with a crypto scope bound to the live session, or to a throwaway + * [BackupSession] holding a recovered ARK that is zeroed afterwards. */ + suspend fun withScope( + block: suspend (ItemWithCryptoScopeUseCase) -> R, + ): Result { + val live = session.arkOrNull() + if (live != null) return Result.Success(block(scopeFor(session))) + + return resultBinding { + val ark = recoverArk().bind() + try { + block(scopeFor(BackupSession(ark))) + } finally { + ark.fill(0) + } + } + } + + private suspend fun recoverArk(): Result = resultBinding { + val wrapped = arkKeyStore.load() + .asResult(ExportError.NotProvisioned).bind() + + val cipher = runCatching { + keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupArkKey, + cryptographicMode = CryptographicMode.Decrypt, + iv = wrapped.iv, + ) + }.getOrNull().asResult(ExportError.DeviceLocked).bind() + + cipher.suspendDoFinal(wrapped.data).bind { ExportError.DeviceLocked } + } + + private fun scopeFor(session: Session): ItemWithCryptoScopeUseCase = + ItemWithCryptoScopeUseCase(vaultRepository, scopeProviderFactory.forSession(session)) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt new file mode 100644 index 000000000..f9734349e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt @@ -0,0 +1,104 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.core.item.domain.repository.PasskeyRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.mapper.toBackupCard +import de.davis.keygo.feature.backup.domain.mapper.toBackupIcon +import de.davis.keygo.feature.backup.domain.mapper.toBackupLogin +import de.davis.keygo.feature.backup.domain.model.CollectedBackup +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupVault +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single + +@Single +internal class BackupCollector( + private val vaultRepository: VaultRepository, + private val loginRepository: LoginRepository, + private val creditCardRepository: CreditCardRepository, + private val passkeyRepository: PasskeyRepository, + private val arkUnlocker: BackupArkUnlocker, +) { + + private data class VaultItems( + val meta: VaultMetadata, + val logins: List, + val cards: List, + ) { + val items get() = logins.size + cards.size + } + + suspend fun collect( + onProgress: suspend (processed: Int, total: Int) -> Unit, + ): Result = resultBinding { + arkUnlocker.withScope { scope -> collectWith(scope, onProgress).bind() }.bind() + } + + private suspend fun collectWith( + scope: ItemWithCryptoScopeUseCase, + onProgress: suspend (processed: Int, total: Int) -> Unit, + ): Result = resultBinding { + val perVault = coroutineScope { + vaultRepository.observeAllVaultMetadata().first().map { meta -> + async { + val logins = async { loginRepository.getLoginsByVault(meta.vaultId) } + val cards = async { creditCardRepository.getCreditCardsByVault(meta.vaultId) } + VaultItems( + meta = meta, + logins = logins.await(), + cards = cards.await(), + ) + } + }.awaitAll() + } + + val total = perVault.sumOf { it.items } + (total > 0).asResult(ExportError.NothingToExport).bind() + + var processed = 0 + val progressMutex = Mutex() + suspend fun I.export(map: suspend CryptographicScope.(I) -> R): R = + scope.withItem(this, map) + .bind { ExportError.CryptoFailed } + .also { progressMutex.withLock { onProgress(++processed, total) } } + + val backupVaults = perVault.map { (meta, logins, cards) -> + val (exportedLogins, exportedCards) = coroutineScope { + val loginResults = logins.map { login -> + async { + val passkeys = passkeyRepository.getPasskeysByLogin(login.id) + login.export { it.toBackupLogin(passkeys) } + } + } + val cardResults = cards.map { card -> async { card.export { it.toBackupCard() } } } + loginResults.awaitAll() to cardResults.awaitAll() + } + + BackupVault( + name = meta.name, + icon = meta.icon.toBackupIcon(), + logins = exportedLogins, + cards = exportedCards, + ) + } + + CollectedBackup(Backup(backupVaults), total) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupDestinationResolver.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupDestinationResolver.kt new file mode 100644 index 000000000..8fb579656 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupDestinationResolver.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +interface BackupDestinationResolver { + + /** + * @param cachedName the destination's name as it was resolved while the grant was live. Given + * one, the provider is not asked again - it may no longer be willing, or able, to answer. + */ + suspend fun resolve( + uri: BackupDestinationUri, + cachedName: String? = null, + ): BackupDestination +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupEscrowReconciler.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupEscrowReconciler.kt new file mode 100644 index 000000000..c3cfc2d47 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupEscrowReconciler.kt @@ -0,0 +1,34 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.feature.backup.domain.usecase.CleanupBackupResourcesUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +/** + * Sweeps the ARK escrow once per process start. + * + * The escrow is only meant to outlive the user's session for as long as a job is actually + * scheduled, so something has to notice when the platform drops that job without ever running it. + * A finished run cannot notice: in that case there is none. Process start is the last trigger left + * that does not wait on the user navigating somewhere, which is why this hangs off startup rather + * than off the backup screen - a user who schedules a backup and never opens the feature again + * would otherwise keep the escrow alive indefinitely. + * + * [CleanupBackupResourcesUseCase.reconcile] needs no session and no ARK, so running it this early + * is safe. It is fire-and-forget: nothing on screen depends on the result, and a failure only + * means the next start tries again. + */ +@Single(createdAtStart = true) +internal class BackupEscrowReconciler( + cleanupBackupResources: CleanupBackupResourcesUseCase, +) { + + init { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + cleanupBackupResources.reconcile() + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupFileStore.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupFileStore.kt new file mode 100644 index 000000000..b1b02aedf --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupFileStore.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupEntry + +interface BackupFileStore { + + /** Reads a single document. [uri] must point at a document, not a folder (import path). */ + suspend fun read(uri: BackupDestinationUri): Result + + /** + * Creates a new document named [fileName] inside the [folder] tree and writes [text] to it. + * [folder] is a tree URI; the document is only materialised here, so an aborted wizard never + * leaves an empty file behind. + */ + suspend fun writeNewDocument( + folder: BackupDestinationUri, + fileName: String, + mimeType: String, + text: String, + ): Result + + suspend fun listBackups( + folder: BackupDestinationUri, + baseName: String, + ): Result, Throwable> + + suspend fun delete(uri: BackupDestinationUri): Result +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupProvisioningLock.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupProvisioningLock.kt new file mode 100644 index 000000000..349a18688 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupProvisioningLock.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.backup.domain + +import kotlinx.coroutines.sync.Mutex +import org.koin.core.annotation.Single + +/** + * Serializes ARK-escrow provisioning against escrow teardown. + * + * The escrow and the job record live in separate DataStores with no cross-store transaction, so + * holding this one lock across each whole section is what stops a cleanup from observing a + * half-provisioned job (escrow written, record not yet) and destroying credentials the new job needs. + */ +@Single +class BackupProvisioningLock { + val mutex = Mutex() +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt new file mode 100644 index 000000000..60278827c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt @@ -0,0 +1,144 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.domain.TransactionRunner +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.feature.backup.domain.mapper.toUpsertCreditCard +import de.davis.keygo.feature.backup.domain.mapper.toUpsertLogin +import de.davis.keygo.feature.backup.domain.mapper.toVaultIcon +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportSummary +import de.davis.keygo.feature.backup.domain.model.ImportTarget +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.vault.domain.usecase.CreateVaultUseCase +import de.davisalessandro.keygo.rust.Backup +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class BackupRestorer( + private val vaultRepository: VaultRepository, + private val loginRepository: LoginRepository, + private val creditCardRepository: CreditCardRepository, + private val createVault: CreateVaultUseCase, + private val createLogin: CreateNewOrUpdateLoginUseCase, + private val createCard: CreateNewOrUpdateCreditCardUseCase, + private val transactionRunner: TransactionRunner, +) { + + suspend fun restore( + backup: Backup, + target: ImportTarget? = null, + onProgress: suspend (processed: Int, total: Int) -> Unit, + ): Result { + val total = backup.vaults.sumOf { it.logins.size + it.cards.size } + if (total == 0) return Result.Failure(ImportError.NothingImported) + + val existingByName = vaultRepository.observeAllVaultMetadata().first() + .associate { it.name to it.vaultId } + .toMutableMap() + + // Persist every vault and item atomically: if the import is cancelled (e.g. the user + // navigates back) or throws, the whole batch rolls back instead of leaving a half-imported + // vault. The existing-vault lookup above stays outside the transaction because collecting a + // Room Flow inside a write transaction can deadlock. + return transactionRunner.runInTransaction { + val acc = ImportAccumulator(total, onProgress) + + // Resolved once, ahead of the loop rather than inside it: a target collapses every + // vault in the backup into the single one the user chose, so `New` has to create + // exactly one vault no matter how many vaults the backup describes. Its icon comes + // from the user too, for the same reason the name does: the vaults it absorbs may + // disagree on one, and the user is the one who said what this vault is. + val targetVaultId = when (target) { + null -> null + is ImportTarget.Existing -> target.vaultId + is ImportTarget.New -> createVault(target.name, target.icon) + .getOrNull() + ?.also { acc.vaultCreated() } + } + + // A vault matched by name keeps whatever icon it already has - the backup describes + // how the vault looked when it was written, not how the user wants it to look now. + for (bvault in backup.vaults) { + val vaultId = if (target != null) targetVaultId + else existingByName[bvault.name] + ?: createVault(bvault.name, bvault.toVaultIcon()) + .getOrNull() + ?.also { existingByName[bvault.name] = it; acc.vaultCreated() } + + if (vaultId == null) { + acc.failAll(bvault.logins.size + bvault.cards.size) + continue + } + + val loginKeys = loginRepository.getLoginsByVault(vaultId) + .map { it.name to it.username }.toMutableSet() + val cardKeys = creditCardRepository.getCreditCardsByVault(vaultId) + .map { it.name to it.holder }.toMutableSet() + + acc.importItems(bvault.logins, loginKeys, { it.title to it.username }) { + createLogin(it.toUpsertLogin(vaultId)) + } + acc.importItems(bvault.cards, cardKeys, { it.title to it.cardholder }) { + createCard(it.toUpsertCreditCard(vaultId)) + } + } + + Result.Success(acc.toSummary()) + } + } + + private class ImportAccumulator( + private val total: Int, + private val onProgress: suspend (processed: Int, total: Int) -> Unit, + ) { + private var imported = 0 + private var skipped = 0 + private var failed = 0 + private var vaultsCreated = 0 + private var processed = 0 + + fun vaultCreated() { + vaultsCreated++ + } + + suspend fun failAll(count: Int) = repeat(count) { + failed++ + tick() + } + + suspend fun importItems( + items: List, + existingKeys: MutableSet>, + keyOf: (T) -> Pair, + create: suspend (T) -> Result<*, *>, + ) { + for (item in items) { + val key = keyOf(item) + when { + key in existingKeys -> skipped++ + create(item) is Result.Success -> { + imported++ + existingKeys += key + } + + else -> failed++ + } + tick() + } + } + + fun toSummary() = ImportSummary(imported, skipped, failed, vaultsCreated) + + private suspend fun tick() { + processed++ + onProgress(processed, total) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt new file mode 100644 index 000000000..0f82e733c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.BackupJob + +interface BackupScheduler { + + suspend fun scheduleRecurringBackup( + job: BackupJob, + interval: BackupInterval + ): Result + + suspend fun scheduleOneTimeBackup(job: BackupJob): Result + fun cancel() + + /** + * Work ids the scheduler still has outstanding - enqueued, blocked, or running. + * + * This is the ground truth for whether a job record can still produce a run. A record that + * looks live by its own bookkeeping but has no outstanding work behind it can never run again, + * so nothing may keep holding its credentials open. + * + * Throws if the scheduler cannot be read; callers must treat that as "unknown" and release + * nothing, never as "no work outstanding". + */ + suspend fun outstandingWorkIds(): Set +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/PersistableUriManager.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/PersistableUriManager.kt new file mode 100644 index 000000000..8402222f2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/PersistableUriManager.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +interface PersistableUriManager { + + fun takePersistableUriPermission(uri: BackupDestinationUri) + fun releasePersistableUriPermission(uri: BackupDestinationUri) +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/SessionExt.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/SessionExt.kt new file mode 100644 index 000000000..59f9b01cf --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/SessionExt.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.security.domain.Session + +/** + * The ARK of the live session, or null when the app is locked. + * + * [Session.ark] throws rather than returning null, and every backup path has to treat "locked" as an + * ordinary branch instead of an error, so the probe is written once here. + */ +internal fun Session.arkOrNull(): ByteArray? = runCatching { ark }.getOrNull() diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/alias/WorkId.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/alias/WorkId.kt new file mode 100644 index 000000000..ea361a639 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/alias/WorkId.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.domain.alias + +/** The key a [de.davis.keygo.feature.backup.domain.repository.BackupJobRepository] record and the + * scheduler's outstanding work are both keyed by - a WorkManager request id for one-time jobs, or + * [de.davis.keygo.feature.backup.worker.BackupWorker.RECURRING_WORK_ID] for the recurring one. */ +typealias WorkId = String diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt new file mode 100644 index 000000000..0c99c17d6 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.feature.backup.domain.model.CsvColumnAnalysis +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.DetectedColumn +import de.davis.keygo.feature.backup.domain.model.MappingConfidence +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.Confidence +import de.davisalessandro.keygo.rust.CsvAnalysis + +/** + * Fold the field-centric Rust analysis (`suggested`/`confidence` keyed by field) onto each + * column: the greedy assignment guarantees at most one field points at any given column. + */ +internal fun CsvAnalysis.toDomain(): CsvColumnAnalysis = CsvColumnAnalysis( + columns = columns.map { column -> + val (type, confidence) = suggestionFor(column.index) + DetectedColumn( + index = column.index.toInt(), + header = column.header, + samples = column.sampleValues, + suggestedType = type, + confidence = confidence, + ) + }, +) + +private fun CsvAnalysis.suggestionFor(index: UInt): Pair = when (index) { + suggested.title -> CsvColumnType.Title to confidence.title?.toDomain() + suggested.url -> CsvColumnType.Url to confidence.url?.toDomain() + suggested.username -> CsvColumnType.Username to confidence.username?.toDomain() + suggested.password -> CsvColumnType.Password to confidence.password?.toDomain() + suggested.notes -> CsvColumnType.Notes to confidence.notes?.toDomain() + suggested.totp -> CsvColumnType.Totp to confidence.totp?.toDomain() + else -> null to null +} + +private fun Confidence.toDomain(): MappingConfidence = when (this) { + Confidence.HIGH -> MappingConfidence.High + Confidence.MEDIUM -> MappingConfidence.Medium + Confidence.LOW -> MappingConfidence.Low +} + +internal fun Map.toColumnMapping(): ColumnMapping { + val byType: Map = entries + .mapNotNull { (index, type) -> type?.let { it to index.toUInt() } } + .toMap() + return ColumnMapping( + title = byType[CsvColumnType.Title], + url = byType[CsvColumnType.Url], + username = byType[CsvColumnType.Username], + password = byType[CsvColumnType.Password], + notes = byType[CsvColumnType.Notes], + totp = byType[CsvColumnType.Totp], + ) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportMappers.kt new file mode 100644 index 000000000..ef20f6c46 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportMappers.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davisalessandro.keygo.rust.BackupCard +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.BackupPasskey +import de.davisalessandro.keygo.rust.ExportPreset + +/** The enum name is the wire vocabulary; [toVaultIcon] reads it back. */ +internal fun Vault.Icon.toBackupIcon(): String = name + +context(scope: CryptographicScope) +internal suspend fun Login.toBackupLogin(passkeys: List): BackupLogin = BackupLogin( + title = name, + notes = note, + tags = tags.map { it.display }, + pinned = pinned, + username = username, + password = passwordCredential?.secret?.decrypt(), + totpSecret = totp?.secret?.decrypt(), + websites = domainInfos.map { it.value }, + passkeys = passkeys.map { it.toBackupPasskey() }, +) + +// The private key is sealed under the login item's key (AAD = loginId + vaultId), so it opens in +// that login's scope - the same one this mapper already runs in. +context(scope: CryptographicScope) +private suspend fun Passkey.toBackupPasskey(): BackupPasskey = BackupPasskey( + userName = user.name, + userDisplayName = user.displayName, + credentialId = credentialId, + privateKey = privateKey.decrypt(), + rp = rp, +) + +context(scope: CryptographicScope) +internal suspend fun CreditCard.toBackupCard(): BackupCard = BackupCard( + title = name, + notes = note, + tags = tags.map { it.display }, + pinned = pinned, + cardholder = holder, + number = cardNumber?.decrypt() ?: "", + expirationMonth = expirationDate?.monthValue?.toUByte(), + expirationYear = expirationDate?.year?.toUShort(), + cvv = cvv?.decrypt(), +) + +internal fun CsvPreset.toRust(): ExportPreset = when (this) { + CsvPreset.KeyGo -> ExportPreset.KEY_GO + CsvPreset.Browser -> ExportPreset.BROWSER +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt new file mode 100644 index 000000000..53e61a003 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davisalessandro.keygo.rust.BackupException + +internal fun BackupException.toImportError(): ImportError = when (this) { + is BackupException.Crypto, + is BackupException.CredentialMismatch -> ImportError.WrongCredential + + else -> ImportError.ParseFailed(this) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt new file mode 100644 index 000000000..7cdccd992 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt @@ -0,0 +1,47 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.feature.item.core.domain.model.UpsertCreditCard +import de.davis.keygo.feature.item.core.domain.model.UpsertLogin +import de.davisalessandro.keygo.rust.BackupCard +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.BackupVault + +/** + * Anything the icon set no longer covers falls back to the default rather than failing the import: + * a backup written by a build that knows an icon this one does not is still worth restoring. + */ +internal fun BackupVault.toVaultIcon(): Vault.Icon = + Vault.Icon.entries.firstOrNull { it.name == icon } ?: Vault.Icon.Default + +internal fun BackupLogin.toUpsertLogin(vaultId: VaultId): UpsertLogin = UpsertLogin.create( + vaultId = vaultId, + name = title, + password = password, + totpUriOrSecret = totpSecret, + username = username, + domains = websites.map { DomainInfo(value = it, eTLD1 = null) }.toSet(), + tags = tags.mapNotNull { Tag.of(it) }.toSet(), + note = notes, +) + +internal fun BackupCard.toUpsertCreditCard(vaultId: VaultId): UpsertCreditCard = + UpsertCreditCard.create( + vaultId = vaultId, + name = title, + cardNumber = number.ifBlank { null }, + expirationDate = expirationString(), + holder = cardholder, + cvv = cvv, + note = notes, + tags = tags.mapNotNull { Tag.of(it) }.toSet(), + ) + +private fun BackupCard.expirationString(): String? { + val month = expirationMonth?.toInt() ?: return null + val year = expirationYear?.toInt() ?: return null + return "%02d/%02d".format(month, year % 100) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestination.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestination.kt new file mode 100644 index 000000000..1935bab27 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestination.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupDestination( + val provider: Provider, + val displayPath: String, + val fileName: String? = null, +) { + + sealed interface Provider { + data object Unknown : Provider + data object OnDevice : Provider + data class ThirdParty(val name: String) : Provider + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestinationUri.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestinationUri.kt new file mode 100644 index 000000000..83a21bc33 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestinationUri.kt @@ -0,0 +1,4 @@ +package de.davis.keygo.feature.backup.domain.model + +@JvmInline +value class BackupDestinationUri(val value: String) \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt new file mode 100644 index 000000000..8de1e28b3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupEntry( + val uri: BackupDestinationUri, + val name: String, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFailureReason.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFailureReason.kt new file mode 100644 index 000000000..7d4a61adf --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFailureReason.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.backup.domain.model + +/** + * Why a backup failed, in a form that survives persistence and can be shown to the user. + * + * Deliberately narrower than [ExportError]: a retryable error only reaches a terminal record once + * its retries run out (as [RetriesExhausted]), and a serialization failure is split into the + * sub-cases the export path can actually produce so the hub row can name what went wrong. + * + * Names are persisted verbatim in `backup_jobs.pb` - renaming a constant orphans existing records. + */ +enum class BackupFailureReason { + NothingToExport, + CryptoFailed, + SerializationFailed, + CryptoSerializationFailed, + + WriteFailed, + NotProvisioned, + + /** The device stayed locked (or the session stayed closed) for every attempt the job had. */ + RetriesExhausted, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFileName.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFileName.kt new file mode 100644 index 000000000..edcbc8414 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFileName.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.model + +const val BACKUP_BASE_NAME = "keygo-backup" + +/** + * e.g. `keygo-backup-1700000000000.json`. The embedded epoch-millis timestamp keeps names unique per + * run and lexicographically ordered by recency, which the pruning logic relies on. + */ +fun FileFormat.backupFileName(timestamp: Long): String = + "$BACKUP_BASE_NAME-$timestamp.$extension" diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupInterval.kt new file mode 100644 index 000000000..8d37a9db6 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupInterval.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupInterval( + val count: Int, + val unit: IntervalUnit, +) + +enum class IntervalUnit { + Days, + Weeks, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt new file mode 100644 index 000000000..7a692300c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData + +data class BackupJob( + val uri: BackupDestinationUri, + val wrappedPassphrase: CryptographicData?, + val format: FileFormat, + /** How JSON payloads are sealed; null for CSV. Persisted jobs without the field are Passphrase. */ + val encryption: EncryptionMethod? = null, + /** CSV column layout; null for JSON. Persisted jobs without the field are Browser. */ + val csvPreset: CsvPreset? = null, + /** Number of backups to retain in the destination folder; null means keep all (never prune). */ + val keepCount: Int? = null, + val createdAt: Long = 0L, + val finishedAt: Long? = null, + /** Outcome of the last run; null when never run. A Failure carries its own reason. */ + val lastResult: BackupResult? = null, + val cancelled: Boolean = false, + /** + * What [uri] is called, resolved while the folder grant was live. Only the provider can answer + * that, and only while it honours the grant - which ends before this record does - so the name + * is captured once at scheduling time and read back from here forever after. Null on records + * written before the field existed; those fall back to asking the provider. + */ + val destinationName: String? = null, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt new file mode 100644 index 000000000..31988fe67 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface BackupResult { + data object Success : BackupResult + + /** @param reason why the run failed, null when it predates this field or was not recorded */ + data class Failure(val reason: BackupFailureReason? = null) : BackupResult +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupWorkStatus.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupWorkStatus.kt new file mode 100644 index 000000000..13ab9dacb --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupWorkStatus.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupWorkStatus( + val id: String, + val kind: DispatchedBackup.Kind, + val state: DispatchedBackup.State, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CollectedBackup.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CollectedBackup.kt new file mode 100644 index 000000000..84c129cab --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CollectedBackup.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davisalessandro.keygo.rust.Backup + +data class CollectedBackup( + val backup: Backup, + val itemCount: Int, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnAnalysis.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnAnalysis.kt new file mode 100644 index 000000000..892b91fd9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnAnalysis.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.backup.domain.model + +data class DetectedColumn( + val index: Int, + val header: String, + val samples: List, + val suggestedType: CsvColumnType?, + val confidence: MappingConfidence?, +) + +data class CsvColumnAnalysis( + val columns: List, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnType.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnType.kt new file mode 100644 index 000000000..ec5c1a8bd --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnType.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.domain.model + +/** A data type the user can assign to a CSV column. Absence (null) means "Ignore". */ +enum class CsvColumnType { + Title, + Url, + Username, + Password, + Notes, + Totp, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt new file mode 100644 index 000000000..f4d835049 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain.model + +enum class CsvPreset { + /** Every field incl. TOTP; round-trips through KeyGo's import. */ + KeyGo, + + /** Browser-importable layout (Chrome, Edge, ...): no TOTP column. */ + Browser, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/DispatchedBackup.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/DispatchedBackup.kt new file mode 100644 index 000000000..06ce40ebb --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/DispatchedBackup.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.feature.backup.domain.model + +data class DispatchedBackup( + val id: String, + val kind: Kind, + val state: State, + val format: FileFormat?, + val destination: BackupDestination?, + val timestamp: Long = 0L, + /** Why the run behind this row failed; null unless the job's persisted result is a Failure. */ + val failureReason: BackupFailureReason? = null, +) { + enum class Kind { OneTime, Recurring } + + sealed interface State { + data object Enqueued : State + + // In-flight detail lives here rather than in a parallel field, so it cannot outlive the run: + // a finished backup carries no progress by construction. Null until the worker reports. + data class Running(val progress: ExportProgress.InFlight? = null) : State + + data object Succeeded : State + data object Failed : State + data object Cancelled : State + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/EncryptionMethod.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/EncryptionMethod.kt new file mode 100644 index 000000000..d4402296f --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/EncryptionMethod.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.model + +/** Irrelevant for CSV, which is plaintext by design. */ +enum class EncryptionMethod { + /** Sealed with a user-chosen passphrase; restorable anywhere. */ + Passphrase, + + /** Sealed with the Account Root Key; restorable only into the same account. */ + Ark, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportDetails.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportDetails.kt new file mode 100644 index 000000000..62bc335d8 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportDetails.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.domain.model + +data class ExportDetails( + val format: FileFormat, + val interval: BackupInterval?, + val passphrase: String, + val uri: BackupDestinationUri, + // null for one-time exports and for "keep all" recurring; otherwise the retention limit. + val keepCount: Int? = null, + val encryption: EncryptionMethod? = null, + val csvPreset: CsvPreset? = null, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt new file mode 100644 index 000000000..f2bb0b4fb --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davisalessandro.keygo.rust.BackupException + +sealed interface ExportError { + data object SessionLocked : ExportError + data object NothingToExport : ExportError + data object CryptoFailed : ExportError + data class SerializationFailed(val cause: BackupException) : ExportError + data object WriteFailed : ExportError + data object NotProvisioned : ExportError + data object DeviceLocked : ExportError +} + +/** + * Failures that mean "try again later", not "this backup failed". A retryable outcome must never be + * recorded as terminal and must never release the job's credentials - the retry still needs them. + */ +internal val ExportError.retryable: Boolean + get() = this == ExportError.DeviceLocked || this == ExportError.SessionLocked + +/** Null when the error is [retryable], and so never becomes a terminal failure. */ +internal val ExportError.failureReason: BackupFailureReason? + get() = when (this) { + ExportError.SessionLocked, ExportError.DeviceLocked -> null + ExportError.NothingToExport -> BackupFailureReason.NothingToExport + ExportError.CryptoFailed -> BackupFailureReason.CryptoFailed + is ExportError.SerializationFailed -> cause.failureReason + ExportError.WriteFailed -> BackupFailureReason.WriteFailed + ExportError.NotProvisioned -> BackupFailureReason.NotProvisioned + } + +// The Rust cause names what went wrong; keep only the variants the export path can raise and fold +// the import-only ones into the generic reason. The free-form message is intentionally dropped. +private val BackupException.failureReason: BackupFailureReason + get() = when (this) { + is BackupException.Crypto -> BackupFailureReason.CryptoSerializationFailed + else -> BackupFailureReason.SerializationFailed + } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportProgress.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportProgress.kt new file mode 100644 index 000000000..d1f205c4b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportProgress.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface ExportProgress { + sealed interface InFlight : ExportProgress + + data class Running(val processed: Int, val total: Int) : InFlight + data object Writing : InFlight + data class Succeeded(val itemCount: Int) : ExportProgress + data class Failed(val error: ExportError) : ExportProgress +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt new file mode 100644 index 000000000..bfed18ce4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.domain.model + +enum class FileFormat(val mimeType: String, val extension: String) { + JSON("application/json", "json"), + CSV("text/csv", "csv"); + + val recommended: Boolean + get() = this == JSON + + val encrypted: Boolean + get() = this == JSON +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FinishExportWizardError.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FinishExportWizardError.kt new file mode 100644 index 000000000..e9845d619 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FinishExportWizardError.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface FinishExportWizardError { + data object PassphraseEmpty : FinishExportWizardError + data object CryptoFailed : FinishExportWizardError + data object SchedulePersistenceFailed : FinishExportWizardError + data object DestinationPermissionDenied : FinishExportWizardError +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt new file mode 100644 index 000000000..7bf4d1490 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davisalessandro.keygo.rust.BackupException + +sealed interface ImportError { + data object SessionLocked : ImportError + data object FileUnreadable : ImportError + data object EmptyFile : ImportError + data object WrongCredential : ImportError + data object PassphraseRequired : ImportError + data class ParseFailed(val cause: BackupException) : ImportError + data object NothingImported : ImportError +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportProgress.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportProgress.kt new file mode 100644 index 000000000..c9bda1b5d --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportProgress.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface ImportProgress { + data object Reading : ImportProgress + data object Parsing : ImportProgress + data class Running(val processed: Int, val total: Int) : ImportProgress + data class Succeeded(val summary: ImportSummary) : ImportProgress + data class Failed(val error: ImportError) : ImportProgress +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt new file mode 100644 index 000000000..c0c565c65 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davisalessandro.keygo.rust.ColumnMapping + +data class ImportRequest( + val uri: BackupDestinationUri, + val format: FileFormat, + val passphrase: String?, + val csvMapping: ColumnMapping? = null, + /** `null` restores the vaults named in the backup; see [ImportTarget]. */ + val target: ImportTarget? = null, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportSummary.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportSummary.kt new file mode 100644 index 000000000..c0d2e0bab --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportSummary.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +data class ImportSummary( + val imported: Int, + val skipped: Int, + val failed: Int, + val vaultsCreated: Int, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt new file mode 100644 index 000000000..62e95d74b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault + +/** + * Where an import should put its items. + * + * A `null` target (the absence of this type) means "use the vaults described by the backup", which + * is what JSON restores do: their vault names are real and worth preserving. CSV imports always + * carry a target, because the vault name in a parsed CSV backup is a placeholder the parser invents. + */ +sealed interface ImportTarget { + + /** Put everything in a vault that already exists. */ + data class Existing(val vaultId: VaultId) : ImportTarget + + /** + * Create a vault named [name] with [icon] and put everything in it. Always creates, even when a + * vault of this name already exists: the user was shown the existing vaults and chose to make a + * new one. + */ + data class New( + val name: String, + val icon: Vault.Icon = Vault.Icon.Default, + ) : ImportTarget +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt new file mode 100644 index 000000000..4710f92e8 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.backup.domain.model + +data class LastBackup( + val finishedAt: Long, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/MappingConfidence.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/MappingConfidence.kt new file mode 100644 index 000000000..e32a51e49 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/MappingConfidence.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.domain.model + +enum class MappingConfidence { + High, + Medium, + Low, +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupArkKeyStore.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupArkKeyStore.kt new file mode 100644 index 000000000..e19eb77b9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupArkKeyStore.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain.repository + +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData + +interface BackupArkKeyStore { + suspend fun save(data: CryptographicData) + suspend fun load(): CryptographicData? + suspend fun clear() +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt new file mode 100644 index 000000000..ad0cbff97 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.backup.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import kotlinx.coroutines.flow.Flow + +interface BackupJobRepository { + + suspend fun getJob(workId: WorkId): BackupJob? + suspend fun getJobs(): Map + suspend fun putJob(workId: WorkId, job: BackupJob): Result + suspend fun markFinished( + workId: WorkId, + result: BackupResult, + finishedAt: Long = System.currentTimeMillis() + ) + + suspend fun markCancelled(workId: WorkId, cancelledAt: Long = System.currentTimeMillis()) + suspend fun clearPassphrase(workId: WorkId) + fun observeJobs(): Flow> +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt new file mode 100644 index 000000000..8f5b353c8 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain.repository + +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import kotlinx.coroutines.flow.Flow + +interface DispatchedBackupRepository { + fun observe(): Flow> + suspend fun cancel(id: String) +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCase.kt new file mode 100644 index 000000000..920fb1123 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCase.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.mapper.toDomain +import de.davis.keygo.feature.backup.domain.mapper.toImportError +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.CsvColumnAnalysis +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.rust.backup.analyzeWithResult +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface +import org.koin.core.annotation.Single + +@Single +internal class AnalyzeCsvUseCase( + private val fileStore: BackupFileStore, + private val csvBackupManager: CsvBackupManagerInterface, +) { + + suspend operator fun invoke(uri: BackupDestinationUri): Result = + resultBinding { + val text = fileStore.read(uri).bind { ImportError.FileUnreadable } + if (text.isBlank()) + Result.Failure(ImportError.EmptyFile).bind() + + csvBackupManager.analyzeWithResult(text).bind { it.toImportError() }.toDomain() + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt new file mode 100644 index 000000000..37eb09049 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.domain.repository.DispatchedBackupRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import org.koin.core.annotation.Single + +@Single +internal class CancelBackupUseCase( + private val repository: DispatchedBackupRepository, + private val jobRepository: BackupJobRepository, + private val cleanupBackupResources: CleanupBackupResourcesUseCase, +) { + + suspend operator fun invoke(id: String, kind: DispatchedBackup.Kind) { + repository.cancel(id) + + val workId: WorkId = when (kind) { + DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID + DispatchedBackup.Kind.OneTime -> id + } + jobRepository.markCancelled(workId) + cleanupBackupResources(workId) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt new file mode 100644 index 000000000..23674ff45 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt @@ -0,0 +1,115 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.PersistableUriManager +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single + +/** + * Hands back everything a job needed once it stops needing it: its wrapped passphrase, its + * persistable folder grant, and - once no job is live at all - the escrowed ARK copy and the + * auth-less key aliases that protect both. + * + * Self-guarded: if `workId`'s record is still live, this does nothing. A recurring schedule's + * record persists across runs and its next run reads its passphrase back out of it, so cleaning + * up a still-live job would destroy credentials a future run needs. + */ +@Single +internal class CleanupBackupResourcesUseCase( + private val jobRepository: BackupJobRepository, + private val arkKeyStore: BackupArkKeyStore, + private val keyStoreManager: KeyStoreManager, + private val persistableUriManager: PersistableUriManager, + private val provisioningLock: BackupProvisioningLock, + private val scheduler: BackupScheduler, +) { + + suspend operator fun invoke(workId: WorkId): Unit = provisioningLock.mutex.withLock { + val outstanding = outstandingWorkIds() + val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return + + if (current[workId]?.isLive(workId, outstanding) == true) return + + sweep(current, outstanding) + + // Liveness and destination are read off the pre-sweep snapshot on purpose: clearing a + // passphrase changes neither, and only the key release below cares what was cleared. + val done = current[workId] + val live = current.filter { (id, job) -> job.isLive(id, outstanding) } + + if (done != null && live.values.none { it.uri == done.uri }) + runCatching { persistableUriManager.releasePersistableUriPermission(done.uri) } + } + + /** + * Frees the escrow for work the scheduler no longer has, even though no run ever finished to + * say so. + * + * [invoke] runs at the end of a dispatch. When the platform prunes or abandons the work + * instead, that end never comes: the record still reads as pending, and the escrowed ARK stays + * readable behind it forever. This checks every record against the scheduler's actual + * outstanding work and releases what no live job needs any more. + */ + suspend fun reconcile(): Unit = provisioningLock.mutex.withLock { + val outstanding = outstandingWorkIds() ?: return + val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return + sweep(current, outstanding) + } + + /** + * Hands back the passphrase of every record the scheduler has no work for, then releases the + * aliases that leaves unused. + * + * Keyed on liveness rather than on a single work id, because [reconcile] has no work id to + * offer: the job it cleans up is one no dispatch ever ended, so nothing ever named it. Both + * entry points share this so a dropped job cannot hand back its escrowed ARK while leaving the + * wrapped passphrase - and the auth-less alias that opens it - behind. + */ + private suspend fun sweep(current: Map, outstanding: Set?) { + current.forEach { (id, job) -> + if (job.wrappedPassphrase != null && !job.isLive(id, outstanding)) + runCatching { jobRepository.clearPassphrase(id) } + } + + // Re-read: a failed clear must leave the record holding its passphrase, so the shared + // alias below survives. + val jobs = runCatching { jobRepository.getJobs() }.getOrNull() ?: return + releaseUnusedKeys(jobs, jobs.filter { (id, job) -> job.isLive(id, outstanding) }) + } + + private suspend fun releaseUnusedKeys( + jobs: Map, + live: Map, + ) { + if (jobs.values.none { it.wrappedPassphrase != null }) + runCatching { keyStoreManager.deleteKey(KeyId.BackupPassphraseKey) } + + // A failed clear must leave the alias in place - otherwise the escrowed ciphertext + // outlives the only key that can open it. + if (live.isEmpty() && runCatching { arkKeyStore.clear() }.isSuccess) + runCatching { keyStoreManager.deleteKey(KeyId.BackupArkKey) } + } + + /** Null when the scheduler cannot be read - never an empty set, which would read as "nothing + * is scheduled" and tear down credentials that are still in use. */ + private suspend fun outstandingWorkIds(): Set? = + runCatching { scheduler.outstandingWorkIds() }.getOrNull() + + // A recurring schedule stamps finishedAt after every run, so only its absence - or cancellation + // - ends it. A one-time job is live until it finishes. Either way a record counts as live only + // while the scheduler still has work behind it: once it does not, no future run can read these + // credentials. A null `outstanding` means the scheduler could not be read, so the record's own + // bookkeeping is trusted and nothing is released on its account. + private fun BackupJob.isLive(workId: WorkId, outstanding: Set?): Boolean = + (outstanding == null || workId in outstanding) && + !cancelled && + (workId == BackupWorker.RECURRING_WORK_ID || finishedAt == null) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt new file mode 100644 index 000000000..35c10f43d --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt @@ -0,0 +1,140 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.crypto.suspendDoFinal +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.ResultBinding +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.BackupArkUnlocker +import de.davis.keygo.feature.backup.domain.BackupCollector +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.mapper.toRust +import de.davis.keygo.feature.backup.domain.model.BACKUP_BASE_NAME +import de.davis.keygo.feature.backup.domain.model.BackupEntry +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.backupFileName +import de.davis.keygo.rust.backup.exportWithResult +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface +import de.davisalessandro.keygo.rust.JsonBackupManagerInterface +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import org.koin.core.annotation.Single + +@Single +internal class ExportBackupUseCase( + private val collector: BackupCollector, + private val fileStore: BackupFileStore, + private val jsonBackupManager: JsonBackupManagerInterface, + private val csvBackupManager: CsvBackupManagerInterface, + private val keyStoreManager: KeyStoreManager, + private val arkUnlocker: BackupArkUnlocker, +) { + + operator fun invoke(job: BackupJob): Flow = channelFlow { + resultBinding { + val collected = collector.collect { p, t -> send(ExportProgress.Running(p, t)) }.bind() + + send(ExportProgress.Writing) + + val serialized = serialize(job, collected.backup).bind() + + val fileName = job.format.backupFileName(System.currentTimeMillis()) + + fileStore.writeNewDocument(job.uri, fileName, job.format.mimeType, serialized) + .bind { ExportError.WriteFailed } + + collected.itemCount + }.onSuccess { count -> + prune(job) + send(ExportProgress.Succeeded(count)) + }.onFailure { failure -> + send(ExportProgress.Failed(failure)) + } + } + + // Best-effort: keep the newest [BackupJob.keepCount] documents of this format in the folder. + // Failures here never fail the backup itself - the new file is already written. + private suspend fun prune(job: BackupJob) { + val keep = job.keepCount ?: return + val existing = fileStore.listBackups(job.uri, BACKUP_BASE_NAME).getOrNull() ?: return + existing + // Only documents this app wrote are prune candidates. Anything else that happens to + // share the base name - a user's own renamed copy, or a SAF collision rename like + // "keygo-backup-1700000000000 (1).json" - has no parsable timestamp, and must be left + // alone rather than sorted to the end and deleted as if it were the oldest backup. + .mapNotNull { entry -> entry.timestamp(job.format)?.let { entry to it } } + .sortedByDescending { (_, timestamp) -> timestamp } + .drop(keep) + .forEach { (entry, _) -> fileStore.delete(entry.uri) } + } + + /** The embedded epoch-millis stamp, or null if this is not a document this app wrote. */ + private fun BackupEntry.timestamp(format: FileFormat): Long? = + name.takeIf { it.endsWith(".${format.extension}") } + ?.removePrefix("$BACKUP_BASE_NAME-") + ?.removeSuffix(".${format.extension}") + ?.toLongOrNull() + + private suspend fun serialize(job: BackupJob, backup: Backup): Result = + resultBinding { + when (job.format) { + FileFormat.JSON -> when (job.encryption) { + EncryptionMethod.Ark -> arkUnlocker.withArk { ark -> + jsonBackupManager.exportWithResult(backup, BackupCredential.Ark(ark)) + .bindToSerializationFailed() + }.bind() + + // null on a persisted pre-field job means passphrase (see mapper). + EncryptionMethod.Passphrase, null -> { + val passphrase = decryptPassphrase(job).bind() + try { + jsonBackupManager + .exportWithResult(backup, BackupCredential.Passphrase(passphrase)) + .bindToSerializationFailed() + } finally { + passphrase.fill(0) + } + } + } + + FileFormat.CSV -> csvBackupManager.exportWithResult( + backup, + (job.csvPreset ?: CsvPreset.Browser).toRust(), + ).bindToSerializationFailed() + } + } + + context(binder: ResultBinding) + private fun Result.bindToSerializationFailed(): String = + with(binder) { bind { ExportError.SerializationFailed(it) } } + + private suspend fun decryptPassphrase(job: BackupJob): Result = + resultBinding { + val wrapped = job.wrappedPassphrase + ?: return Result.Failure(ExportError.CryptoFailed) + + val cipher = runCatching { + keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupPassphraseKey, + cryptographicMode = CryptographicMode.Decrypt, + iv = wrapped.iv, + ) + }.getOrNull().asResult(ExportError.DeviceLocked).bind() + + cipher.suspendDoFinal(wrapped.data).bind { ExportError.CryptoFailed } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt new file mode 100644 index 000000000..ed5e72cbb --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt @@ -0,0 +1,123 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.domain.KeyStoreManager +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.suspendDoFinal +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.PersistableUriManager +import de.davis.keygo.feature.backup.domain.arkOrNull +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.ExportDetails +import de.davis.keygo.feature.backup.domain.model.FinishExportWizardError +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single + +@Single +class FinishExportWizardUseCase( + private val backupScheduler: BackupScheduler, + private val destinationResolver: BackupDestinationResolver, + private val keyStoreManager: KeyStoreManager, + private val persistableUriManager: PersistableUriManager, + private val session: Session, + private val arkKeyStore: BackupArkKeyStore, + private val provisioningLock: BackupProvisioningLock, +) { + + suspend operator fun invoke(details: ExportDetails): Result = + resultBinding { + // Hold the lock across the whole provision-and-schedule section: a concurrent cleanup + // must never observe a half-provisioned job (escrow written, record not yet) and + // destroy the credentials this job needs. + provisioningLock.mutex.withLock { + // encryption == null on an encrypted format fails closed to the passphrase path. + val passphraseRequired = + details.format.encrypted && details.encryption != EncryptionMethod.Ark + if (passphraseRequired && details.passphrase.isBlank()) + return Result.Failure(FinishExportWizardError.PassphraseEmpty) + + val wrappedPassphrase = if (passphraseRequired) + wrapPassphrase(details.passphrase).bind() + else null + + // The worker may run long after the wizard closes (and across reboots), so hold on + // to folder access for both one-time and recurring backups. + runCatching { persistableUriManager.takePersistableUriPermission(details.uri) } + .getOrNull() + .asResult(FinishExportWizardError.DestinationPermissionDenied) + .bind() + + // Read the name here, under the grant just taken - this is the last moment it is + // guaranteed readable. The record outlives the grant, and the hub still has to say + // where the backup went after the grant is handed back. The wizard picks with + // OpenDocumentTree, so this is always a folder's name. + val destination = destinationResolver.resolve(details.uri) + + val job = BackupJob( + uri = details.uri, + wrappedPassphrase = wrappedPassphrase, + format = details.format, + encryption = details.encryption, + csvPreset = details.csvPreset, + keepCount = details.keepCount, + destinationName = destination.displayPath, + ) + + provisionBackupArk().bind() + + when (val interval = details.interval) { + null -> backupScheduler.scheduleOneTimeBackup(job) + else -> backupScheduler.scheduleRecurringBackup(job, interval) + } + // No record was written to drive this grant's release, and the escrow/aliases + // only self-heal via a later cleanup - but the persistable URI grant would leak + // against the platform cap. Best-effort release it while unwinding the failure. + .onFailure { + runCatching { + persistableUriManager.releasePersistableUriPermission(details.uri) + } + } + .bind { FinishExportWizardError.SchedulePersistenceFailed } + + return Result.Success(Unit) + } + } + + private suspend fun wrapPassphrase( + passphrase: String, + ): Result = resultBinding { + val cipher = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupPassphraseKey, + cryptographicMode = CryptographicMode.Encrypt, + ) + + CryptographicData( + data = cipher.suspendDoFinal(passphrase.encodeToByteArray()) + .bind { FinishExportWizardError.CryptoFailed }, + iv = cipher.iv, + ) + } + + private suspend fun provisionBackupArk() = resultBinding { + val ark = session.arkOrNull() + .asResult(FinishExportWizardError.CryptoFailed).bind() + + val cipher = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupArkKey, + cryptographicMode = CryptographicMode.Encrypt, + ) + + val data = cipher.suspendDoFinal(ark).bind { FinishExportWizardError.CryptoFailed } + arkKeyStore.save(CryptographicData(data, cipher.iv)) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt new file mode 100644 index 000000000..9f02a7d4a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt @@ -0,0 +1,104 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.fold +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.BackupRestorer +import de.davis.keygo.feature.backup.domain.arkOrNull +import de.davis.keygo.feature.backup.domain.mapper.toImportError +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportRequest +import de.davis.keygo.rust.backup.analyzeWithResult +import de.davis.keygo.rust.backup.importWithResult +import de.davis.keygo.rust.backup.inspectWithResult +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface +import de.davisalessandro.keygo.rust.JsonBackupManagerInterface +import de.davisalessandro.keygo.rust.JsonEncryption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import org.koin.core.annotation.Single + +@Single +internal class ImportBackupUseCase( + private val fileStore: BackupFileStore, + private val jsonBackupManager: JsonBackupManagerInterface, + private val csvBackupManager: CsvBackupManagerInterface, + private val restorer: BackupRestorer, + private val session: Session, +) { + + /** + * [channelFlow], not [kotlinx.coroutines.flow.flow]: [BackupRestorer] reports progress from + * inside a Room transaction, which runs the block in its own coroutine. `flow` forbids emitting + * across a coroutine boundary, so the progress ticks must go through a channel. + */ + operator fun invoke(request: ImportRequest): Flow = channelFlow { + val outcome = resultBinding { + if (session.arkOrNull() == null) + Result.Failure(ImportError.SessionLocked).bind() + + send(ImportProgress.Reading) + val text = fileStore.read(request.uri).bind { ImportError.FileUnreadable } + if (text.isBlank()) + Result.Failure(ImportError.EmptyFile).bind() + + send(ImportProgress.Parsing) + val backup = parse(request, text).bind() + + restorer.restore(backup, request.target) { p, t -> + send(ImportProgress.Running(p, t)) + }.bind() + } + + send( + outcome.fold( + onSuccess = { ImportProgress.Succeeded(it) }, + onFailure = { ImportProgress.Failed(it) }, + ), + ) + } + + private suspend fun parse(request: ImportRequest, text: String): Result = + resultBinding { + when (request.format) { + FileFormat.JSON -> { + val credential = when ( + jsonBackupManager.inspectWithResult(text).bind { it.toImportError() } + ) { + JsonEncryption.PASSPHRASE -> request.passphrase + ?.takeIf(String::isNotBlank) + ?.let { BackupCredential.Passphrase(it.encodeToByteArray()) } + ?: return Result.Failure(ImportError.PassphraseRequired) + + JsonEncryption.ARK -> BackupCredential.Ark( + session.arkOrNull() + ?: return Result.Failure(ImportError.SessionLocked), + ) + } + // Zero the derived passphrase bytes once Rust is done with them, mirroring the + // export path. The live ARK belongs to the session and is left alone. + try { + jsonBackupManager.importWithResult(text, credential) + .bind { it.toImportError() } + } finally { + (credential as? BackupCredential.Passphrase)?.bytes?.fill(0) + } + } + + FileFormat.CSV -> { + val mapping = request.csvMapping ?: csvBackupManager.analyzeWithResult(text) + .bind { it.toImportError() }.suggested + + csvBackupManager.importWithResult(text, mapping) + .bind { it.toImportError() } + .backup + } + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt new file mode 100644 index 000000000..a823717d0 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt @@ -0,0 +1,52 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.domain.repository.DispatchedBackupRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class ObserveDispatchedBackupsUseCase( + private val repository: DispatchedBackupRepository, + private val jobRepository: BackupJobRepository, + private val destinationResolver: BackupDestinationResolver, +) { + + operator fun invoke(): Flow> = + repository.observe().map { statuses -> + // One read of the job store per emission, not one per row. + val jobs = jobRepository.getJobs() + statuses.map { it.enrich(jobs) } + } + + private suspend fun BackupWorkStatus.enrich(jobs: Map): DispatchedBackup { + val workId: WorkId = when (kind) { + DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID + DispatchedBackup.Kind.OneTime -> id + } + val job = jobs[workId] + return DispatchedBackup( + id = id, + kind = kind, + state = state, + format = job?.format, + destination = job?.let { destinationResolver.resolve(it.uri, it.destinationName) }, + timestamp = job?.let { it.finishedAt ?: it.createdAt } ?: 0L, + // Read from the persisted result, not the live state (a failed recurring run is ENQUEUED + // again by the time it is observed, so its record is the only witness) - but a cancelled + // schedule keeps its last failure on the record, and a red reason under a "Cancelled" + // pill reads as a contradiction, so suppress it there. + failureReason = (job?.lastResult as? BackupResult.Failure) + ?.takeIf { state != DispatchedBackup.State.Cancelled } + ?.reason, + ) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCase.kt new file mode 100644 index 000000000..9c042a898 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCase.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.LastBackup +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class ObserveLastBackupUseCase( + private val jobRepository: BackupJobRepository, +) { + + operator fun invoke(): Flow = + jobRepository.observeJobs().map { jobs -> + jobs.filter { it.lastResult == BackupResult.Success && it.finishedAt != null } + .maxByOrNull { it.finishedAt!! } + ?.let { LastBackup(it.finishedAt!!) } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt new file mode 100644 index 000000000..695ec6e4c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt @@ -0,0 +1,48 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.failureReason +import de.davis.keygo.feature.backup.domain.model.retryable +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import org.koin.core.annotation.Single + +@Single +internal class RecordBackupOutcomeUseCase( + private val jobRepository: BackupJobRepository, + private val cleanupBackupResources: CleanupBackupResourcesUseCase, +) { + + /** + * [canRetry] is false on the job's last attempt: a retryable error then becomes terminal, so the + * record closes and the cleanup below can hand back the job's credentials. + */ + suspend operator fun invoke( + workId: WorkId, + terminal: ExportProgress?, + canRetry: Boolean = true, + ) { + val result = when (terminal) { + is ExportProgress.Succeeded -> BackupResult.Success + is ExportProgress.Failed -> when { + terminal.error.retryable && canRetry -> return + // failureReason is null exactly for the retryable errors, which reach here only + // once their attempts are spent. + else -> BackupResult.Failure( + terminal.error.failureReason ?: BackupFailureReason.RetriesExhausted, + ) + } + + else -> return + } + jobRepository.markFinished(workId, result) + + // A recurring schedule still needs its credentials for the next run - but a run finishing + // is a good moment to notice that some *other* job no longer needs its own. + if (workId == BackupWorker.RECURRING_WORK_ID) cleanupBackupResources.reconcile() + else cleanupBackupResources(workId) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt new file mode 100644 index 000000000..eb3571e04 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.backup.presentation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import de.davis.keygo.feature.backup.presentation.export.ExportWizardScreen +import de.davis.keygo.feature.backup.presentation.hub.BackupHubScreen +import de.davis.keygo.feature.backup.presentation.import.ImportWizardScreen + +fun NavGraphBuilder.backupGraph( + navigateToDestination: (Any) -> Unit, + navigateUp: () -> Unit, +) { + composable { + BackupHubScreen( + navigateToExport = { + navigateToDestination(BackupExportRoute) + }, + navigateToImport = { + navigateToDestination(BackupImportRoute) + }, + ) + } + + composable { + ExportWizardScreen(navigateUp = navigateUp) + } + + composable { + ImportWizardScreen(navigateUp = navigateUp) + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt new file mode 100644 index 000000000..2c8cdc3bc --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.backup.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit + +internal val BackupInterval.displayName + @Composable + get() = when (unit) { + IntervalUnit.Days -> pluralStringResource( + R.plurals.backup_interval_days, + count = count, + count + ) + + IntervalUnit.Weeks -> pluralStringResource( + R.plurals.backup_interval_weeks, + count = count, + count + ) + } + +internal val IntervalUnit.label + @Composable + get() = when (this) { + IntervalUnit.Days -> stringResource(R.string.interval_unit_days) + IntervalUnit.Weeks -> stringResource(R.string.interval_unit_weeks) + } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt new file mode 100644 index 000000000..603b8699a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt @@ -0,0 +1,100 @@ +package de.davis.keygo.feature.backup.presentation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.Title +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal val FileFormat.displayName + @Composable + get() = stringResource( + R.string.file_type_backup, + name + ) + +internal val FileFormat.icon + get() = when (this) { + FileFormat.CSV -> Icons.AutoMirrored.Default.List + FileFormat.JSON -> Icons.Default.Lock + } + +internal val EncryptionMethod.displayName + @Composable + get() = stringResource( + when (this) { + EncryptionMethod.Passphrase -> R.string.encryption_method_passphrase + EncryptionMethod.Ark -> R.string.encryption_method_ark + } + ) + +internal val EncryptionMethod.description + @Composable + get() = stringResource( + when (this) { + EncryptionMethod.Passphrase -> R.string.encryption_method_passphrase_description + EncryptionMethod.Ark -> R.string.encryption_method_ark_description + } + ) + +internal val EncryptionMethod.icon + get() = when (this) { + EncryptionMethod.Passphrase -> Icons.Default.Password + EncryptionMethod.Ark -> Icons.Default.PhoneAndroid + } + +internal val CsvPreset.displayName + @Composable + get() = stringResource( + when (this) { + CsvPreset.Browser -> R.string.csv_preset_browser + CsvPreset.KeyGo -> R.string.csv_preset_keygo + } + ) + +internal val CsvPreset.description + @Composable + get() = stringResource( + when (this) { + CsvPreset.Browser -> R.string.csv_preset_browser_description + CsvPreset.KeyGo -> R.string.csv_preset_keygo_description + } + ) + +internal val CsvColumnType?.displayName + @Composable + get() = stringResource( + when (this) { + CsvColumnType.Title -> R.string.csv_type_title + CsvColumnType.Url -> R.string.csv_type_url + CsvColumnType.Username -> R.string.csv_type_username + CsvColumnType.Password -> R.string.csv_type_password + CsvColumnType.Notes -> R.string.csv_type_notes + CsvColumnType.Totp -> R.string.csv_type_totp + null -> R.string.csv_type_ignore + } + ) + +internal val CsvColumnType?.icon + get() = when (this) { + CsvColumnType.Title -> Icons.Default.Title + CsvColumnType.Url -> Icons.Default.Link + CsvColumnType.Username -> Icons.Default.Person + CsvColumnType.Password -> Icons.Default.Password + CsvColumnType.Notes -> Icons.AutoMirrored.Filled.Notes + CsvColumnType.Totp -> Icons.Default.Timer + null -> Icons.Default.Block + } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt new file mode 100644 index 000000000..c09b00c0e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.presentation + +import kotlinx.serialization.Serializable + +@Serializable +object BackupHubRoute + +@Serializable +object BackupExportRoute + +@Serializable +object BackupImportRoute \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt new file mode 100644 index 000000000..f0a3142c1 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt @@ -0,0 +1,179 @@ +package de.davis.keygo.feature.backup.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination + +@Composable +internal fun BackupFileChooser( + destination: BackupDestination?, + onChoose: () -> Unit, + chooserIcon: ImageVector, + chooserTitle: String, + chooserSubtitle: String, + chooserAction: String, + changeLabel: String, + fileNameLabel: String, + modifier: Modifier = Modifier, +) { + when (destination) { + null -> ChooserCard( + icon = chooserIcon, + title = chooserTitle, + subtitle = chooserSubtitle, + action = chooserAction, + onChoose = onChoose, + modifier = modifier, + ) + + else -> SelectedCard( + destination = destination, + changeLabel = changeLabel, + fileNameLabel = fileNameLabel, + onChange = onChoose, + modifier = modifier, + ) + } +} + +@Composable +private fun ChooserCard( + icon: ImageVector, + title: String, + subtitle: String, + action: String, + onChoose: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onChoose, + modifier = modifier + .fillMaxWidth() + .dashedBorder( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CardDefaults.shape, + ), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + Text(text = action, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun SelectedCard( + destination: BackupDestination, + changeLabel: String, + fileNameLabel: String, + onChange: () -> Unit, + modifier: Modifier = Modifier, +) { + SegmentedListItem( + onClick = onChange, + shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large), + modifier = modifier.fillMaxWidth(), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon( + imageVector = if (destination.fileName != null) Icons.Default.Description + else Icons.Default.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + overlineContent = { Text(text = destination.provider.label) }, + supportingContent = { + Text( + text = fileNameLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + // Not a button: the whole row already invokes onChange, and a nested control would give + // one action two focus stops and two ripples. This is the affordance's label. + trailingContent = { + Text( + text = changeLabel, + color = MaterialTheme.colorScheme.primary, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = destination.displayPath, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun Modifier.dashedBorder( + width: Dp, + color: Color, + shape: Shape, + on: Dp = 6.dp, + off: Dp = 4.dp, +) = drawWithContent { + drawContent() + drawOutline( + outline = shape.createOutline(size, layoutDirection, this), + color = color, + style = Stroke( + width = width.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(on.toPx(), off.toPx())), + ), + ) +} + +private val BackupDestination.Provider.label + @Composable + get() = when (this) { + BackupDestination.Provider.Unknown -> stringResource(R.string.destination_provider_unknown) + BackupDestination.Provider.OnDevice -> stringResource(R.string.destination_provider_on_device) + is BackupDestination.Provider.ThirdParty -> name + } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupListDefaults.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupListDefaults.kt new file mode 100644 index 000000000..82299a8ee --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupListDefaults.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.presentation.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +internal val segmentContainerColor: Color + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.surfaceContainerHigh diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupWarningCard.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupWarningCard.kt new file mode 100644 index 000000000..6c9f71921 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupWarningCard.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.backup.presentation.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * Reserved for permanent outcomes. A recoverable mistake gets ordinary supporting text, since a + * warning the user learns to scroll past protects nothing. + */ +@Composable +internal fun BackupWarningCard(text: String, modifier: Modifier = Modifier) { + ListItem( + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + leadingContentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + leadingContent = { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + ) + }, + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = text) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt new file mode 100644 index 000000000..45e5588a9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.backup.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +internal fun IconBadge( + icon: ImageVector, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + iconSize: Dp = 20.dp, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(containerColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(iconSize), + ) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt new file mode 100644 index 000000000..c087b6648 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt @@ -0,0 +1,174 @@ +package de.davis.keygo.feature.backup.presentation.component + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Reusable, step-based wizard chrome shared by the export and import backup flows. + * + * Owns the generic behaviour: an animated top progress indicator, a [TopAppBar] whose navigation + * icon walks back through [steps] (or calls [navigateUp] on the first step), predictive-back that + * seeks the step transition, and an optional bottom continue [Button]. Callers supply only the + * per-step [title] and body [content] plus the label/icon rendered inside the continue button. + * + * @param currentStep must be an element of [steps]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun Wizard( + steps: List, + currentStep: T, + title: String, + onBack: () -> Unit, + onContinue: () -> Unit, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + showContinueButton: Boolean = true, + canContinue: Boolean = true, + continueButtonContent: @Composable RowScope.() -> Unit, + content: @Composable (T) -> Unit, +) { + val currentIndex = steps.indexOf(currentStep).coerceAtLeast(0) + + val transitionState = remember { SeekableTransitionState(currentStep) } + val transition = rememberTransition(transitionState, label = "wizard_step") + val resetScope = rememberCoroutineScope() + + LaunchedEffect(currentStep) { + if (transitionState.currentState != currentStep) + transitionState.animateTo(currentStep) + } + + PredictiveBackHandler(enabled = currentIndex > 0) { progress -> + val previousStep = steps[currentIndex - 1] + try { + progress.collect { backEvent -> + transitionState.seekTo(backEvent.progress, targetState = previousStep) + } + onBack() + } catch (_: CancellationException) { + resetScope.launch { transitionState.animateTo(transitionState.currentState) } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = title) + }, + navigationIcon = { + IconButton( + onClick = { + if (currentIndex == 0) navigateUp() + else onBack() + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + bottomBar = { + AnimatedVisibility(visible = showContinueButton) { + Box( + modifier = Modifier + .padding(vertical = 12.dp, horizontal = 4.dp) + .imePadding(), + ) { + Button( + onClick = onContinue, + enabled = canContinue, + modifier = Modifier.fillMaxWidth(), + content = continueButtonContent, + ) + } + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(start = 4.dp, end = 4.dp) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(steps.size) { page -> + val color by animateColorAsState( + targetValue = if (page <= currentIndex) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primaryContainer, + label = "indicator_page", + ) + Box( + modifier = Modifier + .height(8.dp) + .weight(1f) + .clip(CircleShape) + .background(color), + ) + } + } + transition.AnimatedContent( + modifier = Modifier.weight(1f), + transitionSpec = { + fadeIn(animationSpec = tween(700)) togetherWith + fadeOut(animationSpec = tween(700)) + }, + ) { step -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + content(step) + } + } + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt new file mode 100644 index 000000000..558785e62 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt @@ -0,0 +1,162 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.component.Wizard +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardStep +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiState +import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.export.model.SelectDestinationState +import de.davis.keygo.feature.backup.presentation.export.model.SelectFormatState +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState + +@Composable +internal fun ExportWizardContent( + state: ExportWizardUiState, + onEvent: (ExportWizardUiEvent) -> Unit, + navigateUp: () -> Unit, +) { + Wizard( + steps = state.steps, + currentStep = state.step, + title = state.step.title, + onBack = { onEvent(ExportWizardUiEvent.Back) }, + onContinue = { onEvent(ExportWizardUiEvent.Continue) }, + navigateUp = navigateUp, + showContinueButton = state.showsContinueButton, + canContinue = state.canContinue, + continueButtonContent = { + val recurring = state.scheduleState.mode == ScheduleMode.Recurring + val isReview = state.step == ExportWizardStep.Review + AnimatedVisibility( + visible = isReview + ) { + Icon( + imageVector = if (recurring) Icons.Default.Schedule else Icons.Default.Backup, + contentDescription = null, + modifier = Modifier + .padding(end = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize), + ) + } + Text( + text = stringResource( + when { + isReview && recurring -> R.string.schedule_backup + isReview -> R.string.create_backup + else -> R.string.continue_step + } + ), + ) + }, + ) { step -> + when (step) { + ExportWizardStep.SelectFormat -> SelectFileFormatContent(onEvent = onEvent) + ExportWizardStep.Schedule -> SelectScheduleContent( + state = state.scheduleState, + onEvent = onEvent, + ) + + ExportWizardStep.SelectDestination -> SelectDestinationContent( + state = state.destinationState, + scheduleState = state.scheduleState, + format = state.formatState.format, + onEvent = onEvent, + ) + + ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( + state = state.providePassphraseState, + onEvent = onEvent, + ) + + ExportWizardStep.SelectCsvPreset -> SelectCsvPresetContent( + preset = state.formatState.csvPreset, + onEvent = onEvent, + ) + + ExportWizardStep.Review -> state.formatState.format?.let { format -> + ReviewBackupContent( + format = format, + scheduleState = state.scheduleState, + destinationState = state.destinationState, + passphraseState = state.providePassphraseState, + csvPreset = state.formatState.csvPreset, + ) + } + } + } +} + +private val ExportWizardStep.title + @Composable + get() = when (this) { + ExportWizardStep.SelectFormat -> stringResource(R.string.select_file_format_title) + ExportWizardStep.Schedule -> stringResource(R.string.select_schedule_title) + ExportWizardStep.SelectDestination -> stringResource(R.string.select_destination_title) + ExportWizardStep.ProvidePassphrase -> stringResource(R.string.provide_passphrase_title) + ExportWizardStep.SelectCsvPreset -> stringResource(R.string.select_csv_preset_title) + ExportWizardStep.Review -> stringResource(R.string.review_backup_title) + } + +private class ExportWizardUiStateProvider : PreviewParameterProvider { + + override val values = ExportWizardStep.entries.asSequence().map { + ExportWizardUiState( + formatState = SelectFormatState( + format = if (it == ExportWizardStep.SelectCsvPreset) FileFormat.CSV else FileFormat.JSON, + ), + scheduleState = SelectScheduleState(), + destinationState = SelectDestinationState( + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), + displayPath = "Backups / KeyGo", + ), + ), + providePassphraseState = ProvidePassphraseState( + passphraseTextFieldState = TextFieldState(), + confirmPassphraseTextFieldState = TextFieldState(), + passphraseScore = PasswordScore.Strong, + ), + step = it, + ) + } +} + +@Preview +@Composable +private fun ExportWizardContentPreview(@PreviewParameter(ExportWizardUiStateProvider::class) state: ExportWizardUiState) { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + ExportWizardContent( + state = state, + onEvent = {}, + navigateUp = {}, + ) + } + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardScreen.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardScreen.kt new file mode 100644 index 000000000..e1b86ab3a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardScreen.kt @@ -0,0 +1,36 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ExportWizardScreen(navigateUp: () -> Unit) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + val folderPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) + } + + ObserveAsEvents(flow = viewModel.event) { + when (it) { + ExportWizardEvent.Finished -> navigateUp() + ExportWizardEvent.PickFolder -> folderPicker.launch(null) + } + } + + ExportWizardContent( + state = state, + onEvent = viewModel::onEvent, + navigateUp = navigateUp, + ) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt new file mode 100644 index 000000000..a78f57c4b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt @@ -0,0 +1,248 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.domain.snackbar.SnackbarManager +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.ExportDetails +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.FinishExportWizardError +import de.davis.keygo.feature.backup.domain.usecase.FinishExportWizardUseCase +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardStep +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiState +import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.export.model.SelectDestinationState +import de.davis.keygo.feature.backup.presentation.export.model.SelectFormatState +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState +import de.davis.keygo.feature.backup.presentation.export.model.exportStepsFor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@KoinViewModel +internal class ExportWizardViewModel( + private val backupDestinationResolver: BackupDestinationResolver, + private val passwordStrengthEstimator: PasswordStrengthEstimator, + private val finishExportWizard: FinishExportWizardUseCase, + private val snackbarManager: SnackbarManager, +) : ViewModel() { + + private val passphraseTextFieldState = TextFieldState() + private val confirmPassphraseTextFieldState = TextFieldState() + + private val _formatState = MutableStateFlow(SelectFormatState()) + private val _scheduleState = MutableStateFlow(SelectScheduleState()) + private val _destinationState = MutableStateFlow(SelectDestinationState()) + private val _providePassphraseState = MutableStateFlow( + ProvidePassphraseState( + passphraseTextFieldState = passphraseTextFieldState, + confirmPassphraseTextFieldState = confirmPassphraseTextFieldState, + ) + ) + + private val _step = MutableStateFlow(ExportWizardStep.SelectFormat) + + val state = combine( + _formatState, + _scheduleState, + _destinationState, + _providePassphraseState, + _step + ) { formatState, scheduleState, destinationState, providePassphraseState, step -> + ExportWizardUiState( + formatState = formatState, + scheduleState = scheduleState, + destinationState = destinationState, + providePassphraseState = providePassphraseState, + step = step, + ) + } + .onStart { + observePassphrase() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ExportWizardUiState( + formatState = _formatState.value, + scheduleState = _scheduleState.value, + destinationState = _destinationState.value, + providePassphraseState = _providePassphraseState.value, + step = _step.value, + ) + ) + + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun observePassphrase() { + snapshotFlow { + val passphrase = passphraseTextFieldState.text + val valid = + passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text) + passphrase to valid + } + .debounce(150.milliseconds) + .distinctUntilChanged() + .mapLatest { (pwd, valid) -> passwordStrengthEstimator(pwd.toString()) to valid } + .onEach { (score, valid) -> + _providePassphraseState.update { + it.copy( + passphraseScore = score, + valid = valid + ) + } + } + .flowOn(Dispatchers.Default) + .launchIn(viewModelScope) + } + + fun onEvent(event: ExportWizardUiEvent) { + when (event) { + ExportWizardUiEvent.Back -> previousStep() + + ExportWizardUiEvent.Continue -> + if (_step.value == ExportWizardStep.Review) finishExport() + else nextStep() + + ExportWizardUiEvent.ChooseDestination -> _event.trySend(ExportWizardEvent.PickFolder) + + is ExportWizardUiEvent.FileFormatSelected -> { + _formatState.update { + it.copy(format = event.format) + } + val recurringAllowed = event.format.encrypted + _scheduleState.update { + it.copy( + recurringAllowed = recurringAllowed, + mode = if (!recurringAllowed && it.mode == ScheduleMode.Recurring) ScheduleMode.OneTime + else it.mode, + ) + } + nextStep() + } + + is ExportWizardUiEvent.ScheduleModeSelected -> _scheduleState.update { + if (event.mode == ScheduleMode.Recurring && !it.recurringAllowed) it + else it.copy(mode = event.mode) + } + + is ExportWizardUiEvent.IntervalUnitSelected -> _scheduleState.update { + it.copy(interval = it.interval.copy(unit = event.unit)) + } + + is ExportWizardUiEvent.IntervalCountChanged -> _scheduleState.update { + it.copy(interval = it.interval.copy(count = event.count.coerceAtLeast(1))) + } + + is ExportWizardUiEvent.KeepCountChanged -> _scheduleState.update { + it.copy(keepCount = event.count.coerceAtLeast(1)) + } + + is ExportWizardUiEvent.KeepAllChanged -> _scheduleState.update { + it.copy(keepAll = event.keepAll) + } + + is ExportWizardUiEvent.EncryptionMethodSelected -> _providePassphraseState.update { + it.copy(method = event.method) + } + + is ExportWizardUiEvent.CsvPresetSelected -> _formatState.update { + it.copy(csvPreset = event.preset) + } + } + } + + fun onDestinationPicked(uri: BackupDestinationUri?) { + if (uri == null) return + + viewModelScope.launch { + val destination = backupDestinationResolver.resolve(uri) + + _destinationState.update { + it.copy(destination = destination, uri = uri) + } + } + } + + private fun finishExport() { + val details = currentExportDetails() ?: return + + viewModelScope.launch { + finishExportWizard(details).onSuccess { + _event.trySend(ExportWizardEvent.Finished) + }.onFailure { error -> + if (error == FinishExportWizardError.PassphraseEmpty) + _step.update { ExportWizardStep.ProvidePassphrase } + + val messageRes = when (error) { + FinishExportWizardError.PassphraseEmpty -> R.string.export_error_passphrase_empty + FinishExportWizardError.CryptoFailed -> R.string.export_error_crypto + FinishExportWizardError.SchedulePersistenceFailed -> R.string.export_error_schedule + FinishExportWizardError.DestinationPermissionDenied -> R.string.export_error_destination_permission + } + + snackbarManager.sendMessage( + SnackbarMessage(message = ResourceString(messageRes)), + ) + } + } + } + + private fun currentExportDetails(): ExportDetails? { + val format = _formatState.value.format ?: return null + val uri = _destinationState.value.uri ?: return null + val schedule = _scheduleState.value + val recurring = schedule.mode == ScheduleMode.Recurring + + return ExportDetails( + uri = uri, + format = format, + interval = if (recurring) schedule.interval else null, + keepCount = if (recurring && !schedule.keepAll) schedule.keepCount else null, + passphrase = passphraseTextFieldState.text.toString(), + encryption = if (format.encrypted) _providePassphraseState.value.method else null, + csvPreset = if (format == FileFormat.CSV) _formatState.value.csvPreset else null, + ) + } + + private fun nextStep() = _step.update { current -> + val steps = exportStepsFor(_formatState.value.format) + steps[(steps.indexOf(current) + 1).coerceAtMost(steps.lastIndex)] + } + + private fun previousStep() = _step.update { current -> + val steps = exportStepsFor(_formatState.value.format) + steps[(steps.indexOf(current) - 1).coerceAtLeast(0)] + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ProvidePassphraseContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ProvidePassphraseContent.kt new file mode 100644 index 000000000..0fcd4a36f --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ProvidePassphraseContent.kt @@ -0,0 +1,159 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.presentation.component.BackupWarningCard +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.description +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState +import de.davis.keygo.feature.backup.presentation.icon + +@Composable +internal fun ProvidePassphraseContent( + state: ProvidePassphraseState, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + var passphraseHidden by rememberSaveable { mutableStateOf(true) } + var confirmPassphraseHidden by rememberSaveable { mutableStateOf(true) } + var forceCompact by rememberSaveable { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + EncryptionMethod.entries.forEachIndexed { index, method -> + SegmentedListItem( + onClick = { onEvent(ExportWizardUiEvent.EncryptionMethodSelected(method)) }, + shapes = ListItemDefaults.segmentedShapes(index, EncryptionMethod.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = if (state.method == method) MaterialTheme.colorScheme.secondaryContainer + else segmentContainerColor, + contentColor = contentColorFor( + if (state.method == method) MaterialTheme.colorScheme.secondaryContainer + else segmentContainerColor + ), + ), + supportingContent = { + Text(text = method.description) + }, + leadingContent = { + Icon( + imageVector = method.icon, + contentDescription = null, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = method.displayName) + } + } + + // The cost of this method is not visible in its own description, and it only lands once + // the method is actually chosen, so it warns here rather than in the option's subtitle. + AnimatedVisibility(visible = state.method == EncryptionMethod.Ark) { + BackupWarningCard( + text = stringResource(R.string.encryption_method_ark_warning), + modifier = Modifier.padding(top = 8.dp), + ) + } + + AnimatedVisibility(visible = state.method == EncryptionMethod.Passphrase) { + // The segments above now sit at SegmentedGap, so the form needs its own breathing + // room rather than inheriting the gap that separates one segment from the next. + Column( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.export_passphrase_instruction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PassphraseField( + state = state.passphraseTextFieldState, + label = stringResource(R.string.passphrase), + hidden = passphraseHidden, + onToggleHidden = { passphraseHidden = !passphraseHidden }, + modifier = Modifier.onFocusChanged { forceCompact = !it.hasFocus }, + ) + StrengthIndicator( + passwordScore = state.passphraseScore, + forceCompact = forceCompact, + ) + + PassphraseField( + state = state.confirmPassphraseTextFieldState, + label = stringResource(R.string.confirm_passphrase), + hidden = confirmPassphraseHidden, + onToggleHidden = { confirmPassphraseHidden = !confirmPassphraseHidden }, + ) + } + } + } +} + +@Composable +private fun PassphraseField( + state: TextFieldState, + label: String, + hidden: Boolean, + onToggleHidden: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedSecureTextField( + state = state, + modifier = modifier.fillMaxWidth(), + label = { + Text(text = label) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Password, + contentDescription = null, + ) + }, + textObfuscationMode = if (hidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton( + isHidden = hidden, + onClick = onToggleHidden, + ) + }, + ) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt new file mode 100644 index 000000000..aa5f3c95d --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt @@ -0,0 +1,291 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.presentation.component.BackupWarningCard +import de.davis.keygo.feature.backup.presentation.component.IconBadge +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.export.model.SelectDestinationState +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState +import de.davis.keygo.feature.backup.presentation.icon +import de.davis.keygo.feature.backup.presentation.displayName as intervalDisplayName + +@Composable +internal fun ReviewBackupContent( + format: FileFormat, + scheduleState: SelectScheduleState, + destinationState: SelectDestinationState, + passphraseState: ProvidePassphraseState, + csvPreset: CsvPreset, +) { + val encrypted = format.encrypted + val recurring = scheduleState.mode == ScheduleMode.Recurring + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ReviewHeroCard(format = format) + + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + // The segmented shapes need each row's position in the final group to round the + // group's outer corners, and which rows exist depends on the chosen format, + // schedule, and encryption. So collect the rows first, then draw them. + val rows = mutableListOf() + + rows += ReviewRowSpec( + icon = format.icon, + label = stringResource(R.string.review_section_format), + ) { Text(text = format.displayName) } + + rows += ReviewRowSpec( + icon = scheduleState.mode.icon, + label = stringResource(R.string.review_section_schedule), + ) { + Text( + text = if (recurring) scheduleState.interval.intervalDisplayName + else stringResource(R.string.review_schedule_one_time), + ) + } + + if (recurring) rows += ReviewRowSpec( + icon = Icons.Default.DeleteSweep, + label = stringResource(R.string.review_section_retention), + ) { + Text( + text = if (scheduleState.keepAll) stringResource(R.string.review_retention_all) + else pluralStringResource( + R.plurals.review_retention, + scheduleState.keepCount, + scheduleState.keepCount, + ), + ) + } + + rows += ReviewRowSpec( + icon = Icons.Default.Folder, + label = stringResource(R.string.review_section_destination), + ) { + destinationState.destination?.let { destination -> + Text(text = destination.displayPath) + } + } + + rows += ReviewRowSpec( + icon = Icons.Default.Inventory2, + label = stringResource(R.string.review_section_contents), + ) { + Text( + text = stringResource( + if (encrypted) R.string.review_contents_all + else R.string.review_contents_logins, + ), + ) + } + + // The one row whose icon carries a signal rather than decoration, so it takes the + // same colour as its value text instead of the neutral tint. + rows += ReviewRowSpec( + icon = if (encrypted) Icons.Default.Shield else Icons.Default.LockOpen, + label = stringResource(R.string.review_section_encryption), + iconTint = if (encrypted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.error, + ) { + Text( + text = stringResource( + if (encrypted) R.string.review_encryption_on + else R.string.review_encryption_off, + ), + color = if (encrypted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.error, + ) + } + + if (encrypted) { + if (passphraseState.method == EncryptionMethod.Passphrase) rows += ReviewRowSpec( + icon = Icons.Default.Password, + label = stringResource(R.string.review_section_passphrase), + ) { StrengthIndicator(passwordScore = passphraseState.passphraseScore) } + else rows += ReviewRowSpec( + icon = EncryptionMethod.Ark.icon, + label = stringResource(R.string.review_section_encryption), + ) { Text(text = EncryptionMethod.Ark.displayName) } + } + + if (format == FileFormat.CSV) rows += ReviewRowSpec( + icon = Icons.AutoMirrored.Default.List, + label = stringResource(R.string.review_section_csv_preset), + ) { Text(text = csvPreset.displayName) } + + val neutralTint = MaterialTheme.colorScheme.onSurfaceVariant + rows.forEachIndexed { position, row -> + ReviewRow( + index = position, + count = rows.size, + icon = row.icon, + label = row.label, + iconTint = row.iconTint ?: neutralTint, + value = row.value, + ) + } + } + + // Last stop before the export runs, so both irreversible outcomes get restated here: + // a readable file, or one bound to a key that a reinstall destroys. + if (!encrypted) BackupWarningCard( + text = stringResource(R.string.review_plaintext_warning), + ) + else if (passphraseState.method == EncryptionMethod.Ark) BackupWarningCard( + text = stringResource(R.string.encryption_method_ark_warning), + ) + } +} + +@Composable +private fun ReviewHeroCard(format: FileFormat) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconBadge( + icon = format.icon, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + size = 72.dp, + iconSize = 36.dp, + ) + + Text( + text = stringResource(R.string.review_ready_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = stringResource(R.string.review_ready_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + } + } +} + +/** A review row before it knows its position in the group. A null tint means the neutral default. */ +private class ReviewRowSpec( + val icon: ImageVector, + val label: String, + val iconTint: Color? = null, + val value: @Composable () -> Unit, +) + +@Composable +private fun ReviewRow( + index: Int, + count: Int, + icon: ImageVector, + label: String, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + value: @Composable () -> Unit, +) { + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon(imageVector = icon, contentDescription = null, tint = iconTint) + }, + overlineContent = { Text(text = label) }, + ) { + value() + } +} + +private class ReviewFormatProvider : PreviewParameterProvider { + override val values = FileFormat.entries.asSequence() +} + +@Preview +@Composable +private fun ReviewBackupContentPreview( + @PreviewParameter(ReviewFormatProvider::class) format: FileFormat, +) { + MaterialTheme { + Surface(modifier = Modifier.fillMaxWidth()) { + ReviewBackupContent( + format = format, + scheduleState = SelectScheduleState( + mode = ScheduleMode.Recurring, + interval = BackupInterval(count = 3, unit = IntervalUnit.Days), + ), + destinationState = SelectDestinationState( + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), + displayPath = "Backups / KeyGo", + ), + ), + passphraseState = ProvidePassphraseState( + passphraseTextFieldState = TextFieldState(), + confirmPassphraseTextFieldState = TextFieldState(), + passphraseScore = PasswordScore.Strong, + ), + csvPreset = CsvPreset.Browser, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ScheduleComponents.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ScheduleComponents.kt new file mode 100644 index 000000000..557a5a3c2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ScheduleComponents.kt @@ -0,0 +1,405 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AllInclusive +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.label +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun IntervalPicker( + interval: BackupInterval, + onEvent: (ExportWizardUiEvent) -> Unit, + shape: Shape, + modifier: Modifier = Modifier, +) { + val count = interval.count + val unit = interval.unit + ScheduleCard( + title = stringResource(R.string.schedule_repeat_every_label), + footerText = interval.displayName, + footerIcon = Icons.Default.Schedule, + shape = shape, + modifier = modifier, + ) { + NumberStepper( + value = count, + onValueChange = { onEvent(ExportWizardUiEvent.IntervalCountChanged(it)) }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + IntervalUnit.entries.forEachIndexed { index, entry -> + OutlinedToggleButton( + checked = unit == entry, + onCheckedChange = { onEvent(ExportWizardUiEvent.IntervalUnitSelected(entry)) }, + modifier = Modifier + .weight(1f) + .semantics { role = Role.RadioButton }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + else -> ButtonGroupDefaults.connectedTrailingButtonShapes() + }, + colors = ToggleButtonDefaults.outlinedToggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) { + Text(text = entry.label) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun RetentionPicker( + keepCount: Int, + keepAll: Boolean, + onEvent: (ExportWizardUiEvent) -> Unit, + shape: Shape, + modifier: Modifier = Modifier, +) { + val expanded = !keepAll + ScheduleCard( + title = stringResource(R.string.schedule_auto_delete_label), + footerText = if (keepAll) stringResource(R.string.schedule_keep_all_summary) + else pluralStringResource( + R.plurals.schedule_keep_summary, + keepCount, + keepCount, + ), + footerIcon = if (keepAll) Icons.Default.AllInclusive else Icons.Default.DeleteSweep, + shape = shape, + modifier = modifier, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + KeyGoSwitch( + checked = expanded, + onCheckedChange = { onEvent(ExportWizardUiEvent.KeepAllChanged(!it)) }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Text(text = stringResource(R.string.enable)) + } + + // The top gap lives inside the animated region so it shrinks with the stepper. + // Keeping AnimatedVisibility out of the parent's spacedBy avoids the snap that + // happens when its layout node is dropped at the end of the exit transition. + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + ) { + NumberStepper( + value = keepCount, + onValueChange = { onEvent(ExportWizardUiEvent.KeepCountChanged(it)) }, + supporting = { + Text( + text = pluralStringResource( + R.plurals.schedule_keep_unit, + keepCount + ), + ) + }, + ) + } + } + } + } +} + +@Composable +private fun ScheduleCard( + title: String, + footerText: String, + footerIcon: ImageVector, + shape: Shape, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = shape, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + + content() + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, + LocalTextStyle provides LocalTextStyle.current.merge(MaterialTheme.typography.bodyMedium) + ) { + Icon( + imageVector = footerIcon, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + + Text(text = footerText) + } + } + } + } +} + +@Composable +private fun NumberStepper( + value: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + supporting: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + StepperButton( + onStep = { onValueChange((value - 1).coerceAtLeast(STEPPER_MIN)) }, + imageVector = Icons.Default.Remove, + enabled = value > STEPPER_MIN, + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + StepperValueField( + value = value, + onValueChange = onValueChange, + ) + + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodySmall, + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, + ) { + supporting?.invoke() + } + } + + StepperButton( + onStep = { onValueChange(value + 1) }, + imageVector = Icons.Default.Add, + enabled = true, + ) + } +} + +@Composable +private fun StepperButton( + onStep: () -> Unit, + imageVector: ImageVector, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val currentStep by rememberUpdatedState(onStep) + LaunchedEffect(pressed, enabled) { + if (!pressed || !enabled) return@LaunchedEffect + delay(STEP_INITIAL_DELAY_MS) + var interval = STEP_REPEAT_START_MS + while (true) { + currentStep() + delay(interval) + interval = (interval * STEP_REPEAT_DECAY).coerceAtLeast(STEP_REPEAT_MIN_MS) + } + } + FilledTonalIconButton( + onClick = onStep, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StepperValueField( + value: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + var text by remember { mutableStateOf(value.toString()) } + var focused by remember { mutableStateOf(false) } + // Re-sync from the source of truth only while the user is not actively editing. + LaunchedEffect(value) { + if (!focused) text = value.toString() + } + + val commit = { + val committed = text.toIntOrNull()?.coerceAtLeast(STEPPER_MIN) + if (committed != null) onValueChange(committed) + text = (committed ?: value).toString() + } + + // Drop focus the moment the keyboard is dismissed (Done or system back) so the cursor + // disappears; onFocusChanged then commits, falling back to the known value if empty. + val imeVisible = WindowInsets.isImeVisible + LaunchedEffect(imeVisible) { + if (focused && !imeVisible) focusManager.clearFocus() + } + + BasicTextField( + value = text, + onValueChange = { new -> if (new.all(Char::isDigit)) text = new }, + modifier = modifier + .width(72.dp) + .onFocusChanged { focusState -> + if (focused && !focusState.isFocused) commit() + focused = focusState.isFocused + }, + textStyle = MaterialTheme.typography.headlineMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + ) +} + +private const val STEPPER_MIN = 1 + +private val STEP_INITIAL_DELAY_MS = 350L.milliseconds +private val STEP_REPEAT_START_MS = 280L.milliseconds +private val STEP_REPEAT_MIN_MS = 45L.milliseconds +private const val STEP_REPEAT_DECAY = 0.82 + +internal val ScheduleMode.icon: ImageVector + get() = when (this) { + ScheduleMode.OneTime -> Icons.Default.Bolt + ScheduleMode.Recurring -> Icons.Default.Autorenew + } + +internal val ScheduleMode.titleRes: Int + get() = when (this) { + ScheduleMode.OneTime -> R.string.schedule_one_time_title + ScheduleMode.Recurring -> R.string.schedule_recurring_title + } + +internal val ScheduleMode.descriptionRes: Int + get() = when (this) { + ScheduleMode.OneTime -> R.string.schedule_one_time_description + ScheduleMode.Recurring -> R.string.schedule_recurring_description + } + +internal val SegmentTopShape = RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp, +) + +internal val SegmentBottomShape = RoundedCornerShape( + topStart = 4.dp, + topEnd = 4.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectCsvPresetContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectCsvPresetContent.kt new file mode 100644 index 000000000..4a5e44060 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectCsvPresetContent.kt @@ -0,0 +1,55 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.description +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent + +@Composable +internal fun SelectCsvPresetContent( + preset: CsvPreset, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + CsvPreset.entries.forEachIndexed { index, candidate -> + SegmentedListItem( + onClick = { onEvent(ExportWizardUiEvent.CsvPresetSelected(candidate)) }, + shapes = ListItemDefaults.segmentedShapes(index, CsvPreset.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = if (preset == candidate) + MaterialTheme.colorScheme.secondaryContainer + else segmentContainerColor, + contentColor = contentColorFor( + if (preset == candidate) MaterialTheme.colorScheme.secondaryContainer + else segmentContainerColor + ), + ), + supportingContent = { + Text(text = candidate.description) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = candidate.displayName) + } + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt new file mode 100644 index 000000000..92950f625 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt @@ -0,0 +1,123 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.component.BackupFileChooser +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.export.model.SelectDestinationState +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState +import de.davis.keygo.feature.backup.presentation.export.model.backupFileName + +@Composable +internal fun SelectDestinationContent( + state: SelectDestinationState, + scheduleState: SelectScheduleState, + format: FileFormat?, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + BackupFileChooser( + destination = state.destination, + onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, + chooserIcon = Icons.Default.CreateNewFolder, + chooserTitle = stringResource(R.string.destination_choose_title), + chooserSubtitle = stringResource(R.string.destination_choose_subtitle), + chooserAction = stringResource(R.string.destination_choose_action), + changeLabel = stringResource(R.string.file_chooser_change), + fileNameLabel = stringResource( + R.string.destination_filename_label, + state.destination?.fileName ?: format.backupFileName() + ), + ) + + BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) + } +} + +@Composable +private fun BehaviorHint(mode: ScheduleMode, keepAll: Boolean) { + val recurring = mode == ScheduleMode.Recurring + val behaviorRes = when { + !recurring -> R.string.destination_behavior_one_time + keepAll -> R.string.destination_behavior_recurring + else -> R.string.destination_behavior_recurring_pruned + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = mode.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(behaviorRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private class SelectDestinationStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + SelectDestinationState(), + SelectDestinationState( + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), + displayPath = "Backups / KeyGo", + ), + ), + ) +} + +@Preview +@Composable +private fun SelectDestinationContentPreview( + @PreviewParameter(SelectDestinationStateProvider::class) state: SelectDestinationState, +) { + MaterialTheme { + Surface(modifier = Modifier.fillMaxWidth()) { + SelectDestinationContent( + state = state, + scheduleState = SelectScheduleState(mode = ScheduleMode.Recurring), + format = FileFormat.JSON, + onEvent = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectFileFormatContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectFileFormatContent.kt new file mode 100644 index 000000000..4ccf02241 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectFileFormatContent.kt @@ -0,0 +1,69 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.icon + +@Composable +internal fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + FileFormat.entries.forEachIndexed { index, type -> + SegmentedListItem( + onClick = { onEvent(ExportWizardUiEvent.FileFormatSelected(type)) }, + shapes = ListItemDefaults.segmentedShapes(index, FileFormat.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = segmentContainerColor, + contentColor = contentColorFor(segmentContainerColor), + ), + supportingContent = { + Text(text = type.description) + }, + leadingContent = { + Icon( + imageVector = type.icon, + contentDescription = null, + ) + }, + overlineContent = when { + type.recommended -> { + { Text(text = stringResource(R.string.recommended)) } + } + + else -> null + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = type.displayName) + } + } + } +} + +private val FileFormat.description + @Composable + get() = when (this) { + FileFormat.JSON -> stringResource(R.string.export_description_json) + FileFormat.CSV -> stringResource(R.string.export_description_csv) + } \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectScheduleContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectScheduleContent.kt new file mode 100644 index 000000000..ec9bf27f4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectScheduleContent.kt @@ -0,0 +1,129 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState + +@Composable +internal fun SelectScheduleContent( + state: SelectScheduleState, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + ScheduleMode.entries.forEachIndexed { index, mode -> + val selected = state.mode == mode + val recurringDisabled = mode == ScheduleMode.Recurring && !state.recurringAllowed + SegmentedListItem( + checked = selected, + enabled = !recurringDisabled, + onCheckedChange = { onEvent(ExportWizardUiEvent.ScheduleModeSelected(mode)) }, + shapes = ListItemDefaults.segmentedShapes(index, ScheduleMode.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = segmentContainerColor, + contentColor = contentColorFor(segmentContainerColor), + ), + leadingContent = { + Icon( + imageVector = mode.icon, + contentDescription = null, + ) + }, + supportingContent = { + Text( + text = stringResource( + if (recurringDisabled) R.string.schedule_recurring_requires_encryption + else mode.descriptionRes, + ), + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(mode.titleRes)) + } + + AnimatedVisibility( + visible = selected && mode == ScheduleMode.Recurring, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + IntervalPicker( + interval = state.interval, + onEvent = onEvent, + shape = SegmentTopShape + ) + RetentionPicker( + keepCount = state.keepCount, + keepAll = state.keepAll, + onEvent = onEvent, + shape = SegmentBottomShape + ) + } + } + } + } +} + +private class SelectScheduleStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + SelectScheduleState(mode = ScheduleMode.OneTime), + SelectScheduleState( + mode = ScheduleMode.Recurring, + interval = BackupInterval(count = 3, unit = IntervalUnit.Days), + ), + ) +} + +@Preview +@Composable +private fun SelectScheduleContentPreview( + @PreviewParameter(SelectScheduleStateProvider::class) state: SelectScheduleState, +) { + MaterialTheme { + Surface(modifier = Modifier.fillMaxWidth()) { + SelectScheduleContent( + state = state, + onEvent = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt new file mode 100644 index 000000000..d18ed0dbf --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.BACKUP_BASE_NAME +import de.davis.keygo.feature.backup.domain.model.FileFormat + +/** An illustrative document name shown on the destination card (actual files are timestamped). */ +internal fun FileFormat?.backupFileName(): String = + if (this == null) BACKUP_BASE_NAME else "$BACKUP_BASE_NAME.$extension" diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardEvent.kt new file mode 100644 index 000000000..0dc8b630a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardEvent.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +internal sealed interface ExportWizardEvent { + + data object Finished : ExportWizardEvent + data object PickFolder : ExportWizardEvent +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStep.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStep.kt new file mode 100644 index 000000000..b17c8c1d5 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStep.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal enum class ExportWizardStep { + SelectFormat, + Schedule, + SelectDestination, + ProvidePassphrase, + SelectCsvPreset, + Review, +} + +internal fun exportStepsFor(format: FileFormat?): List = + ExportWizardStep.entries.filter { step -> + when (step) { + ExportWizardStep.ProvidePassphrase -> format?.encrypted ?: true + ExportWizardStep.SelectCsvPreset -> format == FileFormat.CSV + else -> true + } + } \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt new file mode 100644 index 000000000..b31071267 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.IntervalUnit + +internal sealed interface ExportWizardUiEvent { + data object Back : ExportWizardUiEvent + data object Continue : ExportWizardUiEvent + data object ChooseDestination : ExportWizardUiEvent + data class FileFormatSelected(val format: FileFormat) : ExportWizardUiEvent + data class ScheduleModeSelected(val mode: ScheduleMode) : ExportWizardUiEvent + data class IntervalUnitSelected(val unit: IntervalUnit) : ExportWizardUiEvent + data class IntervalCountChanged(val count: Int) : ExportWizardUiEvent + data class KeepCountChanged(val count: Int) : ExportWizardUiEvent + data class KeepAllChanged(val keepAll: Boolean) : ExportWizardUiEvent + data class EncryptionMethodSelected(val method: EncryptionMethod) : ExportWizardUiEvent + data class CsvPresetSelected(val preset: CsvPreset) : ExportWizardUiEvent +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiState.kt new file mode 100644 index 000000000..8ebd148f7 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiState.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod + +@Stable +internal data class ExportWizardUiState( + val formatState: SelectFormatState, + val scheduleState: SelectScheduleState, + val destinationState: SelectDestinationState, + val providePassphraseState: ProvidePassphraseState, + val step: ExportWizardStep = ExportWizardStep.SelectFormat, +) { + val steps: List = exportStepsFor(formatState.format) + + val canContinue: Boolean = when (step) { + ExportWizardStep.SelectDestination -> destinationState.destination != null + ExportWizardStep.ProvidePassphrase -> + providePassphraseState.method == EncryptionMethod.Ark || providePassphraseState.valid + + else -> true + } + + val showsContinueButton: Boolean = step != ExportWizardStep.SelectFormat +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ProvidePassphraseState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ProvidePassphraseState.kt new file mode 100644 index 000000000..0ca6bb950 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ProvidePassphraseState.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod + +@Stable +internal data class ProvidePassphraseState( + val passphraseTextFieldState: TextFieldState, + val confirmPassphraseTextFieldState: TextFieldState, + val passphraseScore: PasswordScore = PasswordScore.None, + val valid: Boolean = false, + val method: EncryptionMethod = EncryptionMethod.Passphrase, +) \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ScheduleMode.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ScheduleMode.kt new file mode 100644 index 000000000..3942731d2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ScheduleMode.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +enum class ScheduleMode { + OneTime, + Recurring, +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectDestinationState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectDestinationState.kt new file mode 100644 index 000000000..f7872843a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectDestinationState.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +@Stable +internal data class SelectDestinationState( + val destination: BackupDestination? = null, + val uri: BackupDestinationUri? = null, +) \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt new file mode 100644 index 000000000..a7830f878 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.FileFormat + +@Stable +internal data class SelectFormatState( + val format: FileFormat? = null, + val csvPreset: CsvPreset = CsvPreset.Browser, +) \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectScheduleState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectScheduleState.kt new file mode 100644 index 000000000..e96c71156 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectScheduleState.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit + +@Stable +internal data class SelectScheduleState( + val mode: ScheduleMode = ScheduleMode.Recurring, + val interval: BackupInterval = BackupInterval(count = 3, unit = IntervalUnit.Days), + val keepCount: Int = 5, + val keepAll: Boolean = false, + val recurringAllowed: Boolean = true, +) \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt new file mode 100644 index 000000000..7a5aee807 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.presentation.hub.model.BackupGroup +import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection + +// A recurring backup that is merely enqueued is waiting for its next period, not working. +internal val DispatchedBackup.section: BackupSection + get() = when (state) { + is DispatchedBackup.State.Running -> BackupSection.InProgress + + DispatchedBackup.State.Enqueued -> if (kind == DispatchedBackup.Kind.Recurring) BackupSection.Scheduled + else BackupSection.InProgress + + DispatchedBackup.State.Succeeded, + DispatchedBackup.State.Failed, + DispatchedBackup.State.Cancelled -> BackupSection.Recent + } + +internal fun List.toGroups(): List { + val bySection = groupBy { it.section } + return BackupSection.entries.mapNotNull { section -> + val items = bySection[section]?.sortedByDescending { it.timestamp } + ?: return@mapNotNull null + BackupGroup(section, items) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt new file mode 100644 index 000000000..dca1b0ae4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt @@ -0,0 +1,419 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import android.content.res.Configuration +import android.text.format.DateUtils +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.component.IconBadge +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.hub.model.BackupGroup +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiState +import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection + +private const val DETAIL_SEPARATOR = " \u2022 " + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEvent) -> Unit) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + topBar = { + MediumFlexibleTopAppBar( + title = { + Text(text = stringResource(R.string.dispatched_backups)) + }, + scrollBehavior = scrollBehavior, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(8.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HubActions( + onExport = { onEvent(BackupHubUiEvent.OnScheduleBackupClick) }, + onImport = { onEvent(BackupHubUiEvent.OnRestoreBackup) }, + ) + + AnimatedContent( + targetState = state.hasItems, + modifier = Modifier.weight(1f), + ) { hasItems -> + when { + hasItems -> LazyColumn( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + contentPadding = PaddingValues(bottom = 16.dp), + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + state.groups.forEach { group -> + stickyHeader(key = "header-${group.section}") { + BackupSectionHeader(group) + } + itemsIndexed( + items = group.items, + key = { _, item -> item.id }, + ) { idx, item -> + DispatchedBackupRow( + item = item, + index = idx, + count = group.items.size, + onCancel = { + onEvent( + BackupHubUiEvent.OnCancelBackup( + item.id, + item.kind, + ) + ) + }, + ) + } + } + } + + else -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_dispatched_backups), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun HubActions(onExport: () -> Unit, onImport: () -> Unit) { + val buttonSize = ButtonDefaults.MediumContainerHeight + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + val actionModifier = Modifier + .heightIn(buttonSize) + .weight(1f) + val shapes = ButtonDefaults.shapesFor(buttonSize) + val contentPadding = ButtonDefaults.contentPaddingFor(buttonSize) + + OutlinedButton( + onClick = onExport, + modifier = actionModifier, + shapes = shapes, + contentPadding = contentPadding, + ) { + HubActionLabel( + buttonSize = buttonSize, + icon = Icons.Default.Upload, + label = stringResource(R.string.export_backup), + ) + } + Button( + onClick = onImport, + modifier = actionModifier, + shapes = shapes, + contentPadding = contentPadding, + ) { + HubActionLabel( + buttonSize = buttonSize, + icon = Icons.Default.Download, + label = stringResource(R.string.import_backup), + ) + } + } +} + +@Composable +private fun HubActionLabel(buttonSize: Dp, icon: ImageVector, label: String) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), + ) + Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) + Text( + text = label, + style = ButtonDefaults.textStyleFor(buttonSize), + softWrap = false, + maxLines = 1 + ) +} + +@Composable +private fun BackupSectionHeader(group: BackupGroup) { + Surface(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(group.section.label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } +} + +@Composable +private fun DispatchedBackupRow( + item: DispatchedBackup, + index: Int, + count: Int, + onCancel: () -> Unit, +) { + val statusColors = item.statusColors() + val cancellable = item.state == DispatchedBackup.State.Enqueued || + item.state is DispatchedBackup.State.Running + + val defaultShapes = + if (count == 1) ListItemDefaults.shapes(shape = MaterialTheme.shapes.large) else + ListItemDefaults.shapes() + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index, count, defaultShapes), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + IconBadge( + icon = item.icon, + containerColor = statusColors.container, + contentColor = statusColors.content, + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val progress = (item.state as? DispatchedBackup.State.Running)?.progress + if (progress != null) BackupProgress(progress = progress) + else Text(text = item.detailText()) + + item.failureReason?.let { FailureLine(item = item, reason = it) } + } + }, + trailingContent = { + if (cancellable) IconButton(onClick = onCancel, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel_backup), + modifier = Modifier.size(18.dp), + ) + } + }, + // The provider rides in the overline rather than in the supporting line, which a running + // backup replaces with its progress bar - that is exactly when "where is this going?" is + // worth answering. + overlineContent = { + Text( + text = listOfNotNull(item.kind.label, item.destination.providerText()) + .joinToString(DETAIL_SEPARATOR), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.destination.displayText(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun BackupProgress(progress: ExportProgress.InFlight) { + when (progress) { + is ExportProgress.Running -> { + LinearProgressIndicator( + progress = { progress.processed.toFloat() / progress.total }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(R.string.backup_progress, progress.processed, progress.total), + style = MaterialTheme.typography.labelSmall, + ) + } + + ExportProgress.Writing -> LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +// A live schedule keeps its "Scheduled" pill even when its last run failed, so the reason needs +// framing there; in Recent the row already reads "Failed" and the bare reason is enough. +@Composable +private fun FailureLine(item: DispatchedBackup, reason: BackupFailureReason) { + val reasonText = reason.label + Text( + text = if (item.section == BackupSection.Scheduled) stringResource( + R.string.backup_failure_last_run, + reasonText + ) + else reasonText, + style = MaterialTheme.typography.bodySmall, + color = if (reason == BackupFailureReason.NothingToExport) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error, + ) +} + +/** + * Parts joined by [DETAIL_SEPARATOR]: "Queued" and the format while it waits to dispatch, or the + * format and "2 min ago" once it settles. "Queued" is scoped to In progress: a recurring job + * sitting in Scheduled is waiting for its next period, not queued to run now. + */ +@Composable +private fun DispatchedBackup.detailText(): String { + val queued = state == DispatchedBackup.State.Enqueued && section == BackupSection.InProgress + return listOfNotNull( + stringResource(R.string.backup_state_enqueued).takeIf { queued }, + format?.displayName, + timestamp.takeIf { it > 0L && section == BackupSection.Recent }?.let { relativeTime(it) }, + ).joinToString(DETAIL_SEPARATOR) +} + +private fun relativeTime(epochMillis: Long): String = + DateUtils.getRelativeTimeSpanString( + epochMillis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun BackupHubContentPreview() { + KeyGoTheme { + Surface(modifier = Modifier.fillMaxSize()) { + BackupHubContent( + state = BackupHubUiState( + groups = listOf( + DispatchedBackup( + id = "1", + kind = DispatchedBackup.Kind.Recurring, + state = DispatchedBackup.State.Running(ExportProgress.Running(2, 5)), + format = FileFormat.JSON, + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", + ), + timestamp = 4L, + ), + DispatchedBackup( + id = "1-", + kind = DispatchedBackup.Kind.OneTime, + state = DispatchedBackup.State.Enqueued, + format = FileFormat.JSON, + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), + displayPath = "Backups", + ), + timestamp = 4L, + ), + DispatchedBackup( + id = "2", + kind = DispatchedBackup.Kind.Recurring, + state = DispatchedBackup.State.Enqueued, + format = FileFormat.JSON, + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), + displayPath = "Nextcloud/KeyGo", + ), + timestamp = 3L, + failureReason = BackupFailureReason.WriteFailed, + ), + DispatchedBackup( + id = "3", + kind = DispatchedBackup.Kind.OneTime, + state = DispatchedBackup.State.Succeeded, + format = FileFormat.CSV, + destination = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + ), + timestamp = System.currentTimeMillis(), + ), + DispatchedBackup( + id = "4", + kind = DispatchedBackup.Kind.OneTime, + state = DispatchedBackup.State.Failed, + format = FileFormat.JSON, + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", + ), + timestamp = 1L, + failureReason = BackupFailureReason.WriteFailed, + ), + DispatchedBackup( + id = "5", + kind = DispatchedBackup.Kind.OneTime, + state = DispatchedBackup.State.Failed, + format = FileFormat.CSV, + destination = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + ), + timestamp = 2L, + failureReason = BackupFailureReason.NothingToExport, + ), + ).toGroups(), + ), + onEvent = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubScreen.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubScreen.kt new file mode 100644 index 000000000..ce454e0d9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubScreen.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubEvent +import org.koin.androidx.compose.koinViewModel + +@Composable +fun BackupHubScreen( + navigateToExport: () -> Unit, + navigateToImport: () -> Unit, +) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(flow = viewModel.event) { + when (it) { + BackupHubEvent.NavigateToExport -> navigateToExport() + BackupHubEvent.NavigateToImport -> navigateToImport() + } + } + + BackupHubContent( + state = state, + onEvent = viewModel::onEvent, + ) +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModel.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModel.kt new file mode 100644 index 000000000..3ea3a28a2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModel.kt @@ -0,0 +1,50 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.feature.backup.domain.usecase.CancelBackupUseCase +import de.davis.keygo.feature.backup.domain.usecase.ObserveDispatchedBackupsUseCase +import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class BackupHubViewModel( + observeDispatchedBackups: ObserveDispatchedBackupsUseCase, + observeLastBackup: ObserveLastBackupUseCase, + private val cancelBackup: CancelBackupUseCase, +) : ViewModel() { + + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + val state: StateFlow = + combine(observeDispatchedBackups(), observeLastBackup()) { items, lastBackup -> + BackupHubUiState(lastBackup = lastBackup, groups = items.toGroups()) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = BackupHubUiState(), + ) + + fun onEvent(event: BackupHubUiEvent) { + when (event) { + BackupHubUiEvent.OnScheduleBackupClick -> + _event.trySend(BackupHubEvent.NavigateToExport) + + BackupHubUiEvent.OnRestoreBackup -> _event.trySend(BackupHubEvent.NavigateToImport) + + is BackupHubUiEvent.OnCancelBackup -> + viewModelScope.launch { cancelBackup(event.id, event.kind) } + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt new file mode 100644 index 000000000..0f325aa70 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt @@ -0,0 +1,85 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup + +internal val DispatchedBackup.icon: ImageVector + get() = when (state) { + is DispatchedBackup.State.Running -> Icons.Default.CloudUpload + DispatchedBackup.State.Enqueued -> Icons.Default.Schedule + DispatchedBackup.State.Succeeded -> Icons.Default.Check + DispatchedBackup.State.Failed -> Icons.Default.Close + DispatchedBackup.State.Cancelled -> Icons.Default.Block + } + +internal data class StatusColors(val container: Color, val content: Color) + +@Composable +@ReadOnlyComposable +internal fun DispatchedBackup.statusColors(): StatusColors = with(MaterialTheme.colorScheme) { + when (state) { + is DispatchedBackup.State.Running -> StatusColors(primaryContainer, onPrimaryContainer) + DispatchedBackup.State.Enqueued -> StatusColors(secondaryContainer, onSecondaryContainer) + DispatchedBackup.State.Succeeded -> StatusColors(tertiaryContainer, onTertiaryContainer) + DispatchedBackup.State.Failed -> StatusColors(errorContainer, onErrorContainer) + DispatchedBackup.State.Cancelled -> StatusColors(surfaceContainerHighest, onSurfaceVariant) + } +} + +internal val DispatchedBackup.Kind.label: String + @Composable + get() = when (this) { + DispatchedBackup.Kind.OneTime -> R.string.backup_kind_one_time + DispatchedBackup.Kind.Recurring -> R.string.backup_kind_recurring + }.let { stringResource(it) } + +internal val BackupFailureReason.label: String + @Composable + get() = when (this) { + BackupFailureReason.NothingToExport -> R.string.backup_failure_nothing_to_export + BackupFailureReason.CryptoFailed -> R.string.backup_failure_crypto + BackupFailureReason.SerializationFailed -> R.string.backup_failure_serialization + BackupFailureReason.CryptoSerializationFailed -> R.string.backup_failure_serialization_crypto + BackupFailureReason.WriteFailed -> R.string.backup_failure_write + BackupFailureReason.NotProvisioned -> R.string.backup_failure_not_provisioned + BackupFailureReason.RetriesExhausted -> R.string.backup_failure_retries_exhausted + }.let { stringResource(it) } + +/** + * Where the backup went, as opposed to [displayText]'s where *inside* it. Null when there is + * nothing worth naming - the row then carries its kind alone rather than the word "Unknown". + */ +@Composable +internal fun BackupDestination?.providerText(): String? = when (val provider = this?.provider) { + null, BackupDestination.Provider.Unknown -> null + BackupDestination.Provider.OnDevice -> stringResource(R.string.destination_provider_on_device) + is BackupDestination.Provider.ThirdParty -> provider.name +} + +@Composable +internal fun BackupDestination?.displayText(): String { + val destination = this ?: return stringResource(R.string.destination_provider_unknown) + return when (val provider = destination.provider) { + BackupDestination.Provider.Unknown -> stringResource(R.string.destination_provider_unknown) + + BackupDestination.Provider.OnDevice -> + destination.displayPath.ifBlank { stringResource(R.string.destination_provider_on_device) } + + is BackupDestination.Provider.ThirdParty -> + destination.displayPath.ifBlank { provider.name } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupGroup.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupGroup.kt new file mode 100644 index 000000000..0c99f67a5 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupGroup.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +import androidx.compose.runtime.Immutable +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup + +@Immutable +internal data class BackupGroup( + val section: BackupSection, + val items: List, +) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt new file mode 100644 index 000000000..95d8b5726 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +internal sealed interface BackupHubEvent { + data object NavigateToExport : BackupHubEvent + data object NavigateToImport : BackupHubEvent +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiEvent.kt new file mode 100644 index 000000000..43b1e334b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiEvent.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup + +internal sealed interface BackupHubUiEvent { + data object OnScheduleBackupClick : BackupHubUiEvent + data object OnRestoreBackup : BackupHubUiEvent + data class OnCancelBackup(val id: String, val kind: DispatchedBackup.Kind) : BackupHubUiEvent +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiState.kt new file mode 100644 index 000000000..cb22b99ec --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiState.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.LastBackup + +@Stable +internal data class BackupHubUiState( + val lastBackup: LastBackup? = null, + val groups: List = emptyList(), +) { + val hasItems: Boolean get() = groups.isNotEmpty() +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupSection.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupSection.kt new file mode 100644 index 000000000..dced018a7 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupSection.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +import androidx.annotation.StringRes +import de.davis.keygo.feature.backup.R + +internal enum class BackupSection(@get:StringRes val label: Int) { + InProgress(R.string.backup_section_in_progress), + Scheduled(R.string.backup_section_scheduled), + Recent(R.string.backup_section_recent), +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt new file mode 100644 index 000000000..81fe3871b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt @@ -0,0 +1,259 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.RemoveCircleOutline +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportSummary +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor + +@Composable +internal fun ImportRunningContent( + progress: ImportProgress, + modifier: Modifier = Modifier, +) { + val label = when (progress) { + ImportProgress.Reading -> stringResource(R.string.import_reading) + ImportProgress.Parsing -> stringResource(R.string.import_parsing) + else -> stringResource(R.string.import_running) + } + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.import_running_title), + style = MaterialTheme.typography.titleLarge, + ) + Column( + modifier = Modifier.padding(top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (progress is ImportProgress.Running && progress.total > 0) { + LinearProgressIndicator( + progress = { progress.processed.toFloat() / progress.total }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource( + R.string.import_progress, + progress.processed, + progress.total, + ), + style = MaterialTheme.typography.labelMedium, + ) + } else { + CircularProgressIndicator() + } + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +internal fun ImportResultContent( + summary: ImportSummary, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.TaskAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + text = stringResource(R.string.import_result_title), + style = MaterialTheme.typography.titleLarge, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + val rows = listOf( + SummaryRowSpec( + icon = Icons.Default.CheckCircle, + label = stringResource(R.string.import_result_imported), + value = summary.imported, + iconTint = MaterialTheme.colorScheme.primary, + ), + SummaryRowSpec( + icon = Icons.Default.RemoveCircleOutline, + label = stringResource(R.string.import_result_skipped), + value = summary.skipped, + ), + // Only tinted as an error when something actually failed; a zero here is good news + // and should not be shouted in red. + SummaryRowSpec( + icon = Icons.Default.ErrorOutline, + label = stringResource(R.string.import_result_failed), + value = summary.failed, + iconTint = if (summary.failed > 0) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ), + SummaryRowSpec( + icon = Icons.Default.Folder, + label = stringResource(R.string.import_result_vaults), + value = summary.vaultsCreated, + ), + ) + + val neutralTint = MaterialTheme.colorScheme.onSurfaceVariant + rows.forEachIndexed { position, row -> + SummaryRow( + index = position, + count = rows.size, + icon = row.icon, + label = row.label, + value = row.value, + iconTint = row.iconTint ?: neutralTint, + ) + } + } + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.import_done)) + } + } +} + +/** A summary row before it knows its position in the group. A null tint means the neutral default. */ +private class SummaryRowSpec( + val icon: ImageVector, + val label: String, + val value: Int, + val iconTint: Color? = null, +) + +@Composable +private fun SummaryRow( + index: Int, + count: Int, + icon: ImageVector, + label: String, + value: Int, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon(imageVector = icon, contentDescription = null, tint = iconTint) + }, + trailingContent = { + Text(text = value.toString(), style = MaterialTheme.typography.titleMedium) + }, + ) { + Text(text = label) + } +} + +@Composable +internal fun ImportErrorContent( + error: ImportError, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val message = when (error) { + ImportError.FileUnreadable -> stringResource(R.string.import_error_file_unreadable) + ImportError.EmptyFile -> stringResource(R.string.import_error_empty) + ImportError.NothingImported -> stringResource(R.string.import_error_nothing) + ImportError.SessionLocked -> stringResource(R.string.import_error_session_locked) + is ImportError.ParseFailed -> stringResource(R.string.import_error_parse) + else -> stringResource(R.string.import_error_generic) + } + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + text = stringResource(R.string.import_error_title), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + OutlinedButton( + onClick = onBack, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.import_try_again)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ImportResultContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ImportResultContent( + summary = ImportSummary( + imported = 42, + skipped = 3, + failed = 1, + vaultsCreated = 1, + ), + onDone = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt new file mode 100644 index 000000000..1dfa586d5 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt @@ -0,0 +1,237 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportSummary +import de.davis.keygo.feature.backup.presentation.component.BackupFileChooser +import de.davis.keygo.feature.backup.presentation.component.Wizard +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardStep +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiState +import kotlinx.coroutines.delay + +@Composable +internal fun ImportWizardContent( + state: ImportWizardUiState, + onEvent: (ImportWizardUiEvent) -> Unit, + navigateUp: () -> Unit, +) { + AnimatedContent( + targetState = state.progress, + contentKey = { it.phaseKey }, + label = "importPhase", + ) { progress -> + when (progress) { + is ImportProgress.Succeeded -> ImportResultContent( + summary = progress.summary, + onDone = navigateUp, + ) + + is ImportProgress.Failed -> ImportErrorContent( + error = progress.error, + onBack = { onEvent(ImportWizardUiEvent.Back) }, + ) + + ImportProgress.Reading, + ImportProgress.Parsing, + is ImportProgress.Running -> ImportRunningContent(progress = progress) + + null -> InputWizard(state = state, onEvent = onEvent, navigateUp = navigateUp) + } + } +} + +/** + * Groups [ImportProgress] into the distinct screens the wizard renders so that + * [AnimatedContent] only transitions when the screen actually changes. In particular the + * running phases share one key, letting [ImportProgress.Running] tick its progress without + * re-triggering the enter/exit animation. + */ +private val ImportProgress?.phaseKey: Int + get() = when (this) { + null -> 0 + ImportProgress.Reading, + ImportProgress.Parsing -> 1 + + is ImportProgress.Running -> 2 + + is ImportProgress.Succeeded -> 3 + is ImportProgress.Failed -> 4 + } + +@Composable +private fun InputWizard( + state: ImportWizardUiState, + onEvent: (ImportWizardUiEvent) -> Unit, + navigateUp: () -> Unit, +) { + Wizard( + steps = state.steps, + currentStep = state.step, + title = state.step.title, + onBack = { onEvent(ImportWizardUiEvent.Back) }, + onContinue = { onEvent(ImportWizardUiEvent.Continue) }, + navigateUp = navigateUp, + showContinueButton = state.showContinueButton, + canContinue = state.canContinue, + continueButtonContent = { + Text(text = stringResource(R.string.continue_step)) + }, + ) { step -> + when (step) { + ImportWizardStep.SelectFile -> Column(modifier = Modifier.fillMaxSize()) { + BackupFileChooser( + destination = state.backupDestination, + onChoose = { onEvent(ImportWizardUiEvent.ChooseFile) }, + chooserIcon = Icons.Default.FileOpen, + chooserTitle = stringResource(R.string.import_choose_title), + chooserSubtitle = stringResource(R.string.import_choose_subtitle), + chooserAction = stringResource(R.string.import_choose_action), + changeLabel = stringResource(R.string.file_chooser_change), + fileNameLabel = stringResource( + R.string.import_selected_label, + state.backupDestination?.fileName.orEmpty() + ), + ) + } + + ImportWizardStep.MapColumns -> MapColumnsContent( + columns = state.columns, + duplicateTypes = state.duplicateTypes, + fileName = state.backupDestination?.fileName, + onTypeChange = { index, type -> + onEvent(ImportWizardUiEvent.ChangeColumnType(index, type)) + }, + ) + + ImportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( + passphraseState = state.passphraseState, + isError = state.passphraseError, + ) + + ImportWizardStep.SelectVault -> SelectVaultContent( + vaults = state.vaults, + selectedVaultId = state.selectedVaultId, + creatingNewVault = state.creatingNewVault, + newVaultNameState = state.newVaultNameState, + newVaultIcon = state.newVaultIcon, + onSelectVault = { onEvent(ImportWizardUiEvent.SelectVault(it)) }, + onCreateNewVault = { onEvent(ImportWizardUiEvent.CreateNewVault) }, + onSelectNewVaultIcon = { + onEvent(ImportWizardUiEvent.SelectNewVaultIcon(it)) + }, + ) + } + } +} + +private val ImportWizardStep.title + @Composable + get() = when (this) { + ImportWizardStep.SelectFile -> stringResource(R.string.select_import_file_title) + ImportWizardStep.MapColumns -> stringResource(R.string.map_columns_title) + ImportWizardStep.SelectVault -> stringResource(R.string.select_vault_title) + ImportWizardStep.ProvidePassphrase -> stringResource(R.string.provide_passphrase_title) + } + +private class ImportWizardUiStateProvider : PreviewParameterProvider { + + override val values = ImportWizardStep.entries.asSequence().map { step -> + when (step) { + ImportWizardStep.MapColumns -> ImportWizardUiState( + step = step, + backupDestination = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Downloads", + fileName = "passwords.csv", + ), + columns = previewColumnRows, + duplicateTypes = setOf(CsvColumnType.Url), + ) + + else -> ImportWizardUiState(step = step) + } + } + sequenceOf( + ImportWizardUiState(progress = ImportProgress.Reading), + ImportWizardUiState(progress = ImportProgress.Failed(ImportError.NothingImported)), + ImportWizardUiState(progress = ImportProgress.Parsing), + ImportWizardUiState(progress = ImportProgress.Running(1, 10)), + ImportWizardUiState( + progress = ImportProgress.Succeeded( + ImportSummary( + imported = 10, + skipped = 2, + failed = 3, + vaultsCreated = 4 + ) + ) + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun ImportWizardTransitionPreview() { + val phases = remember { + listOf( + ImportProgress.Reading, + ImportProgress.Parsing, + ImportProgress.Running(3, 10), + ImportProgress.Succeeded( + ImportSummary(imported = 10, skipped = 2, failed = 3, vaultsCreated = 4), + ), + ) + } + var index by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + while (true) { + delay(1500) + index = (index + 1) % phases.size + } + } + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ImportWizardContent( + state = ImportWizardUiState(progress = phases[index]), + onEvent = {}, + navigateUp = {}, + ) + } + } +} + +@Preview +@Composable +private fun ImportWizardContentPreview(@PreviewParameter(ImportWizardUiStateProvider::class) state: ImportWizardUiState) { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ImportWizardContent( + state = state, + onEvent = {}, + navigateUp = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardScreen.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardScreen.kt new file mode 100644 index 000000000..3b32b0aac --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardScreen.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardEvent +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ImportWizardScreen(navigateUp: () -> Unit) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + viewModel.onFilePicked(uri?.let { BackupDestinationUri(it.toString()) }) + } + + ObserveAsEvents(flow = viewModel.event) { + when (it) { + ImportWizardEvent.PickFile -> filePicker.launch( + arrayOf("application/json", "text/csv", "*/*"), + ) + } + } + + ImportWizardContent( + state = state, + onEvent = viewModel::onEvent, + navigateUp = navigateUp, + ) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt new file mode 100644 index 000000000..ec58a1193 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt @@ -0,0 +1,274 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.model.getIdOrNull +import de.davis.keygo.core.util.fold +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.mapper.toColumnMapping +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.CsvColumnAnalysis +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportRequest +import de.davis.keygo.feature.backup.domain.model.ImportTarget +import de.davis.keygo.feature.backup.domain.usecase.AnalyzeCsvUseCase +import de.davis.keygo.feature.backup.domain.usecase.ImportBackupUseCase +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardEvent +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardStep +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiState +import de.davis.keygo.feature.backup.presentation.import.model.toMappingRows +import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase +import de.davisalessandro.keygo.rust.ColumnMapping +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class ImportWizardViewModel( + private val backupDestinationResolver: BackupDestinationResolver, + private val importBackup: ImportBackupUseCase, + private val analyzeCsv: AnalyzeCsvUseCase, + private val observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, +) : ViewModel() { + + private val passphraseState = TextFieldState() + private val newVaultNameState = TextFieldState() + + private val _state = MutableStateFlow( + ImportWizardUiState( + passphraseState = passphraseState, + newVaultNameState = newVaultNameState, + ), + ) + val state = _state.asStateFlow() + + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + private var importJob: Job? = null + private var analysisJob: Job? = null + private var vaultStepSeeded = false + + init { + snapshotFlow { passphraseState.text.toString() } + .onEach { text -> _state.update { it.copy(passphraseValid = text.isNotBlank()) } } + .launchIn(viewModelScope) + + snapshotFlow { newVaultNameState.text.toString() } + .onEach { text -> _state.update { it.copy(newVaultNameValid = text.isNotBlank()) } } + .launchIn(viewModelScope) + + observeVaultsAndSelection() + .onEach { (vaults, selection) -> + _state.update { + it.copy(vaults = vaults, contextVaultId = selection.getIdOrNull()) + } + } + .launchIn(viewModelScope) + } + + fun onEvent(event: ImportWizardUiEvent) { + when (event) { + ImportWizardUiEvent.ChooseFile -> _event.trySend(ImportWizardEvent.PickFile) + ImportWizardUiEvent.Continue -> onContinue() + ImportWizardUiEvent.Back -> back() + is ImportWizardUiEvent.ChangeColumnType -> _state.update { state -> + state.copy( + columns = state.columns.map { column -> + if (column.index == event.columnIndex) column.copy(selectedType = event.type) + else column + }, + duplicateTypes = emptySet(), + ) + } + + is ImportWizardUiEvent.SelectVault -> _state.update { + it.copy(selectedVaultId = event.vaultId, creatingNewVault = false) + } + + ImportWizardUiEvent.CreateNewVault -> _state.update { + it.copy(creatingNewVault = true) + } + + is ImportWizardUiEvent.SelectNewVaultIcon -> _state.update { + it.copy(newVaultIcon = event.icon) + } + } + } + + fun onFilePicked(uri: BackupDestinationUri?) { + if (uri == null) return + + vaultStepSeeded = false + viewModelScope.launch { + val destination = backupDestinationResolver.resolve(uri) + _state.update { + it.copy(backupDestination = destination, uri = uri) + } + } + } + + private fun onContinue() = when (_state.value.step) { + ImportWizardStep.SelectFile -> onSelectFileContinue() + ImportWizardStep.MapColumns -> validateMapping() + ImportWizardStep.SelectVault -> startTargetedImport() + ImportWizardStep.ProvidePassphrase -> startImport(passphrase = passphraseState.text.toString()) + } + + private fun onSelectFileContinue() = when (_state.value.format) { + FileFormat.CSV -> runAnalysis() + FileFormat.JSON -> startImport(passphrase = null) + null -> Unit + } + + private fun runAnalysis() { + val uri = _state.value.uri ?: return + + analysisJob?.cancel() + analysisJob = viewModelScope.launch { + analyzeCsv(uri).fold( + onSuccess = ::onAnalyzed, + onFailure = { error -> _state.update { it.copy(progress = ImportProgress.Failed(error)) } }, + ) + } + } + + private fun onAnalyzed(analysis: CsvColumnAnalysis) = _state.update { + it.copy( + columns = analysis.toMappingRows(), + step = ImportWizardStep.MapColumns, + duplicateTypes = emptySet(), + ) + } + + private fun validateMapping() { + val columns = _state.value.columns + val assigned = columns.mapNotNull { it.selectedType } + if (assigned.isEmpty()) return + + val duplicates = assigned.groupingBy { it }.eachCount() + .filterValues { it > 1 }.keys + if (duplicates.isNotEmpty()) _state.update { it.copy(duplicateTypes = duplicates) } + else enterSelectVault() + } + + /** + * Seeding happens once per file, guarded by [vaultStepSeeded]: re-entering this step via + * Back/Continue must not re-seed, or the user's own pick (or typed name) would be silently + * discarded right before an irreversible bulk write. [onFilePicked] resets the flag so a second + * import in the same session re-seeds correctly. + */ + private fun enterSelectVault() { + if (vaultStepSeeded) { + _state.update { it.copy(step = ImportWizardStep.SelectVault) } + return + } + vaultStepSeeded = true + + val current = _state.value + val contextVault = current.contextVaultId + ?.takeIf { id -> current.vaults.any { it.vaultId == id } } + + if (contextVault == null) + newVaultNameState.setTextAndPlaceCursorAtEnd(current.suggestedVaultName) + + _state.update { + it.copy( + step = ImportWizardStep.SelectVault, + selectedVaultId = contextVault, + creatingNewVault = contextVault == null, + ) + } + } + + private fun startTargetedImport() { + val current = _state.value + val target = current.resolveTarget(newVaultNameState.text.toString()) ?: return + + startImport( + passphrase = null, + csvMapping = current.columns.associate { it.index to it.selectedType }.toColumnMapping(), + target = target, + ) + } + + private fun startImport( + passphrase: String?, + csvMapping: ColumnMapping? = null, + target: ImportTarget? = null, + ) { + val current = _state.value + val uri = current.uri ?: return + val format = current.format ?: return + + importJob?.cancel() + importJob = viewModelScope.launch { + importBackup( + ImportRequest( + uri = uri, + format = format, + passphrase = passphrase, + csvMapping = csvMapping, + target = target, + ), + ).collect(::onProgress) + } + } + + private fun onProgress(progress: ImportProgress) { + if (progress is ImportProgress.Failed) handleFailure(progress.error) + else _state.update { it.copy(progress = progress, passphraseError = false) } + } + + private fun handleFailure(error: ImportError) = when (error) { + ImportError.PassphraseRequired -> _state.update { + it.copy(step = ImportWizardStep.ProvidePassphrase, progress = null) + } + + ImportError.WrongCredential -> _state.update { + it.copy( + step = ImportWizardStep.ProvidePassphrase, + progress = null, + passphraseError = true, + ) + } + + else -> _state.update { it.copy(progress = ImportProgress.Failed(error)) } + } + + private fun back() = _state.update { + when { + it.progress is ImportProgress.Failed -> it.copy( + progress = null, + step = ImportWizardStep.SelectFile, + passphraseError = false, + ) + + it.step == ImportWizardStep.SelectVault -> it.copy(step = ImportWizardStep.MapColumns) + + it.step == ImportWizardStep.MapColumns -> it.copy( + step = ImportWizardStep.SelectFile, + columns = emptyList(), + duplicateTypes = emptySet(), + ) + + it.step == ImportWizardStep.ProvidePassphrase -> + it.copy(step = ImportWizardStep.SelectFile, passphraseError = false) + + else -> it + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/MapColumnsContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/MapColumnsContent.kt new file mode 100644 index 000000000..c816cb200 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/MapColumnsContent.kt @@ -0,0 +1,410 @@ +package de.davis.keygo.feature.backup.presentation.import + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.TableChart +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.MappingConfidence +import de.davis.keygo.feature.backup.presentation.component.IconBadge +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.icon +import de.davis.keygo.feature.backup.presentation.import.model.ColumnMappingRow + +@Composable +internal fun MapColumnsContent( + columns: List, + duplicateTypes: Set, + fileName: String?, + onTypeChange: (columnIndex: Int, type: CsvColumnType?) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(COLUMN_GAP), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + item(key = "intro") { + MapColumnsIntroCard(columnCount = columns.size, fileName = fileName) + } + + items(columns, key = { it.index }) { column -> + val selected = column.selectedType + ColumnMappingGroup( + column = column, + isDuplicate = selected != null && selected in duplicateTypes, + onTypeChange = { onTypeChange(column.index, it) }, + ) + } + } +} + +private val TYPE_OPTIONS: List = CsvColumnType.entries + null + + +/** + * Gap between one column's group of segments and the next. Deliberately several times + * [ListItemDefaults.SegmentedGap] (2.dp), which separates the segments *within* a group. That + * ratio is what tells the reader where one column ends and the next begins. + */ +private val COLUMN_GAP = 8.dp + +private const val SEGMENTS_PER_COLUMN = 3 + +/** + * Explains what this step is for and which file it is about. Left-aligned rather than a centered + * hero like `ReviewHeroCard`, since it scrolls above a list and must not consume the viewport. + */ +@Composable +private fun MapColumnsIntroCard(columnCount: Int, fileName: String?) { + val container = MaterialTheme.colorScheme.secondaryContainer + val content = MaterialTheme.colorScheme.onSecondaryContainer + + SegmentedListItem( + shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large), + colors = ListItemDefaults.segmentedColors( + containerColor = container, + contentColor = content, + ), + leadingContent = { + IconBadge( + icon = Icons.Default.TableChart, + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ) + }, + supportingContent = { + Text( + text = if (fileName != null) pluralStringResource( + R.plurals.map_columns_intro_subtitle, + columnCount, + columnCount, + fileName, + ) + else pluralStringResource( + R.plurals.map_columns_intro_subtitle_no_file, + columnCount, + columnCount, + ), + ) + }, + ) { + Text( + text = stringResource(R.string.map_columns_intro_title), + style = MaterialTheme.typography.titleMedium, + ) + } +} + +/** + * One CSV column, rendered as a group of three segments: where it came from, what it maps to, and + * what is actually in it. Segmented shapes plus [ListItemDefaults.SegmentedGap] group them, which + * is the same construction the export wizard's pick-one steps use. + */ +@Composable +private fun ColumnMappingGroup( + column: ColumnMappingRow, + isDuplicate: Boolean, + onTypeChange: (CsvColumnType?) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SourceSegment(columnIndex = column.index, header = column.header) + + TypeSegment( + selectedType = column.selectedType, + isDuplicate = isDuplicate, + showVerify = column.needsVerification, + onTypeChange = onTypeChange, + ) + + SamplesSegment(samples = column.samples) + } +} + +/** + * The file side of the group: a neutral icon and the column's raw CSV header. Neutral colouring is + * the signal that this text came from the user's file rather than being something they chose. + */ +@Composable +private fun SourceSegment(columnIndex: Int, header: String) { + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(0, SEGMENTS_PER_COLUMN), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon( + imageVector = Icons.Default.TableChart, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + overlineContent = { + Text(text = stringResource(R.string.csv_column_source_label, columnIndex + 1)) + }, + ) { + Text(text = header, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** + * The KeyGo side of the group: the chosen type, tappable to open the type menu. The overline labels + * and this row's trailing dropdown affordance are what tell the file side and the KeyGo side apart, + * since all three segments carry the same weight of leading icon. This is the only segment of the + * three that is genuinely interactive. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TypeSegment( + selectedType: CsvColumnType?, + isDuplicate: Boolean, + showVerify: Boolean, + onTypeChange: (CsvColumnType?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val duplicateMessage = stringResource(R.string.csv_type_duplicate_error) + val selectedName = selectedType.displayName + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + SegmentedListItem( + onClick = { }, + shapes = ListItemDefaults.segmentedShapes(1, SEGMENTS_PER_COLUMN), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon(imageVector = selectedType.icon, contentDescription = null) + }, + overlineContent = { Text(text = stringResource(R.string.csv_type_row_label)) }, + supportingContent = if (isDuplicate) { + { + Text( + text = duplicateMessage, + color = MaterialTheme.colorScheme.error, + ) + } + } else if (showVerify) { + { + Text( + text = stringResource(R.string.csv_type_verify_hint), + color = MaterialTheme.colorScheme.tertiary, + ) + } + } else null, + trailingContent = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .typePickerSemantics( + isError = isDuplicate, + message = duplicateMessage, + ), + ) { + Text( + text = selectedName, + color = if (isDuplicate) MaterialTheme.colorScheme.error + else Color.Unspecified, + ) + } + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MenuDefaults.groupStandardContainerColor, + shape = MenuDefaults.standaloneGroupShape, + ) { + TYPE_OPTIONS.forEachIndexed { index, option -> + DropdownMenuItem( + selected = selectedType == option, + onClick = { + onTypeChange(option) + expanded = false + }, + text = { Text(text = option.displayName) }, + leadingIcon = { + Icon(imageVector = option.icon, contentDescription = null) + }, + selectedLeadingIcon = { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + }, + shapes = MenuDefaults.itemShape(index, TYPE_OPTIONS.size) + ) + } + } + } +} + +/** + * Restores what the old read-only `OutlinedTextField` announced for free, and which + * [SegmentedListItem] does not: that this row is a picker, and whether its setting is invalid. + * + * - [Role.DropdownList]: the segment's `onClick` otherwise marks it a generic button. + * - [error]: `SegmentedListItem` has no `isError`, so a duplicated type currently announces as + * valid even though the supporting text says otherwise. + * + * Deliberately does not set `stateDescription`: the headline `Text` already renders the selected + * type, and `SegmentedListItem` merges its descendants into one semantics node, so adding it here + * would make TalkBack announce the type name twice. + */ +private fun Modifier.typePickerSemantics( + isError: Boolean, + message: String, +) = semantics { + role = Role.DropdownList + if (isError) error(message) +} + +/** + * Real values read from the user's CSV, rendered monospaced so they read as file data rather than + * as UI copy. The analyzer already caps how many it returns, so this renders all of them. + * + * No leading content, so the text sits at the list item's own start padding rather than aligning + * past an icon it does not have. + */ +@Composable +private fun SamplesSegment(samples: List) { + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(2, SEGMENTS_PER_COLUMN), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + overlineContent = { Text(text = stringResource(R.string.csv_samples_label)) }, + ) { + // Toned down, not shrunk: monospace already reads small, and these are the values the step + // asks the user to check. + if (samples.isEmpty()) Text( + text = stringResource(R.string.csv_samples_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + samples.forEach { sample -> + Text( + text = sample, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +internal val previewColumnRows = listOf( + ColumnMappingRow( + index = 0, + header = "name", + samples = listOf("Email", "Bank"), + suggestedType = CsvColumnType.Title, + confidence = MappingConfidence.High, + selectedType = CsvColumnType.Title, + ), + ColumnMappingRow( + index = 1, + header = "login_uri", + samples = listOf("https://example.com", "https://bank.example.com/login"), + suggestedType = CsvColumnType.Url, + confidence = MappingConfidence.High, + selectedType = CsvColumnType.Url, + ), + ColumnMappingRow( + index = 2, + header = "field_a", + // A full DISPLAY_SAMPLES-sized set: the samples segment has to stay legible at the + // most values the analyzer can return, not just at the one or two shorter columns. + samples = listOf( + "s3cr3t", + "hunter2", + "correct-horse-battery-staple", + "pa55w0rd!", + "letmein" + ), + suggestedType = CsvColumnType.Password, + confidence = MappingConfidence.Low, + selectedType = CsvColumnType.Password, + ), + // Guessed Password, corrected by the user to Totp: the verify hint must NOT show here even + // though the confidence is Medium, because the confidence describes a guess no longer in use. + ColumnMappingRow( + index = 3, + header = "otp_seed", + samples = listOf("otpauth://totp/Example:me@example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Example"), + suggestedType = CsvColumnType.Password, + confidence = MappingConfidence.Medium, + selectedType = CsvColumnType.Totp, + ), + // No suggestion at all, and the user has pointed it at a type column 1 already claims. + ColumnMappingRow( + index = 4, + header = "extra_unmapped_legacy_export_column_name", + samples = emptyList(), + suggestedType = null, + confidence = null, + selectedType = CsvColumnType.Url, + ), +) + +@Preview(showBackground = true) +@Composable +private fun MapColumnsContentPreview() { + MapColumnsContentPreviewContent() +} + +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MapColumnsContentPreviewDark() { + MapColumnsContentPreviewContent() +} + +@Composable +private fun MapColumnsContentPreviewContent() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + MapColumnsContent( + columns = previewColumnRows, + duplicateTypes = setOf(CsvColumnType.Url), + fileName = "passwords.csv", + onTypeChange = { _, _ -> }, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ProvidePassphraseContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ProvidePassphraseContent.kt new file mode 100644 index 000000000..f2ef2f403 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ProvidePassphraseContent.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.feature.backup.R + +@Composable +internal fun ProvidePassphraseContent( + passphraseState: TextFieldState, + isError: Boolean, + modifier: Modifier = Modifier, +) { + var hidden by rememberSaveable { mutableStateOf(true) } + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.import_passphrase_instruction), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedSecureTextField( + state = passphraseState, + modifier = Modifier.fillMaxWidth(), + label = { Text(text = stringResource(R.string.passphrase)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.Password, contentDescription = null) + }, + isError = isError, + supportingText = if (isError) { + { Text(text = stringResource(R.string.import_passphrase_error)) } + } else null, + textObfuscationMode = if (hidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton(isHidden = hidden, onClick = { hidden = !hidden }) + }, + ) + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/SelectVaultContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/SelectVaultContent.kt new file mode 100644 index 000000000..b32337a86 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/SelectVaultContent.kt @@ -0,0 +1,255 @@ +package de.davis.keygo.feature.backup.presentation.import + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.presentation.VaultIconPicker +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.presentation.component.segmentContainerColor + +/** + * Where the import lands. A full step rather than a dropdown: the choice is worth the room, and a + * dropdown has nowhere to put the name field the "new vault" option needs. + */ +@Composable +internal fun SelectVaultContent( + vaults: List, + selectedVaultId: VaultId?, + creatingNewVault: Boolean, + newVaultNameState: TextFieldState, + newVaultIcon: Vault.Icon, + onSelectVault: (VaultId) -> Unit, + onCreateNewVault: () -> Unit, + onSelectNewVaultIcon: (Vault.Icon) -> Unit, + modifier: Modifier = Modifier, +) { + val segmentCount = vaults.size + 1 + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + itemsIndexed(vaults, key = { _, vault -> vault.vaultId }) { index, vault -> + VaultSegment( + vault = vault, + selected = !creatingNewVault && vault.vaultId == selectedVaultId, + shapes = ListItemDefaults.segmentedShapes(index, segmentCount), + onClick = { onSelectVault(vault.vaultId) }, + ) + } + + item(key = "new-vault") { + NewVaultSegment( + selected = creatingNewVault, + nameState = newVaultNameState, + icon = newVaultIcon, + shapes = ListItemDefaults.segmentedShapes(vaults.size, segmentCount), + onClick = onCreateNewVault, + onSelectIcon = onSelectNewVaultIcon, + ) + } + } +} + +@Composable +private fun VaultSegment( + vault: VaultMetadata, + selected: Boolean, + shapes: ListItemShapes, + onClick: () -> Unit, +) { + SegmentedListItem( + selected = selected, + onClick = onClick, + shapes = shapes, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon( + imageVector = vault.icon.toImageVector(), + contentDescription = null + ) + }, + supportingContent = { + Text( + text = pluralStringResource( + R.plurals.select_vault_item_count, + vault.count, + vault.count, + ), + ) + }, + trailingContent = { + if (selected) Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = vault.name) + } +} + +@Composable +private fun NewVaultSegment( + selected: Boolean, + nameState: TextFieldState, + icon: Vault.Icon, + shapes: ListItemShapes, + onClick: () -> Unit, + onSelectIcon: (Vault.Icon) -> Unit, +) { + val focusManager = LocalFocusManager.current + + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SegmentedListItem( + selected = selected, + onClick = onClick, + shapes = shapes, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon( + imageVector = if (selected) icon.toImageVector() else Icons.Default.Add, + contentDescription = null + ) + }, + trailingContent = { + if (selected) Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + // The typed name stands in for the label only while this row is the destination. A + // deselected row would otherwise keep naming a vault the import is not going to + // create, and lose the wording that says what picking it does. + val fallback = stringResource(R.string.select_vault_new) + Text(text = if (selected) nameState.text.ifBlank { fallback }.toString() else fallback) + } + + if (selected) { + OutlinedTextField( + state = nameState, + label = { Text(text = stringResource(R.string.select_vault_name_label)) }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + leadingIcon = { + AnimatedContent(icon) { icon -> + Icon( + imageVector = icon.toImageVector(), + contentDescription = null + ) + } + }, + ) + + Text( + text = stringResource(R.string.select_vault_icon_label), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 8.dp), + ) + + VaultIconPicker( + selected = icon, + // Picking an icon after typing a name means the keyboard is still up and covering + // the grid the user is reaching for. + onSelect = { picked -> + onSelectIcon(picked) + focusManager.clearFocus() + }, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + } +} + +private val previewVaults = listOf( + VaultMetadata(vaultId = newVaultId(), name = "Personal", icon = Vault.Icon.Person, count = 12), + VaultMetadata(vaultId = newVaultId(), name = "Work", icon = Vault.Icon.Work, count = 4), + VaultMetadata( + vaultId = newVaultId(), + name = "Shopping", + icon = Vault.Icon.ShoppingCart, + count = 7 + ), +) + +@Preview(showBackground = true) +@Composable +private fun SelectVaultContentExistingPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SelectVaultContent( + vaults = previewVaults, + selectedVaultId = previewVaults.first().vaultId, + creatingNewVault = false, + newVaultNameState = TextFieldState(), + newVaultIcon = Vault.Icon.Default, + onSelectVault = {}, + onCreateNewVault = {}, + onSelectNewVaultIcon = {}, + ) + } + } +} + +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SelectVaultContentNewVaultPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SelectVaultContent( + vaults = previewVaults, + selectedVaultId = null, + creatingNewVault = true, + newVaultNameState = TextFieldState("passwords"), + newVaultIcon = Vault.Icon.Work, + onSelectVault = {}, + onCreateNewVault = {}, + onSelectNewVaultIcon = {}, + ) + } + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ColumnMappingRow.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ColumnMappingRow.kt new file mode 100644 index 000000000..a1217292f --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ColumnMappingRow.kt @@ -0,0 +1,34 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import androidx.compose.runtime.Immutable +import de.davis.keygo.feature.backup.domain.model.CsvColumnAnalysis +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.MappingConfidence + +/** + * One CSV column as the map-columns step edits it: what the analyzer found, plus the type currently + * assigned to it. + */ +@Immutable +internal data class ColumnMappingRow( + val index: Int, + val header: String, + val samples: List, + val suggestedType: CsvColumnType?, + val confidence: MappingConfidence?, + val selectedType: CsvColumnType?, +) { + val needsVerification: Boolean = selectedType == suggestedType && + (confidence == MappingConfidence.Medium || confidence == MappingConfidence.Low) +} + +internal fun CsvColumnAnalysis.toMappingRows(): List = columns.map { column -> + ColumnMappingRow( + index = column.index, + header = column.header, + samples = column.samples, + suggestedType = column.suggestedType, + confidence = column.confidence, + selectedType = column.suggestedType, + ) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt new file mode 100644 index 000000000..05b080a98 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +internal sealed interface ImportWizardEvent { + data object PickFile : ImportWizardEvent +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt new file mode 100644 index 000000000..335891ba3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +internal enum class ImportWizardStep { + SelectFile, + MapColumns, + SelectVault, + ProvidePassphrase, +} + +private val CsvLane = listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault, +) + +private val JsonLane = listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.ProvidePassphrase, +) + +/** The steps walked so far on [step]'s lane, so the wizard can draw its progress. */ +internal fun importStepsFor(step: ImportWizardStep): List { + val lane = if (step == ImportWizardStep.ProvidePassphrase) JsonLane else CsvLane + return lane.subList(0, lane.indexOf(step) + 1) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt new file mode 100644 index 000000000..8714fb455 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.feature.backup.domain.model.CsvColumnType + +internal sealed interface ImportWizardUiEvent { + data object Back : ImportWizardUiEvent + data object Continue : ImportWizardUiEvent + data object ChooseFile : ImportWizardUiEvent + data class ChangeColumnType(val columnIndex: Int, val type: CsvColumnType?) : ImportWizardUiEvent + data class SelectVault(val vaultId: VaultId) : ImportWizardUiEvent + data object CreateNewVault : ImportWizardUiEvent + data class SelectNewVaultIcon(val icon: Vault.Icon) : ImportWizardUiEvent +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt new file mode 100644 index 000000000..4f076ee6b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt @@ -0,0 +1,69 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportTarget + +@Stable +internal data class ImportWizardUiState( + val passphraseState: TextFieldState = TextFieldState(), + val newVaultNameState: TextFieldState = TextFieldState(), + val backupDestination: BackupDestination? = null, + val uri: BackupDestinationUri? = null, + val step: ImportWizardStep = ImportWizardStep.SelectFile, + val passphraseValid: Boolean = false, + val passphraseError: Boolean = false, + val progress: ImportProgress? = null, + val columns: List = emptyList(), + val duplicateTypes: Set = emptySet(), + val vaults: List = emptyList(), + val selectedVaultId: VaultId? = null, + val creatingNewVault: Boolean = false, + val newVaultNameValid: Boolean = false, + val newVaultIcon: Vault.Icon = Vault.Icon.Default, + /** The vault the user is currently working in; seeds the destination choice. */ + val contextVaultId: VaultId? = null, +) { + val format: FileFormat? = backupDestination?.fileName?.let { name -> + when { + name.endsWith(".${FileFormat.JSON.extension}", ignoreCase = true) -> FileFormat.JSON + name.endsWith(".${FileFormat.CSV.extension}", ignoreCase = true) -> FileFormat.CSV + else -> null + } + } + + val steps: List = importStepsFor(step) + + /** `passwords.csv` -> `passwords`. Distinct per import, unlike the parser's `CSV Import`. */ + val suggestedVaultName: String = + backupDestination?.fileName?.substringBeforeLast('.').orEmpty() + + val canContinue: Boolean = when (step) { + ImportWizardStep.SelectFile -> backupDestination != null + ImportWizardStep.MapColumns -> columns.any { it.selectedType != null } + ImportWizardStep.SelectVault -> + if (creatingNewVault) newVaultNameValid else selectedVaultId != null + + ImportWizardStep.ProvidePassphrase -> passphraseValid + } + + val showContinueButton: Boolean = progress == null + + /** + * A function rather than a computed property: the new-vault name lives in a [TextFieldState], + * and a `val` would capture whatever it held when this state object was built rather than what + * the user has typed since. + */ + fun resolveTarget(newVaultName: String): ImportTarget? = + if (creatingNewVault) newVaultName.trim().takeIf(String::isNotBlank) + ?.let { ImportTarget.New(it, newVaultIcon) } + else selectedVaultId?.let(ImportTarget::Existing) +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt new file mode 100644 index 000000000..3ad478ec9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt @@ -0,0 +1,78 @@ +package de.davis.keygo.feature.backup.worker + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import de.davis.keygo.feature.backup.data.mapper.toProgressData +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.retryable +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.domain.usecase.ExportBackupUseCase +import de.davis.keygo.feature.backup.domain.usecase.RecordBackupOutcomeUseCase +import org.koin.android.annotation.KoinWorker + +internal fun resultFor(terminal: ExportProgress?, canRetry: Boolean): ListenableWorker.Result = + when (terminal) { + is ExportProgress.Succeeded -> ListenableWorker.Result.success() + is ExportProgress.Failed -> + if (terminal.error.retryable && canRetry) ListenableWorker.Result.retry() + else ListenableWorker.Result.failure() + + else -> ListenableWorker.Result.failure() + } + +@KoinWorker +internal class BackupWorker( + appContext: Context, + params: WorkerParameters, + private val backupJobRepository: BackupJobRepository, + private val exportBackup: ExportBackupUseCase, + private val recordOutcome: RecordBackupOutcomeUseCase, +) : CoroutineWorker(appContext, params) { + + private val isRecurring = TAG_RECURRING in tags + + override suspend fun doWork(): Result { + val workId: WorkId = if (isRecurring) RECURRING_WORK_ID else id.toString() + val job = backupJobRepository.getJob(workId) ?: return Result.failure() + + val canRetry = runAttemptCount + 1 < MAX_ATTEMPTS + + var terminal: ExportProgress? = null + exportBackup(job).collect { progress -> + if (progress is ExportProgress.InFlight) + setProgress(progress.toProgressData()) + terminal = progress + } + + // The Rust cause exists only here - it is never persisted and never shown to the user. + val error = (terminal as? ExportProgress.Failed)?.error + if (error is ExportError.SerializationFailed) + Log.e(TAG, "Backup serialization failed for $workId", error.cause) + + recordOutcome(workId, terminal, canRetry) + + return resultFor(terminal, canRetry) + } + + companion object { + const val UNIQUE_WORK_NAME = "backup_worker" + + /** + * Attempts a single dispatch gets before its retryable failures are recorded as terminal. + * Bounded so an escrowed ARK is never held open by a job that keeps deferring forever. + */ + const val MAX_ATTEMPTS = 5 + + /** Recurring work is a singleton; one-time job records are keyed by their WorkManager id. */ + const val RECURRING_WORK_ID: WorkId = "recurring_backup" + + const val TAG = "backup" + const val TAG_RECURRING = "backup_recurring" + const val TAG_ONE_TIME = "backup_one_time" + } +} diff --git a/feature/backup/src/main/proto/backup_ark_data.proto b/feature/backup/src/main/proto/backup_ark_data.proto new file mode 100644 index 000000000..56de57a2e --- /dev/null +++ b/feature/backup/src/main/proto/backup_ark_data.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.backup.data.local.model; +option java_multiple_files = true; + +// A copy of the Account Root Key, wrapped by the non-auth BackupArkKey keystore key, +// so backups can run while the session is locked. ct/iv empty => not provisioned. +message ProtoBackupArkData { + bytes ct = 1; + bytes iv = 2; +} diff --git a/feature/backup/src/main/proto/backup_jobs.proto b/feature/backup/src/main/proto/backup_jobs.proto new file mode 100644 index 000000000..ff492792c --- /dev/null +++ b/feature/backup/src/main/proto/backup_jobs.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.backup.data.local.model; +option java_multiple_files = true; + +message ProtoBackupJob { + string uri = 1; + string format = 2; + + optional bytes passphrase_ct = 3; + optional bytes passphrase_iv = 4; + + int64 created_at = 5; + optional int64 finished_at = 6; + optional string last_result = 7; + + optional int32 keep_count = 8; + + // EncryptionMethod.name; absent on a JSON job means Passphrase (pre-existing jobs). + optional string encryption = 9; + // CsvPreset.name; absent on a CSV job means Browser (pre-existing jobs). + optional string csv_preset = 10; + + // Set when the user cancels the job (or the schedule); a cancelled record is no longer live and + // its credentials are released. + optional bool cancelled = 11; + + // BackupFailureReason.name; set only alongside a last_result of "Failure". + optional string last_error = 12; + + // Destination label captured while the folder grant was still live. The grant is handed back once + // no live job needs it, and the provider refuses to name the document afterwards - so the record + // carries the name it will be shown under. Absent on jobs written before this field existed. + optional string destination_name = 13; +} + +message ProtoBackupJobs { + map jobs = 1; +} diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml new file mode 100644 index 000000000..a58f2a147 --- /dev/null +++ b/feature/backup/src/main/res/values/strings.xml @@ -0,0 +1,209 @@ + + + Recommended + + CSV + JSON + + Backups + No backups dispatched + Cancel + %1$d / %2$d + + Export + Import + + In progress + Scheduled + Recent + + Queued + + One-time + Recurring + There was nothing to back up + Couldn\'t unlock your data + Couldn\'t prepare the backup file + Couldn\'t encrypt the backup + Couldn\'t save the backup file + Backup isn\'t set up on this device + Couldn\'t run while the device stayed locked + + Last run failed. %1$s + %1$s Backup + + + Daily + Every %1$d days + + + + Weekly + Every %1$d weeks + + + Continue + Passphrase + Confirm Passphrase + + Export all data, including vaults, logins, cards, and TOTP, to a secure, encrypted JSON file. + Export logins only. Note: This format is unencrypted and stored as plain text. + + Set a passphrase to encrypt your backup. You will need this to restore your data later.\nNote: This passphrase is securely saved on your device for periodic backups, but is never saved for one-time exports. + + Passphrase + Choose a passphrase. The backup can be restored on any device with it. + This device & account + Uses this account\'s key - nothing to remember. Restores only into this account. + Only this account on this device can decrypt this backup. If you reinstall KeyGo, lose your device, or reset your account, the file stays encrypted forever and everything in it is lost. Nobody can recover it. + + Browser-compatible + Columns: name, url, username, password, note. Importable by Chrome, Edge and others. No TOTP. + KeyGo + Every column including TOTP. Re-imports into KeyGo without loss. + + Select File Format + Backup Schedule + Backup Location + Provide Passphrase + Choose CSV layout + Review Backup Info + + Unknown + This Device + + Choose where to save + Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. + Choose location + Change + Saved as %s + + Import Backup + Choose a backup file + Pick a .json or .csv backup from this device or a cloud provider like Nextcloud, Drive, or Dropbox. + Choose file + Selected %s + This backup is encrypted. Enter its passphrase to continue. + Wrong passphrase. Please try again. + Importing + Reading file... + Decrypting backup... + Importing items... + %1$d / %2$d + Import complete + Imported + Skipped (already present) + Failed + New vaults + Done + Import failed + Back + Couldn\'t read the selected file. + The selected file is empty. + There was nothing to import in this backup. + This file isn\'t a valid KeyGo backup. + Your vault is locked. Unlock it and try again. + Something went wrong during import. + + Your backup is written here once. + A fresh backup is written here on schedule. Older backups are kept. + A fresh backup is written here on schedule. Older backups are deleted automatically. + + One-time backup + Export once to a location you choose. Nothing is saved or repeated. + Recurring backup + KeyGo automatically creates a fresh backup on the schedule you set. + Recurring backups require an encrypted format. Choose JSON to schedule automatic backups. + + Repeat every + Auto-delete old backups + + Days + Weeks + + + backup + backups + + + + Only the newest backup is kept. Older ones are deleted automatically. + Only the newest %1$d backups are kept. Older ones are deleted automatically. + + + Every backup is kept. Nothing is deleted automatically. + + Schedule Backup + + Ready to back up + Double-check the details below before you export. + + Format + Schedule + Retention + Destination + Encryption + Contents + Passphrase strength + CSV layout + + One-time + + All kept + + Last %1$d backup + Last %1$d backups + + + Encrypted + Not encrypted + + All vaults & items + Logins only + + This file is stored as plain text. Anyone who opens it can read your logins, so keep it somewhere safe. + + Create Backup + + Enable + + Enter a passphrase to encrypt the backup + Couldn\'t secure the passphrase. Please try again + Couldn\'t save the backup. Please try again + Couldn\'t get access to the selected folder. Choose it again + + Map columns + Import into + New vault + Vault name + Icon + + %1$d item + %1$d items + + Title + URL + Username + Password + Notes + TOTP + Ignore + Auto-detected - please verify + This type is used by another column + + Match your CSV to KeyGo + Column %1$d in your file + Import as + Example values from this column + This column is empty + + + %1$d column found in %2$s. Check it lands in the right field - anything set to Ignore is skipped. + %1$d columns found in %2$s. Check each one lands in the right field - anything set to Ignore is skipped. + + + + %1$d column found. Check it lands in the right field - anything set to Ignore is skipped. + %1$d columns found. Check each one lands in the right field - anything set to Ignore is skipped. + + diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt new file mode 100644 index 000000000..2e8fe2d68 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt @@ -0,0 +1,117 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.model.PasskeyUser +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Totp +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davisalessandro.keygo.rust.BackupCard +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.BackupVault +import java.time.YearMonth + +/** Encrypts [plaintext] the same way [FakeCryptographicScopeProvider]'s scope decrypts (XOR). */ +fun secretPayload(plaintext: String) = EncryptedPayload( + ciphertext = FakeCryptographicScopeProvider.transform(plaintext.encodeToByteArray()), + iv = FakeCryptographicScopeProvider.IV, +) + +private val emptyKey get() = KeyInformation(byteArrayOf(), byteArrayOf()) + +fun testVault( + id: VaultId = newVaultId(), + name: String, + icon: Vault.Icon = Vault.Icon.Default, +) = Vault( + id = id, + name = name, + keyInformation = emptyKey, + icon = icon, +) + +/** A vault as it appears inside a backup document. [icon] is blank unless a test is about icons. */ +fun backupVault( + name: String, + logins: List = emptyList(), + cards: List = emptyList(), + icon: String = "", +) = BackupVault(name = name, icon = icon, logins = logins, cards = cards) + +fun testLogin( + vaultId: VaultId, + id: ItemId = newItemId(), + name: String, + username: String? = null, + password: String? = null, + totpSecret: String? = null, + websites: Set = emptySet(), + tags: Set = emptySet(), + note: String? = null, + passkeyRPs: Set = emptySet(), +) = Login( + id = id, + vaultId = vaultId, + name = name, + username = username, + domainInfos = websites.map { DomainInfo(loginId = id, value = it, eTLD1 = null) }.toSet(), + passwordCredential = password?.let { + PasswordCredential(secret = PasswordSecret(secretPayload(it)), score = PasswordScore.Strong) + }, + totp = totpSecret?.let { Totp(loginId = id, secret = Totp.Secret(secretPayload(it))) }, + passkeyRPs = passkeyRPs, + keyInformation = emptyKey, + tags = tags.mapNotNull { Tag.of(it) }.toSet(), + note = note, + pinned = false, +) + +fun testPasskey( + loginId: ItemId, + rp: String, + privateKey: String, + credentialId: ByteArray = rp.encodeToByteArray(), + userName: String = "alice", + userDisplayName: String = "Alice", +) = Passkey( + credentialId = credentialId, + rp = rp, + privateKey = Passkey.PrivateKey(secretPayload(privateKey)), + loginId = loginId, + user = PasskeyUser(name = userName, displayName = userDisplayName), +) + +fun testCard( + vaultId: VaultId, + id: ItemId = newItemId(), + name: String, + holder: String? = null, + number: String? = null, + cvv: String? = null, + expiration: YearMonth? = null, + note: String? = null, +) = CreditCard( + id = id, + vaultId = vaultId, + name = name, + keyInformation = emptyKey, + tags = emptySet(), + note = note, + pinned = false, + holder = holder, + cardNumber = number?.let { CreditCard.CardNumber(secretPayload(it)) }, + cvv = cvv?.let { CreditCard.CVV(secretPayload(it)) }, + expirationDate = expiration, +) diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt new file mode 100644 index 000000000..6c0c5cf17 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasswordStrengthEstimator +import de.davis.keygo.core.item.FakeTransactionRunner +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.feature.backup.domain.BackupRestorer +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUseCase +import de.davis.keygo.feature.vault.domain.usecase.CreateVaultUseCase +import de.davis.keygo.rust.FakeCardFormatter +import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeTotpService +import de.davis.keygo.rust.FakeVaultManager + +internal class RestorerTestEnv { + val vaultRepo = FakeVaultRepository() + val loginRepo = FakeLoginRepository() + val cardRepo = FakeCreditCardRepository() + val transactionRunner = FakeTransactionRunner() + private val scope = FakeCryptographicScopeProvider(FakeItemRepository()) + private val upsert = UpsertVaultItemUseCase(loginRepo, cardRepo) + + private val createLogin = CreateNewOrUpdateLoginUseCase( + cryptographicScopeProvider = scope, + loginRepository = loginRepo, + vaultRepository = vaultRepo, + upsertVaultItem = upsert, + passwordStrengthEstimator = FakePasswordStrengthEstimator(), + totpService = FakeTotpService(), + ) + private val createCard = CreateNewOrUpdateCreditCardUseCase( + creditCardRepository = cardRepo, + cardFormatter = FakeCardFormatter(), + cryptographicScopeProvider = scope, + vaultRepository = vaultRepo, + upsertVaultItem = upsert, + ) + private val createVault = CreateVaultUseCase( + vaultRepository = vaultRepo, + vaultContextRepository = FakeVaultContextRepository(), + vaultManager = FakeVaultManager(), + keyWrapper = FakeKeyWrapper(), + session = FakeSession(startOnConstruct = true), + ) + + val restorer = BackupRestorer( + vaultRepository = vaultRepo, + loginRepository = loginRepo, + creditCardRepository = cardRepo, + createVault = createVault, + createLogin = createLogin, + createCard = createCard, + transactionRunner = transactionRunner, + ) +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImplTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImplTest.kt new file mode 100644 index 000000000..69642891c --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImplTest.kt @@ -0,0 +1,97 @@ +package de.davis.keygo.feature.backup.data + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +internal class BackupDestinationResolverImplTest { + + private val context = RuntimeEnvironment.getApplication() + private val resolver = BackupDestinationResolverImpl(context) + + @Test + fun `a revoked folder grant degrades to the document id instead of crashing`() = runTest { + ShadowContentResolver.registerProviderInternal(AUTHORITY, DeniedProvider()) + + val destination = resolver.resolve(BackupDestinationUri(TREE_URI)) + + assertEquals("folder:Backups", destination.displayPath) + assertEquals(null, destination.fileName) + } + + @Test + fun `a folder the provider still answers for shows its display name`() = runTest { + ShadowContentResolver.registerProviderInternal(AUTHORITY, NamingProvider()) + + val destination = resolver.resolve(BackupDestinationUri(TREE_URI)) + + assertEquals("Backups", destination.displayPath) + } + + @Test + fun `a cached name survives a provider that refuses to answer`() = runTest { + ShadowContentResolver.registerProviderInternal(AUTHORITY, DeniedProvider()) + + val destination = resolver.resolve(BackupDestinationUri(TREE_URI), cachedName = "Backups") + + assertEquals("Backups", destination.displayPath) + } + + @Test + fun `a cached name is trusted over asking the provider again`() = runTest { + ShadowContentResolver.registerProviderInternal(AUTHORITY, NamingProvider()) + + val destination = resolver.resolve(BackupDestinationUri(TREE_URI), cachedName = "Renamed") + + assertEquals("Renamed", destination.displayPath) + } + + /** Stands in for a provider that no longer honours a persisted grant. */ + private class DeniedProvider : StubProvider() { + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor = throw SecurityException("Permission Denial: reading $uri") + } + + private class NamingProvider : StubProvider() { + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor = MatrixCursor(arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) + .apply { addRow(arrayOf("Backups")) } + } + + private abstract class StubProvider : ContentProvider() { + override fun onCreate() = true + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, s: String?, a: Array?) = 0 + override fun update(uri: Uri, v: ContentValues?, s: String?, a: Array?) = 0 + } + + companion object { + private const val AUTHORITY = "com.example.docs" + private const val TREE_URI = "content://$AUTHORITY/tree/folder%3ABackups" + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapperTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapperTest.kt new file mode 100644 index 000000000..b90a613ab --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapperTest.kt @@ -0,0 +1,227 @@ +package de.davis.keygo.feature.backup.data.mapper + +import de.davis.keygo.feature.backup.data.local.model.protoBackupJob +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BackupJobMapperTest { + + @Test + fun `round-trips created, finished and result`() { + val job = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 100L, + finishedAt = 200L, + lastResult = BackupResult.Success, + ) + + val restored = job.toProto().toDomain() + + assertEquals(100L, restored.createdAt) + assertEquals(200L, restored.finishedAt) + assertEquals(BackupResult.Success, restored.lastResult) + assertEquals(FileFormat.JSON, restored.format) + } + + @Test + fun `round-trips keepCount`() { + val job = BackupJob( + uri = BackupDestinationUri("content://tree"), + wrappedPassphrase = null, + format = FileFormat.JSON, + keepCount = 5, + ) + + assertEquals(5, job.toProto().toDomain().keepCount) + } + + @Test + fun `round-trips the destination name`() { + val job = BackupJob( + uri = BackupDestinationUri("content://tree"), + wrappedPassphrase = null, + format = FileFormat.JSON, + destinationName = "Backups", + ) + + assertEquals("Backups", job.toProto().toDomain().destinationName) + } + + @Test + fun `a record written before the destination name existed decodes to null`() { + val proto = protoBackupJob { + uri = "content://tree" + format = FileFormat.JSON.name + } + + assertNull(proto.toDomain().destinationName) + } + + @Test + fun `absent finished fields decode to null`() { + val proto = protoBackupJob { + uri = "content://out.csv" + format = FileFormat.CSV.name + createdAt = 5L + } + + val restored = proto.toDomain() + + assertEquals(5L, restored.createdAt) + assertNull(restored.finishedAt) + assertNull(restored.lastResult) + assertNull(restored.keepCount) + } + + @Test + fun `unparseable result decodes to null`() { + val proto = protoBackupJob { + uri = "content://out.json" + format = FileFormat.JSON.name + lastResult = "garbage" + } + + assertNull(proto.toDomain().lastResult) + } + + @Test + fun `round-trips encryption method and csv preset`() { + val job = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + encryption = EncryptionMethod.Ark, + ) + + assertEquals(EncryptionMethod.Ark, job.toProto().toDomain().encryption) + + val csvJob = BackupJob( + uri = BackupDestinationUri("content://out.csv"), + wrappedPassphrase = null, + format = FileFormat.CSV, + csvPreset = CsvPreset.KeyGo, + ) + + assertEquals(CsvPreset.KeyGo, csvJob.toProto().toDomain().csvPreset) + } + + @Test + fun `json job without encryption field defaults to Passphrase`() { + val proto = protoBackupJob { + uri = "content://out.json" + format = FileFormat.JSON.name + } + + val restored = proto.toDomain() + + assertEquals(EncryptionMethod.Passphrase, restored.encryption) + assertNull(restored.csvPreset) + } + + @Test + fun `csv job without preset field defaults to Browser`() { + val proto = protoBackupJob { + uri = "content://out.csv" + format = FileFormat.CSV.name + } + + val restored = proto.toDomain() + + assertEquals(CsvPreset.Browser, restored.csvPreset) + assertNull(restored.encryption) + } + + @Test + fun `unparseable encryption and preset fall back to defaults`() { + val proto = protoBackupJob { + uri = "content://out.json" + format = FileFormat.JSON.name + encryption = "garbage" + } + + assertEquals(EncryptionMethod.Passphrase, proto.toDomain().encryption) + + val csvProto = protoBackupJob { + uri = "content://out.csv" + format = FileFormat.CSV.name + csvPreset = "garbage" + } + + assertEquals(CsvPreset.Browser, csvProto.toDomain().csvPreset) + } + + @Test + fun `cancelled round-trips through the proto`() { + val job = BackupJob( + uri = BackupDestinationUri("content://folder"), + wrappedPassphrase = null, + format = FileFormat.JSON, + cancelled = true, + ) + + assertTrue(job.toProto().toDomain().cancelled) + } + + @Test + fun `a job without the cancelled field is not cancelled`() { + val job = BackupJob( + uri = BackupDestinationUri("content://folder"), + wrappedPassphrase = null, + format = FileFormat.JSON, + ) + + assertFalse(job.toProto().toDomain().cancelled) + } + + @Test + fun `round-trips a failure reason`() { + val job = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 200L, + lastResult = BackupResult.Failure(BackupFailureReason.WriteFailed), + ) + + val restored = job.toProto().toDomain() + + assertEquals(BackupResult.Failure(BackupFailureReason.WriteFailed), restored.lastResult) + } + + @Test + fun `a failure with no persisted reason decodes to a null reason`() { + val proto = protoBackupJob { + uri = "content://out.json" + format = FileFormat.JSON.name + createdAt = 5L + lastResult = "Failure" + } + + assertEquals(BackupResult.Failure(null), proto.toDomain().lastResult) + } + + @Test + fun `an unrecognised failure reason still decodes to a failure`() { + val proto = protoBackupJob { + uri = "content://out.json" + format = FileFormat.JSON.name + createdAt = 5L + lastResult = "Failure" + lastError = "ReasonFromANewerBuild" + } + + assertEquals(BackupResult.Failure(null), proto.toDomain().lastResult) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapperTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapperTest.kt new file mode 100644 index 000000000..fd6a03d9b --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapperTest.kt @@ -0,0 +1,97 @@ +package de.davis.keygo.feature.backup.data.mapper + +import androidx.work.WorkInfo +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DispatchedBackupMapperTest { + + @Test + fun `maps work states to domain states`() { + assertEquals(DispatchedBackup.State.Enqueued, toState(WorkInfo.State.ENQUEUED)) + assertEquals(DispatchedBackup.State.Enqueued, toState(WorkInfo.State.BLOCKED)) + assertEquals(DispatchedBackup.State.Running(), toState(WorkInfo.State.RUNNING)) + assertEquals(DispatchedBackup.State.Succeeded, toState(WorkInfo.State.SUCCEEDED)) + assertEquals(DispatchedBackup.State.Failed, toState(WorkInfo.State.FAILED)) + assertEquals(DispatchedBackup.State.Cancelled, toState(WorkInfo.State.CANCELLED)) + } + + @Test + fun `running state carries the reported progress`() { + assertEquals( + DispatchedBackup.State.Running(ExportProgress.Running(2, 5)), + toState(WorkInfo.State.RUNNING, ExportProgress.Running(2, 5)), + ) + } + + @Test + fun `recurring tag yields recurring kind`() { + assertEquals( + DispatchedBackup.Kind.Recurring, + toKind(setOf(BackupWorker.TAG, BackupWorker.TAG_RECURRING)), + ) + } + + @Test + fun `without recurring tag yields one-time kind`() { + assertEquals( + DispatchedBackup.Kind.OneTime, + toKind(setOf(BackupWorker.TAG, BackupWorker.TAG_ONE_TIME)), + ) + } + + @Test + fun `running phase with positive total yields running progress`() { + assertEquals( + ExportProgress.Running(2, 5), + toProgress(phase = PROGRESS_PHASE_RUNNING, processed = 2, total = 5), + ) + } + + @Test + fun `running phase with non-positive total yields no progress`() { + assertNull(toProgress(phase = PROGRESS_PHASE_RUNNING, processed = 0, total = 0)) + assertNull(toProgress(phase = PROGRESS_PHASE_RUNNING, processed = 1, total = -1)) + } + + @Test + fun `writing phase yields writing progress`() { + assertEquals( + ExportProgress.Writing, + toProgress(phase = PROGRESS_PHASE_WRITING, processed = 0, total = 0), + ) + } + + @Test + fun `unknown or absent phase yields no progress`() { + assertNull(toProgress(phase = null, processed = 2, total = 5)) + assertNull(toProgress(phase = "unknown", processed = 2, total = 5)) + } + + @Test + fun `progress data round-trips through work data`() { + val running = ExportProgress.Running(2, 5).toProgressData() + assertEquals( + ExportProgress.Running(2, 5), + toProgress( + phase = running.getString(PROGRESS_KEY_PHASE), + processed = running.getInt(PROGRESS_KEY_PROCESSED, 0), + total = running.getInt(PROGRESS_KEY_TOTAL, 0), + ), + ) + + val writing = ExportProgress.Writing.toProgressData() + assertEquals( + ExportProgress.Writing, + toProgress( + phase = writing.getString(PROGRESS_KEY_PHASE), + processed = writing.getInt(PROGRESS_KEY_PROCESSED, 0), + total = writing.getInt(PROGRESS_KEY_TOTAL, 0), + ), + ) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt new file mode 100644 index 000000000..495a7e588 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.backup.data.repository + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BackupJobRetentionTest { + + private fun retained(vararg finishedAt: Pair, max: Int) = + retainedJobKeys(finishedAt.toMap(), max = max, recurringKey = "recurring") + + @Test + fun `keeps the recurring record even when it is the oldest finished job`() { + val keep = retained("recurring" to 1L, "a" to 100L, "b" to 200L, max = 1) + + assertTrue("recurring" in keep) + } + + @Test + fun `keeps unfinished jobs regardless of the cap`() { + val keep = retained("pending" to null, "a" to 100L, "b" to 200L, max = 1) + + assertTrue("pending" in keep) + } + + @Test + fun `caps finished one-time jobs to the most recent`() { + val keep = retained("old" to 100L, "mid" to 200L, "new" to 300L, max = 2) + + assertEquals(setOf("mid", "new"), keep) + } + + @Test + fun `evicts nothing when under the cap`() { + val keep = retained("a" to 100L, "b" to 200L, max = 5) + + assertEquals(setOf("a", "b"), keep) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt new file mode 100644 index 000000000..75c2dcd76 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt @@ -0,0 +1,155 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.data.BackupSession +import de.davis.keygo.feature.backup.domain.model.ExportError +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BackupArkUnlockerTest { + + private val vaultRepo = FakeVaultRepository() + private val keyStore = FakeKeyStoreManager() + private val arkStore = FakeBackupArkKeyStore() + private val factory = FakeCryptographicScopeProviderFactory( + FakeCryptographicScopeProvider(FakeItemRepository()), + ) + + private fun unlocker(session: FakeSession) = BackupArkUnlocker( + session = session, + keyStoreManager = keyStore, + arkKeyStore = arkStore, + scopeProviderFactory = factory, + vaultRepository = vaultRepo, + ) + + private suspend fun provision(ark: ByteArray) { + val cipher = keyStore.getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) + arkStore.save(CryptographicData(cipher.doFinal(ark), cipher.iv)) + } + + @Test + fun `unlocked session builds a scope on the live session`() = runTest { + val session = FakeSession(startOnConstruct = true) + val result = unlocker(session).withScope { } + assertIs>(result) + assertEquals(session, factory.lastSession) + } + + @Test + fun `locked and unprovisioned fails with NotProvisioned`() = runTest { + val result = unlocker(FakeSession(startOnConstruct = false)).withScope { } + assertEquals(Result.Failure(ExportError.NotProvisioned), result) + } + + @Test + fun `locked but provisioned recovers the ARK into a BackupSession`() = runTest { + val ark = ByteArray(32) { (it + 1).toByte() } + provision(ark) + + // The recovered ARK is zeroed once the block returns, so assert on it from inside. + val result = unlocker(FakeSession(startOnConstruct = false)).withScope { + val used = factory.lastSession + assertIs(used) + assertContentEquals(ark, used.ark) + } + + assertIs>(result) + } + + @Test + fun `locked provisioned but device locked fails with DeviceLocked`() = runTest { + provision(ByteArray(32) { it.toByte() }) + keyStore.deviceLocked = true + + val result = unlocker(FakeSession(startOnConstruct = false)).withScope { } + assertEquals(Result.Failure(ExportError.DeviceLocked), result) + } + + @Test + fun `withArk hands over the live session ark`() = runTest { + val session = FakeSession(startOnConstruct = true) + val expected = session.ark.copyOf() + + val result = unlocker(session).withArk { assertContentEquals(expected, it) } + + assertIs>(result) + } + + @Test + fun `withArk recovers the provisioned ark when locked`() = runTest { + val ark = ByteArray(32) { (it + 1).toByte() } + provision(ark) + + val result = unlocker(FakeSession(startOnConstruct = false)).withArk { + assertContentEquals(ark, it) + } + + assertIs>(result) + } + + @Test + fun `withArk fails with NotProvisioned when locked and no ark copy exists`() = runTest { + val result = unlocker(FakeSession(startOnConstruct = false)).withArk { } + + val failure = assertIs>(result) + assertEquals(ExportError.NotProvisioned, failure.error) + } + + @Test + fun `a recovered ark is zeroed after use`() = runTest { + provision(ByteArray(32) { (it + 1).toByte() }) + + var seen: ByteArray? = null + unlocker(FakeSession(startOnConstruct = false)).withArk { ark -> + seen = ark + assertTrue(ark.any { it != 0.toByte() }) + } + + assertTrue(assertNotNull(seen).all { it == 0.toByte() }) + } + + @Test + fun `a recovered ark is zeroed after use in withScope`() = runTest { + val ark = ByteArray(32) { (it + 1).toByte() } + provision(ark) + + val result = unlocker(FakeSession(startOnConstruct = false)).withScope { + val used = factory.lastSession + assertIs(used) + assertContentEquals(ark, used.ark) + } + + assertIs>(result) + + val used = factory.lastSession + assertIs(used) + assertTrue(used.ark.all { it == 0.toByte() }) + } + + @Test + fun `a live session ark is left intact`() = runTest { + // FakeSession seeds ByteArray(32) { it.toByte() } - zeroing it would be zeroing the app's + // own session key. + val session = FakeSession(startOnConstruct = true) + + unlocker(session).withArk { } + + assertTrue(session.ark.any { it != 0.toByte() }) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt new file mode 100644 index 000000000..7d1d4c920 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt @@ -0,0 +1,303 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasskeyRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.domain.model.CollectedBackup +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.testCard +import de.davis.keygo.feature.backup.testLogin +import de.davis.keygo.feature.backup.testPasskey +import de.davis.keygo.feature.backup.testVault +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BackupCollectorTest { + + private val vaultRepo = FakeVaultRepository() + private val loginRepo = FakeLoginRepository() + private val cardRepo = FakeCreditCardRepository() + private val passkeyRepo = FakePasskeyRepository() + private val factory = FakeCryptographicScopeProviderFactory( + FakeCryptographicScopeProvider(FakeItemRepository()), + ) + + private fun collector( + session: FakeSession = FakeSession(startOnConstruct = true), + unlockerVaultRepo: FakeVaultRepository = vaultRepo, + ) = BackupCollector( + vaultRepository = vaultRepo, + loginRepository = loginRepo, + creditCardRepository = cardRepo, + passkeyRepository = passkeyRepo, + arkUnlocker = BackupArkUnlocker( + session = session, + keyStoreManager = FakeKeyStoreManager(), + arkKeyStore = FakeBackupArkKeyStore(), + scopeProviderFactory = factory, + vaultRepository = unlockerVaultRepo, + ), + ) + + @Test + fun `empty database fails with NothingToExport`() = runTest { + val result = collector().collect { _, _ -> } + assertIs>(result) + assertEquals(ExportError.NothingToExport, result.error) + } + + @Test + fun `collects and decrypts a login into the backup`() = runTest { + val vault = testVault(name = "Personal") + vaultRepo.seed(vault) + loginRepo.seed( + testLogin( + vaultId = vault.id, + name = "Email", + username = "alice", + password = "s3cr3t", + totpSecret = "JBSWY3DPEHPK3PXP", + websites = setOf("https://mail.example"), + tags = setOf("work"), + note = "remember", + ) + ) + + val result = collector().collect { _, _ -> } + val collected: CollectedBackup = assertNotNull(result.getOrNull()) + val login = collected.backup.vaults.single().logins.single() + assertEquals("Email", login.title) + assertEquals("alice", login.username) + assertEquals("s3cr3t", login.password) + assertEquals("JBSWY3DPEHPK3PXP", login.totpSecret) + assertEquals(listOf("https://mail.example"), login.websites) + assertEquals(listOf("work"), login.tags) + assertEquals("remember", login.notes) + assertEquals(1, collected.itemCount) + } + + @Test + fun `a login with multiple websites exports all of them`() = runTest { + val vault = testVault(name = "Personal") + vaultRepo.seed(vault) + loginRepo.seed( + testLogin( + vaultId = vault.id, + name = "Email", + websites = setOf("https://mail.example", "https://mail.example.org"), + ) + ) + + val result = collector().collect { _, _ -> } + val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() + + assertEquals( + setOf("https://mail.example", "https://mail.example.org"), + login.websites.toSet(), + ) + } + + @Test + fun `collects and decrypts a card into the backup`() = runTest { + val vault = testVault(name = "Wallet") + vaultRepo.seed(vault) + cardRepo.seed( + testCard( + vaultId = vault.id, + name = "Visa", + holder = "Alice", + number = "4111111111111111", + cvv = "123", + expiration = YearMonth.of(2030, 7), + note = "card note", + ) + ) + + val result = collector().collect { _, _ -> } + val collected: CollectedBackup = assertNotNull(result.getOrNull()) + val card = collected.backup.vaults.single().cards.single() + assertEquals("Visa", card.title) + assertEquals("Alice", card.cardholder) + assertEquals("4111111111111111", card.number) + assertEquals("123", card.cvv) + assertEquals(7u.toUByte(), card.expirationMonth) + assertEquals(2030u.toUShort(), card.expirationYear) + assertEquals("card note", card.notes) + } + + @Test + fun `keeps items grouped by their vault`() = runTest { + val a = testVault(name = "A") + val b = testVault(name = "B") + vaultRepo.seed(a, b) + loginRepo.seed(testLogin(vaultId = a.id, name = "InA")) + cardRepo.seed(testCard(vaultId = b.id, name = "InB", number = "4111111111111111")) + + val collected: CollectedBackup = + assertNotNull(collector().collect { _, _ -> }.getOrNull()) + val byName = collected.backup.vaults.associateBy { it.name } + + assertEquals(listOf("InA"), byName.getValue("A").logins.map { it.title }) + assertEquals(listOf("InB"), byName.getValue("B").cards.map { it.title }) + assertEquals(2, collected.itemCount) + } + + @Test + fun `exports each vault's icon under its enum name`() = runTest { + val work = testVault(name = "Work", icon = Vault.Icon.Business) + val personal = testVault(name = "Personal", icon = Vault.Icon.Home) + vaultRepo.seed(work, personal) + loginRepo.seed(testLogin(vaultId = work.id, name = "InWork")) + loginRepo.seed(testLogin(vaultId = personal.id, name = "InPersonal")) + + val collected: CollectedBackup = + assertNotNull(collector().collect { _, _ -> }.getOrNull()) + + assertEquals( + mapOf("Work" to "Business", "Personal" to "Home"), + collected.backup.vaults.associate { it.name to it.icon }, + ) + } + + @Test + fun `reports progress up to the total`() = runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed( + testLogin(vaultId = vault.id, name = "L1"), + testLogin(vaultId = vault.id, name = "L2") + ) + + val seen = mutableListOf>() + val result = collector().collect { processed, total -> seen += processed to total } + + assertIs>(result) + assertEquals(listOf(1 to 2, 2 to 2), seen) + } + + // Logins and cards now export concurrently, so this uses real threads (Dispatchers.Default) + // rather than runTest's single-threaded virtual scheduler, which can't reproduce a genuine + // interleaving race, and repeats it hundreds of times since a race is a probabilistic failure, + // not a deterministic one - a single run passing proves nothing. + @Test + fun `progress never arrives out of order across many concurrent runs`() = + runBlocking(Dispatchers.Default) { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + val logins = (1..25).map { testLogin(vaultId = vault.id, name = "L$it") } + val cards = (1..25).map { testCard(vaultId = vault.id, name = "C$it") } + loginRepo.seed(*logins.toTypedArray()) + cardRepo.seed(*cards.toTypedArray()) + val total = logins.size + cards.size + + repeat(1000) { + val seen = CopyOnWriteArrayList() + + val result = collector().collect { processed, _ -> seen += processed } + + assertIs>(result) + assertEquals((1..total).toList(), seen.toList()) + } + } + + @Test + fun `crypto scope failure surfaces CryptoFailed`() = runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + + // The crypto-scope use case is given an empty vault repo, so it cannot find the + // vault key and fails to build a scope - the collector maps any such failure to CryptoFailed. + val result = collector(unlockerVaultRepo = FakeVaultRepository()).collect { _, _ -> } + + assertIs>(result) + assertEquals(ExportError.CryptoFailed, result.error) + } + + @Test + fun `locked and unprovisioned session fails with NotProvisioned before reporting progress`() = + runTest { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email")) + + val seen = mutableListOf>() + val result = collector(session = FakeSession(startOnConstruct = false)) + .collect { processed, total -> seen += processed to total } + + assertEquals(Result.Failure(ExportError.NotProvisioned), result) + assertTrue(seen.isEmpty()) + } + + @Test + fun `a login's passkeys are collected and decrypted`() = runTest { + val vault = testVault(name = "Personal") + vaultRepo.seed(vault) + val loginId = newItemId() + loginRepo.seed( + testLogin( + vaultId = vault.id, + id = loginId, + name = "Email", + passkeyRPs = setOf("example.com", "example.org"), + ) + ) + passkeyRepo.seed( + testPasskey(loginId = loginId, rp = "example.com", privateKey = "pk-one"), + testPasskey(loginId = loginId, rp = "example.org", privateKey = "pk-two"), + ) + + val result = collector().collect { _, _ -> } + val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() + + assertEquals(listOf("example.com", "example.org"), login.passkeys.map { it.rp }) + assertEquals("pk-one", login.passkeys.first().privateKey.decodeToString()) + assertEquals("alice", login.passkeys.first().userName) + } + + @Test + fun `a login without passkeys exports an empty list`() = runTest { + val vault = testVault(name = "Personal") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email", password = "s3cr3t")) + + val result = collector().collect { _, _ -> } + val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() + + assertTrue(login.passkeys.isEmpty()) + } + + @Test + fun `passkeys are exported even when the login's passkeyRPs set is empty`() = runTest { + val vault = testVault(name = "Personal") + vaultRepo.seed(vault) + val loginId = newItemId() + // passkeyRPs deliberately left empty (default) - the table is the source of truth. + loginRepo.seed(testLogin(vaultId = vault.id, id = loginId, name = "Email")) + passkeyRepo.seed(testPasskey(loginId = loginId, rp = "example.com", privateKey = "pk-one")) + + val result = collector().collect { _, _ -> } + val login = assertNotNull(result.getOrNull()).backup.vaults.single().logins.single() + + assertEquals(listOf("example.com"), login.passkeys.map { it.rp }) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorerTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorerTest.kt new file mode 100644 index 000000000..0728d5ad6 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorerTest.kt @@ -0,0 +1,300 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.RestorerTestEnv +import de.davis.keygo.feature.backup.backupVault +import de.davis.keygo.feature.backup.domain.model.ImportTarget +import de.davis.keygo.feature.backup.testLogin +import de.davis.keygo.feature.backup.testVault +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.BackupVault +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class BackupRestorerTest { + + private fun login( + title: String, + username: String? = null, + websites: List = emptyList(), + ) = BackupLogin( + title = title, + notes = null, + tags = emptyList(), + pinned = false, + username = username, + password = "pw", + totpSecret = null, + websites = websites, + passkeys = emptyList(), + ) + + private fun backup(vararg vaults: BackupVault) = Backup(vaults.toList()) + private fun vault(name: String, logins: List, icon: String = "") = + backupVault(name = name, logins = logins, icon = icon) + + @Test + fun `empty backup fails with NothingImported`() = runTest { + val env = RestorerTestEnv() + val result = env.restorer.restore(Backup(emptyList())) { _, _ -> } + assertIs>(result) + } + + @Test + fun `creates a new vault and imports its logins`() = runTest { + val env = RestorerTestEnv() + val result = env.restorer.restore( + backup(vault("Imported", listOf(login("Email", "alice"), login("Bank", "bob")))), + ) { _, _ -> } + + val summary = (result as Result.Success).success + assertEquals(2, summary.imported) + assertEquals(1, summary.vaultsCreated) + assertEquals(2, env.loginRepo.observeLoginsCount()) + } + + @Test + fun `a login with multiple websites imports all of them`() = runTest { + val env = RestorerTestEnv() + + env.restorer.restore( + backup( + vault( + "Imported", + listOf( + login( + "Email", + websites = listOf("https://mail.example", "https://mail.example.org") + ) + ), + ), + ), + ) { _, _ -> } + + val vaultId = env.vaultRepo.observeAllVaultMetadata().first().single().vaultId + val imported = env.loginRepo.getLoginsByVault(vaultId).single() + assertEquals( + setOf("https://mail.example", "https://mail.example.org"), + imported.domainInfos.map { it.value }.toSet(), + ) + } + + @Test + fun `reuses an existing vault by name`() = runTest { + val env = RestorerTestEnv() + env.vaultRepo.seed(testVault(name = "Personal")) + + val summary = (env.restorer.restore( + backup(vault("Personal", listOf(login("Email", "alice")))), + ) { _, _ -> } as Result.Success).success + + assertEquals(0, summary.vaultsCreated) + assertEquals(1, summary.imported) + } + + @Test + fun `a created vault takes the icon the backup carries`() = runTest { + val env = RestorerTestEnv() + + env.restorer.restore( + backup(vault("Work", listOf(login("Email")), icon = "Business")), + ) { _, _ -> } + + assertEquals( + Vault.Icon.Business, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + + @Test + fun `an icon this build does not know falls back to the default`() = runTest { + val env = RestorerTestEnv() + + env.restorer.restore( + backup(vault("Work", listOf(login("Email")), icon = "Telescope")), + ) { _, _ -> } + + val metadata = env.vaultRepo.observeAllVaultMetadata().first().single() + assertEquals(Vault.Icon.Default, metadata.icon) + assertEquals(1, env.loginRepo.observeLoginsCount()) + } + + @Test + fun `a vault matched by name keeps its own icon`() = runTest { + val env = RestorerTestEnv() + env.vaultRepo.seed(testVault(name = "Personal", icon = Vault.Icon.Star)) + + env.restorer.restore( + backup(vault("Personal", listOf(login("Email")), icon = "Business")), + ) { _, _ -> } + + assertEquals( + Vault.Icon.Star, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + + @Test + fun `a New target takes the icon the user chose, not one of the sources it absorbs`() = + runTest { + val env = RestorerTestEnv() + + env.restorer.restore( + backup( + vault("First", listOf(login("Email")), icon = "Business"), + vault("Second", listOf(login("Bank")), icon = "Work"), + ), + ImportTarget.New("passwords", Vault.Icon.Star), + ) { _, _ -> } + + assertEquals( + Vault.Icon.Star, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + + @Test + fun `skips a login that already exists by name and username`() = runTest { + val env = RestorerTestEnv() + val existing = testVault(name = "Personal") + env.vaultRepo.seed(existing) + env.loginRepo.seed(testLogin(vaultId = existing.id, name = "Email", username = "alice")) + + val summary = (env.restorer.restore( + backup(vault("Personal", listOf(login("Email", "alice"), login("New", "carol")))), + ) { _, _ -> } as Result.Success).success + + assertEquals(1, summary.imported) + assertEquals(1, summary.skipped) + } + + @Test + fun `reports progress for every item`() = runTest { + val env = RestorerTestEnv() + val seen = mutableListOf>() + env.restorer.restore( + backup(vault("V", listOf(login("A"), login("B")))), + ) { p, t -> seen += p to t } + assertEquals(listOf(1 to 2, 2 to 2), seen) + } + + @Test + fun `vault creation failure marks items failed and reports progress per item`() = runTest { + // A blank vault name makes CreateVaultUseCase fail, so the vault cannot be created. + val env = RestorerTestEnv() + val seen = mutableListOf>() + val summary = (env.restorer.restore( + backup(vault("", listOf(login("A"), login("B")))), + ) { p, t -> seen += p to t } as Result.Success).success + + assertEquals(0, summary.imported) + assertEquals(0, summary.vaultsCreated) + assertEquals(2, summary.failed) + assertEquals(listOf(1 to 2, 2 to 2), seen) + } + + @Test + fun `wraps the whole restore in a single transaction`() = runTest { + val env = RestorerTestEnv() + env.restorer.restore( + backup( + vault("V1", listOf(login("A"), login("B"))), + vault("V2", listOf(login("C"))), + ), + ) { _, _ -> } + + assertEquals(1, env.transactionRunner.enteredCount) + } + + @Test + fun `Existing target routes every item into that vault`() = runTest { + val env = RestorerTestEnv() + val existing = testVault(name = "Personal") + env.vaultRepo.seed(existing) + + val summary = (env.restorer.restore( + backup(vault("CSV Import", listOf(login("Email", "alice"), login("Bank", "bob")))), + ImportTarget.Existing(existing.id), + ) { _, _ -> } as Result.Success).success + + assertEquals(2, summary.imported) + assertEquals(0, summary.vaultsCreated) + assertEquals(2, env.loginRepo.getLoginsByVault(existing.id).size) + } + + @Test + fun `New target creates exactly one vault for the whole backup`() = runTest { + val env = RestorerTestEnv() + + val summary = (env.restorer.restore( + backup( + vault("First", listOf(login("Email", "alice"))), + vault("Second", listOf(login("Bank", "bob"))), + ), + ImportTarget.New("passwords"), + ) { _, _ -> } as Result.Success).success + + assertEquals(2, summary.imported) + assertEquals(1, summary.vaultsCreated) + assertEquals( + listOf("passwords"), + env.vaultRepo.observeAllVaultMetadata().first().map { it.name }, + ) + } + + @Test + fun `New target creates a vault even when one of that name already exists`() = runTest { + val env = RestorerTestEnv() + env.vaultRepo.seed(testVault(name = "passwords")) + + val summary = (env.restorer.restore( + backup(vault("CSV Import", listOf(login("Email", "alice")))), + ImportTarget.New("passwords"), + ) { _, _ -> } as Result.Success).success + + assertEquals(1, summary.vaultsCreated) + assertEquals(2, env.vaultRepo.observeAllVaultMetadata().first().size) + } + + @Test + fun `a target ignores the vault names carried by the backup`() = runTest { + val env = RestorerTestEnv() + val existing = testVault(name = "Personal") + env.vaultRepo.seed(existing) + + env.restorer.restore( + backup(vault("CSV Import", listOf(login("Email", "alice")))), + ImportTarget.Existing(existing.id), + ) { _, _ -> } + + assertEquals( + listOf("Personal"), + env.vaultRepo.observeAllVaultMetadata().first().map { it.name }, + ) + } + + // The created vault must join the same transaction as the items, so a rollback takes both. + // FakeTransactionRunner deliberately does not model rollback (it documents this), so asserting + // "the vault disappears on failure" against it would pass vacuously. What is real at this layer + // is that exactly one transaction wraps vault creation *and* every item write. + @Test + fun `New target creates its vault inside the single import transaction`() = runTest { + val env = RestorerTestEnv() + + env.restorer.restore( + backup(vault("First", listOf(login("Email", "alice"), login("Bank", "bob")))), + ImportTarget.New("passwords"), + ) { _, _ -> } + + assertEquals(1, env.transactionRunner.enteredCount) + } +} + +private suspend fun FakeLoginRepository.observeLoginsCount(): Int = + observeLogins().first().size diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappersTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappersTest.kt new file mode 100644 index 000000000..94efc3bc7 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappersTest.kt @@ -0,0 +1,67 @@ +package de.davis.keygo.feature.backup.domain.mapper + +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.MappingConfidence +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.Confidence +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvColumn +import de.davisalessandro.keygo.rust.FieldConfidence +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CsvMappingMappersTest { + + @Test + fun `toDomain folds suggested type and confidence onto each column`() { + val analysis = CsvAnalysis( + columns = listOf( + CsvColumn(index = 0u, header = "name", sampleValues = listOf("Email", "Bank")), + CsvColumn(index = 1u, header = "field_a", sampleValues = listOf("alice@ex.com")), + CsvColumn(index = 2u, header = "favorite", sampleValues = listOf("1", "0")), + ), + suggested = ColumnMapping( + title = 0u, url = null, username = 1u, password = null, notes = null, totp = null, + ), + confidence = FieldConfidence( + title = Confidence.HIGH, + url = null, + username = Confidence.MEDIUM, + password = null, + notes = null, + totp = null, + ), + ) + + val domain = analysis.toDomain() + + assertEquals(3, domain.columns.size) + assertEquals(CsvColumnType.Title, domain.columns[0].suggestedType) + assertEquals(MappingConfidence.High, domain.columns[0].confidence) + assertEquals(listOf("Email", "Bank"), domain.columns[0].samples) + assertEquals(CsvColumnType.Username, domain.columns[1].suggestedType) + assertEquals(MappingConfidence.Medium, domain.columns[1].confidence) + assertNull(domain.columns[2].suggestedType) // unmatched column + assertNull(domain.columns[2].confidence) + } + + @Test + fun `toColumnMapping places each assigned type at its column index`() { + val assignment = mapOf( + 0 to CsvColumnType.Title, + 1 to null, // Ignore + 2 to CsvColumnType.Password, + 3 to CsvColumnType.Totp, + ) + + val mapping = assignment.toColumnMapping() + + assertEquals(0u, mapping.title) + assertEquals(2u, mapping.password) + assertEquals(3u, mapping.totp) + assertNull(mapping.url) + assertNull(mapping.username) + assertNull(mapping.notes) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/model/ExportErrorTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/model/ExportErrorTest.kt new file mode 100644 index 000000000..828a6fa1c --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/model/ExportErrorTest.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davisalessandro.keygo.rust.BackupException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ExportErrorTest { + + @Test + fun `retryable errors have no persistable reason`() { + assertNull(ExportError.SessionLocked.failureReason) + assertNull(ExportError.DeviceLocked.failureReason) + } + + @Test + fun `terminal errors map to their reason`() { + assertEquals(BackupFailureReason.NothingToExport, ExportError.NothingToExport.failureReason) + assertEquals(BackupFailureReason.CryptoFailed, ExportError.CryptoFailed.failureReason) + assertEquals(BackupFailureReason.WriteFailed, ExportError.WriteFailed.failureReason) + assertEquals(BackupFailureReason.NotProvisioned, ExportError.NotProvisioned.failureReason) + } + + @Test + fun `a crypto serialization failure names the crypto sub-case`() { + val error = ExportError.SerializationFailed(BackupException.Crypto("aead failure")) + + assertEquals(BackupFailureReason.CryptoSerializationFailed, error.failureReason) + } + + @Test + fun `a non-crypto serialization failure folds into the generic reason`() { + val json = ExportError.SerializationFailed(BackupException.Json("field 'items' malformed")) + val csv = ExportError.SerializationFailed(BackupException.Csv("row 3 malformed")) + val empty = ExportError.SerializationFailed(BackupException.EmptyCsv()) + val header = ExportError.SerializationFailed(BackupException.MalformedHeader()) + + assertEquals(BackupFailureReason.SerializationFailed, json.failureReason) + assertEquals(BackupFailureReason.SerializationFailed, csv.failureReason) + assertEquals(BackupFailureReason.SerializationFailed, empty.failureReason) + assertEquals(BackupFailureReason.SerializationFailed, header.failureReason) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCaseTest.kt new file mode 100644 index 000000000..e48693259 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCaseTest.kt @@ -0,0 +1,85 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.FakeBackupFileStore +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.CsvColumnAnalysis +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.MappingConfidence +import de.davis.keygo.rust.FakeCsvBackupManager +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.Confidence +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvColumn +import de.davisalessandro.keygo.rust.FieldConfidence +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class AnalyzeCsvUseCaseTest { + + private val fileStore = FakeBackupFileStore() + private val csv = FakeCsvBackupManager() + private fun useCase() = AnalyzeCsvUseCase(fileStore, csv) + private val uri = BackupDestinationUri("content://in.csv") + + @Test + fun `maps analysis to domain columns`() = runTest { + fileStore.contents = "name,pwd\nEmail,s3cr3t\n" + csv.analyzeResult = CsvAnalysis( + columns = listOf( + CsvColumn(0u, "name", listOf("Email")), + CsvColumn(1u, "pwd", listOf("s3cr3t")), + ), + suggested = ColumnMapping(0u, null, null, 1u, null, null), + confidence = FieldConfidence( + title = Confidence.HIGH, + url = null, + username = null, + password = Confidence.LOW, + notes = null, + totp = null, + ), + ) + + val result = useCase()(uri) + + val analysis = assertIs>(result).success as CsvColumnAnalysis + assertEquals(2, analysis.columns.size) + assertEquals(CsvColumnType.Title, analysis.columns[0].suggestedType) + assertEquals(CsvColumnType.Password, analysis.columns[1].suggestedType) + assertEquals(MappingConfidence.Low, analysis.columns[1].confidence) + } + + @Test + fun `unreadable file fails with FileUnreadable`() = runTest { + fileStore.readError = IllegalStateException("disk error") + + val result = useCase()(uri) + + assertEquals(ImportError.FileUnreadable, assertIs>(result).error) + } + + @Test + fun `blank file fails with EmptyFile`() = runTest { + fileStore.contents = " " + + val result = useCase()(uri) + + assertEquals(ImportError.EmptyFile, assertIs>(result).error) + } + + @Test + fun `analyze exception maps to ParseFailed`() = runTest { + fileStore.contents = "name\nEmail\n" + val cause = BackupException.Csv("bad csv") + csv.analyzeException = cause + + val result = useCase()(uri) + + assertEquals(ImportError.ParseFailed(cause), assertIs>(result).error) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt new file mode 100644 index 000000000..b8d7f6f0a --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt @@ -0,0 +1,119 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.ExportDetails +import de.davis.keygo.feature.backup.domain.model.FileFormat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The ARK escrow (`backup_ark_data.pb`) and the job records (`backup_jobs.pb`) are separate + * DataStores with no cross-store transaction, and cleanup decides what to tear down purely from the + * job records. A shared [BackupProvisioningLock] serializes the whole provision-and-schedule section + * against the whole cleanup body so a cleanup can never observe a half-provisioned job (escrow + * written, record not yet) and destroy credentials the new job needs. + */ +class BackupProvisioningSerializationTest { + + private val jobRepository = FakeBackupJobRepository() + private val arkKeyStore = + FakeBackupArkKeyStore(CryptographicData(byteArrayOf(7), byteArrayOf(8))) + private val keyStoreManager = FakeKeyStoreManager() + private val uriManager = FakePersistableUriManager() + private val session = FakeSession(startOnConstruct = true) + private val lock = BackupProvisioningLock() + private val scheduler = FakeBackupScheduler(jobRepository) + + // Provisioning parks here after saving B's escrow but before B's record is written. + private val gate = CompletableDeferred() + + private val finish = FinishExportWizardUseCase( + backupScheduler = FakeBackupScheduler( + jobRepository = jobRepository, + gate = gate, + oneTimeWorkId = "B", + ), + destinationResolver = FakeBackupDestinationResolver(), + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + session = session, + arkKeyStore = arkKeyStore, + provisioningLock = lock, + ) + + private val cleanup = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = lock, + scheduler = scheduler, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `cleanup cannot tear down a job that is concurrently mid-provisioning`() = runTest { + // Job A: a finished one-time job holding no passphrase - non-live, nothing keeps the shared + // credentials alive, so a cleanup that races the provisioning of B would strip them. + jobRepository.jobs["A"] = BackupJob( + uri = BackupDestinationUri("content://A"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 1L, + ) + + // Provision job B (passphrase-encrypted): it takes the lock, creates BackupPassphraseKey and + // BackupArkKey, saves B's escrow, then parks at the gate with B's record not yet written. + val provisioning = launch { + finish( + ExportDetails( + format = FileFormat.JSON, + interval = null, + passphrase = "secret", + uri = BackupDestinationUri("content://B"), + encryption = EncryptionMethod.Passphrase, + ), + ) + } + runCurrent() + + // Concurrent cleanup of the already-finished A. With the lock held it must block and tear + // nothing down: B's escrow and both shared aliases must still be intact. + val cleaning = launch { cleanup("A") } + runCurrent() + + assertNotNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + + // Release provisioning: it writes B's live record and drops the lock, then cleanup runs and, + // seeing B live, spares the escrow and both aliases. + gate.complete(Unit) + advanceUntilIdle() + + assertTrue(provisioning.isCompleted) + assertTrue(cleaning.isCompleted) + assertNotNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupWorkActionsUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupWorkActionsUseCaseTest.kt new file mode 100644 index 000000000..3c8595c78 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupWorkActionsUseCaseTest.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakeDispatchedBackupRepository +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BackupWorkActionsUseCaseTest { + + private val repository = FakeDispatchedBackupRepository() + private val jobRepository = FakeBackupJobRepository() + private val uriManager = FakePersistableUriManager() + private val scheduler = FakeBackupScheduler(jobRepository) + + private val cancelBackup = CancelBackupUseCase( + repository = repository, + jobRepository = jobRepository, + cleanupBackupResources = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = FakeBackupArkKeyStore(), + keyStoreManager = FakeKeyStoreManager(), + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ), + ) + + private fun job() = BackupJob( + uri = BackupDestinationUri("content://folder"), + wrappedPassphrase = null, + format = FileFormat.JSON, + ) + + @Test + fun `cancel forwards the id to the repository`() = runTest { + jobRepository.jobs["work-42"] = job() + + cancelBackup("work-42", DispatchedBackup.Kind.OneTime) + + assertEquals(listOf("work-42"), repository.cancelledIds) + } + + @Test + fun `cancelling a one-time backup marks its record and frees the folder`() = runTest { + jobRepository.jobs["work-42"] = job() + + cancelBackup("work-42", DispatchedBackup.Kind.OneTime) + + assertTrue(jobRepository.jobs.getValue("work-42").cancelled) + assertEquals(listOf(BackupDestinationUri("content://folder")), uriManager.released) + } + + @Test + fun `cancelling a recurring backup marks the recurring record, not the work id`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = job() + + cancelBackup("work-uuid", DispatchedBackup.Kind.Recurring) + + assertTrue(jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID).cancelled) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCaseTest.kt new file mode 100644 index 000000000..46831d09c --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCaseTest.kt @@ -0,0 +1,327 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.test.runTest +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CleanupBackupResourcesUseCaseTest { + + private val jobRepository = FakeBackupJobRepository() + private val arkKeyStore = + FakeBackupArkKeyStore(CryptographicData(byteArrayOf(1), byteArrayOf(2))) + private val keyStoreManager = FakeKeyStoreManager() + private val uriManager = FakePersistableUriManager() + private val scheduler = FakeBackupScheduler(jobRepository) + + private val useCase = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ) + + private val folder = BackupDestinationUri("content://folder") + + private fun job( + uri: BackupDestinationUri = folder, + wrapped: CryptographicData? = CryptographicData(byteArrayOf(9), byteArrayOf(8)), + finishedAt: Long? = null, + cancelled: Boolean = false, + ) = BackupJob( + uri = uri, + wrappedPassphrase = wrapped, + format = FileFormat.JSON, + finishedAt = finishedAt, + cancelled = cancelled, + ) + + /** The fake only ever removes a key on [FakeKeyStoreManager.deleteKey]; it never creates one + * implicitly, so a test asserting a key survives cleanup must seed it first. */ + private fun seedKey(keyId: KeyId) { + keyStoreManager.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt) + } + + @Test + fun `a finished one-time job releases every credential it held`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + + useCase("w") + + assertNull(jobRepository.jobs.getValue("w").wrappedPassphrase) + assertEquals(listOf(folder), uriManager.released) + assertNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey !in keyStoreManager.keys) + assertTrue(KeyId.BackupPassphraseKey !in keyStoreManager.keys) + } + + @Test + fun `a live recurring schedule keeps the ark escrow`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + // A recurring record is live even with finishedAt stamped: it is set after every run. + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = + job(uri = BackupDestinationUri("content://other"), finishedAt = 5L) + seedKey(KeyId.BackupArkKey) + seedKey(KeyId.BackupPassphraseKey) + + useCase("w") + + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertContentEquals(byteArrayOf(2), escrow?.iv) + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + // The recurring job still holds a wrapped passphrase, so its key must survive too. + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } + + @Test + fun `cleanup on a still-live recurring schedule leaves everything untouched`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = job(finishedAt = 5L) + + useCase(BackupWorker.RECURRING_WORK_ID) + + assertEquals( + CryptographicData(byteArrayOf(9), byteArrayOf(8)), + jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID).wrappedPassphrase, + ) + assertTrue(uriManager.released.isEmpty()) + assertTrue(keyStoreManager.keys.isEmpty()) + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertContentEquals(byteArrayOf(2), escrow?.iv) + } + + @Test + fun `the folder grant survives while a live job still targets it`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + jobRepository.jobs["pending"] = job(finishedAt = null) + + useCase("w") + + assertTrue(uriManager.released.isEmpty()) + } + + @Test + fun `a job cancelled before it ran is not live and frees the escrow`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = null, cancelled = true) + + useCase("w") + + assertNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey !in keyStoreManager.keys) + } + + @Test + fun `a cancelled recurring schedule is not live and frees everything`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = job(cancelled = true) + + useCase(BackupWorker.RECURRING_WORK_ID) + + assertNull(jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID).wrappedPassphrase) + assertEquals(listOf(folder), uriManager.released) + assertNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey !in keyStoreManager.keys) + assertTrue(KeyId.BackupPassphraseKey !in keyStoreManager.keys) + } + + @Test + fun `the passphrase key survives while another record still holds one`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + jobRepository.jobs["pending"] = job(finishedAt = null) + seedKey(KeyId.BackupPassphraseKey) + + useCase("w") + + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } + + @Test + fun `reconcile frees the escrow of a job the scheduler has dropped`() = runTest { + // The record still reads live - nothing ever closed it - but the platform gave the work up, + // so no run can come of it and its escrowed ARK must not outlive it. + jobRepository.jobs["w"] = job(finishedAt = null) + scheduler.abandon("w") + seedKey(KeyId.BackupArkKey) + + useCase.reconcile() + + assertNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey !in keyStoreManager.keys) + } + + @Test + fun `reconcile keeps the escrow while the scheduler still has the work`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = null) + seedKey(KeyId.BackupArkKey) + + useCase.reconcile() + + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + } + + @Test + fun `reconcile keeps the escrow when the scheduler cannot be read`() = runTest { + // An unreadable scheduler must never read as "nothing is scheduled" - that would tear down + // credentials a perfectly live job still needs. + jobRepository.jobs["w"] = job(finishedAt = null) + scheduler.abandon("w") + scheduler.outstandingFailure = IOException("boom") + seedKey(KeyId.BackupArkKey) + + useCase.reconcile() + + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + } + + @Test + fun `reconcile hands back the passphrase of a job the scheduler has dropped`() = runTest { + // No dispatch ever ended this job, so nothing ever cleared its passphrase. Freeing the ARK + // escrow without freeing this too would strand the wrapped passphrase and the auth-less + // alias that opens it. + jobRepository.jobs["w"] = job(finishedAt = null) + scheduler.abandon("w") + seedKey(KeyId.BackupPassphraseKey) + + useCase.reconcile() + + assertNull(jobRepository.jobs.getValue("w").wrappedPassphrase) + assertTrue(KeyId.BackupPassphraseKey !in keyStoreManager.keys) + } + + @Test + fun `reconcile leaves a live job's passphrase alone`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = null) + seedKey(KeyId.BackupPassphraseKey) + + useCase.reconcile() + + assertEquals( + CryptographicData(byteArrayOf(9), byteArrayOf(8)), + jobRepository.jobs.getValue("w").wrappedPassphrase, + ) + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } + + @Test + fun `a finished job also hands back a dropped sibling's passphrase`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + jobRepository.jobs["dropped"] = job(finishedAt = null) + scheduler.abandon("dropped") + seedKey(KeyId.BackupPassphraseKey) + + useCase("w") + + assertNull(jobRepository.jobs.getValue("dropped").wrappedPassphrase) + assertTrue(KeyId.BackupPassphraseKey !in keyStoreManager.keys) + } + + @Test + fun `a dropped job no longer holds the escrow open for a finished one`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + jobRepository.jobs["dropped"] = job(finishedAt = null) + scheduler.abandon("dropped") + + useCase("w") + + assertNull(arkKeyStore.load()) + assertTrue(KeyId.BackupArkKey !in keyStoreManager.keys) + } + + @Test + fun `cleanup returns normally even when reading jobs throws`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + val failingReads = object : BackupJobRepository by jobRepository { + override suspend fun getJobs(): Map = throw IOException("boom") + } + val useCase = CleanupBackupResourcesUseCase( + jobRepository = failingReads, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ) + + useCase("w") + + assertTrue(keyStoreManager.keys.isEmpty()) + assertTrue(uriManager.released.isEmpty()) + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertContentEquals(byteArrayOf(2), escrow?.iv) + } + + @Test + fun `a failed ark clear leaves the ark key alone`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + val failingClear = object : BackupArkKeyStore by arkKeyStore { + override suspend fun clear(): Unit = throw IOException("boom") + } + val useCase = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = failingClear, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ) + seedKey(KeyId.BackupArkKey) + + useCase("w") + + // clear() failed, so the escrowed ciphertext is still on disk - the alias that opens it + // must not be deleted underneath it. + assertTrue(KeyId.BackupArkKey in keyStoreManager.keys) + val escrow = arkKeyStore.load() + assertContentEquals(byteArrayOf(1), escrow?.data) + assertContentEquals(byteArrayOf(2), escrow?.iv) + } + + @Test + fun `a failed passphrase clear leaves the passphrase key alone`() = runTest { + jobRepository.jobs["w"] = job(finishedAt = 1L) + val failingClear = object : BackupJobRepository by jobRepository { + override suspend fun clearPassphrase(workId: WorkId): Unit = throw IOException("boom") + } + val useCase = CleanupBackupResourcesUseCase( + jobRepository = failingClear, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ) + seedKey(KeyId.BackupPassphraseKey) + + useCase("w") + + // clearPassphrase failed, so the record still holds a wrapped passphrase and getJobs + // correctly reports it - the alias must not be deleted underneath it. + assertTrue(KeyId.BackupPassphraseKey in keyStoreManager.keys) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt new file mode 100644 index 000000000..51c5f9231 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt @@ -0,0 +1,342 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakePasskeyRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupFileStore +import de.davis.keygo.feature.backup.domain.BackupArkUnlocker +import de.davis.keygo.feature.backup.domain.BackupCollector +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupEntry +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.testLogin +import de.davis.keygo.feature.backup.testVault +import de.davis.keygo.rust.FakeCsvBackupManager +import de.davis.keygo.rust.FakeJsonBackupManager +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.ExportPreset +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ExportBackupUseCaseTest { + + private val vaultRepo = FakeVaultRepository() + private val loginRepo = FakeLoginRepository() + private val cardRepo = FakeCreditCardRepository() + private val passkeyRepo = FakePasskeyRepository() + private val scope = FakeCryptographicScopeProvider(FakeItemRepository()) + private val keyStore = FakeKeyStoreManager() + private val arkStore = FakeBackupArkKeyStore() + private val factory = FakeCryptographicScopeProviderFactory(scope) + private val fileStore = FakeBackupFileStore() + private val json = FakeJsonBackupManager() + private val csv = FakeCsvBackupManager() + + private val folder = BackupDestinationUri("content://tree") + + private fun useCase(session: FakeSession): ExportBackupUseCase { + val arkUnlocker = BackupArkUnlocker(session, keyStore, arkStore, factory, vaultRepo) + return ExportBackupUseCase( + collector = BackupCollector( + vaultRepository = vaultRepo, + loginRepository = loginRepo, + creditCardRepository = cardRepo, + passkeyRepository = passkeyRepo, + arkUnlocker = arkUnlocker, + ), + fileStore = fileStore, + jsonBackupManager = json, + csvBackupManager = csv, + keyStoreManager = keyStore, + arkUnlocker = arkUnlocker, + ) + } + + private suspend fun provision(session: FakeSession) { + val cipher = keyStore.getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Encrypt) + arkStore.save(CryptographicData(cipher.doFinal(session.ark), cipher.iv)) + } + + private val csvJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.CSV, + ) + + private fun unlocked() = FakeSession(startOnConstruct = true) + + private fun seedSingleLogin() { + val vault = testVault(name = "V") + vaultRepo.seed(vault) + loginRepo.seed(testLogin(vaultId = vault.id, name = "Email", username = "alice")) + } + + private fun seedExistingBackup(name: String) { + fileStore.backups += BackupEntry(BackupDestinationUri("${folder.value}/$name"), name) + } + + @Test + fun `locked and unprovisioned session fails with NotProvisioned`() = runTest { + seedSingleLogin() + val emissions = useCase(FakeSession(startOnConstruct = false))(csvJob).toList() + assertEquals(ExportProgress.Failed(ExportError.NotProvisioned), emissions.last()) + } + + @Test + fun `locked but provisioned session exports successfully`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + provision(unlocked()) + val emissions = useCase(FakeSession(startOnConstruct = false))(csvJob).toList() + assertIs(emissions.last()) + } + + @Test + fun `csv export writes a timestamped document into the folder and succeeds`() = runTest { + seedSingleLogin() + csv.exportResult = "name,url,username,password,note\nEmail,,alice,,\n" + + val emissions = useCase(unlocked())(csvJob).toList() + + assertEquals(ExportProgress.Running(1, 1), emissions[0]) + assertEquals(ExportProgress.Writing, emissions[1]) + assertEquals(ExportProgress.Succeeded(1), emissions.last()) + assertEquals(csv.exportResult, fileStore.writtenText) + assertEquals(folder, fileStore.writtenFolder) + assertEquals(FileFormat.CSV.mimeType, fileStore.writtenMimeType) + assertTrue(fileStore.writtenFileName!!.startsWith("keygo-backup-")) + assertTrue(fileStore.writtenFileName!!.endsWith(".csv")) + } + + @Test + fun `empty database fails with NothingToExport`() = runTest { + val emissions = useCase(unlocked())(csvJob).toList() + val failed = assertIs(emissions.last()) + assertEquals(ExportError.NothingToExport, failed.error) + } + + @Test + fun `write failure surfaces WriteFailed`() = runTest { + seedSingleLogin() + fileStore.writeError = RuntimeException("disk full") + + val emissions = useCase(unlocked())(csvJob).toList() + assertEquals(ExportError.WriteFailed, (emissions.last() as ExportProgress.Failed).error) + } + + @Test + fun `json job without passphrase fails with CryptoFailed`() = runTest { + seedSingleLogin() + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.JSON, + ) + + val emissions = useCase(unlocked())(jsonJob).toList() + + val failed = assertIs(emissions.last()) + assertEquals(ExportError.CryptoFailed, failed.error) + } + + @Test + fun `passphrase decryption on a locked device fails with DeviceLocked`() = runTest { + seedSingleLogin() + val cipher = keyStore.getOrCreateCipherFor(KeyId.BackupPassphraseKey, CryptographicMode.Encrypt) + val wrappedPassphrase = CryptographicData(cipher.doFinal("pw".encodeToByteArray()), cipher.iv) + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = wrappedPassphrase, + format = FileFormat.JSON, + ) + keyStore.deviceLocked = true + + val emissions = useCase(unlocked())(jsonJob).toList() + + val failed = assertIs(emissions.last()) + assertEquals(ExportError.DeviceLocked, failed.error) + } + + @Test + fun `ark json job seals with the session ark`() = runTest { + seedSingleLogin() + json.exportResult = "{}" + val session = unlocked() + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.JSON, + encryption = EncryptionMethod.Ark, + ) + + val emissions = useCase(session)(jsonJob).toList() + + assertIs(emissions.last()) + val credential = assertIs(json.exportCalls.single().credential) + assertContentEquals(session.ark, credential.key) + } + + @Test + fun `ark json job on a locked provisioned device uses the recovered ark`() = runTest { + seedSingleLogin() + json.exportResult = "{}" + val unlockedSession = unlocked() + provision(unlockedSession) + val jsonJob = BackupJob( + uri = folder, + wrappedPassphrase = null, + format = FileFormat.JSON, + encryption = EncryptionMethod.Ark, + ) + + val emissions = useCase(FakeSession(startOnConstruct = false))(jsonJob).toList() + + assertIs(emissions.last()) + val credential = assertIs(json.exportCalls.single().credential) + assertContentEquals(unlockedSession.ark, credential.key) + } + + @Test + fun `csv job exports with its configured preset`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + + useCase(unlocked())(csvJob.copy(csvPreset = CsvPreset.KeyGo)).toList() + + assertEquals(ExportPreset.KEY_GO, csv.exportCalls.single().preset) + } + + @Test + fun `csv job without preset falls back to Browser`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + + useCase(unlocked())(csvJob).toList() + + assertEquals(ExportPreset.BROWSER, csv.exportCalls.single().preset) + } + + @Test + fun `csv serialization failure surfaces SerializationFailed`() = runTest { + seedSingleLogin() + csv.exportException = BackupException.EmptyCsv() + + val emissions = useCase(unlocked())(csvJob).toList() + + val failed = assertIs(emissions.last()) + assertIs(failed.error) + } + + @Test + fun `recurring job prunes backups beyond keepCount keeping the newest`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-2.csv") + seedExistingBackup("keygo-backup-3.csv") + + useCase(unlocked())(csvJob.copy(keepCount = 2)).toList() + + assertEquals( + setOf("${folder.value}/keygo-backup-1.csv", "${folder.value}/keygo-backup-2.csv"), + fileStore.deleted.map { it.value }.toSet(), + ) + } + + @Test + fun `job without keepCount keeps all backups`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-2.csv") + + useCase(unlocked())(csvJob).toList() + + assertEquals(emptyList(), fileStore.deleted) + } + + @Test + fun `pruning ignores documents of a different format`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-1.json") + + useCase(unlocked())(csvJob.copy(keepCount = 1)).toList() + + assertEquals( + listOf("${folder.value}/keygo-backup-1.csv"), + fileStore.deleted.map { it.value }) + } + + @Test + fun `pruning leaves files it did not write alone`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + // Shares the base name and extension but carries no epoch-millis stamp, so it is not a + // document this app wrote - a user's own renamed copy, kept in the same folder. + seedExistingBackup("keygo-backup-before-trip.csv") + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-2.csv") + + useCase(unlocked())(csvJob.copy(keepCount = 1)).toList() + + assertEquals( + setOf("${folder.value}/keygo-backup-1.csv", "${folder.value}/keygo-backup-2.csv"), + fileStore.deleted.map { it.value }.toSet(), + ) + } + + @Test + fun `pruning leaves a collision-renamed document alone`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + // What SAF produces when the name it is asked for is already taken. It parses as neither a + // timestamp nor anything else orderable, so it must not be treated as the oldest backup. + seedExistingBackup("keygo-backup-1700000000000 (1).csv") + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-2.csv") + + useCase(unlocked())(csvJob.copy(keepCount = 1)).toList() + + assertEquals( + setOf("${folder.value}/keygo-backup-1.csv", "${folder.value}/keygo-backup-2.csv"), + fileStore.deleted.map { it.value }.toSet(), + ) + } + + @Test + fun `prune failure does not fail a successful backup`() = runTest { + seedSingleLogin() + csv.exportResult = "data" + seedExistingBackup("keygo-backup-1.csv") + seedExistingBackup("keygo-backup-2.csv") + fileStore.listError = RuntimeException("boom") + + val emissions = useCase(unlocked())(csvJob.copy(keepCount = 1)).toList() + + assertEquals(ExportProgress.Succeeded(1), emissions.last()) + assertEquals(emptyList(), fileStore.deleted) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt new file mode 100644 index 000000000..bfeebab9d --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt @@ -0,0 +1,200 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.ExportDetails +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.FinishExportWizardError +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertContentEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FinishExportWizardUseCaseTest { + + private val scheduler = FakeBackupScheduler() + private val persistable = FakePersistableUriManager() + private val session = FakeSession(startOnConstruct = true) + private val keyStoreManager = FakeKeyStoreManager() + private val arkKeyStore = FakeBackupArkKeyStore() + private val destinationResolver = FakeBackupDestinationResolver() + + private fun useCase() = FinishExportWizardUseCase( + backupScheduler = scheduler, + destinationResolver = destinationResolver, + keyStoreManager = keyStoreManager, + persistableUriManager = persistable, + session = session, + arkKeyStore = arkKeyStore, + provisioningLock = BackupProvisioningLock(), + ) + + private val uri = BackupDestinationUri("content://tree") + + // CSV keeps the passphrase/crypto path out of the picture; the mapping under test is + // schedule/keepCount/permission, not encryption. + private fun details(interval: BackupInterval? = null, keepCount: Int? = null) = ExportDetails( + format = FileFormat.CSV, + interval = interval, + passphrase = "", + uri = uri, + keepCount = keepCount, + ) + + private fun jsonDetails( + encryption: EncryptionMethod, + passphrase: String = "", + ) = ExportDetails( + format = FileFormat.JSON, + interval = null, + passphrase = passphrase, + uri = uri, + encryption = encryption, + ) + + @Test + fun `recurring backup persists keepCount and takes folder permission`() = runTest { + val result = useCase()( + details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days), keepCount = 5), + ) + + assertIs>(result) + assertEquals(5, scheduler.recurringJob?.keepCount) + assertEquals(listOf(uri), persistable.taken) + } + + @Test + fun `one-time backup takes folder permission and keeps all`() = runTest { + val result = useCase()(details()) + + assertIs>(result) + assertNull(scheduler.oneTimeJob?.keepCount) + assertEquals(listOf(uri), persistable.taken) + } + + @Test + fun `the destination name is captured into the record while the grant is live`() = runTest { + destinationResolver.result = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Backups", + ) + + useCase()(details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days))) + + assertEquals("Backups", scheduler.recurringJob?.destinationName) + } + + @Test + fun `the name is read only after the grant is secured`() = runTest { + persistable.throwOnTake = SecurityException("denied") + + useCase()(details()) + + // Asking a provider we were just refused would only ever return the fallback, and storing + // that would bake a wrong label into the record for good. + assertNull(destinationResolver.lastUri) + } + + @Test + fun `permission failure returns DestinationPermissionDenied and does not schedule`() = runTest { + persistable.throwOnTake = SecurityException("denied") + + val result = useCase()( + details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days), keepCount = 5), + ) + + val failure = assertIs>(result) + assertEquals(FinishExportWizardError.DestinationPermissionDenied, failure.error) + assertNull(scheduler.recurringJob) + } + + @Test + fun `configuring a backup provisions the ARK copy`() = runTest { + useCase()(details(interval = BackupInterval(count = 3, unit = IntervalUnit.Days))) + + val wrapped = arkKeyStore.load() + assertNotNull(wrapped) + val recovered = keyStoreManager + .getOrCreateCipherFor(KeyId.BackupArkKey, CryptographicMode.Decrypt, wrapped.iv) + .doFinal(wrapped.data) + assertContentEquals(session.ark, recovered) + } + + @Test + fun `ark encryption schedules without a passphrase`() = runTest { + val result = useCase()(jsonDetails(encryption = EncryptionMethod.Ark)) + + assertIs>(result) + val job = assertNotNull(scheduler.oneTimeJob) + assertEquals(EncryptionMethod.Ark, job.encryption) + assertNull(job.wrappedPassphrase) + } + + @Test + fun `passphrase encryption with blank passphrase fails with PassphraseEmpty`() = runTest { + val result = useCase()(jsonDetails(encryption = EncryptionMethod.Passphrase)) + + val failure = assertIs>(result) + assertEquals(FinishExportWizardError.PassphraseEmpty, failure.error) + } + + @Test + fun `passphrase encryption wraps the passphrase into the job`() = runTest { + val result = useCase()( + jsonDetails(encryption = EncryptionMethod.Passphrase, passphrase = "secret"), + ) + + assertIs>(result) + val job = assertNotNull(scheduler.oneTimeJob) + assertEquals(EncryptionMethod.Passphrase, job.encryption) + assertNotNull(job.wrappedPassphrase) + } + + @Test + fun `csv preset is carried onto the job`() = runTest { + useCase()(details().copy(csvPreset = CsvPreset.KeyGo)) + + assertEquals(CsvPreset.KeyGo, scheduler.oneTimeJob?.csvPreset) + } + + @Test + fun `a failed schedule releases the just-taken folder grant`() = runTest { + // A failed schedule persists no record, so nothing will ever drive the grant's release - + // the use case must release it itself to avoid leaking against the platform cap. + scheduler.result = Result.Failure(Unit) + + val result = useCase()(details()) + + val failure = assertIs>(result) + assertEquals(FinishExportWizardError.SchedulePersistenceFailed, failure.error) + assertEquals(listOf(uri), persistable.taken) + assertEquals(listOf(uri), persistable.released) + } + + @Test + fun `a successful schedule keeps the folder grant`() = runTest { + val result = useCase()(details()) + + assertIs>(result) + assertEquals(listOf(uri), persistable.taken) + assertTrue(persistable.released.isEmpty()) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt new file mode 100644 index 000000000..c31d6b238 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt @@ -0,0 +1,361 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.FakeBackupFileStore +import de.davis.keygo.feature.backup.RestorerTestEnv +import de.davis.keygo.feature.backup.backupVault +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.model.ImportRequest +import de.davis.keygo.feature.backup.domain.model.ImportTarget +import de.davis.keygo.feature.backup.testVault +import de.davis.keygo.rust.FakeCsvBackupManager +import de.davis.keygo.rust.FakeJsonBackupManager +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvImportResult +import de.davisalessandro.keygo.rust.FieldConfidence +import de.davisalessandro.keygo.rust.ImportReport +import de.davisalessandro.keygo.rust.JsonEncryption +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ImportBackupUseCaseTest { + + private val env = RestorerTestEnv() + private val fileStore = FakeBackupFileStore() + private val json = FakeJsonBackupManager() + private val csv = FakeCsvBackupManager() + + private fun useCase(session: FakeSession = FakeSession(startOnConstruct = true)) = + ImportBackupUseCase(fileStore, json, csv, env.restorer, session) + + private fun jsonRequest(passphrase: String? = "pw") = ImportRequest( + uri = BackupDestinationUri("content://in.json"), + format = FileFormat.JSON, + passphrase = passphrase, + ) + + private fun login(title: String) = BackupLogin( + title = title, + notes = null, + tags = emptyList(), + pinned = false, + username = null, + password = "pw", + totpSecret = null, + websites = emptyList(), + passkeys = emptyList(), + ) + + @Test + fun `locked session fails fast`() = runTest { + val emissions = useCase(FakeSession(startOnConstruct = false))(jsonRequest()).toList() + assertEquals(listOf(ImportProgress.Failed(ImportError.SessionLocked)), emissions) + } + + @Test + fun `unreadable file emits FileUnreadable`() = runTest { + fileStore.readError = IllegalStateException("disk error") + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Reading, emissions[0]) + assertEquals(ImportProgress.Failed(ImportError.FileUnreadable), emissions[1]) + } + + @Test + fun `blank file emits EmptyFile`() = runTest { + fileStore.contents = " " + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Reading, emissions[0]) + assertEquals(ImportProgress.Failed(ImportError.EmptyFile), emissions[1]) + } + + @Test + fun `json parse failure maps CredentialMismatch to WrongCredential`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importException = BackupException.CredentialMismatch() + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Parsing, emissions[1]) + assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) + } + + @Test + fun `json parse failure maps Crypto to WrongCredential`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importException = BackupException.Crypto("bad key") + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) + } + + @Test + fun `json parse failure maps Json to ParseFailed`() = runTest { + fileStore.contents = """not-json""" + val cause = BackupException.Json("bad json") + json.importException = cause + + val emissions = useCase()(jsonRequest()).toList() + + val failed = assertIs(emissions.last()) + assertEquals(ImportError.ParseFailed(cause), failed.error) + } + + @Test + fun `json import restores and succeeds`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Reading, emissions[0]) + assertEquals(ImportProgress.Parsing, emissions[1]) + val success = assertIs(emissions.last()) + assertEquals(1, success.summary.imported) + } + + @Test + fun `csv import uses the suggested mapping`() = runTest { + fileStore.contents = "name,username\nEmail,alice\n" + csv.analyzeResult = CsvAnalysis( + columns = emptyList(), + suggested = ColumnMapping(0u, null, 1u, null, null, null), + confidence = FieldConfidence(null, null, null, null, null, null), + ) + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + + val emissions = useCase()( + ImportRequest(BackupDestinationUri("content://in.csv"), FileFormat.CSV, null), + ).toList() + + val success = assertIs(emissions.last()) + assertEquals(1, success.summary.imported) + assertEquals(csv.analyzeResult.suggested, csv.importCalls.last().mapping) + } + + @Test + fun `csv import prefers the caller-provided mapping over the suggestion`() = runTest { + fileStore.contents = "name,secret\nEmail,alice\n" + csv.analyzeResult = CsvAnalysis( + columns = emptyList(), + suggested = ColumnMapping(0u, null, 1u, null, null, null), // suggests username=1 + confidence = FieldConfidence(null, null, null, null, null, null), + ) + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val edited = ColumnMapping(0u, null, null, 1u, null, null) // user says col 1 = password + + val emissions = useCase()( + ImportRequest( + uri = BackupDestinationUri("content://in.csv"), + format = FileFormat.CSV, + passphrase = null, + csvMapping = edited, + ), + ).toList() + + assertIs(emissions.last()) + assertEquals(edited, csv.importCalls.last().mapping) + } + + @Test + fun `csv analyze failure maps CredentialMismatch to WrongCredential`() = runTest { + fileStore.contents = "name\nEmail\n" + csv.analyzeException = BackupException.CredentialMismatch() + + val emissions = useCase()( + ImportRequest(BackupDestinationUri("content://in.csv"), FileFormat.CSV, null), + ).toList() + + assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) + } + + @Test + fun `empty backup from restorer emits NothingImported`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(emptyList()) + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Failed(ImportError.NothingImported), emissions.last()) + } + + @Test + fun `running progress is emitted during restore`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup( + listOf(backupVault("V", listOf(login("A"), login("B")))), + ) + + val emissions = useCase()(jsonRequest()).toList() + + val running = emissions.filterIsInstance() + assertEquals(2, running.size) + assertEquals(ImportProgress.Running(1, 2), running[0]) + assertEquals(ImportProgress.Running(2, 2), running[1]) + } + + @Test + fun `ark-sealed json imports with the session ark`() = runTest { + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.ARK + json.importResult = Backup(listOf(backupVault("V", listOf(login("A"))))) + val session = FakeSession(startOnConstruct = true) + + val emissions = useCase(session)(jsonRequest(passphrase = null)).toList() + + assertIs(emissions.last()) + val credential = assertIs(json.importCalls.single().credential) + assertContentEquals(session.ark, credential.key) + } + + @Test + fun `passphrase bytes are zeroed once the import returns`() = runTest { + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.PASSPHRASE + json.importResult = Backup(listOf(backupVault("V", listOf(login("A"))))) + // Hold the array the use case hands to Rust, rather than the fake's defensive copy. + var handed: ByteArray? = null + json.onImport = { _, credential -> + handed = (credential as BackupCredential.Passphrase).bytes + } + + val emissions = useCase()(jsonRequest(passphrase = "hunter2")).toList() + + assertIs(emissions.last()) + assertContentEquals(ByteArray(7), handed) + } + + @Test + fun `passphrase-sealed json without a passphrase fails with PassphraseRequired`() = runTest { + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.PASSPHRASE + + val emissions = useCase()(jsonRequest(passphrase = null)).toList() + + assertEquals(ImportProgress.Failed(ImportError.PassphraseRequired), emissions.last()) + assertEquals(emptyList(), json.importCalls) + } + + @Test + fun `unreadable header surfaces as import failure`() = runTest { + fileStore.contents = "{}" + json.inspectException = BackupException.MalformedHeader() + + val emissions = useCase()(jsonRequest()).toList() + + assertIs(emissions.last()) + } + + @Test + fun `session locked between read and parse fails with SessionLocked instead of throwing`() = + runTest { + val session = FakeSession(startOnConstruct = true) + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.ARK + val lockDuringRead = object : BackupFileStore by fileStore { + override suspend fun read(uri: BackupDestinationUri): Result { + val result = fileStore.read(uri) + session.endSession() + return result + } + } + + val emissions = ImportBackupUseCase(lockDuringRead, json, csv, env.restorer, session)( + jsonRequest(passphrase = null), + ).toList() + + assertEquals( + listOf( + ImportProgress.Reading, + ImportProgress.Parsing, + ImportProgress.Failed(ImportError.SessionLocked), + ), + emissions, + ) + } + + @Test + fun `a request targeting an existing vault imports into it`() = runTest { + val existing = testVault(name = "Personal") + env.vaultRepo.seed(existing) + fileStore.contents = "name,username\nEmail,alice\n" + csv.analyzeResult = CsvAnalysis( + columns = emptyList(), + suggested = ColumnMapping(0u, null, 1u, null, null, null), + confidence = FieldConfidence(null, null, null, null, null, null), + ) + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + + val emissions = useCase()( + ImportRequest( + uri = BackupDestinationUri("content://in.csv"), + format = FileFormat.CSV, + passphrase = null, + target = ImportTarget.Existing(existing.id), + ), + ).toList() + + val success = assertIs(emissions.last()) + assertEquals(1, success.summary.imported) + assertEquals(0, success.summary.vaultsCreated) + assertEquals(1, env.loginRepo.getLoginsByVault(existing.id).size) + } + + @Test + fun `a request targeting a new vault creates it and ignores the parsed vault name`() = runTest { + fileStore.contents = "name,username\nEmail,alice\n" + csv.analyzeResult = CsvAnalysis( + columns = emptyList(), + suggested = ColumnMapping(0u, null, 1u, null, null, null), + confidence = FieldConfidence(null, null, null, null, null, null), + ) + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + + val emissions = useCase()( + ImportRequest( + uri = BackupDestinationUri("content://in.csv"), + format = FileFormat.CSV, + passphrase = null, + target = ImportTarget.New("passwords"), + ), + ).toList() + + val success = assertIs(emissions.last()) + assertEquals(1, success.summary.vaultsCreated) + assertEquals( + listOf("passwords"), + env.vaultRepo.observeAllVaultMetadata().first().map { it.name }, + ) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt new file mode 100644 index 000000000..856611bb6 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt @@ -0,0 +1,217 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeDispatchedBackupRepository +import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ObserveDispatchedBackupsUseCaseTest { + + private val repository = FakeDispatchedBackupRepository() + private val jobRepository = FakeBackupJobRepository() + private val destinationResolver = FakeBackupDestinationResolver() + private val useCase = + ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver) + + private fun status( + id: String, + kind: DispatchedBackup.Kind = DispatchedBackup.Kind.OneTime, + state: DispatchedBackup.State = DispatchedBackup.State.Running(), + ) = BackupWorkStatus(id, kind, state) + + @Test + fun `enriches a one-time worker from its persisted job`() = runTest { + jobRepository.jobs["work-1"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + ) + destinationResolver.result = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", + ) + repository.statuses.value = listOf(status(id = "work-1")) + + val result = useCase().first().single() + + assertEquals("work-1", result.id) + assertEquals(FileFormat.JSON, result.format) + assertEquals(BackupDestination.Provider.ThirdParty("Drive"), result.destination?.provider) + } + + @Test + fun `the stored destination name is handed to the resolver instead of the provider`() = runTest { + jobRepository.jobs["work-1"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + destinationName = "Backups", + ) + repository.statuses.value = listOf(status(id = "work-1")) + + useCase().first() + + assertEquals("Backups", destinationResolver.lastCachedName) + } + + @Test + fun `recurring worker is enriched from the recurring job key`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://recurring.csv"), + wrappedPassphrase = null, + format = FileFormat.CSV, + ) + repository.statuses.value = listOf( + status(id = "any-runtime-id", kind = DispatchedBackup.Kind.Recurring), + ) + + val result = useCase().first().single() + + assertEquals(FileFormat.CSV, result.format) + assertEquals(BackupDestinationUri("content://recurring.csv"), destinationResolver.lastUri) + } + + @Test + fun `missing job leaves format and destination null`() = runTest { + repository.statuses.value = listOf(status(id = "orphan")) + + val result = useCase().first().single() + + assertNull(result.format) + assertNull(result.destination) + } + + @Test + fun `running progress passes through unchanged`() = runTest { + repository.statuses.value = listOf( + status(id = "w", state = DispatchedBackup.State.Running(ExportProgress.Running(3, 7))), + ) + + val result = useCase().first().single() + + assertEquals(DispatchedBackup.State.Running(ExportProgress.Running(3, 7)), result.state) + } + + @Test + fun `writing progress passes through unchanged`() = runTest { + repository.statuses.value = listOf( + status(id = "w", state = DispatchedBackup.State.Running(ExportProgress.Writing)), + ) + + val result = useCase().first().single() + + assertEquals(DispatchedBackup.State.Running(ExportProgress.Writing), result.state) + } + + @Test + fun `timestamp prefers finishedAt then createdAt`() = runTest { + jobRepository.jobs["finished"] = BackupJob( + uri = BackupDestinationUri("content://a.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 10L, + finishedAt = 99L, + ) + jobRepository.jobs["active"] = BackupJob( + uri = BackupDestinationUri("content://b.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 42L, + ) + repository.statuses.value = listOf(status(id = "finished"), status(id = "active")) + + val result = useCase().first() + + assertEquals(99L, result.first { it.id == "finished" }.timestamp) + assertEquals(42L, result.first { it.id == "active" }.timestamp) + } + + @Test + fun `a failed job surfaces its reason`() = runTest { + jobRepository.jobs["w"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 9L, + lastResult = BackupResult.Failure(BackupFailureReason.WriteFailed), + ) + repository.statuses.value = listOf(status(id = "w", state = DispatchedBackup.State.Failed)) + + assertEquals(BackupFailureReason.WriteFailed, useCase().first().single().failureReason) + } + + @Test + fun `a still-scheduled recurring job surfaces the reason its last run failed with`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 9L, + lastResult = BackupResult.Failure(BackupFailureReason.CryptoFailed), + ) + repository.statuses.value = listOf( + status( + id = "runtime-id", + kind = DispatchedBackup.Kind.Recurring, + state = DispatchedBackup.State.Enqueued, + ), + ) + + assertEquals(BackupFailureReason.CryptoFailed, useCase().first().single().failureReason) + } + + @Test + fun `a succeeded job surfaces no reason`() = runTest { + jobRepository.jobs["w"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 9L, + lastResult = BackupResult.Success, + ) + repository.statuses.value = listOf(status(id = "w", state = DispatchedBackup.State.Succeeded)) + + assertNull(useCase().first().single().failureReason) + } + + @Test + fun `a cancelled recurring schedule with a failed last run surfaces no reason`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 9L, + lastResult = BackupResult.Failure(BackupFailureReason.CryptoFailed), + ) + repository.statuses.value = listOf( + status( + id = "runtime-id", + kind = DispatchedBackup.Kind.Recurring, + state = DispatchedBackup.State.Cancelled, + ), + ) + + assertNull(useCase().first().single().failureReason) + } + + @Test + fun `a missing job surfaces no reason`() = runTest { + repository.statuses.value = listOf(status(id = "orphan")) + + assertNull(useCase().first().single().failureReason) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCaseTest.kt new file mode 100644 index 000000000..73231d90b --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCaseTest.kt @@ -0,0 +1,49 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.FileFormat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ObserveLastBackupUseCaseTest { + + private val jobRepository = FakeBackupJobRepository() + private val useCase = ObserveLastBackupUseCase(jobRepository) + + private fun job( + uri: String, + finishedAt: Long?, + result: BackupResult?, + ) = BackupJob( + uri = BackupDestinationUri(uri), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = finishedAt, + lastResult = result, + ) + + @Test + fun `picks the newest successful finish across jobs`() = runTest { + jobRepository.jobs["one-time"] = job("content://one.json", 100L, BackupResult.Success) + jobRepository.jobs["recurring"] = job("content://rec.json", 300L, BackupResult.Success) + jobRepository.jobs["failed"] = job("content://bad.json", 999L, BackupResult.Failure()) + + val last = useCase().first() + + assertEquals(300L, last?.finishedAt) + } + + @Test + fun `null when no successful backup exists`() = runTest { + jobRepository.jobs["failed"] = job("content://bad.json", 5L, BackupResult.Failure()) + jobRepository.jobs["never-run"] = job("content://idle.json", null, null) + + assertNull(useCase().first()) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCaseTest.kt new file mode 100644 index 000000000..15033785e --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCaseTest.kt @@ -0,0 +1,214 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupFailureReason +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.worker.BackupWorker +import de.davisalessandro.keygo.rust.BackupException +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RecordBackupOutcomeUseCaseTest { + + private val jobRepository = FakeBackupJobRepository() + private val arkKeyStore = FakeBackupArkKeyStore() + private val keyStoreManager = FakeKeyStoreManager() + private val uriManager = FakePersistableUriManager() + private val scheduler = FakeBackupScheduler(jobRepository) + private val useCase = RecordBackupOutcomeUseCase( + jobRepository = jobRepository, + cleanupBackupResources = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, + ), + ) + + private fun seed() { + jobRepository.jobs["w"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 1L, + ) + } + + @Test + fun `success records finishedAt and Success`() = runTest { + seed() + useCase("w", ExportProgress.Succeeded(itemCount = 3)) + + val saved = jobRepository.jobs.getValue("w") + assertNotNull(saved.finishedAt) + assertEquals(BackupResult.Success, saved.lastResult) + assertEquals(1L, saved.createdAt) + } + + @Test + fun `failure records Failure`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.WriteFailed)) + + val saved = jobRepository.jobs.getValue("w") + assertEquals(BackupResult.Failure(BackupFailureReason.WriteFailed), saved.lastResult) + assertNotNull(saved.finishedAt) + } + + @Test + fun `session locked does not record`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.SessionLocked)) + + assertNull(jobRepository.jobs.getValue("w").finishedAt) + } + + @Test + fun `null terminal does not record`() = runTest { + seed() + useCase("w", null) + + assertNull(jobRepository.jobs.getValue("w").finishedAt) + } + + @Test + fun `device locked does not record`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.DeviceLocked)) + + val saved = jobRepository.jobs.getValue("w") + assertNull(saved.finishedAt) + assertNull(saved.lastResult) + assertTrue(uriManager.released.isEmpty()) + assertTrue(keyStoreManager.keys.isEmpty()) + } + + @Test + fun `device locked on the last attempt records RetriesExhausted`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.DeviceLocked), canRetry = false) + + val saved = jobRepository.jobs.getValue("w") + assertNotNull(saved.finishedAt) + assertEquals(BackupResult.Failure(BackupFailureReason.RetriesExhausted), saved.lastResult) + } + + @Test + fun `a spent one-time job releases its escrow instead of holding it for a retry`() = runTest { + seed() + arkKeyStore.save(CryptographicData(byteArrayOf(1), byteArrayOf(2))) + + useCase("w", ExportProgress.Failed(ExportError.DeviceLocked), canRetry = false) + + assertNull(arkKeyStore.load()) + } + + @Test + fun `a finished one-time backup releases its credentials`() = runTest { + seed() + useCase("w", ExportProgress.Succeeded(itemCount = 1)) + + assertEquals(listOf(BackupDestinationUri("content://out.json")), uriManager.released) + } + + @Test + fun `a recurring run keeps its credentials for the next run`() = runTest { + val wrappedPassphrase = + CryptographicData(data = byteArrayOf(1, 2, 3), iv = byteArrayOf(4, 5, 6)) + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = wrappedPassphrase, + format = FileFormat.JSON, + createdAt = 1L, + ) + + useCase(BackupWorker.RECURRING_WORK_ID, ExportProgress.Succeeded(itemCount = 1)) + + assertTrue(uriManager.released.isEmpty()) + // The schedule is still live, so the reconcile this outcome triggers must leave alone the + // passphrase the next run reads back out of the record. + val saved = jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID) + assertNotNull(saved.wrappedPassphrase) + assertContentEquals(wrappedPassphrase.data, saved.wrappedPassphrase.data) + assertContentEquals(wrappedPassphrase.iv, saved.wrappedPassphrase.iv) + } + + @Test + fun `a late outcome on a cancelled recurring schedule hands back its passphrase`() = runTest { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = + CryptographicData(data = byteArrayOf(1, 2, 3), iv = byteArrayOf(4, 5, 6)), + format = FileFormat.JSON, + createdAt = 1L, + cancelled = true, + ) + + useCase(BackupWorker.RECURRING_WORK_ID, ExportProgress.Succeeded(itemCount = 1)) + + // Cancelled can never read as live again, so no run can ever need this passphrase. The + // cancel path already clears it via CleanupBackupResourcesUseCase.invoke; a late outcome + // arriving afterwards reaches the same conclusion instead of stranding it. + assertNull(jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID).wrappedPassphrase) + // reconcile still touches no folder grants - only invoke releases those. + assertTrue(uriManager.released.isEmpty()) + assertTrue(keyStoreManager.keys.isEmpty()) + } + + @Test + fun `a write failure records its reason`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.WriteFailed)) + + assertEquals( + BackupResult.Failure(BackupFailureReason.WriteFailed), + jobRepository.jobs.getValue("w").lastResult, + ) + } + + @Test + fun `a serialization failure records the generic reason without the cause message`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.SerializationFailed(BackupException.Json("bad")))) + + assertEquals( + BackupResult.Failure(BackupFailureReason.SerializationFailed), + jobRepository.jobs.getValue("w").lastResult, + ) + } + + @Test + fun `a success leaves no reason behind`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.CryptoFailed)) + useCase("w", ExportProgress.Succeeded(itemCount = 3)) + + assertEquals(BackupResult.Success, jobRepository.jobs.getValue("w").lastResult) + } + + @Test + fun `a retryable failure records no reason`() = runTest { + seed() + useCase("w", ExportProgress.Failed(ExportError.SessionLocked)) + + assertNull(jobRepository.jobs.getValue("w").lastResult) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStepTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStepTest.kt new file mode 100644 index 000000000..c6e858f7b --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStepTest.kt @@ -0,0 +1,70 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.foundation.text.input.TextFieldState +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod +import de.davis.keygo.feature.backup.domain.model.FileFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ExportWizardStepTest { + + @Test + fun `json flow shows the passphrase step and skips the csv preset step`() { + assertEquals( + listOf( + ExportWizardStep.SelectFormat, + ExportWizardStep.Schedule, + ExportWizardStep.SelectDestination, + ExportWizardStep.ProvidePassphrase, + ExportWizardStep.Review, + ), + exportStepsFor(FileFormat.JSON), + ) + } + + @Test + fun `csv flow shows the preset step and skips the passphrase step`() { + assertEquals( + listOf( + ExportWizardStep.SelectFormat, + ExportWizardStep.Schedule, + ExportWizardStep.SelectDestination, + ExportWizardStep.SelectCsvPreset, + ExportWizardStep.Review, + ), + exportStepsFor(FileFormat.CSV), + ) + } + + @Test + fun `unknown format keeps the passphrase step and no preset step`() { + assertEquals( + listOf( + ExportWizardStep.SelectFormat, + ExportWizardStep.Schedule, + ExportWizardStep.SelectDestination, + ExportWizardStep.ProvidePassphrase, + ExportWizardStep.Review, + ), + exportStepsFor(null), + ) + } + + @Test + fun `ark method makes the passphrase step valid without a passphrase`() { + val state = ExportWizardUiState( + formatState = SelectFormatState(format = FileFormat.JSON), + scheduleState = SelectScheduleState(), + destinationState = SelectDestinationState(), + providePassphraseState = ProvidePassphraseState( + passphraseTextFieldState = TextFieldState(), + confirmPassphraseTextFieldState = TextFieldState(), + method = EncryptionMethod.Ark, + ), + step = ExportWizardStep.ProvidePassphrase, + ) + + assertTrue(state.canContinue) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt new file mode 100644 index 000000000..e3c3bf533 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt @@ -0,0 +1,91 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackupGroupingTest { + + private fun item( + id: String, + state: DispatchedBackup.State, + kind: DispatchedBackup.Kind = DispatchedBackup.Kind.OneTime, + timestamp: Long = 0L, + ) = DispatchedBackup( + id = id, + kind = kind, + state = state, + format = null, + destination = null, + timestamp = timestamp, + ) + + @Test + fun `sections appear in fixed order and skip empties`() { + val groups = listOf( + item("a", DispatchedBackup.State.Succeeded), + item("b", DispatchedBackup.State.Enqueued, DispatchedBackup.Kind.Recurring), + item("c", DispatchedBackup.State.Running()), + ).toGroups() + + assertEquals( + listOf(BackupSection.InProgress, BackupSection.Scheduled, BackupSection.Recent), + groups.map { it.section }, + ) + } + + @Test + fun `empty sections are omitted`() { + val groups = listOf(item("a", DispatchedBackup.State.Failed)).toGroups() + + assertEquals(listOf(BackupSection.Recent), groups.map { it.section }) + } + + @Test + fun `an enqueued recurring backup is scheduled but a running one is in progress`() { + val groups = listOf( + item("waiting", DispatchedBackup.State.Enqueued, DispatchedBackup.Kind.Recurring), + item("working", DispatchedBackup.State.Running(), DispatchedBackup.Kind.Recurring), + ).toGroups() + + assertEquals( + listOf(BackupSection.InProgress, BackupSection.Scheduled), + groups.map { it.section }, + ) + assertEquals(listOf("working"), groups.first().items.map { it.id }) + assertEquals(listOf("waiting"), groups.last().items.map { it.id }) + } + + @Test + fun `an enqueued one-time backup is in progress`() { + val groups = listOf( + item("queued", DispatchedBackup.State.Enqueued, DispatchedBackup.Kind.OneTime), + ).toGroups() + + assertEquals(listOf(BackupSection.InProgress), groups.map { it.section }) + } + + @Test + fun `succeeded failed and cancelled all land in recent`() { + val groups = listOf( + item("s", DispatchedBackup.State.Succeeded), + item("f", DispatchedBackup.State.Failed), + item("c", DispatchedBackup.State.Cancelled), + ).toGroups() + + assertEquals(listOf(BackupSection.Recent), groups.map { it.section }) + assertEquals(3, groups.single().items.size) + } + + @Test + fun `items within a section are newest first`() { + val groups = listOf( + item("old", DispatchedBackup.State.Succeeded, timestamp = 10L), + item("new", DispatchedBackup.State.Succeeded, timestamp = 30L), + item("mid", DispatchedBackup.State.Succeeded, timestamp = 20L), + ).toGroups() + + assertEquals(listOf("new", "mid", "old"), groups.single().items.map { it.id }) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt new file mode 100644 index 000000000..570367da9 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import de.davis.keygo.core.security.crypto.FakeKeyStoreManager +import de.davis.keygo.feature.backup.FakeBackupArkKeyStore +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.FakeBackupScheduler +import de.davis.keygo.feature.backup.FakeDispatchedBackupRepository +import de.davis.keygo.feature.backup.FakePersistableUriManager +import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver +import de.davis.keygo.feature.backup.domain.BackupProvisioningLock +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.usecase.CancelBackupUseCase +import de.davis.keygo.feature.backup.domain.usecase.CleanupBackupResourcesUseCase +import de.davis.keygo.feature.backup.domain.usecase.ObserveDispatchedBackupsUseCase +import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class BackupHubViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeDispatchedBackupRepository() + private val jobRepository = FakeBackupJobRepository() + private val destinationResolver = FakeBackupDestinationResolver() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private val cleanup = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = FakeBackupArkKeyStore(), + keyStoreManager = FakeKeyStoreManager(), + persistableUriManager = FakePersistableUriManager(), + provisioningLock = BackupProvisioningLock(), + scheduler = FakeBackupScheduler(jobRepository), + ) + + private fun viewModel() = BackupHubViewModel( + observeDispatchedBackups = + ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver), + observeLastBackup = ObserveLastBackupUseCase(jobRepository), + cancelBackup = CancelBackupUseCase( + repository = repository, + jobRepository = jobRepository, + cleanupBackupResources = cleanup, + ), + ) + + @Test + fun `dispatched workers are grouped in the ui state`() = runTest(dispatcher) { + repository.statuses.value = listOf( + BackupWorkStatus("w1", DispatchedBackup.Kind.OneTime, DispatchedBackup.State.Running()), + ) + val vm = viewModel() + + val state = vm.state.first { it.hasItems } + + assertEquals(BackupSection.InProgress, state.groups.single().section) + assertEquals("w1", state.groups.single().items.single().id) + } + + @Test + fun `last successful backup reaches the ui state`() = runTest(dispatcher) { + jobRepository.jobs["done"] = BackupJob( + uri = BackupDestinationUri("content://done.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 500L, + lastResult = BackupResult.Success, + ) + val vm = viewModel() + + val state = vm.state.first { it.lastBackup != null } + + assertEquals(500L, state.lastBackup?.finishedAt) + } + + @Test + fun `cancel event forwards id to the use case`() = runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(BackupHubUiEvent.OnCancelBackup("w1", DispatchedBackup.Kind.OneTime)) + advanceUntilIdle() + + assertEquals(listOf("w1"), repository.cancelledIds) + } + + @Test + fun `OnRestoreBackup emits NavigateToImport`() = runTest { + val viewModel = viewModel() + + viewModel.onEvent(BackupHubUiEvent.OnRestoreBackup) + + assertEquals(BackupHubEvent.NavigateToImport, viewModel.event.first()) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt new file mode 100644 index 000000000..64826f8b3 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -0,0 +1,568 @@ +package de.davis.keygo.feature.backup.presentation.import + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.backup.FakeBackupFileStore +import de.davis.keygo.feature.backup.RestorerTestEnv +import de.davis.keygo.feature.backup.backupVault +import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.CsvColumnType +import de.davis.keygo.feature.backup.domain.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportProgress +import de.davis.keygo.feature.backup.domain.usecase.AnalyzeCsvUseCase +import de.davis.keygo.feature.backup.domain.usecase.ImportBackupUseCase +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardEvent +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardStep +import de.davis.keygo.feature.backup.presentation.import.model.ImportWizardUiEvent +import de.davis.keygo.feature.backup.testVault +import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase +import de.davis.keygo.rust.FakeCsvBackupManager +import de.davis.keygo.rust.FakeJsonBackupManager +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.BackupLogin +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.Confidence +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvColumn +import de.davisalessandro.keygo.rust.CsvImportResult +import de.davisalessandro.keygo.rust.FieldConfidence +import de.davisalessandro.keygo.rust.ImportReport +import de.davisalessandro.keygo.rust.JsonEncryption +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ImportWizardViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val env = RestorerTestEnv() + private val fileStore = FakeBackupFileStore() + private val json = FakeJsonBackupManager() + private val csv = FakeCsvBackupManager() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun jsonDestination() = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + fileName = "keygo.json", + ) + + private fun login(title: String) = BackupLogin( + title = title, + notes = null, + tags = emptyList(), + pinned = false, + username = null, + password = "pw", + totpSecret = null, + websites = emptyList(), + passkeys = emptyList(), + ) + + private fun viewModel( + resolver: FakeBackupDestinationResolver = FakeBackupDestinationResolver(), + session: FakeSession = FakeSession(startOnConstruct = true), + contextRepo: FakeVaultContextRepository = FakeVaultContextRepository(), + ) = ImportWizardViewModel( + resolver, + ImportBackupUseCase(fileStore, json, csv, env.restorer, session), + AnalyzeCsvUseCase(fileStore, csv), + ObserveVaultsAndSelectionUseCase(env.vaultRepo, contextRepo, SortUseCase()), + ) + + private fun ImportWizardViewModel.selectJson() { + onFilePicked(BackupDestinationUri("content://doc/keygo.json")) + } + + private suspend fun ImportWizardViewModel.advanceToMapColumns() { + onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + state.first { it.backupDestination != null } + onEvent(ImportWizardUiEvent.Continue) + state.first { it.step == ImportWizardStep.MapColumns } + } + + private fun csvDestination() = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + fileName = "keygo.csv", + ) + + private fun csvAnalysis() = CsvAnalysis( + columns = listOf( + CsvColumn(0u, "name", listOf("Email")), + CsvColumn(1u, "secret", listOf("s3cr3t")), + ), + suggested = ColumnMapping(0u, null, null, 1u, null, null), + confidence = FieldConfidence( + Confidence.HIGH, null, null, + Confidence.HIGH, null, null, + ), + ) + + @Test + fun `ChooseFile emits PickFile event`() = runTest { + val viewModel = viewModel() + + viewModel.onEvent(ImportWizardUiEvent.ChooseFile) + + assertEquals(ImportWizardEvent.PickFile, viewModel.event.first()) + } + + @Test + fun `onFilePicked resolves and stores destination and uri`() = runTest { + val destination = jsonDestination() + val viewModel = viewModel(FakeBackupDestinationResolver(result = destination)) + val uri = BackupDestinationUri("content://doc/keygo.json") + + viewModel.onFilePicked(uri) + advanceUntilIdle() + + assertEquals(destination, viewModel.state.value.backupDestination) + assertEquals(uri, viewModel.state.value.uri) + } + + @Test + fun `onFilePicked with null leaves state unchanged`() = runTest { + val viewModel = viewModel() + + viewModel.onFilePicked(null) + + assertNull(viewModel.state.value.backupDestination) + assertNull(viewModel.state.value.uri) + } + + @Test + fun `Continue on selected JSON runs import and surfaces the summary`() = runTest { + // ARK-sealed: the one JSON shape that imports straight through without a passphrase step. + json.inspectResult = JsonEncryption.ARK + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + viewModel.selectJson() + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + val succeeded = assertIs(finalState.progress) + assertEquals(1, succeeded.summary.imported) + assertIs(json.importCalls.single().credential) + } + + @Test + fun `passphrase-encrypted backup advances to the passphrase step`() = runTest { + json.inspectResult = JsonEncryption.PASSPHRASE + fileStore.contents = """{"vaults":[]}""" + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + viewModel.selectJson() + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.step == ImportWizardStep.ProvidePassphrase } + + assertNull(finalState.progress) + } + + @Test + fun `wrong passphrase keeps the passphrase step and flags the error`() = runTest { + json.inspectResult = JsonEncryption.PASSPHRASE + fileStore.contents = """{"vaults":[]}""" + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + viewModel.selectJson() + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.ProvidePassphrase } + + json.importException = BackupException.CredentialMismatch() + viewModel.state.value.passphraseState.setTextAndPlaceCursorAtEnd("hunter2") + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.passphraseError } + + assertEquals(ImportWizardStep.ProvidePassphrase, finalState.step) + assertTrue(finalState.passphraseError) + assertNull(finalState.progress) + val credential = assertIs(json.importCalls.last().credential) + assertEquals("hunter2", credential.bytes.decodeToString()) + } + + @Test + fun `terminal import error surfaces as failure`() = runTest { + json.inspectResult = JsonEncryption.ARK + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(emptyList()) + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + viewModel.selectJson() + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Failed } + + assertEquals(ImportProgress.Failed(ImportError.NothingImported), finalState.progress) + } + + @Test + fun `Continue on a CSV file analyzes and advances to MapColumns`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val state = viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + assertEquals(2, state.columns.size) + // selection seeded from the suggestion + assertEquals(CsvColumnType.Title, state.columns[0].selectedType) + assertEquals(CsvColumnType.Password, state.columns[1].selectedType) + assertTrue(csv.importCalls.isEmpty()) // not imported yet + } + + @Test + fun `ChangeColumnType updates the selected type`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Username)) + + assertEquals(CsvColumnType.Username, viewModel.state.value.columns[1].selectedType) + } + + @Test + fun `Continue with a duplicated type blocks import and reports duplicates`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + // Make both columns Title -> duplicate + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Title)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + advanceUntilIdle() + + assertEquals(setOf(CsvColumnType.Title), viewModel.state.value.duplicateTypes) + assertEquals(ImportWizardStep.MapColumns, viewModel.state.value.step) + assertTrue(csv.importCalls.isEmpty()) + } + + @Test + fun `Continue with every column set to Ignore does not start import`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + // user sets every column to Ignore + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(0, null)) + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, null)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + advanceUntilIdle() + + assertNull(viewModel.state.value.progress) // import never even began + assertTrue(csv.importCalls.isEmpty()) + assertEquals(ImportWizardStep.MapColumns, viewModel.state.value.step) + } + + @Test + fun `Continue with a valid mapping imports using the edited mapping`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + // user reassigns col 1 from Password to Username + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Username)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + assertIs(finalState.progress) + val mapping = csv.importCalls.last().mapping + assertEquals(0u, mapping.title) + assertEquals(1u, mapping.username) + assertNull(mapping.password) + } + + @Test + fun `a valid mapping advances to vault selection instead of importing`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val state = viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + assertNull(state.progress) + assertTrue(csv.importCalls.isEmpty()) + } + + @Test + fun `vault selection preselects the current vault context`() = runTest { + val personal = testVault(name = "Personal") + env.vaultRepo.seed(personal) + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel( + FakeBackupDestinationResolver(result = csvDestination()), + contextRepo = FakeVaultContextRepository(VaultContext.ById(personal.id)), + ) + viewModel.advanceToMapColumns() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val state = viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + assertEquals(personal.id, state.selectedVaultId) + assertFalse(state.creatingNewVault) + } + + @Test + fun `vault selection falls back to a new vault named after the file`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val state = viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + assertTrue(state.creatingNewVault) + assertNull(state.selectedVaultId) + assertEquals("keygo", state.newVaultNameState.text.toString()) + } + + @Test + fun `importing into an existing vault does not create one`() = runTest { + val personal = testVault(name = "Personal") + env.vaultRepo.seed(personal) + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + viewModel.onEvent(ImportWizardUiEvent.SelectVault(personal.id)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + val succeeded = assertIs(finalState.progress) + assertEquals(1, succeeded.summary.imported) + assertEquals(0, succeeded.summary.vaultsCreated) + assertEquals(1, env.loginRepo.getLoginsByVault(personal.id).size) + } + + @Test + fun `importing into a new vault creates it`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Succeeded } + + val succeeded = assertIs(finalState.progress) + assertEquals(1, succeeded.summary.vaultsCreated) + assertEquals( + listOf("keygo"), + env.vaultRepo.observeAllVaultMetadata().first().map { it.name }, + ) + } + + @Test + fun `a new vault is created with the icon picked in the wizard`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + viewModel.onEvent(ImportWizardUiEvent.SelectNewVaultIcon(Vault.Icon.ShoppingCart)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.progress is ImportProgress.Succeeded } + + assertEquals( + Vault.Icon.ShoppingCart, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + + @Test + fun `a new vault falls back to the default icon when none is picked`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + csv.importResult = CsvImportResult( + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), + report = ImportReport(imported = 1u, skipped = 0u), + ) + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.progress is ImportProgress.Succeeded } + + assertEquals( + Vault.Icon.Default, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + + @Test + fun `Back from vault selection returns to the mapping with the mapping intact`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.ChangeColumnType(1, CsvColumnType.Username)) + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + viewModel.onEvent(ImportWizardUiEvent.Back) + val state = viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + assertEquals(2, state.columns.size) + assertEquals(CsvColumnType.Username, state.columns[1].selectedType) + } + + @Test + fun `re-entering vault selection after Back preserves a hand-picked vault`() = runTest { + val personal = testVault(name = "Personal") + env.vaultRepo.seed(personal) + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.advanceToMapColumns() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + // user picks the existing vault by hand, instead of the seeded new-vault default + viewModel.onEvent(ImportWizardUiEvent.SelectVault(personal.id)) + viewModel.state.first { it.selectedVaultId == personal.id } + + // back to tweak a column mapping, then continue again + viewModel.onEvent(ImportWizardUiEvent.Back) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + viewModel.onEvent(ImportWizardUiEvent.Continue) + val state = viewModel.state.first { it.step == ImportWizardStep.SelectVault } + + assertEquals(personal.id, state.selectedVaultId) + assertFalse(state.creatingNewVault) + } + + @Test + fun `analyze failure surfaces as failure without routing to passphrase`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + val cause = BackupException.Csv("bad csv") + csv.analyzeException = cause + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + + viewModel.onEvent(ImportWizardUiEvent.Continue) + val finalState = viewModel.state.first { it.progress is ImportProgress.Failed } + + assertEquals(ImportWizardStep.SelectFile, finalState.step) + assertEquals(ImportProgress.Failed(ImportError.ParseFailed(cause)), finalState.progress) + + viewModel.onEvent(ImportWizardUiEvent.Back) + val backState = viewModel.state.first { it.progress == null } + + assertNull(backState.progress) + assertEquals(ImportWizardStep.SelectFile, backState.step) + } + + @Test + fun `Back from MapColumns returns to file selection`() = runTest { + fileStore.contents = "name,secret\nEmail,s3cr3t\n" + csv.analyzeResult = csvAnalysis() + val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) + viewModel.onFilePicked(BackupDestinationUri("content://doc/keygo.csv")) + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.MapColumns } + + viewModel.onEvent(ImportWizardUiEvent.Back) + + assertEquals(ImportWizardStep.SelectFile, viewModel.state.value.step) + } + + @Test + fun `Back from passphrase step returns to file selection`() = runTest { + json.inspectResult = JsonEncryption.PASSPHRASE + fileStore.contents = """{"vaults":[]}""" + val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) + viewModel.selectJson() + advanceUntilIdle() + viewModel.onEvent(ImportWizardUiEvent.Continue) + viewModel.state.first { it.step == ImportWizardStep.ProvidePassphrase } + + viewModel.onEvent(ImportWizardUiEvent.Back) + + assertEquals(ImportWizardStep.SelectFile, viewModel.state.value.step) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt new file mode 100644 index 000000000..cf491f46e --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ImportWizardStepTest { + + @Test + fun `SelectFile shows a single step`() { + assertEquals( + listOf(ImportWizardStep.SelectFile), + importStepsFor(ImportWizardStep.SelectFile), + ) + } + + @Test + fun `ProvidePassphrase reveals the passphrase step`() { + assertEquals( + listOf(ImportWizardStep.SelectFile, ImportWizardStep.ProvidePassphrase), + importStepsFor(ImportWizardStep.ProvidePassphrase), + ) + } + + @Test + fun `SelectVault reveals the file, mapping and vault steps`() { + assertEquals( + listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault, + ), + importStepsFor(ImportWizardStep.SelectVault), + ) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt new file mode 100644 index 000000000..c3a1df867 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt @@ -0,0 +1,109 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.ImportTarget +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ImportWizardUiStateTest { + + private fun destination(fileName: String?) = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + fileName = fileName, + ) + + @Test + fun `canContinue on SelectVault is false when creating a new vault with an invalid name`() { + val state = ImportWizardUiState( + step = ImportWizardStep.SelectVault, + creatingNewVault = true, + newVaultNameValid = false, + ) + + assertFalse(state.canContinue) + } + + @Test + fun `canContinue on SelectVault is true when creating a new vault with a valid name`() { + val state = ImportWizardUiState( + step = ImportWizardStep.SelectVault, + creatingNewVault = true, + newVaultNameValid = true, + ) + + assertTrue(state.canContinue) + } + + @Test + fun `canContinue on SelectVault is false when no existing vault is selected`() { + val state = ImportWizardUiState( + step = ImportWizardStep.SelectVault, + creatingNewVault = false, + selectedVaultId = null, + ) + + assertFalse(state.canContinue) + } + + @Test + fun `canContinue on SelectVault is true when an existing vault is selected`() { + val state = ImportWizardUiState( + step = ImportWizardStep.SelectVault, + creatingNewVault = false, + selectedVaultId = newVaultId(), + ) + + assertTrue(state.canContinue) + } + + @Test + fun `resolveTarget carries the chosen icon into the new vault`() { + val state = ImportWizardUiState( + creatingNewVault = true, + newVaultIcon = Vault.Icon.ShoppingCart, + ) + + assertEquals( + ImportTarget.New("passwords", Vault.Icon.ShoppingCart), + state.resolveTarget("passwords"), + ) + } + + @Test + fun `resolveTarget ignores the chosen icon when an existing vault is the target`() { + val vaultId = newVaultId() + val state = ImportWizardUiState( + creatingNewVault = false, + selectedVaultId = vaultId, + newVaultIcon = Vault.Icon.ShoppingCart, + ) + + assertEquals(ImportTarget.Existing(vaultId), state.resolveTarget("passwords")) + } + + @Test + fun `suggestedVaultName strips the extension`() { + val state = ImportWizardUiState(backupDestination = destination("passwords.csv")) + + assertEquals("passwords", state.suggestedVaultName) + } + + @Test + fun `suggestedVaultName is the file name unchanged when there is no extension`() { + val state = ImportWizardUiState(backupDestination = destination("passwords")) + + assertEquals("passwords", state.suggestedVaultName) + } + + @Test + fun `suggestedVaultName keeps everything before the last dot when there are several`() { + val state = ImportWizardUiState(backupDestination = destination("my.passwords.backup.csv")) + + assertEquals("my.passwords.backup", state.suggestedVaultName) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/worker/BackupWorkerResultTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/worker/BackupWorkerResultTest.kt new file mode 100644 index 000000000..1e63e4c97 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/worker/BackupWorkerResultTest.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.feature.backup.worker + +import androidx.work.ListenableWorker +import de.davis.keygo.feature.backup.domain.model.ExportError +import de.davis.keygo.feature.backup.domain.model.ExportProgress +import kotlin.test.Test +import kotlin.test.assertIs + +class BackupWorkerResultTest { + + @Test + fun `device locked retries`() { + assertIs( + resultFor(ExportProgress.Failed(ExportError.DeviceLocked), canRetry = true), + ) + } + + @Test + fun `session locked retries`() { + assertIs( + resultFor(ExportProgress.Failed(ExportError.SessionLocked), canRetry = true), + ) + } + + @Test + fun `device locked fails once attempts are spent`() { + // Without this the job would retry forever, and its escrowed ARK would never be released. + assertIs( + resultFor(ExportProgress.Failed(ExportError.DeviceLocked), canRetry = false), + ) + } + + @Test + fun `session locked fails once attempts are spent`() { + assertIs( + resultFor(ExportProgress.Failed(ExportError.SessionLocked), canRetry = false), + ) + } + + @Test + fun `not provisioned fails`() { + assertIs( + resultFor(ExportProgress.Failed(ExportError.NotProvisioned), canRetry = true), + ) + } + + @Test + fun `succeeded returns success`() { + assertIs( + resultFor(ExportProgress.Succeeded(1), canRetry = true), + ) + } + + @Test + fun `null terminal fails`() { + assertIs(resultFor(null, canRetry = true)) + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt new file mode 100644 index 000000000..c39247904 --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore + +class FakeBackupArkKeyStore( + private var stored: CryptographicData? = null, +) : BackupArkKeyStore { + + var clearCount = 0 + private set + + override suspend fun save(data: CryptographicData) { + stored = data + } + + override suspend fun load(): CryptographicData? = stored + + override suspend fun clear() { + clearCount++ + stored = null + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt new file mode 100644 index 000000000..04f6bb292 --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupEntry + +class FakeBackupFileStore : BackupFileStore { + + var contents: String? = null + var readError: Throwable? = null + var writeError: Throwable? = null + var listError: Throwable? = null + var deleteError: Throwable? = null + + // Details of the most recent writeNewDocument call. + var writtenText: String? = null + var writtenFileName: String? = null + var writtenMimeType: String? = null + var writtenFolder: BackupDestinationUri? = null + + // Documents currently present in the folder; writeNewDocument adds, delete removes. + val backups = mutableListOf() + val deleted = mutableListOf() + + override suspend fun read(uri: BackupDestinationUri): Result { + readError?.let { return Result.Failure(it) } + return contents?.let { Result.Success(it) } + ?: Result.Failure(IllegalStateException("no contents")) + } + + override suspend fun writeNewDocument( + folder: BackupDestinationUri, + fileName: String, + mimeType: String, + text: String, + ): Result { + writeError?.let { return Result.Failure(it) } + writtenFolder = folder + writtenFileName = fileName + writtenMimeType = mimeType + writtenText = text + backups += BackupEntry(BackupDestinationUri("${folder.value}/$fileName"), fileName) + return Result.Success(Unit) + } + + override suspend fun listBackups( + folder: BackupDestinationUri, + baseName: String, + ): Result, Throwable> { + listError?.let { return Result.Failure(it) } + return Result.Success(backups.filter { it.name.startsWith(baseName) }) + } + + override suspend fun delete(uri: BackupDestinationUri): Result { + deleteError?.let { return Result.Failure(it) } + deleted += uri + backups.removeAll { it.uri == uri } + return Result.Success(Unit) + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt new file mode 100644 index 000000000..3ff4575d9 --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt @@ -0,0 +1,48 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.data.repository.retainedJobKeys +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class FakeBackupJobRepository( + private val now: () -> Long = { 0L }, +) : BackupJobRepository { + val jobs = mutableMapOf() + + override suspend fun getJob(workId: WorkId): BackupJob? = jobs[workId] + + override suspend fun getJobs(): Map = jobs.toMap() + + override fun observeJobs(): Flow> = flow { emit(jobs.values.toList()) } + + override suspend fun putJob(workId: WorkId, job: BackupJob): Result { + jobs[workId] = job.copy(createdAt = now()) + return Result.Success(Unit) + } + + override suspend fun markFinished(workId: WorkId, result: BackupResult, finishedAt: Long) { + val existing = jobs[workId] ?: return + upsertAndPrune(workId, existing.copy(finishedAt = finishedAt, lastResult = result)) + } + + override suspend fun markCancelled(workId: WorkId, cancelledAt: Long) { + val existing = jobs[workId] ?: return + upsertAndPrune(workId, existing.copy(cancelled = true, finishedAt = cancelledAt)) + } + + private fun upsertAndPrune(workId: WorkId, job: BackupJob) { + jobs[workId] = job + val keep = retainedJobKeys(jobs.mapValues { it.value.finishedAt }) + jobs.keys.retainAll(keep) + } + + override suspend fun clearPassphrase(workId: WorkId) { + val existing = jobs[workId] ?: return + jobs[workId] = existing.copy(wrappedPassphrase = null) + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt new file mode 100644 index 000000000..a1ad5feed --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt @@ -0,0 +1,78 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.alias.WorkId +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.worker.BackupWorker +import kotlinx.coroutines.CompletableDeferred + +/** + * By default this just records the last scheduled job. Pass [jobRepository] to also persist the job + * record - mirroring [de.davis.keygo.feature.backup.data.BackupSchedulerImpl] which calls + * `backupJobRepository.putJob(...)` before enqueueing - so a cleanup reading the same repository + * can observe the job as "live". Pass [gate] to park inside scheduling *before* that record is + * written, reproducing the TOCTOU window between escrow provisioning and the record write. + */ +class FakeBackupScheduler( + private val jobRepository: FakeBackupJobRepository? = null, + private val gate: CompletableDeferred? = null, + private val oneTimeWorkId: WorkId = "one-time", +) : BackupScheduler { + + var recurringJob: BackupJob? = null + var recurringInterval: BackupInterval? = null + var oneTimeJob: BackupJob? = null + var cancelled = false + var result: Result = Result.Success(Unit) + + private val scheduled = mutableSetOf() + private val abandoned = mutableSetOf() + + /** When set, [outstandingWorkIds] throws - the "scheduler unreadable" case. */ + var outstandingFailure: Throwable? = null + + /** + * Defaults to every id [jobRepository] knows about (or, without one, every id scheduled through + * this fake): a healthy scheduler whose view agrees with the records. [abandon] takes an id back + * out, which is how a job the platform quietly gave up on looks - the record still reads live, + * but no run can ever come of it. + */ + override suspend fun outstandingWorkIds(): Set { + outstandingFailure?.let { throw it } + return (jobRepository?.jobs?.keys ?: scheduled) - abandoned + } + + fun abandon(workId: WorkId) { + abandoned += workId + } + + override suspend fun scheduleRecurringBackup( + job: BackupJob, + interval: BackupInterval, + ): Result { + recurringJob = job + recurringInterval = interval + return persist(BackupWorker.RECURRING_WORK_ID, job) + } + + override suspend fun scheduleOneTimeBackup(job: BackupJob): Result { + oneTimeJob = job + return persist(oneTimeWorkId, job) + } + + override fun cancel() { + cancelled = true + } + + // Mirror BackupSchedulerImpl: on success the record is written (putJob), on failure it is not, + // so a failed schedule leaves no record - exactly the case the URI-grant release compensates. + private suspend fun persist(workId: WorkId, job: BackupJob): Result { + gate?.await() + if (result is Result.Failure) return result + jobRepository?.putJob(workId, job) + scheduled += workId + return result + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt new file mode 100644 index 000000000..1d1a8de30 --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import de.davis.keygo.feature.backup.domain.repository.DispatchedBackupRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeDispatchedBackupRepository : DispatchedBackupRepository { + val statuses = MutableStateFlow>(emptyList()) + val cancelledIds = mutableListOf() + + override fun observe(): Flow> = statuses.asStateFlow() + override suspend fun cancel(id: String) { + cancelledIds += id + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt new file mode 100644 index 000000000..b08d01a9a --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.feature.backup.domain.PersistableUriManager +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +class FakePersistableUriManager : PersistableUriManager { + + val taken = mutableListOf() + val released = mutableListOf() + var throwOnTake: Throwable? = null + + override fun takePersistableUriPermission(uri: BackupDestinationUri) { + throwOnTake?.let { throw it } + taken += uri + } + + override fun releasePersistableUriPermission(uri: BackupDestinationUri) { + released += uri + } +} diff --git a/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt new file mode 100644 index 000000000..7cae396c4 --- /dev/null +++ b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.data + +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestination +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri + +class FakeBackupDestinationResolver( + var result: BackupDestination = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + ), +) : BackupDestinationResolver { + + var lastUri: BackupDestinationUri? = null + var lastCachedName: String? = null + + override suspend fun resolve( + uri: BackupDestinationUri, + cachedName: String?, + ): BackupDestination { + lastUri = uri + lastCachedName = cachedName + return result + } +} diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt index b599f3754..bb0f03adf 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt @@ -161,7 +161,7 @@ internal class CreatePasskeyActivity : FragmentActivity() { val authState by viewModel.authState.collectAsStateWithLifecycle() when (authState) { SessionAuthState.TryBiometric -> { - // render nothing — activity stays transparent while system biometric prompt is shown + // render nothing: activity stays transparent while system biometric prompt is shown } SessionAuthState.NeedsPassword -> { diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt index df2044ff1..9e1474567 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyActivity.kt @@ -84,7 +84,7 @@ internal class ProvidePasskeyActivity : FragmentActivity() { val authState by viewModel.authState.collectAsStateWithLifecycle() when (authState) { SessionAuthState.TryBiometric -> { - // render nothing — activity stays transparent while system biometric prompt is shown + // render nothing: activity stays transparent while system biometric prompt is shown } SessionAuthState.NeedsPassword -> { @@ -105,7 +105,7 @@ internal class ProvidePasskeyActivity : FragmentActivity() { } SessionAuthState.Authenticated -> { - // render nothing — operation runs in ViewModel and emits Finish/Abort + // render nothing: operation runs in ViewModel and emits Finish/Abort } } } diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt index 498bccc6e..0215fc765 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt @@ -196,7 +196,7 @@ private fun ScanIndicator(indicator: Indicator) { } // Mirrors which states in [toContent] carry an action, without building the -// content — drives the reveal animation from the latest target state. +// content. Drives the reveal animation from the latest target state. private fun CardScanUiState.hasAction(nfcEnabled: Boolean): Boolean = when (this) { CardScanUiState.Ready -> !nfcEnabled is CardScanUiState.Failure -> true diff --git a/feature/credit-card/src/main/res/values/strings.xml b/feature/credit-card/src/main/res/values/strings.xml index fdfd24579..0679ba451 100644 --- a/feature/credit-card/src/main/res/values/strings.xml +++ b/feature/credit-card/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Couldn\'t read card Try again That doesn\'t look like a payment card. - Card moved too soon — hold it steady and try again. + Card moved too soon. Hold it steady and try again. Couldn\'t read this card\'s data. Something went wrong reading the card. Scan card diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt index 28d20b117..1c06deae8 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt @@ -24,7 +24,7 @@ class CvvInputTransformation(private val maxLength: () -> Int) : InputTransforma /** * The CVV cap depends on the card network, which is derived from [numberState]'s current - * digits — so the transformation reads that sibling field live at transform time. + * digits, so the transformation reads that sibling field live at transform time. */ @Composable fun rememberCvvInputTransformation(numberState: TextFieldState): CvvInputTransformation { diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index 766a05f6a..d3b07f9b7 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -64,7 +64,7 @@ class CreateNewOrUpdateLoginUseCaseTest { } - // Validation — Create + // Validation - Create @Test fun `create with blank name returns BlankName error`() = runTest { @@ -140,7 +140,7 @@ class CreateNewOrUpdateLoginUseCaseTest { assertTrue(result.isSuccess()) } - // Validation — Update + // Validation - Update @Test fun `update with Keep name and Keep password is valid`() = runTest { @@ -206,7 +206,7 @@ class CreateNewOrUpdateLoginUseCaseTest { assertEquals(setOf(ItemUpsertError.Empty), result.error) } - // Success — Create + // Success - Create @Test fun `create with valid fields returns Success`() = runTest { @@ -293,7 +293,7 @@ class CreateNewOrUpdateLoginUseCaseTest { assertEquals(PasswordScore.Strong, updated?.passwordCredential!!.score) } - // Success — Update + // Success - Update @Test fun `update with new name replaces name`() = runTest { @@ -560,7 +560,7 @@ class CreateNewOrUpdateLoginUseCaseTest { assertEquals(cause, error.throwable) } - // TOTP handling — Create + // TOTP handling - Create @Test fun `create with plain secret stores totp with only the secret and default fields`() = runTest { @@ -642,7 +642,7 @@ class CreateNewOrUpdateLoginUseCaseTest { assertNull(storedById(result.getOrNull())?.totp) } - // TOTP handling — Update + // TOTP handling - Update @Test fun `update replacing totp uri with plain secret resets algorithm digits period issuer and account name`() = diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt index 376a275b7..613ab8d57 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt @@ -89,7 +89,7 @@ internal abstract class ItemViewModel( protected abstract val itemState: Flow /** - * Lazy so the `combine` body — which reads the abstract [itemState] — runs on first collection + * Lazy so the `combine` body, which reads the abstract [itemState], runs on first collection * rather than during construction, by which point the subclass is fully initialized (base * property initializers otherwise run before the subclass'). */ diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index 975288dbd..acb4c970f 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -98,7 +98,7 @@ internal class CreditCardViewModel( nameTextFieldState.setTextAndPlaceCursorAtEnd(card.name) notesTextFieldState.setTextAndPlaceCursorAtEnd(card.note ?: "") ccHolderTextFieldState.setTextAndPlaceCursorAtEnd(card.holder ?: "") - // Set the number before the CVV so the network — and thus the CVV cap — is known. + // Set the number before the CVV so the network (and thus the CVV cap) is known. ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(number ?: "") ccCVVTextFieldState.setTextAndPlaceCursorAtEnd(cvv ?: "") ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt index 4131061f9..5482ecf28 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -106,7 +106,7 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi ) Text(text = metadata.name) } - Text(text = "•") + Text(text = "\u2022") Text(text = stringResource(CoreItemR.string.credit_card)) } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt index b65d8345e..c16393fae 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt @@ -3,8 +3,6 @@ package de.davis.keygo.feature.item.view.login import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow @@ -68,8 +66,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -131,7 +127,7 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) ) Text(text = metadata.name) } - Text(text = "•") + Text(text = "\u2022") Text(text = stringResource(CoreItemR.string.password)) } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt index 7c677e6a1..14dc36193 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt @@ -20,7 +20,7 @@ data class ObfuscatedString( } companion object { - private const val DEFAULT_OBFUSCATION_CHAR: Char = '•' + private const val DEFAULT_OBFUSCATION_CHAR: Char = '\u2022' } } diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index d2d70d614..21f2f81a6 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -105,7 +105,7 @@ class ViewCreditCardViewModelTest { @Test fun `card number hidden reveals only the last four digits`() = runTest(dispatcher) { val cardNumber = awaitCardNumber() - assertEquals("•••• •••• •••• 1111", cardNumber.hidden) + assertEquals("**** **** **** 1111".replace('*', '\u2022'), cardNumber.hidden) } private suspend fun awaitCardNumber(): ObfuscatedString { diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt index 4a3589d65..5fe10489d 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt @@ -14,13 +14,13 @@ class ObfuscatedStringTest { @Test fun `hidden is all bullets when no formatted string given`() { val obs = ObfuscatedString("4111111111111111") - assertEquals("•".repeat(16), obs.hidden) + assertEquals(mask("*".repeat(16)), obs.hidden) } @Test fun `hidden preserves spaces from formatted string`() { val obs = ObfuscatedString(raw = "4111111111111111", formatted = "4111 1111 1111 1111") - assertEquals("•••• •••• •••• ••••", obs.hidden) + assertEquals(mask("**** **** **** ****"), obs.hidden) } @Test @@ -36,7 +36,7 @@ class ObfuscatedStringTest { formatted = "4111 1111 1111 1234", visibleSuffixDigits = 4, ) - assertEquals("•••• •••• •••• 1234", obs.hidden) + assertEquals(mask("**** **** **** 1234"), obs.hidden) } @Test @@ -46,7 +46,7 @@ class ObfuscatedStringTest { formatted = "3782 822463 10005", visibleSuffixDigits = 4, ) - assertEquals("•••• •••••• •0005", obs.hidden) + assertEquals(mask("**** ****** *0005"), obs.hidden) } @Test @@ -56,6 +56,9 @@ class ObfuscatedStringTest { formatted = "1234", visibleSuffixDigits = 4, ) - assertEquals("••••", obs.hidden) + assertEquals(mask("****"), obs.hidden) } } + +// Expected values are written with "*" so this file stays ASCII; the real glyph is U+2022. +private fun mask(pattern: String): String = pattern.replace('*', '\u2022') diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index 739a16311..9fc63d384 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -82,7 +82,7 @@ class FilterUseCaseTest { emptyMap() ) - // At Collator.PRIMARY, Apple/apple are equal and banana/Banana are equal — + // At Collator.PRIMARY, Apple/apple are equal and banana/Banana are equal, so // only assert the group ordering, not internal order within equal-strength items val lastAppleIndex = maxOf( result.indexOfFirst { it.name == "Apple" }, @@ -111,7 +111,7 @@ class FilterUseCaseTest { assertTrue(result.all { it.name == "same" }) } - // Edge: special characters ───────────────────────────────────────────── + // Edge: special characters --------------------------------------------- @Test fun `names starting with special characters sort before letters`() { val result = useCase(filterStateAsc, items("banana", "apple", "/path", "&tag"), emptyMap()) @@ -150,7 +150,7 @@ class FilterUseCaseTest { assertEquals(asc.reversed(), desc) } - // Edge: numbers ──────────────────────────────────────────────────────── + // Edge: numbers -------------------------------------------------------- @Test fun `names that are purely numeric`() = sorting("100", "9", "10", "2", "1") diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 85508c572..81ee6bc56 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.feature.autofill) + implementation(projects.feature.backup) implementation(libs.androidx.navigation.compose) @@ -27,6 +28,7 @@ dependencies { testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) testImplementation(testFixtures(projects.feature.autofill)) + testImplementation(testFixtures(projects.feature.backup)) testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) testFixturesImplementation(libs.androidx.compose.runtime) { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt index 67836d39f..0eec8a6ac 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsContent.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.settings.presentation +import android.text.format.DateUtils import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -10,7 +11,6 @@ import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Password -import androidx.compose.material.icons.filled.SettingsBackupRestore import androidx.compose.material.icons.filled.Update import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -19,6 +19,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.util.presentation.UIText +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString import de.davis.keygo.feature.settings.R import de.davis.keygo.feature.settings.presentation.component.SettingsList @@ -40,14 +42,14 @@ internal fun SettingsContent( action( title = R.string.settings_reset_password, icon = Icons.Default.LockReset, - supporting = R.string.settings_reset_password_description, + supporting = ResourceString(R.string.settings_reset_password_description), onClick = { onEvent(SettingsUiEvent.ResetPassword) }, ) if (state.biometricsAvailable) toggle( title = R.string.settings_use_biometrics, icon = Icons.Default.Fingerprint, - supporting = R.string.settings_use_biometrics_description, + supporting = ResourceString(R.string.settings_use_biometrics_description), checked = state.biometricsEnabled, onCheckedChange = { onEvent(SettingsUiEvent.SetBiometrics(it)) }, ) @@ -55,7 +57,7 @@ internal fun SettingsContent( toggle( title = R.string.settings_autofill, icon = Icons.Default.Password, - supporting = R.string.settings_autofill_description, + supporting = ResourceString(R.string.settings_autofill_description), checked = state.autofillEnabled, onCheckedChange = { onEvent(SettingsUiEvent.SetAutofill(it)) }, ) @@ -63,17 +65,10 @@ internal fun SettingsContent( section(title = R.string.settings_backup) { action( - title = R.string.settings_export_data, + title = R.string.settings_backup_and_restore, icon = Icons.Default.Backup, - supporting = R.string.settings_export_data_description, - onClick = { onEvent(SettingsUiEvent.ExportData) }, - ) - - action( - title = R.string.settings_import_data, - icon = Icons.Default.SettingsBackupRestore, - supporting = R.string.settings_import_data_description, - onClick = { onEvent(SettingsUiEvent.ImportData) }, + supporting = lastBackupText(state.lastBackupAt), + onClick = { onEvent(SettingsUiEvent.OpenBackup) }, ) } @@ -101,6 +96,22 @@ internal fun SettingsContent( } } +/** + * Relative rather than absolute ("2 days ago", not a date): what matters at a glance is how stale + * the newest backup is, so the row doubles as a nudge once it starts reading in weeks. + */ +private fun lastBackupText(lastBackupAt: Long?): UIText = when (lastBackupAt) { + null -> ResourceString(R.string.settings_backup_none) + else -> ResourceString( + R.string.settings_backup_last, + DateUtils.getRelativeTimeSpanString( + lastBackupAt, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString(), + ) +} + @Preview @Composable private fun SettingsContentPreview() { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt index e9cb1b7de..6f4ae48eb 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsEvent.kt @@ -8,6 +8,5 @@ internal sealed interface SettingsEvent { data class EnableBiometric(val enable: Boolean) : SettingsEvent data object ReportIssue : SettingsEvent - data object ExportData : SettingsEvent - data object ImportData : SettingsEvent + data object NavigateToBackup : SettingsEvent } \ No newline at end of file diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt index b3e1f3186..c584c5ae4 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsRoutes.kt @@ -23,16 +23,14 @@ object ChangePasswordRoute : RouteDestination { fun NavGraphBuilder.settingsGraph( onOpenChangePassword: () -> Unit, onShowLibraries: () -> Unit, + onOpenBackup: () -> Unit, onUp: () -> Unit, ) = navigation(startDestination = SettingsHomeRoute) { composable { SettingsScreen( showLibraries = onShowLibraries, onOpenChangePassword = onOpenChangePassword, - - // TODO: #51 - onExportDataClicked = {}, - onImportDataClicked = {} + onOpenBackup = onOpenBackup, ) } composable { diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt index a232a34fc..a11c13481 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsScreen.kt @@ -27,8 +27,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun SettingsScreen( showLibraries: () -> Unit, - onExportDataClicked: () -> Unit, - onImportDataClicked: () -> Unit, + onOpenBackup: () -> Unit, onOpenChangePassword: () -> Unit, ) { val viewModel = koinViewModel() @@ -84,8 +83,7 @@ fun SettingsScreen( SettingsEvent.ReportIssue -> urlHandler.openUri(ISSUES_URL) - SettingsEvent.ExportData -> onExportDataClicked() - SettingsEvent.ImportData -> onImportDataClicked() + SettingsEvent.NavigateToBackup -> onOpenBackup() } } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt index a5a7930c2..a0f34e653 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiEvent.kt @@ -4,8 +4,7 @@ internal sealed interface SettingsUiEvent { data class SetBiometrics(val enabled: Boolean) : SettingsUiEvent data class SetAutofill(val enabledRequest: Boolean) : SettingsUiEvent data object ResetPassword : SettingsUiEvent - data object ExportData : SettingsUiEvent - data object ImportData : SettingsUiEvent + data object OpenBackup : SettingsUiEvent data object ReportIssue : SettingsUiEvent data object LibrariesClicked : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt index 0f89aa613..be69d75b0 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsUiState.kt @@ -5,4 +5,6 @@ internal data class SettingsUiState( val biometricsAvailable: Boolean = false, val biometricsEnabled: Boolean = false, val version: String = "2.0.0", + /** When the newest successful backup finished, or `null` while none has. */ + val lastBackupAt: Long? = null, ) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt index 36e1f146b..7c6305110 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.feature.autofill.domain.repository.AutofillServiceRepository +import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +22,7 @@ internal class SettingsViewModel( private val autofillServiceRepository: AutofillServiceRepository, accountRepository: AccountRepository, appVersionRepository: AppVersionRepository, + observeLastBackup: ObserveLastBackupUseCase, ) : ViewModel() { private val versionName = appVersionRepository.versionName @@ -41,12 +43,14 @@ internal class SettingsViewModel( accountRepository.observe(), autofillEnabled, biometricsAvailable, - ) { account, autofill, biometrics -> + observeLastBackup(), + ) { account, autofill, biometrics, lastBackup -> SettingsUiState( autofillEnabled = autofill, biometricsAvailable = biometrics, biometricsEnabled = biometrics && account?.biometricWrappedArk != null, version = versionName, + lastBackupAt = lastBackup?.finishedAt, ) }.stateIn( scope = viewModelScope, @@ -77,8 +81,7 @@ internal class SettingsViewModel( SettingsUiEvent.ResetPassword -> _event.trySend(SettingsEvent.NavigateToChangePassword) - SettingsUiEvent.ExportData -> _event.trySend(SettingsEvent.ExportData) - SettingsUiEvent.ImportData -> _event.trySend(SettingsEvent.ImportData) + SettingsUiEvent.OpenBackup -> _event.trySend(SettingsEvent.NavigateToBackup) SettingsUiEvent.LibrariesClicked -> _event.trySend(SettingsEvent.NavigateToLibraries) SettingsUiEvent.ReportIssue -> _event.trySend(SettingsEvent.ReportIssue) diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt index d3daa967d..930b961ef 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsDsl.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector +import de.davis.keygo.core.util.presentation.UIText @DslMarker internal annotation class SettingsDsl @@ -26,7 +27,7 @@ internal class SectionScope { checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector? = null, - @StringRes supporting: Int? = null, + supporting: UIText? = null, ) { entries += SettingsEntry.Toggle( title = title, @@ -42,7 +43,7 @@ internal class SectionScope { onClick: () -> Unit, icon: ImageVector? = null, navigationIcon: ImageVector? = null, - @StringRes supporting: Int? = null, + supporting: UIText? = null, ) { entries += SettingsEntry.Action( title = title, @@ -57,7 +58,7 @@ internal class SectionScope { @StringRes title: Int, value: String, icon: ImageVector? = null, - @StringRes supporting: Int? = null, + supporting: UIText? = null, onClick: (() -> Unit)? = null, ) { entries += SettingsEntry.Value( diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt index e48ba4e14..d4a9ccfa9 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsEntry.kt @@ -2,19 +2,20 @@ package de.davis.keygo.feature.settings.presentation.component import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector +import de.davis.keygo.core.util.presentation.UIText internal sealed interface SettingsEntry { @get:StringRes val title: Int val icon: ImageVector? - @get:StringRes - val supporting: Int? + /** [UIText] rather than a string resource: some rows describe live state. */ + val supporting: UIText? data class Toggle( @param:StringRes override val title: Int, override val icon: ImageVector? = null, - @param:StringRes override val supporting: Int? = null, + override val supporting: UIText? = null, val checked: Boolean, val onCheckedChange: (Boolean) -> Unit, ) : SettingsEntry @@ -22,7 +23,7 @@ internal sealed interface SettingsEntry { data class Action( @param:StringRes override val title: Int, override val icon: ImageVector? = null, - @param:StringRes override val supporting: Int? = null, + override val supporting: UIText? = null, val navigationIcon: ImageVector? = null, val onClick: () -> Unit, ) : SettingsEntry @@ -30,7 +31,7 @@ internal sealed interface SettingsEntry { data class Value( @param:StringRes override val title: Int, override val icon: ImageVector? = null, - @param:StringRes override val supporting: Int? = null, + override val supporting: UIText? = null, val value: String, val onClick: (() -> Unit)? = null, ) : SettingsEntry diff --git a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt index d0ae373c9..22597d257 100644 --- a/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt +++ b/feature/settings/src/main/kotlin/de/davis/keygo/feature/settings/presentation/component/SettingsList.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import de.davis.keygo.core.util.presentation.asString @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -57,7 +58,11 @@ internal fun SettingsList( ) { index, entry -> SettingsEntryRow( entry = entry, - shapes = ListItemDefaults.segmentedShapes(index, section.entries.size), + shapes = if (section.entries.size == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) + else ListItemDefaults.segmentedShapes( + index, + section.entries.size, + ), colors = ListItemDefaults.segmentedColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), @@ -96,8 +101,8 @@ private fun SettingsEntryRow( } } } - val supportingContent: (@Composable () -> Unit)? = entry.supporting?.let { res -> - { Text(text = stringResource(res)) } + val supportingContent: (@Composable () -> Unit)? = entry.supporting?.let { supporting -> + { Text(text = supporting.asString()) } } val headlineContent: @Composable () -> Unit = { Text(text = stringResource(entry.title)) } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index cf81c3655..0c29d34df 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -10,10 +10,9 @@ Could not update biometric unlock Backup - Export Data - Save an encrypted backup of your vaults to a file - Import Data - Restore your vaults from a backup file + Backup & Restore + Last backup %1$s + No backups yet About Report an issue diff --git a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt index 319f9d571..551d549a5 100644 --- a/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/de/davis/keygo/feature/settings/presentation/SettingsViewModelTest.kt @@ -4,6 +4,12 @@ import de.davis.keygo.core.feature.autofill.FakeAutofillServiceRepository import de.davis.keygo.core.feature.settings.FakeAppVersionRepository import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.security.crypto.FakeBiometricAvailabilityRepository +import de.davis.keygo.feature.backup.FakeBackupJobRepository +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.model.BackupResult +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -16,6 +22,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -27,6 +34,7 @@ class SettingsViewModelTest { private val biometricAvailability = FakeBiometricAvailabilityRepository() private val autofillServiceRepository = FakeAutofillServiceRepository() private val appVersionRepository = FakeAppVersionRepository() + private val backupJobRepository = FakeBackupJobRepository() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -39,6 +47,7 @@ class SettingsViewModelTest { autofillServiceRepository = autofillServiceRepository, accountRepository = accountRepository, appVersionRepository = appVersionRepository, + observeLastBackup = ObserveLastBackupUseCase(backupJobRepository), ) @Test @@ -92,4 +101,34 @@ class SettingsViewModelTest { assertEquals(SettingsEvent.EnableBiometric(enable = true), vm.event.first()) } + + @Test + fun `opening backup emits NavigateToBackup`() = runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(SettingsUiEvent.OpenBackup) + + assertEquals(SettingsEvent.NavigateToBackup, vm.event.first()) + } + + @Test + fun `state carries the newest successful backup timestamp`() = runTest(dispatcher) { + backupJobRepository.jobs["one-time"] = BackupJob( + uri = BackupDestinationUri("content://backup.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + finishedAt = 1_700_000_000_000L, + lastResult = BackupResult.Success, + ) + val vm = viewModel() + + assertEquals(1_700_000_000_000L, vm.state.first { it.lastBackupAt != null }.lastBackupAt) + } + + @Test + fun `state reports no last backup while none has completed`() = runTest(dispatcher) { + val vm = viewModel() + + assertNull(vm.state.first().lastBackupAt) + } } diff --git a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/QRScanner.kt b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/QRScanner.kt index 3237ed1a7..f9f869d43 100644 --- a/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/QRScanner.kt +++ b/feature/totp/src/main/kotlin/de/davis/keygo/feature/totp/presentation/component/QRScanner.kt @@ -104,7 +104,7 @@ fun QRScanner( } else -> { - // First time — request immediately + // First time: request immediately LaunchedEffect(Unit) { permissionState.launchPermissionRequest() } diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt index dbc06fcd6..ae349970e 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt @@ -61,7 +61,7 @@ class MoveItemsToVaultUseCase( } // Phase 2: commit all moves in one SQLite transaction. On failure, every row rolls - // back — items remain in the source vault, decryptable under the source vault key. + // back, so items remain in the source vault, decryptable under the source vault key. itemRepository.moveItemsToVault(rewrapped, dstVaultId) .bind { MoveItemsError.PersistFailed(it) } } diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt index 64366b69d..aa498d0b0 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt @@ -3,22 +3,16 @@ package de.davis.keygo.feature.vault.presentation.components import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -30,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.presentation.VaultIconPicker import de.davis.keygo.core.item.presentation.toImageVector import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.vault.R @@ -68,7 +63,6 @@ fun VaultCreationDialog( ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun VaultCreationDialogContent( vaultState: VaultState.CreateOrUpdate, @@ -114,30 +108,13 @@ private fun VaultCreationDialogContent( style = MaterialTheme.typography.labelMedium, ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Vault.Icon.entries.forEach { icon -> - FilledTonalIconToggleButton( - checked = vaultState.icon == icon, - onCheckedChange = { - onIconClick(icon) - focusManager.clearFocus() - }, - modifier = Modifier - .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize()), - shapes = IconButtonDefaults.toggleableShapes(), - ) { - Icon( - imageVector = icon.toImageVector(), - contentDescription = null - ) - } - } - } + VaultIconPicker( + selected = vaultState.icon, + onSelect = { icon -> + onIconClick(icon) + focusManager.clearFocus() + }, + ) } } diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt index 96b31edcd..dd236b028 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt @@ -95,7 +95,7 @@ fun VaultSelectionSheet( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun VaultSelectionSheetContent( vaultState: VaultState.Select, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a9871c1c..1e865096e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ passGen = "0.1.0-beta" robolectric = "4.16.1" emvnfccard = "3.1.0" aboutlibraries = "14.2.1" +work = "2.11.2" [libraries] google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } @@ -64,7 +65,7 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha21" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha24" } androidx-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } androidx-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } @@ -80,12 +81,15 @@ androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.r androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } + com-google-accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koinBOM" } koin-core = { group = "io.insert-koin", name = "koin-core" } koin-annotations = { group = "io.insert-koin", name = "koin-annotations" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } +koin-androidx-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } diff --git a/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt new file mode 100644 index 000000000..40da9f6ca --- /dev/null +++ b/migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.migration.legacy_data.data + +import javax.crypto.Cipher +import javax.crypto.SecretKey + +/** + * Byte-for-byte v1's `Cryptography.encryptWithIV`: the 12 byte GCM IV prefixed to the ciphertext in + * one blob, AES-256-GCM, 128 bit tag. + * + * The one transcription of v1's wire format in this module, on purpose. A test that passes against + * it means compatibility with the bytes real v1 installs wrote; a second copy could drift and leave + * the tests agreeing with each other about the wrong framing. + */ +fun encryptLikeV1(plaintext: ByteArray, key: SecretKey): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher.iv + cipher.doFinal(plaintext) +} diff --git a/rust/rust-code/.cargo/config.toml b/rust/rust-code/.cargo/config.toml index f013054f9..71772e9f6 100644 --- a/rust/rust-code/.cargo/config.toml +++ b/rust/rust-code/.cargo/config.toml @@ -7,7 +7,7 @@ # if the app minSdk changes. # # Prefer `cargo ndk -t build --release -p keygo-bindings` over setting -# PATH manually — it picks the right linker/sysroot automatically. +# PATH manually, since it picks the right linker/sysroot automatically. [target.aarch64-linux-android] linker = "aarch64-linux-android26-clang" diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock index 185da12a5..90040b104 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/rust-code/Cargo.lock @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher", "cpubits", @@ -54,9 +54,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -180,13 +180,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -207,6 +207,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -278,9 +284,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -389,9 +395,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -399,9 +405,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstyle", "clap_lex", @@ -410,14 +416,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -517,6 +523,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.10.1" @@ -610,13 +637,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -665,9 +692,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -692,6 +719,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -710,9 +746,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" @@ -843,9 +879,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "goblin" @@ -892,6 +928,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -903,9 +948,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -1074,6 +1119,7 @@ name = "keygo-bindings" version = "0.1.0" dependencies = [ "lib", + "serde_json", "thiserror", "tokio", "uniffi 0.31.2", @@ -1095,16 +1141,22 @@ dependencies = [ "aes-gcm-siv", "argon2", "async-trait", + "base32", + "base64", "bcs", "ciborium", "coset", + "csv", "ed25519-dalek", + "email_address", + "hkdf", "passkey", "passkey-authenticator", "passkey-types", "rand 0.10.2", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "totp-rs", "url", @@ -1114,9 +1166,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -1411,9 +1463,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1426,9 +1478,9 @@ checksum = "51315bca45305dd8aa64b831b33e71abac528ca8058c0651346a39b8d3009498" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1559,6 +1611,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scroll" version = "0.12.0" @@ -1606,9 +1664,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1616,29 +1674,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -1820,6 +1878,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1861,22 +1930,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1891,9 +1960,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "pin-project-lite", ] @@ -1907,12 +1976,27 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1922,11 +2006,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -1946,6 +2039,7 @@ dependencies = [ "base32", "constant_time_eq", "hmac", + "serde", "sha1", "sha2 0.10.9", "url", @@ -2015,7 +2109,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros 0.31.2", "uniffi_meta 0.31.2", "uniffi_pipeline 0.31.2", @@ -2041,7 +2135,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", + "toml 1.1.4+spec-1.1.0", "uniffi_internal_macros 0.32.0", "uniffi_meta 0.32.0", "uniffi_pipeline 0.32.0", @@ -2123,7 +2217,7 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_meta 0.31.2", ] @@ -2140,7 +2234,7 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "toml", + "toml 1.1.4+spec-1.1.0", "uniffi_meta 0.32.0", ] @@ -2395,18 +2489,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", diff --git a/rust/rust-code/bindings/Cargo.toml b/rust/rust-code/bindings/Cargo.toml index f98e924b3..3bf8429af 100644 --- a/rust/rust-code/bindings/Cargo.toml +++ b/rust/rust-code/bindings/Cargo.toml @@ -17,6 +17,9 @@ uuid = "1.23.1" [build-dependencies] uniffi = { version = "0.32.0", features = ["build"] } +[dev-dependencies] +serde_json = "1.0" + [[bin]] name = "uniffi-bindgen" path = "uniffi-bindgen.rs" diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs new file mode 100644 index 000000000..3eab2be1a --- /dev/null +++ b/rust/rust-code/bindings/src/backup.rs @@ -0,0 +1,456 @@ +use std::sync::Arc; + +use lib::backup as core; +use lib::crypto::AccountRootKey; + +#[derive(uniffi::Record)] +pub struct Backup { + pub vaults: Vec, +} + +#[derive(uniffi::Record)] +pub struct BackupVault { + pub name: String, + pub icon: String, + pub logins: Vec, + pub cards: Vec, +} + +#[derive(uniffi::Record)] +pub struct BackupLogin { + pub title: String, + pub notes: Option, + pub tags: Vec, + pub pinned: bool, + pub username: Option, + pub password: Option, + pub totp_secret: Option, + pub websites: Vec, + pub passkeys: Vec, +} + +#[derive(uniffi::Record)] +pub struct BackupCard { + pub title: String, + pub notes: Option, + pub tags: Vec, + pub pinned: bool, + pub cardholder: Option, + pub number: String, + pub expiration_month: Option, + pub expiration_year: Option, + pub cvv: Option, +} + +#[derive(uniffi::Record)] +pub struct BackupPasskey { + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +impl From for BackupPasskey { + fn from(p: core::Passkey) -> Self { + Self { + user_name: p.user_name, + user_display_name: p.user_display_name, + credential_id: p.credential_id, + private_key: p.private_key, + rp: p.rp, + } + } +} + +impl From for core::Passkey { + fn from(p: BackupPasskey) -> Self { + Self { + user_name: p.user_name, + user_display_name: p.user_display_name, + credential_id: p.credential_id, + private_key: p.private_key, + rp: p.rp, + } + } +} + +impl From for BackupLogin { + fn from(l: core::Login) -> Self { + Self { + title: l.title, + notes: l.notes, + tags: l.tags, + pinned: l.pinned, + username: l.username, + password: l.password, + totp_secret: l.totp_secret, + websites: l.websites, + passkeys: l.passkeys.into_iter().map(Into::into).collect(), + } + } +} + +impl From for core::Login { + fn from(l: BackupLogin) -> Self { + Self { + title: l.title, + notes: l.notes, + tags: l.tags, + pinned: l.pinned, + username: l.username, + password: l.password, + totp_secret: l.totp_secret, + websites: l.websites, + passkeys: l.passkeys.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BackupCard { + fn from(c: core::Card) -> Self { + Self { + title: c.title, + notes: c.notes, + tags: c.tags, + pinned: c.pinned, + cardholder: c.cardholder, + number: c.number, + expiration_month: c.expiration_month, + expiration_year: c.expiration_year, + cvv: c.cvv, + } + } +} + +impl From for core::Card { + fn from(c: BackupCard) -> Self { + Self { + title: c.title, + notes: c.notes, + tags: c.tags, + pinned: c.pinned, + cardholder: c.cardholder, + number: c.number, + expiration_month: c.expiration_month, + expiration_year: c.expiration_year, + cvv: c.cvv, + } + } +} + +impl From for BackupVault { + fn from(v: core::Vault) -> Self { + Self { + name: v.name, + icon: v.icon, + logins: v.logins.into_iter().map(Into::into).collect(), + cards: v.cards.into_iter().map(Into::into).collect(), + } + } +} + +impl From for core::Vault { + fn from(v: BackupVault) -> Self { + Self { + name: v.name, + icon: v.icon, + logins: v.logins.into_iter().map(Into::into).collect(), + cards: v.cards.into_iter().map(Into::into).collect(), + } + } +} + +impl From for Backup { + fn from(b: core::Backup) -> Self { + Self { + vaults: b.vaults.into_iter().map(Into::into).collect(), + } + } +} + +impl From for core::Backup { + fn from(b: Backup) -> Self { + Self { + vaults: b.vaults.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(uniffi::Record)] +pub struct CsvColumn { + pub index: u32, + pub header: String, + pub sample_values: Vec, +} + +#[derive(uniffi::Enum)] +pub enum Confidence { + High, + Medium, + Low, +} + +#[derive(uniffi::Record)] +pub struct FieldConfidence { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(uniffi::Record, Default)] +pub struct ColumnMapping { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(uniffi::Record)] +pub struct CsvAnalysis { + pub columns: Vec, + pub suggested: ColumnMapping, + pub confidence: FieldConfidence, +} + +#[derive(uniffi::Record)] +pub struct ImportReport { + pub imported: u32, + pub skipped: u32, +} + +#[derive(uniffi::Record)] +pub struct CsvImportResult { + pub backup: Backup, + pub report: ImportReport, +} + +#[derive(uniffi::Enum)] +pub enum ExportPreset { + KeyGo, + Browser, +} + +#[derive(uniffi::Enum)] +pub enum JsonEncryption { + Passphrase, + Ark, +} + +impl From for Confidence { + fn from(c: core::Confidence) -> Self { + match c { + core::Confidence::High => Self::High, + core::Confidence::Medium => Self::Medium, + core::Confidence::Low => Self::Low, + } + } +} + +impl From for CsvColumn { + fn from(c: core::CsvColumn) -> Self { + Self { + index: c.index, + header: c.header, + sample_values: c.sample_values, + } + } +} + +impl From for FieldConfidence { + fn from(f: core::FieldConfidence) -> Self { + Self { + title: f.title.map(Into::into), + url: f.url.map(Into::into), + username: f.username.map(Into::into), + password: f.password.map(Into::into), + notes: f.notes.map(Into::into), + totp: f.totp.map(Into::into), + } + } +} + +impl From for ColumnMapping { + fn from(m: core::ColumnMapping) -> Self { + Self { + title: m.title.map(|i| i as u32), + url: m.url.map(|i| i as u32), + username: m.username.map(|i| i as u32), + password: m.password.map(|i| i as u32), + notes: m.notes.map(|i| i as u32), + totp: m.totp.map(|i| i as u32), + } + } +} + +impl From for core::ColumnMapping { + fn from(m: ColumnMapping) -> Self { + Self { + title: m.title.map(|i| i as usize), + url: m.url.map(|i| i as usize), + username: m.username.map(|i| i as usize), + password: m.password.map(|i| i as usize), + notes: m.notes.map(|i| i as usize), + totp: m.totp.map(|i| i as usize), + } + } +} + +impl From for CsvAnalysis { + fn from(a: core::CsvAnalysis) -> Self { + Self { + columns: a.columns.into_iter().map(Into::into).collect(), + suggested: a.suggested.into(), + confidence: a.confidence.into(), + } + } +} + +impl From for ImportReport { + fn from(r: core::ImportReport) -> Self { + Self { + imported: r.imported, + skipped: r.skipped, + } + } +} + +impl From for core::ExportPreset { + fn from(p: ExportPreset) -> Self { + match p { + ExportPreset::KeyGo => core::ExportPreset::KeyGo, + ExportPreset::Browser => core::ExportPreset::Browser, + } + } +} + +#[derive(uniffi::Enum)] +pub enum BackupCredential { + Passphrase { bytes: Vec }, + Ark { key: AccountRootKey }, +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum BackupError { + #[error("crypto error: {0}")] + Crypto(String), + #[error("json error: {0}")] + Json(String), + #[error("invalid base64 in backup payload or header")] + Base64, + #[error("unsupported backup version: {0}")] + UnsupportedVersion(u32), + #[error("malformed encryption header")] + MalformedHeader, + #[error("credential does not match the backup's key source")] + CredentialMismatch, + #[error("malformed csv: {0}")] + Csv(String), + #[error("csv contained no rows")] + EmptyCsv, +} + +impl From for BackupError { + fn from(e: core::BackupError) -> Self { + use core::BackupError as E; + match e { + E::Crypto(c) => Self::Crypto(format!("{c}")), + E::Json(j) => Self::Json(format!("{j}")), + E::Base64 => Self::Base64, + E::UnsupportedVersion(v) => Self::UnsupportedVersion(v), + E::MalformedHeader => Self::MalformedHeader, + E::CredentialMismatch => Self::CredentialMismatch, + E::Csv(s) => Self::Csv(s), + E::EmptyCsv => Self::EmptyCsv, + } + } +} + +#[derive(uniffi::Object)] +pub struct JsonBackupManager; + +#[uniffi::export] +impl JsonBackupManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn export( + &self, + backup: Backup, + credential: BackupCredential, + ) -> Result { + let backup: core::Backup = backup.into(); + let json = match credential { + BackupCredential::Passphrase { bytes } => { + core::json::export(&backup, core::BackupCredential::Passphrase(&bytes)) + } + BackupCredential::Ark { key } => { + core::json::export(&backup, core::BackupCredential::Ark(&key)) + } + }?; + Ok(json) + } + + pub fn import( + &self, + data: String, + credential: BackupCredential, + ) -> Result { + let backup = match credential { + BackupCredential::Passphrase { bytes } => { + core::json::import(&data, core::BackupCredential::Passphrase(&bytes)) + } + BackupCredential::Ark { key } => { + core::json::import(&data, core::BackupCredential::Ark(&key)) + } + }?; + Ok(backup.into()) + } + + pub fn inspect(&self, data: String) -> Result { + Ok(match core::json::inspect(&data)? { + core::encryption::KeySource::Passphrase => JsonEncryption::Passphrase, + core::encryption::KeySource::Ark => JsonEncryption::Ark, + }) + } +} + +#[derive(uniffi::Object)] +pub struct CsvBackupManager; + +#[uniffi::export] +impl CsvBackupManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn analyze(&self, data: String) -> Result { + Ok(core::csv::analyze(&data)?.into()) + } + + pub fn import( + &self, + data: String, + mapping: ColumnMapping, + ) -> Result { + let mapping: core::ColumnMapping = mapping.into(); + let (backup, report) = core::csv::import(&data, &mapping)?; + Ok(CsvImportResult { + backup: backup.into(), + report: report.into(), + }) + } + + pub fn export(&self, backup: Backup, preset: ExportPreset) -> Result { + let backup: core::Backup = backup.into(); + Ok(core::csv::export(&backup, preset.into())?) + } +} diff --git a/rust/rust-code/bindings/src/item.rs b/rust/rust-code/bindings/src/item.rs index d15546e8c..a7a080634 100644 --- a/rust/rust-code/bindings/src/item.rs +++ b/rust/rust-code/bindings/src/item.rs @@ -30,7 +30,7 @@ pub struct EncryptedItemBlob { pub enum ItemCryptoError { #[error("Encryption failed")] EncryptionFailed, - #[error("Decryption failed — ciphertext invalid or tampered")] + #[error("Decryption failed: ciphertext invalid or tampered")] DecryptionFailed, #[error("Invalid key material")] InvalidKey, diff --git a/rust/rust-code/bindings/src/key_wrap.rs b/rust/rust-code/bindings/src/key_wrap.rs index 0357e35c5..bd706ef84 100644 --- a/rust/rust-code/bindings/src/key_wrap.rs +++ b/rust/rust-code/bindings/src/key_wrap.rs @@ -25,7 +25,7 @@ pub struct WrappedKeyBlob { pub enum KeyWrapError { #[error("Key wrap failed")] WrapFailed, - #[error("Key unwrap failed — wrong key or corrupted data")] + #[error("Key unwrap failed: wrong key or corrupted data")] UnwrapFailed, #[error("Invalid key material")] InvalidKey, diff --git a/rust/rust-code/bindings/src/lib.rs b/rust/rust-code/bindings/src/lib.rs index 93e42391e..5bf113fcb 100644 --- a/rust/rust-code/bindings/src/lib.rs +++ b/rust/rust-code/bindings/src/lib.rs @@ -1,4 +1,5 @@ mod account; +mod backup; mod card; mod item; mod key_derivation; diff --git a/rust/rust-code/lib/Cargo.toml b/rust/rust-code/lib/Cargo.toml index 07cc95543..167bea334 100644 --- a/rust/rust-code/lib/Cargo.toml +++ b/rust/rust-code/lib/Cargo.toml @@ -15,6 +15,7 @@ passkey-authenticator = { version = "0.5.0", features = ["tokio", "testable"] } # Needed so passkey JSON responses are serialized into base64 strings passkey-types = { version = "0.5.0", features = ["serialize_bytes_as_base64_string"] } +base64 = "0.22" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" thiserror = "2.0.18" @@ -24,4 +25,9 @@ uuid = { version = "1.23.0", features = ["serde", "v4"] } rand = { version = "0.10.0", features = ["sys_rng"] } bcs = "0.2.0" argon2 = "0.5.3" -totp-rs = { version = "5.7.1", features = ["otpauth", "zeroize"] } +hkdf = "0.12" +sha2 = "0.10" +totp-rs = { version = "5.7.1", features = ["otpauth", "zeroize", "serde_support"] } +csv = "1.4.0" +email_address = "0.2.9" +base32 = "0.5.1" diff --git a/rust/rust-code/lib/src/b64.rs b/rust/rust-code/lib/src/b64.rs new file mode 100644 index 000000000..4eb2124cd --- /dev/null +++ b/rust/rust-code/lib/src/b64.rs @@ -0,0 +1,43 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::{Deserialize, Deserializer, Serializer}; + +pub(crate) fn encode(bytes: impl AsRef<[u8]>) -> String { + STANDARD.encode(bytes) +} + +pub(crate) fn decode(encoded: &str) -> Result, base64::DecodeError> { + STANDARD.decode(encoded) +} + +/// serde adapter for `#[serde(with = "crate::b64")]` on `Vec` fields. +pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&encode(bytes)) +} + +pub(crate) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let s = String::deserialize(deserializer)?; + decode(&s).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Holder { + #[serde(with = "crate::b64")] + data: Vec, + } + + #[test] + fn round_trip() { + let h = Holder { + data: vec![0, 1, 2, 250, 255], + }; + let json = serde_json::to_string(&h).unwrap(); + assert_eq!(json, r#"{"data":"AAEC+v8="}"#); + let back: Holder = serde_json::from_str(&json).unwrap(); + assert_eq!(back, h); + } +} diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs new file mode 100644 index 000000000..26c54e09f --- /dev/null +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -0,0 +1,302 @@ +use crate::b64; +use crate::backup::BackupError; +use crate::backup::key::BackupKey; +use crate::crypto::keys::AccountRootKey; +use crate::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; +use crate::crypto::primitive::argon2::Argon2Params; +use crate::crypto::random::random_bytes; +use serde::{Deserialize, Serialize}; + +const NONCE_LEN: usize = 12; + +#[derive(Serialize, Deserialize)] +pub struct EncryptionHeader { + pub source: KeySource, + pub kdf: Kdf, + #[serde(with = "b64")] + pub nonce: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] +pub enum KeySource { + Passphrase, + Ark, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Kdf { + Argon2id { + #[serde(with = "b64")] + salt: Vec, + mem_kib: u32, + iters: u32, + lanes: u32, + }, + HkdfSha256 { + #[serde(with = "b64")] + salt: Vec, + }, +} + +impl Kdf { + fn argon2id(salt: Vec, params: Argon2Params) -> Self { + Kdf::Argon2id { + salt, + mem_kib: params.mem_kib, + iters: params.iters, + lanes: params.lanes, + } + } + + fn hkdf_sha256(salt: Vec) -> Self { + Kdf::HkdfSha256 { salt } + } +} + +/// Evolve it only via a version bump, never by editing fields in place. +#[derive(Serialize)] +pub struct BackupAad { + pub version: u32, + pub source: KeySource, +} + +impl AeadEncryptor for BackupKey { + type Aad = BackupAad; +} + +#[derive(Clone, Copy)] +pub enum BackupCredential<'a> { + Passphrase(&'a [u8]), + Ark(&'a AccountRootKey), +} + +pub struct SealedPayload { + pub header: EncryptionHeader, + pub ciphertext: Vec, +} + +pub fn seal( + plaintext: &[u8], + cred: BackupCredential<'_>, + version: u32, +) -> Result { + let salt = random_bytes::<16>(); + let (backup_key, source, kdf) = match cred { + BackupCredential::Passphrase(passphrase) => { + let argon_param = Argon2Params::default(); + ( + BackupKey::from_passphrase(passphrase, &salt, argon_param)?, + KeySource::Passphrase, + Kdf::argon2id(salt.to_vec(), argon_param), + ) + } + BackupCredential::Ark(ark) => ( + BackupKey::from_ark(ark, &salt)?, + KeySource::Ark, + Kdf::hkdf_sha256(salt.to_vec()), + ), + }; + let aad = BackupAad { version, source }; + let ciphertext = backup_key.encrypt_data(plaintext, &aad)?; + Ok(SealedPayload { + header: EncryptionHeader { + source, + kdf, + nonce: ciphertext.nonce_bytes().to_vec(), + }, + ciphertext: ciphertext.ciphertext().to_vec(), + }) +} + +pub fn open( + header: &EncryptionHeader, + ciphertext: &[u8], + cred: BackupCredential<'_>, + version: u32, +) -> Result, BackupError> { + if header.nonce.len() != NONCE_LEN { + return Err(BackupError::MalformedHeader); + } + + let key = match (header.source, &header.kdf, cred) { + // Credential's source disagrees with the header's declared source. + (KeySource::Passphrase, _, BackupCredential::Ark(_)) + | (KeySource::Ark, _, BackupCredential::Passphrase(_)) => { + return Err(BackupError::CredentialMismatch); + } + + // Source matches credential and the KDF matches the source: derive. + ( + KeySource::Passphrase, + Kdf::Argon2id { + salt, + mem_kib, + iters, + lanes, + }, + BackupCredential::Passphrase(passphrase), + ) => BackupKey::from_passphrase( + passphrase, + salt, + Argon2Params { + mem_kib: *mem_kib, + iters: *iters, + lanes: *lanes, + }, + )?, + (KeySource::Ark, Kdf::HkdfSha256 { salt }, BackupCredential::Ark(ark)) => { + BackupKey::from_ark(ark, salt)? + } + + // Source matches credential but the KDF disagrees with the source. + (KeySource::Passphrase, _, BackupCredential::Passphrase(_)) + | (KeySource::Ark, _, BackupCredential::Ark(_)) => { + return Err(BackupError::MalformedHeader); + } + }; + + let aad = BackupAad { + version, + source: header.source, + }; + let parts = AeadCiphertext::::from_parts_bytes(ciphertext.to_vec(), &header.nonce); + Ok(key.decrypt_data(&parts, &aad)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::CURRENT_VERSION; + use crate::crypto::error::CryptoError; + use crate::crypto::key::KeyMaterial; + use crate::crypto::keys::AccountRootKey; + use crate::crypto::primitive::argon2::MAX_ARGON2_MEM_KIB; + + #[test] + fn key_source_serialization() { + assert_eq!( + serde_json::to_value(KeySource::Passphrase).unwrap(), + serde_json::json!("passphrase") + ); + assert_eq!( + serde_json::to_value(KeySource::Ark).unwrap(), + serde_json::json!("ark") + ); + } + + #[test] + fn encryption_header_field_names() { + let header = EncryptionHeader { + source: KeySource::Passphrase, + kdf: Kdf::argon2id(vec![1u8; 16], Argon2Params::default()), + nonce: vec![2u8; 12], + }; + let v = serde_json::to_value(&header).unwrap(); + assert_eq!(v["source"], serde_json::json!("passphrase")); + assert_eq!(v["kdf"]["type"], serde_json::json!("argon2id")); + assert!(v["kdf"]["salt"].is_string()); + assert!(v["nonce"].is_string()); + } + + #[test] + fn backup_key_encrypts_and_decrypts_with_aad() { + let salt = [4u8; 16]; + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); + let aad = BackupAad { + version: CURRENT_VERSION, + source: KeySource::Passphrase, + }; + let ct = key.encrypt_data(b"secret-bytes", &aad).unwrap(); + let pt = key.decrypt_data(&ct, &aad).unwrap(); + assert_eq!(pt, b"secret-bytes"); + } + + #[test] + fn backup_key_decrypt_fails_with_different_aad() { + let salt = [4u8; 16]; + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); + let aad = BackupAad { + version: 1, + source: KeySource::Passphrase, + }; + let wrong = BackupAad { + version: 2, + source: KeySource::Passphrase, + }; + let ct = key.encrypt_data(b"secret", &aad).unwrap(); + assert!(matches!( + key.decrypt_data(&ct, &wrong), + Err(CryptoError::DecryptionFailed), + )); + } + + #[test] + fn seal_then_open_round_trips() { + let cred = BackupCredential::Passphrase(b"pw"); + let sealed = seal(b"hello-bytes", cred, CURRENT_VERSION).unwrap(); + let pt = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); + assert_eq!(pt, b"hello-bytes"); + } + + #[test] + fn open_rejects_wrong_nonce_length() { + let cred = BackupCredential::Passphrase(b"pw"); + let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); + sealed.header.nonce.push(0); + let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!(err, BackupError::MalformedHeader)); + } + + #[test] + fn ark_seal_records_hkdf_kdf() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let sealed = seal(b"x", BackupCredential::Ark(&ark), CURRENT_VERSION).unwrap(); + assert!(matches!(sealed.header.source, KeySource::Ark)); + assert!(matches!(sealed.header.kdf, Kdf::HkdfSha256 { .. })); + } + + #[test] + fn ark_seal_then_open_round_trips() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let cred = BackupCredential::Ark(&ark); + let sealed = seal(b"hello-ark", cred, CURRENT_VERSION).unwrap(); + let pt = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); + assert_eq!(pt, b"hello-ark"); + } + + #[test] + fn open_rejects_ark_source_with_argon2id_kdf() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let cred = BackupCredential::Ark(&ark); + let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); + // Header still says source=Ark but now carries a passphrase-style KDF. + sealed.header.kdf = Kdf::argon2id(vec![1u8; 16], Argon2Params::default()); + let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!(err, BackupError::MalformedHeader)); + } + + #[test] + fn open_rejects_passphrase_source_with_hkdf_kdf() { + let cred = BackupCredential::Passphrase(b"pw"); + let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); + sealed.header.kdf = Kdf::hkdf_sha256(vec![1u8; 16]); + let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!(err, BackupError::MalformedHeader)); + } + + #[test] + fn open_rejects_argon2_params_exceeding_memory_limit() { + let cred = BackupCredential::Passphrase(b"pw"); + let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); + // A hostile header claiming an enormous memory cost must fail key + // derivation cleanly - not abort the process, and not be silently ignored + // (which is what happened before the params were threaded through). + if let Kdf::Argon2id { mem_kib, .. } = &mut sealed.header.kdf { + *mem_kib = MAX_ARGON2_MEM_KIB + 1; + } + let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)),)); + } +} diff --git a/rust/rust-code/lib/src/backup/error.rs b/rust/rust-code/lib/src/backup/error.rs new file mode 100644 index 000000000..fa9d14074 --- /dev/null +++ b/rust/rust-code/lib/src/backup/error.rs @@ -0,0 +1,21 @@ +use crate::crypto::error::CryptoError; + +#[derive(Debug, thiserror::Error)] +pub enum BackupError { + #[error("crypto error: {0}")] + Crypto(#[from] CryptoError), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("invalid base64 in backup payload or header")] + Base64, + #[error("unsupported backup version: {0}")] + UnsupportedVersion(u32), + #[error("malformed encryption header")] + MalformedHeader, + #[error("credential does not match the backup's key source")] + CredentialMismatch, + #[error("malformed csv: {0}")] + Csv(String), + #[error("csv contained no rows")] + EmptyCsv, +} diff --git a/rust/rust-code/lib/src/backup/format/csv.rs b/rust/rust-code/lib/src/backup/format/csv.rs new file mode 100644 index 000000000..d1aaaf279 --- /dev/null +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -0,0 +1,1091 @@ +use crate::backup::{Backup, BackupError, Login, Vault}; +use crate::totp::is_valid_totp_secret; +use crate::url::sanitize_to_https_url; +use csv::StringRecord; +use email_address::Options; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ColumnMapping { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Confidence { + High, + Medium, + Low, +} + +impl Confidence { + fn from_score(score: u32) -> Confidence { + if score >= 90 { + Confidence::High + } else if score >= 45 { + Confidence::Medium + } else { + Confidence::Low + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct FieldConfidence { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct CsvColumn { + pub index: u32, + pub header: String, + pub sample_values: Vec, +} + +pub struct CsvAnalysis { + pub columns: Vec, + pub suggested: ColumnMapping, + pub confidence: FieldConfidence, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ImportReport { + pub imported: u32, + pub skipped: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Field { + Title, + Url, + Username, + Password, + Notes, + Totp, +} + +const ALL_FIELDS: [Field; 6] = [ + Field::Title, + Field::Url, + Field::Username, + Field::Password, + Field::Notes, + Field::Totp, +]; + +impl Field { + /// Borrow this field's value from a login, or `None` for an absent optional. + /// The title is always present. + fn read(self, login: &Login) -> Option<&str> { + match self { + Field::Title => Some(login.title.as_str()), + Field::Url => login.websites.first().map(String::as_str), + Field::Username => login.username.as_deref(), + Field::Password => login.password.as_deref(), + Field::Notes => login.notes.as_deref(), + Field::Totp => login.totp_secret.as_deref(), + } + } +} + +impl ColumnMapping { + fn set(&mut self, field: Field, idx: usize) { + match field { + Field::Title => self.title = Some(idx), + Field::Url => self.url = Some(idx), + Field::Username => self.username = Some(idx), + Field::Password => self.password = Some(idx), + Field::Notes => self.notes = Some(idx), + Field::Totp => self.totp = Some(idx), + } + } +} + +impl FieldConfidence { + fn set(&mut self, field: Field, conf: Confidence) { + match field { + Field::Title => self.title = Some(conf), + Field::Url => self.url = Some(conf), + Field::Username => self.username = Some(conf), + Field::Password => self.password = Some(conf), + Field::Notes => self.notes = Some(conf), + Field::Totp => self.totp = Some(conf), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportPreset { + /// KeyGo's own layout: every field, with headers that [`analyze`] maps back + /// at High confidence, so a backup round-trips through [`import`]. + KeyGo, + /// A browser-importable layout (Chrome, Edge, ...): `name,url,username,password,note` + /// and no TOTP column. + Browser, +} + +impl ExportPreset { + fn columns(self) -> &'static [(&'static str, Field)] { + use Field::*; + match self { + ExportPreset::KeyGo => &[ + ("title", Title), + ("url", Url), + ("username", Username), + ("password", Password), + ("notes", Notes), + ("totp", Totp), + ], + ExportPreset::Browser => &[ + ("name", Title), + ("url", Url), + ("username", Username), + ("password", Password), + ("note", Notes), + ], + } + } +} + +const DELIMITERS: [u8; 4] = *b",;\t|"; + +/// Strip a leading UTF-8 BOM, if present. +fn strip_bom(data: &str) -> &str { + data.strip_prefix('\u{feff}').unwrap_or(data) +} + +fn detect_delimiter(data: &str) -> u8 { + let mut best = b','; + let mut best_score = -1i64; + + for &delim in &DELIMITERS { + let mut rdr = csv::ReaderBuilder::new() + .delimiter(delim) + .has_headers(false) // Treat all lines as data for counting + .flexible(true) + .from_reader(data.as_bytes()); + + let mut columns = Vec::with_capacity(5); + for result in rdr.records().take(5) { + match result { + Ok(record) => columns.push(record.len()), + Err(_) => break, // If parsing fails wildly, abandon this delimiter + } + } + if columns.is_empty() { + continue; + } + + let max = *columns.iter().max().unwrap_or(&1); + if max <= 1 { + continue; + } + + let consistent = columns.iter().all(|&c| c == columns[0]); + let score = (consistent as i64) * 1000 + max as i64; + if score > best_score { + best_score = score; + best = delim; + } + } + best +} + +fn build_reader(data: &str) -> csv::Reader<&[u8]> { + csv::ReaderBuilder::new() + .delimiter(detect_delimiter(data)) + .has_headers(true) + .flexible(true) + .from_reader(data.as_bytes()) +} + +fn looks_like_email(s: &str) -> bool { + email_address::EmailAddress::parse_with_options(s, Options::default().with_required_tld()) + .is_ok() +} + +fn looks_like_url(s: &str) -> bool { + !looks_like_email(s) && sanitize_to_https_url(s).is_ok() +} + +fn looks_like_totp(s: &str) -> bool { + let s = s.trim(); + if s.is_empty() { + return false; + } + if s.to_ascii_lowercase().starts_with("otpauth://") { + return true; + } + + is_valid_totp_secret(s) && s.len() >= 16 +} + +const HEADER_EXACT: u32 = 100; +const HEADER_CONTAINS: u32 = 30; +const VALUE_MAX: u32 = 50; +const MIN_SCORE: u32 = 25; + +/// Lowercase a header and collapse every run of non-alphanumeric characters +/// (spaces, `_`, `-`, `.`, `/`, ...) into a single space, trimming the ends. This +/// makes `login_uri`, `Login-URI`, `login.uri`, and `Login URI` all compare +/// equal, so a header matches the keyword tables regardless of separator style. +fn normalize_header(header: &str) -> String { + let mut out = String::with_capacity(header.len()); + let mut pending_space = false; + for c in header.chars().flat_map(char::to_lowercase) { + if c.is_alphanumeric() { + if pending_space && !out.is_empty() { + out.push(' '); + } + pending_space = false; + out.push(c); + } else { + pending_space = true; + } + } + out +} + +/// Score a single field against an already-[`normalize_header`]d header. +fn header_score(field: Field, header: &str) -> u32 { + let (exact, contains): (&[&str], &[&str]) = match field { + Field::Title => ( + &[ + "title", + "name", + "account", + "account name", + "item", + "entry", + "display name", + "service", + ], + &["title", "name"], + ), + Field::Url => ( + &[ + "url", + "uri", + "website", + "web site", + "web", + "site", + "link", + "host", + "hostname", + "domain", + "login uri", + "login url", + ], + &[ + "url", "uri", "website", "web", "site", "host", "domain", "link", + ], + ), + Field::Username => ( + &[ + "username", + "user name", + "user", + "user id", + "userid", + "login", + "login name", + "login username", + "email", + "e mail", + ], + &["user", "login", "email"], + ), + Field::Password => ( + &[ + "password", + "pass", + "pwd", + "passwd", + "secret", + "login password", + ], + &["password", "passwd", "pwd"], + ), + Field::Notes => ( + &[ + "notes", + "note", + "comment", + "comments", + "description", + "extra", + "memo", + ], + &["note", "comment", "description", "memo"], + ), + Field::Totp => ( + &[ + "totp", + "otp", + "otpauth", + "2fa", + "two factor", + "twofactor", + "authenticator", + "seed", + "login totp", + ], + &["totp", "otp", "2fa", "authenticator"], + ), + }; + if exact.contains(&header) { + HEADER_EXACT + } else if contains.iter().any(|k| header.contains(k)) { + HEADER_CONTAINS + } else { + 0 + } +} + +struct Profile { + url: f32, + email: f32, + totp: f32, +} + +impl Profile { + fn score(&self, field: Field) -> u32 { + let frac = match field { + Field::Url => self.url, + Field::Username => self.email, + Field::Totp => self.totp, + _ => 0.0, + }; + (frac * VALUE_MAX as f32) as u32 + } +} + +fn profile_column(samples: &[StringRecord], col: usize) -> Profile { + let mut total = 0u32; + let mut url = 0u32; + let mut email = 0u32; + let mut totp = 0u32; + for row in samples { + if let Some(cell) = row.get(col) { + let cell = cell.trim(); + if cell.is_empty() { + continue; + } + total += 1; + + // url and email are mutually exclusive: looks_like_url already + // rejects anything that parses as an email. + if looks_like_url(cell) { + url += 1; + } else if looks_like_email(cell) { + email += 1; + } + + if looks_like_totp(cell) { + totp += 1; + } + } + } + let t = total.max(1) as f32; + Profile { + url: url as f32 / t, + email: email as f32 / t, + totp: totp as f32 / t, + } +} + +/// Greedy best-fit assignment: each column maps to at most one field and each +/// field to at most one column, taking the highest scores first. Ties resolve by +/// field declaration order, then column index, for determinism. +fn build_mapping(headers: &[String], samples: &[StringRecord]) -> (ColumnMapping, FieldConfidence) { + let profiles: Vec = (0..headers.len()) + .map(|c| profile_column(samples, c)) + .collect(); + + let mut candidates: Vec<(u32, usize, usize)> = Vec::new(); // (score, field_idx, col) + for (col, header) in headers.iter().enumerate() { + let h = normalize_header(header); + + for (field_idx, field) in ALL_FIELDS.into_iter().enumerate() { + let score = header_score(field, &h) + profiles[col].score(field); + if score >= MIN_SCORE { + candidates.push((score, field_idx, col)); + } + } + } + candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + + let mut mapping = ColumnMapping::default(); + let mut confidence = FieldConfidence::default(); + let mut used_cols = vec![false; headers.len()]; + let mut used_fields = [false; ALL_FIELDS.len()]; + + for (score, field_idx, col) in candidates { + if used_cols[col] || used_fields[field_idx] { + continue; + } + let field = ALL_FIELDS[field_idx]; + mapping.set(field, col); + confidence.set(field, Confidence::from_score(score)); + used_cols[col] = true; + used_fields[field_idx] = true; + } + + (mapping, confidence) +} + +/// must be `>= DISPLAY_SAMPLES`. +const SAMPLE_ROWS: usize = 10; +const DISPLAY_SAMPLES: usize = 5; + +/// Inspect a CSV and return a review-ready analysis: the columns (with a few +/// sample values each), a suggested editable mapping, and per-field confidence. +/// +/// Column types are inferred from the header names and from the first +/// [`SAMPLE_ROWS`] parseable data rows (malformed rows are skipped). Only those +/// rows are read, so the result is deterministic and the cost is independent of +/// file size. +pub fn analyze(data: &str) -> Result { + let data = strip_bom(data); + if data.trim().is_empty() { + return Err(BackupError::EmptyCsv); + } + + let mut reader = build_reader(data); + let headers: Vec = reader + .headers() + .map_err(|e| BackupError::Csv(e.to_string()))? + .iter() + .map(|h| h.trim().to_string()) + .collect(); + if headers.is_empty() { + return Err(BackupError::EmptyCsv); + } + + // Read at most the first SAMPLE_ROWS parseable rows, skipping malformed + // ones. `take` keeps this lazy, so a large file is never fully scanned. + let samples: Vec = reader + .records() + .filter_map(Result::ok) + .take(SAMPLE_ROWS) + .collect(); + + let (suggested, confidence) = build_mapping(&headers, &samples); + + let columns = headers + .into_iter() + .enumerate() + .map(|(i, header)| CsvColumn { + index: i as u32, + header, + sample_values: samples + .iter() + .filter_map(|row| row.get(i)) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .take(DISPLAY_SAMPLES) + .map(|s| s.to_string()) + .collect(), + }) + .collect(); + + Ok(CsvAnalysis { + columns, + suggested, + confidence, + }) +} + +pub fn import(data: &str, mapping: &ColumnMapping) -> Result<(Backup, ImportReport), BackupError> { + let data = strip_bom(data); + if data.trim().is_empty() { + return Err(BackupError::EmptyCsv); + } + + let mut reader = build_reader(data); + + let mut logins = Vec::new(); + let mut report = ImportReport::default(); + + for record in reader.records() { + let record = match record { + Ok(rec) => rec, + Err(_) => { + report.skipped += 1; + continue; + } + }; + + let field = |col: Option| -> Option { + col.and_then(|i| record.get(i)) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + + let title = field(mapping.title); + let url = field(mapping.url); + let username = field(mapping.username); + let password = field(mapping.password); + let notes = field(mapping.notes); + let totp = field(mapping.totp); + + if title.is_none() + && url.is_none() + && username.is_none() + && password.is_none() + && notes.is_none() + && totp.is_none() + { + report.skipped += 1; + continue; + } + + report.imported += 1; + logins.push(Login { + title: title + .or_else(|| url.clone()) + .or_else(|| username.clone()) + .unwrap_or_else(|| "Untitled".to_owned()), + username, + password, + totp_secret: totp, + websites: url.into_iter().collect(), + notes, + ..Default::default() + }); + } + + let backup = Backup { + vaults: vec![Vault { + name: "CSV Import".to_string(), + logins, + ..Default::default() + }], + }; + Ok((backup, report)) +} + +pub fn export(backup: &Backup, preset: ExportPreset) -> Result { + let columns = preset.columns(); + let mut writer = csv::Writer::from_writer(Vec::new()); + + let csv_err = |e: csv::Error| BackupError::Csv(e.to_string()); + writer + .write_record(columns.iter().map(|&(header, _)| header)) + .map_err(csv_err)?; + + for login in backup.vaults.iter().flat_map(|v| &v.logins) { + let row = columns + .iter() + .map(|(_, field)| field.read(login).unwrap_or_default()); + writer.write_record(row).map_err(csv_err)?; + } + + let bytes = writer + .into_inner() + .map_err(|e| BackupError::Csv(e.to_string()))?; + String::from_utf8(bytes).map_err(|e| BackupError::Csv(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::BackupError; + + fn rows(data: &[&[&str]]) -> Vec { + data.iter() + .map(|r| r.iter().map(|c| c.to_string()).collect()) + .collect() + } + + fn hdrs(h: &[&str]) -> Vec { + h.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn maps_chrome_headers() { + let headers = hdrs(&["name", "url", "username", "password", "note"]); + let (m, c) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.url, Some(1)); + assert_eq!(m.username, Some(2)); + assert_eq!(m.password, Some(3)); + assert_eq!(m.notes, Some(4)); + assert_eq!(c.password, Some(Confidence::High)); // exact header match + } + + #[test] + fn maps_bitwarden_headers() { + let headers = hdrs(&[ + "folder", + "favorite", + "type", + "name", + "notes", + "fields", + "reprompt", + "login_uri", + "login_username", + "login_password", + "login_totp", + ]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(3)); + assert_eq!(m.notes, Some(4)); + assert_eq!(m.url, Some(7)); + assert_eq!(m.username, Some(8)); + assert_eq!(m.password, Some(9)); + assert_eq!(m.totp, Some(10)); + } + + #[test] + fn maps_keepass_headers_case_insensitively() { + let headers = hdrs(&["Account", "Login Name", "Password", "Web Site", "Comments"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.username, Some(1)); + assert_eq!(m.password, Some(2)); + assert_eq!(m.url, Some(3)); + assert_eq!(m.notes, Some(4)); + } + + #[test] + fn maps_ms_headers_titlecase() { + let headers = hdrs(&["Name", "Url", "Username", "Password", "Notes"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m.title, Some(0)); + assert_eq!(m.url, Some(1)); + assert_eq!(m.username, Some(2)); + assert_eq!(m.password, Some(3)); + assert_eq!(m.notes, Some(4)); + } + + #[test] + fn value_sniffing_drives_vague_headers() { + // Columns 1 and 2 have meaningless headers; only their values reveal them. + let headers = hdrs(&["name", "field_a", "field_b"]); + let samples = rows(&[ + &["Site One", "alice@example.com", "https://one.example"], + &["Site Two", "bob@example.com", "https://two.example"], + ]); + let (m, c) = build_mapping(&headers, &samples); + assert_eq!(m.title, Some(0)); + assert_eq!(m.username, Some(1)); // emails + assert_eq!(m.url, Some(2)); // urls + assert_eq!(c.username, Some(Confidence::Medium)); // value-only match + } + + #[test] + fn unmatched_columns_stay_unmapped() { + let headers = hdrs(&["folder", "favorite", "reprompt"]); + let (m, _) = build_mapping(&headers, &[]); + assert_eq!(m, ColumnMapping::default()); + } + + #[test] + fn header_separators_normalize_to_exact_match() { + // Underscore, hyphen, dot, mixed case, and repeated spaces all normalize + // to one exact-match phrase and earn High (not merely "contains") + // confidence. + for h in [ + "login_username", + "login-username", + "login.username", + "Login Username", + "LOGIN USERNAME", + ] { + let headers = hdrs(&[h, "login-password"]); + let (m, c) = build_mapping(&headers, &[]); + assert_eq!(m.username, Some(0), "{h:?} should map to username"); + assert_eq!(m.password, Some(1), "{h:?} row: password should map"); + assert_eq!( + c.username, + Some(Confidence::High), + "{h:?} should be an exact match" + ); + } + } + + #[test] + fn normalize_header_collapses_separators() { + assert_eq!(normalize_header(" Login_URI "), "login uri"); + assert_eq!(normalize_header("E-Mail"), "e mail"); + assert_eq!(normalize_header("web..site"), "web site"); + assert_eq!(normalize_header("___"), ""); + } + + #[test] + fn analyze_samples_only_leading_rows_deterministically() { + // 12 data rows, but only the first SAMPLE_ROWS feed the analysis and the + // result is stable across runs (no randomness). + let mut csv = String::from("name,login_uri,login_username\n"); + for i in 0..12 { + csv.push_str(&format!( + "Site {i},https://s{i}.example,user{i}@example.com\n" + )); + } + let a1 = analyze(&csv).unwrap(); + let a2 = analyze(&csv).unwrap(); + assert_eq!(a1.columns[0].sample_values, a2.columns[0].sample_values); + // Display samples are the first rows, in file order. + assert_eq!( + a1.columns[0].sample_values, + vec!["Site 0", "Site 1", "Site 2", "Site 3", "Site 4"] + ); + // Separator-styled headers map (and value sniffing agrees). + assert_eq!(a1.suggested.url, Some(1)); + assert_eq!(a1.suggested.username, Some(2)); + assert_eq!(a1.confidence.url, Some(Confidence::High)); + } + + #[test] + fn detects_comma_semicolon_tab() { + assert_eq!(detect_delimiter("a,b,c\n1,2,3"), b','); + assert_eq!(detect_delimiter("a;b;c\n1;2;3"), b';'); + assert_eq!(detect_delimiter("a\tb\tc\n1\t2\t3"), b'\t'); + } + + #[test] + fn semicolon_wins_when_commas_only_inside_fields() { + // header has no commas; a data cell does. The semicolon count is + // consistent across lines, so it must win over the ragged comma count. + let data = "name;url;notes\nSite;https://x.com;\"a, b, c\""; + assert_eq!(detect_delimiter(data), b';'); + } + + #[test] + fn strips_leading_bom() { + assert_eq!(strip_bom("\u{feff}name,url"), "name,url"); + assert_eq!(strip_bom("name,url"), "name,url"); + } + + #[test] + fn email_detection() { + assert!(looks_like_email("alice@example.com")); + assert!(looks_like_email("a.b+c@mail.co.uk")); + assert!(!looks_like_email("alice@localhost")); // no dot in domain + assert!(!looks_like_email("not an email")); + assert!(!looks_like_email("https://example.com")); + assert!(!looks_like_email("")); + } + + #[test] + fn url_detection() { + assert!(looks_like_url("https://example.com/login")); + assert!(looks_like_url("http://sub.example.org")); + assert!(looks_like_url("example.com")); // bare host + assert!(!looks_like_url("alice@example.com")); // email, not url + assert!(!looks_like_url("just a note")); + assert!(!looks_like_url("")); + } + + #[test] + fn totp_detection() { + assert!(looks_like_totp( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP" + )); + assert!(looks_like_totp("JBSWY3DPEHPK3PXP234")); // base32, >=16 chars + assert!(!looks_like_totp("jbsw y3dp ehpk 3pxp 234")); // spaced/lowercase: not importable as-is + assert!(!looks_like_totp("short")); // too short + assert!(!looks_like_totp("has-symbols-!@#$%^&*()")); // not base32 + assert!(!looks_like_totp("")); + } + + const CHROME_CSV: &str = "name,url,username,password,note\n\ +Email,https://mail.example,alice,s3cr3t,primary\n\ +Bank,https://bank.example,bob,hunter2,\n"; + + #[test] + fn analyze_chrome_csv() { + let a = analyze(CHROME_CSV).unwrap(); + assert_eq!(a.columns.len(), 5); + assert_eq!(a.columns[0].header, "name"); + assert_eq!(a.columns[0].sample_values, vec!["Email", "Bank"]); + assert_eq!(a.suggested.username, Some(2)); + assert_eq!(a.suggested.password, Some(3)); + assert_eq!(a.confidence.password, Some(Confidence::High)); + } + + #[test] + fn analyze_detects_semicolons_and_bom() { + let data = "\u{feff}name;url;password\nSite;https://x.example;pw\n"; + let a = analyze(data).unwrap(); + assert_eq!(a.columns.len(), 3); + assert_eq!(a.suggested.url, Some(1)); + assert_eq!(a.suggested.password, Some(2)); + } + + #[test] + fn analyze_empty_input_errors() { + assert!(matches!(analyze(""), Err(BackupError::EmptyCsv))); + assert!(matches!(analyze(" \n "), Err(BackupError::EmptyCsv))); + } + + #[test] + fn import_chrome_csv_with_suggested_mapping() { + let a = analyze(CHROME_CSV).unwrap(); + let (backup, report) = import(CHROME_CSV, &a.suggested).unwrap(); + assert_eq!( + report, + ImportReport { + imported: 2, + skipped: 0 + } + ); + let v = &backup.vaults[0]; + assert_eq!(v.name, "CSV Import"); + assert_eq!(v.logins[0].title, "Email"); + assert_eq!(v.logins[0].username.as_deref(), Some("alice")); + assert_eq!(v.logins[0].password.as_deref(), Some("s3cr3t")); + assert_eq!( + v.logins[0].websites, + vec!["https://mail.example".to_string()] + ); + assert_eq!(v.logins[0].notes.as_deref(), Some("primary")); + assert_eq!(v.logins[1].notes, None); // empty cell -> None + } + + #[test] + fn import_honors_user_edited_mapping() { + let mut a = analyze(CHROME_CSV).unwrap(); + a.suggested.notes = None; // user removes the notes mapping + let (backup, _) = import(CHROME_CSV, &a.suggested).unwrap(); + assert!(backup.vaults[0].logins.iter().all(|l| l.notes.is_none())); + } + + #[test] + fn import_skips_empty_rows_and_reports() { + // 2 valid rows, 1 all-empty row. + let data = "name,username,password\nA,alice,pw1\n,,\nB,bob,pw2\n"; + let mapping = ColumnMapping { + title: Some(0), + username: Some(1), + password: Some(2), + ..Default::default() + }; + let (backup, report) = import(data, &mapping).unwrap(); + assert_eq!( + report, + ImportReport { + imported: 2, + skipped: 1 + } + ); + assert_eq!(backup.vaults[0].logins.len(), 2); + } + + #[test] + fn import_tolerates_ragged_rows() { + // Second row has fewer columns than the header; it must not error. + let data = "name,username,password\nFull,alice,pw\nPartial,bob\n"; + let mapping = ColumnMapping { + title: Some(0), + username: Some(1), + password: Some(2), + ..Default::default() + }; + let (backup, report) = import(data, &mapping).unwrap(); + assert_eq!(report.imported, 2); + let partial = &backup.vaults[0].logins[1]; + assert_eq!(partial.username.as_deref(), Some("bob")); + assert_eq!(partial.password, None); // missing column -> None + } + + #[test] + fn import_title_fallback_chain() { + let data = "url,username,password\nhttps://only-url.example,,\n,carol,\n,,lonelypw\n"; + let mapping = ColumnMapping { + url: Some(0), + username: Some(1), + password: Some(2), + ..Default::default() + }; + let (backup, _) = import(data, &mapping).unwrap(); + let logins = &backup.vaults[0].logins; + assert_eq!(logins[0].title, "https://only-url.example"); // url fallback + assert_eq!(logins[1].title, "carol"); // username fallback + assert_eq!(logins[2].title, "Untitled"); // last-resort + } + + #[test] + fn import_empty_input_errors() { + let mapping = ColumnMapping::default(); + assert!(matches!(import("", &mapping), Err(BackupError::EmptyCsv))); + } + + fn login(title: &str) -> Login { + Login { + title: title.to_owned(), + ..Default::default() + } + } + + fn vault(logins: Vec) -> Backup { + Backup { + vaults: vec![Vault { + logins, + ..Default::default() + }], + } + } + + #[test] + fn export_keygo_writes_header_and_all_fields() { + let backup = vault(vec![ + Login { + title: "Email".into(), + username: Some("alice".into()), + password: Some("s3cr3t".into()), + totp_secret: Some("JBSWY3DPEHPK3PXP".into()), + websites: vec!["https://mail.example".into()], + notes: Some("primary".into()), + ..Default::default() + }, + login("Bare"), + ]); + let csv = export(&backup, ExportPreset::KeyGo).unwrap(); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "title,url,username,password,notes,totp"); + assert_eq!( + lines[1], + "Email,https://mail.example,alice,s3cr3t,primary,JBSWY3DPEHPK3PXP" + ); + // Only the title is set; every optional becomes an empty cell. + assert_eq!(lines[2], "Bare,,,,,"); + } + + #[test] + fn export_browser_uses_browser_headers_and_omits_totp() { + let backup = vault(vec![Login { + title: "Email".into(), + username: Some("alice".into()), + password: Some("s3cr3t".into()), + totp_secret: Some("JBSWY3DPEHPK3PXP".into()), + websites: vec!["https://mail.example".into()], + notes: Some("primary".into()), + ..Default::default() + }]); + let csv = export(&backup, ExportPreset::Browser).unwrap(); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "name,url,username,password,note"); + assert_eq!(lines[1], "Email,https://mail.example,alice,s3cr3t,primary"); + // The browser layout carries no TOTP column. + assert!(!csv.contains("JBSWY3DPEHPK3PXP")); + } + + #[test] + fn export_empty_backup_writes_header_only() { + let csv = export(&Backup { vaults: vec![] }, ExportPreset::KeyGo).unwrap(); + assert_eq!( + csv.lines().collect::>(), + ["title,url,username,password,notes,totp"] + ); + } + + #[test] + fn export_flattens_logins_across_vaults() { + let backup = Backup { + vaults: vec![ + Vault { + logins: vec![login("A")], + ..Default::default() + }, + Vault { + logins: vec![login("B")], + ..Default::default() + }, + ], + }; + let lines: Vec = export(&backup, ExportPreset::Browser) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + assert_eq!(lines.len(), 3); // header + one row per login + assert_eq!(lines[1], "A,,,,"); + assert_eq!(lines[2], "B,,,,"); + } + + #[test] + fn export_keygo_round_trips_through_import() { + let backup = vault(vec![Login { + title: "Email".into(), + username: Some("alice".into()), + password: Some("s3cr3t".into()), + totp_secret: Some("JBSWY3DPEHPK3PXP".into()), + websites: vec!["https://mail.example".into()], + notes: Some("primary".into()), + ..Default::default() + }]); + let csv = export(&backup, ExportPreset::KeyGo).unwrap(); + let analysis = analyze(&csv).unwrap(); + let (restored, report) = import(&csv, &analysis.suggested).unwrap(); + assert_eq!(report.imported, 1); + let l = &restored.vaults[0].logins[0]; + assert_eq!(l.title, "Email"); + assert_eq!(l.username.as_deref(), Some("alice")); + assert_eq!(l.password.as_deref(), Some("s3cr3t")); + assert_eq!(l.websites, vec!["https://mail.example".to_string()]); + assert_eq!(l.notes.as_deref(), Some("primary")); + assert_eq!(l.totp_secret.as_deref(), Some("JBSWY3DPEHPK3PXP")); + } + + #[test] + fn export_escapes_delimiters_quotes_and_newlines() { + let backup = vault(vec![Login { + title: "Comma, Inc.".into(), + username: Some("a".into()), + password: Some("p".into()), + notes: Some("line1\nline2 \"quoted\"".into()), + ..Default::default() + }]); + let csv = export(&backup, ExportPreset::KeyGo).unwrap(); + // The exact escaping is the csv crate's job; assert it re-imports losslessly. + let analysis = analyze(&csv).unwrap(); + let (restored, _) = import(&csv, &analysis.suggested).unwrap(); + let l = &restored.vaults[0].logins[0]; + assert_eq!(l.title, "Comma, Inc."); + assert_eq!(l.notes.as_deref(), Some("line1\nline2 \"quoted\"")); + } + + #[test] + fn export_preset_headers_analyze_as_high_confidence() { + // Every header an export preset emits must be recognized by `analyze` at + // High confidence; otherwise an exported file would re-import with a weak + // or missing column mapping. A "contains"-only match scores Low, so this + // assertion fails if any preset header drifts off an exact keyword. + fn confidence_of(c: &FieldConfidence, field: Field) -> Option<&Confidence> { + match field { + Field::Title => c.title.as_ref(), + Field::Url => c.url.as_ref(), + Field::Username => c.username.as_ref(), + Field::Password => c.password.as_ref(), + Field::Notes => c.notes.as_ref(), + Field::Totp => c.totp.as_ref(), + } + } + + for preset in [ExportPreset::KeyGo, ExportPreset::Browser] { + // Drive the real export path: an empty backup yields just the header row. + let csv = export(&Backup { vaults: vec![] }, preset).unwrap(); + let analysis = analyze(&csv).unwrap(); + for &(header, field) in preset.columns() { + assert_eq!( + confidence_of(&analysis.confidence, field), + Some(&Confidence::High), + "{preset:?} header {header:?} should analyze at High confidence", + ); + } + } + } +} diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs new file mode 100644 index 000000000..2c427b4fa --- /dev/null +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -0,0 +1,434 @@ +use crate::b64; +use crate::backup::encryption::{self, BackupCredential, EncryptionHeader, KeySource}; +use crate::backup::{Backup, BackupError, CURRENT_VERSION, MIN_SUPPORTED_VERSION}; +use serde::{Deserialize, Serialize}; + +/// A JSON backup is always sealed - there is no plaintext envelope. `payload` is the base64 +/// ciphertext whose header sits in `encryption`. +#[derive(Serialize, Deserialize)] +pub struct BackupEnvelope { + pub version: u32, + pub encryption: EncryptionHeader, + pub payload: String, +} + +pub fn export(backup: &Backup, cred: BackupCredential<'_>) -> Result { + let payload_bytes = serde_json::to_vec(backup)?; + let sealed = encryption::seal(&payload_bytes, cred, CURRENT_VERSION)?; + let env = BackupEnvelope { + version: CURRENT_VERSION, + encryption: sealed.header, + payload: b64::encode(sealed.ciphertext), + }; + Ok(serde_json::to_string(&env)?) +} + +pub fn import(data: &str, cred: BackupCredential<'_>) -> Result { + let env = parse_envelope(data)?; + let ciphertext = b64::decode(&env.payload).map_err(|_| BackupError::Base64)?; + let payload_bytes = encryption::open(&env.encryption, &ciphertext, cred, env.version)?; + Ok(serde_json::from_slice(&payload_bytes)?) +} + +/// Report which credential a backup file needs, without decrypting it. +pub fn inspect(data: &str) -> Result { + Ok(parse_envelope(data)?.encryption.source) +} + +fn parse_envelope(data: &str) -> Result { + let env: BackupEnvelope = serde_json::from_str(data)?; + if !(MIN_SUPPORTED_VERSION..=CURRENT_VERSION).contains(&env.version) { + return Err(BackupError::UnsupportedVersion(env.version)); + } + Ok(env) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::encryption::{Kdf, KeySource}; + use crate::backup::{Card, Login, Passkey, Vault}; + use crate::crypto::error::CryptoError; + use crate::crypto::key::KeyMaterial; + use crate::crypto::keys::AccountRootKey; + + fn sample_backup() -> Backup { + Backup { + vaults: vec![Vault { + name: "Personal".into(), + icon: "Work".into(), + logins: vec![Login { + title: "Email".into(), + notes: Some("primary".into()), + tags: vec!["mail".into()], + pinned: true, + username: Some("alice".into()), + password: Some("s3cr3t-password".into()), + totp_secret: None, + websites: vec!["https://mail.example".into()], + passkeys: vec![], + }], + cards: vec![Card { + title: "Visa".into(), + notes: None, + tags: vec![], + pinned: false, + cardholder: Some("Alice".into()), + number: "4111111111111111".into(), + expiration_month: Some(12), + expiration_year: Some(2030), + cvv: Some("123".into()), + }], + }], + } + } + + fn json_eq(a: &Backup, b: &Backup) { + assert_eq!( + serde_json::to_string(a).unwrap(), + serde_json::to_string(b).unwrap() + ); + } + + // A real v1 passphrase-encrypted backup, frozen at generation time. Its job is + // to fail loudly if the on-disk format or - critically - the BCS layout of + // `BackupAad` ever drifts, since either makes existing backups undecryptable + // with no compile error. Regenerate ONLY alongside a deliberate version bump. + const GOLDEN_V1_PASSPHRASE: &[u8] = b"golden-pw"; + const GOLDEN_V1: &str = r#"{"version":1,"encryption":{"source":"passphrase","kdf":{"type":"argon2id","salt":"wFYCrxdwhGR19jk2BBUasQ==","mem_kib":65536,"iters":3,"lanes":4},"nonce":"V8dD5ja/CUoL2vYg"},"payload":"GpZBGMDvOgwOPVpst4TnyCMrC+lXMQ8KOPEgeK8GSTZafA/YGBCO+9J7BFmHvtuSuXAaNrlsEviPxFdQCCGJlljW1lJoGJ2Cny0RDlw+75fdH/a4MNwVplSvSJYxeZoYO3wEh8RDyHw3fHlXrQ/u+en1+0psRkdt1Gnvkv+ULxKEOsjTOz0fqzioOYWK/oyNW1h6qJ6x09aTOsBzv1Jv6Wx3vMNzqXGCgFxI9UTzWmdp7hs2JRHHcegTzMaX2cw1lV+shWNpyqEu1anSC6Zc8qfk1vxDj06xGjWZsFeBCfk/8j5Eu5y/c/nDKvcQKC8I3PmPXBBrCmoi/mRDHqu2sMSJQKqBebLv4MkWJUjV3CvfWDJcBHv/ovfNAgmNhNEzZJBA8iC5Pwat0vxopszcsU2bpwg0bPG3hawQkrLkz9sJGnJEsJHSpPSsBj/fS/FqnvkeyrEGH+4TbUqX26LSbFVgmLR3LJtLvP9M3xv99dWsdeqH+C9YmKsXtvXbXCcA20ygL7wc6oCgQbFWGwA7N1lnk1oKDDdaftCu"}"#; + + #[test] + fn golden_v1_passphrase_still_decrypts() { + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); + // Assert on stable, semantic values so the test survives future additive + // schema changes (new Option fields) without needing a fresh golden. + let vault = &backup.vaults[0]; + let login = &vault.logins[0]; + let card = &vault.cards[0]; + assert_eq!(vault.name, "Personal"); + assert_eq!(login.title, "Email"); + assert_eq!(login.password.as_deref(), Some("s3cr3t-password")); + assert_eq!(login.totp_secret, None); + assert_eq!(card.number, "4111111111111111"); + assert_eq!(card.expiration_year, Some(2030)); + } + + #[test] + fn passkeys_round_trip_through_an_encrypted_backup() { + let mut original = sample_backup(); + original.vaults[0].logins[0].passkeys = vec![ + Passkey { + user_name: "alice".into(), + user_display_name: "Alice".into(), + credential_id: vec![1, 2, 3], + private_key: vec![4, 5, 6], + rp: "example.com".into(), + }, + Passkey { + user_name: "alice".into(), + user_display_name: "Alice".into(), + credential_id: vec![7, 8, 9], + private_key: vec![10, 11, 12], + rp: "example.org".into(), + }, + ]; + + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); + + let passkeys = &restored.vaults[0].logins[0].passkeys; + assert_eq!(passkeys.len(), 2); + assert_eq!(passkeys[0].private_key, vec![4, 5, 6]); + assert_eq!(passkeys[1].rp, "example.org"); + } + + #[test] + fn vault_icon_round_trips_through_an_encrypted_backup() { + let original = sample_backup(); + + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); + + assert_eq!(restored.vaults[0].icon, "Work"); + } + + #[test] + fn golden_v1_vault_has_no_icon() { + // The frozen golden predates the field; serde(default) must read it as an empty string + // rather than failing the whole import. + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); + assert!(backup.vaults[0].icon.is_empty()); + } + + #[test] + fn golden_v1_login_has_no_passkeys() { + // The frozen golden predates the field; serde(default) must read it as an empty list + // rather than failing the whole import. + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); + assert!(backup.vaults[0].logins[0].passkeys.is_empty()); + } + + #[test] + fn golden_v1_login_has_no_websites() { + // The frozen golden predates the field (and its predecessor was a single `website` + // string, not this list); serde(default) must read it as an empty list rather than + // failing the whole import. + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); + assert!(backup.vaults[0].logins[0].websites.is_empty()); + } + + #[test] + fn multiple_websites_round_trip_through_an_encrypted_backup() { + let mut original = sample_backup(); + original.vaults[0].logins[0].websites = vec![ + "https://mail.example".into(), + "https://mail.example.org".into(), + ]; + + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); + + assert_eq!( + restored.vaults[0].logins[0].websites, + vec![ + "https://mail.example".to_string(), + "https://mail.example.org".to_string() + ], + ); + } + + #[test] + fn version_below_minimum_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + v["version"] = serde_json::json!(0); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!(err, BackupError::UnsupportedVersion(0))); + } + + #[test] + fn passphrase_round_trip() { + let original = sample_backup(); + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); + json_eq(&restored, &original); + } + + // A real v1 ARK-encrypted backup, frozen at generation time, mirroring + // GOLDEN_V1. It fails loudly if the ARK wire format or HKDF derivation drifts. + // Regenerate ONLY alongside a deliberate version bump. + const GOLDEN_V1_ARK_KEY: [u8; 32] = [9u8; 32]; + const GOLDEN_V1_ARK: &str = r#"{"version":1,"encryption":{"source":"ark","kdf":{"type":"hkdf_sha256","salt":"DbSH5vXm1d1XLo2gANptBQ=="},"nonce":"r2uEXqscOq6avV5g"},"payload":"6KlcJQxQ7q2SnHyJ/0YTWEs/GMZk4npIW8+0AUdVyA58gUMk4IUoNYpuCJEZ+6O0SAuaDd68JLpDgcTc3QmSCjxjCpSehOiPXIACk8+yXlb1oS9wlu1+cSuKrNqKJdMdvmSx/3UabH6rznZvN/qyHC6SMK71vtBoG9hgHt7wquYHNv1RIL7Rd5m1BMpmivooS6gfbviYHxVruxSXXSI/KBQ9wjf/XFpZeP5oTubY6snFkfTAJmpDIWY4K/ZVhQX8yLzqFnocRndYpSDHqCn7QbcKpalZcs6vKP5pJUsZl4SuX/3DhOQ1Sn2oHzfpUyVo6TB4+CuZ4fZppnbr4b+/A1kIphHkIMWkNgICnpvS25Yxc4XwtK31ytIa6Ngt2GK5pb6Wh5CS2gRWMWKC6qyexFhZR0UA9ZnczIzp1Y8YuLaTVqk5ZHH63IBqG0Z8pEpohIyyyaX80rmmv4MGwv89+VSCsRsR2+MeiMKe7YA/Gu1FlisLlpiO6Kw4w7P2N2f6JVBgtk/H9aLh3GfsgVHlfNHQzfN6zVXhRffk"}"#; + + #[test] + fn golden_v1_ark_still_decrypts() { + let ark = AccountRootKey::try_from_bytes(&GOLDEN_V1_ARK_KEY).unwrap(); + let backup = import(GOLDEN_V1_ARK, BackupCredential::Ark(&ark)).unwrap(); + let vault = &backup.vaults[0]; + let login = &vault.logins[0]; + let card = &vault.cards[0]; + assert_eq!(vault.name, "Personal"); + assert_eq!(login.title, "Email"); + assert_eq!(login.password.as_deref(), Some("s3cr3t-password")); + assert_eq!(card.number, "4111111111111111"); + assert_eq!(card.expiration_year, Some(2030)); + } + + #[test] + fn ark_round_trip() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let original = sample_backup(); + let json = export(&original, BackupCredential::Ark(&ark)).unwrap(); + let restored = import(&json, BackupCredential::Ark(&ark)).unwrap(); + json_eq(&restored, &original); + } + + #[test] + fn wrong_passphrase_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"right")).unwrap(); + let err = import(&json, BackupCredential::Passphrase(b"wrong")).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn wrong_ark_fails() { + let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let wrong = AccountRootKey::try_from_bytes(&[6u8; 32]).unwrap(); + let json = export(&sample_backup(), BackupCredential::Ark(&right)).unwrap(); + let err = import(&json, BackupCredential::Ark(&wrong)).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed), + )); + } + + #[test] + fn empty_passphrase_is_rejected() { + let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); + assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)))); + } + + #[test] + fn credential_source_mismatch_fails() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); + let err = import(&json, BackupCredential::Passphrase(b"x")).unwrap_err(); + assert!(matches!(err, BackupError::CredentialMismatch)); + } + + #[test] + fn tampered_ciphertext_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + let mut ct = b64::decode(v["payload"].as_str().unwrap()).unwrap(); + ct[0] ^= 0x01; + v["payload"] = serde_json::Value::String(b64::encode(&ct)); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn tampered_header_salt_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + v["encryption"]["kdf"]["salt"] = serde_json::Value::String(b64::encode([0u8; 16])); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn plaintext_envelope_is_rejected() { + // An envelope with no encryption header is no longer a shape this format admits: the + // field is required, so a plaintext file fails to parse rather than importing silently. + let v = serde_json::json!({ + "version": 1, + "encryption": null, + "payload": { "vaults": [] }, + }); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!(err, BackupError::Json(_))); + } + + #[test] + fn inspect_rejects_plaintext_envelope() { + let v = serde_json::json!({ + "version": 1, + "encryption": null, + "payload": { "vaults": [] }, + }); + assert!(matches!( + inspect(&v.to_string()).unwrap_err(), + BackupError::Json(_), + )); + } + + #[test] + fn unknown_version_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + v["version"] = serde_json::json!(2); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!(err, BackupError::UnsupportedVersion(2))); + } + + #[test] + fn malformed_base64_payload_fails() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + v["payload"] = serde_json::json!("not valid base64!!!"); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + assert!(matches!(err, BackupError::Base64)); + } + + #[test] + fn encrypted_envelope_hides_plaintext() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["encryption"]["source"], serde_json::json!("passphrase")); + assert!(v["encryption"]["kdf"]["salt"].is_string()); + assert!(v["encryption"]["nonce"].is_string()); + assert!(v["payload"].is_string()); + assert!(!json.contains("s3cr3t-password")); + assert!(!json.contains("4111111111111111")); + } + + #[test] + fn encrypted_envelope_serde_round_trip() { + let env = BackupEnvelope { + version: CURRENT_VERSION, + encryption: EncryptionHeader { + source: KeySource::Passphrase, + kdf: Kdf::Argon2id { + salt: vec![1u8; 16], + mem_kib: 65536, + iters: 3, + lanes: 4, + }, + nonce: vec![2u8; 12], + }, + payload: "AAAA".into(), + }; + let json = serde_json::to_string(&env).unwrap(); + let back: BackupEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(back.payload, "AAAA"); + assert!(matches!(back.encryption.source, KeySource::Passphrase)); + assert!(matches!(back.encryption.kdf, Kdf::Argon2id { .. })); + } + + #[test] + fn inspect_reports_passphrase() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + assert!(matches!(inspect(&json).unwrap(), KeySource::Passphrase)); + } + + #[test] + fn inspect_reports_ark() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); + assert!(matches!(inspect(&json).unwrap(), KeySource::Ark)); + } + + #[test] + fn inspect_rejects_unsupported_version() { + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); + v["version"] = serde_json::json!(0); + assert!(matches!( + inspect(&v.to_string()).unwrap_err(), + BackupError::UnsupportedVersion(0), + )); + } + + #[test] + fn inspect_rejects_malformed_json() { + assert!(matches!( + inspect("not json").unwrap_err(), + BackupError::Json(_) + )); + } +} diff --git a/rust/rust-code/lib/src/backup/format/mod.rs b/rust/rust-code/lib/src/backup/format/mod.rs new file mode 100644 index 000000000..ee8044786 --- /dev/null +++ b/rust/rust-code/lib/src/backup/format/mod.rs @@ -0,0 +1,2 @@ +pub mod csv; +pub mod json; diff --git a/rust/rust-code/lib/src/backup/key.rs b/rust/rust-code/lib/src/backup/key.rs new file mode 100644 index 000000000..1f4ae465e --- /dev/null +++ b/rust/rust-code/lib/src/backup/key.rs @@ -0,0 +1,86 @@ +use crate::crypto::error::CryptoResult; +use crate::crypto::key::KeyMaterial; +use crate::crypto::keys::AccountRootKey; +use crate::crypto::primitive::argon2::{Argon2Params, derive_argon2id_with_params}; +use crate::crypto::primitive::hkdf::derive_hkdf_sha256; +use crate::define_aead_key; +use aes_gcm_siv::Aes256GcmSiv; + +const DOMAIN_PASSPHRASE: &[u8] = b"v1:backup/passphrase"; +const DOMAIN_ARK: &[u8] = b"v1:backup/ark"; + +define_aead_key! { + pub struct BackupKey(Aes256GcmSiv); +} + +impl BackupKey { + pub(crate) fn from_passphrase( + passphrase: &[u8], + salt: &[u8], + params: Argon2Params, + ) -> CryptoResult { + let derived = derive_argon2id_with_params(passphrase, salt, DOMAIN_PASSPHRASE, params)?; + Self::try_from_bytes(&derived) + } + + pub(crate) fn from_ark(ark: &AccountRootKey, salt: &[u8]) -> CryptoResult { + let derived = derive_hkdf_sha256(ark.as_bytes(), salt, DOMAIN_ARK)?; + Self::try_from_bytes(&derived) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::key::KeyMaterial; + use crate::crypto::keys::AccountRootKey; + + const SALT: &[u8] = &[3u8; 16]; + + #[test] + fn passphrase_key_is_deterministic() { + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); + let b = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn passphrase_key_changes_with_salt() { + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); + let b = + BackupKey::from_passphrase(b"hunter2", &[9u8; 16], Argon2Params::default()).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn passphrase_key_changes_with_params() { + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); + let b = BackupKey::from_passphrase( + b"hunter2", + SALT, + Argon2Params { + iters: Argon2Params::default().iters + 1, + ..Argon2Params::default() + }, + ) + .unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn ark_key_is_deterministic() { + let ark = AccountRootKey::try_from_bytes(&[7u8; 32]).unwrap(); + let a = BackupKey::from_ark(&ark, SALT).unwrap(); + let b = BackupKey::from_ark(&ark, SALT).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn passphrase_and_ark_paths_are_domain_separated() { + let raw = [7u8; 32]; + let ark = AccountRootKey::try_from_bytes(&raw).unwrap(); + let pass = BackupKey::from_passphrase(&raw, SALT, Argon2Params::default()).unwrap(); + let ark_key = BackupKey::from_ark(&ark, SALT).unwrap(); + assert_ne!(pass.as_bytes(), ark_key.as_bytes()); + } +} diff --git a/rust/rust-code/lib/src/backup/mod.rs b/rust/rust-code/lib/src/backup/mod.rs new file mode 100644 index 000000000..6023e3ed0 --- /dev/null +++ b/rust/rust-code/lib/src/backup/mod.rs @@ -0,0 +1,25 @@ +pub mod encryption; +pub mod error; +pub mod format; +pub mod key; +pub mod model; + +pub use encryption::BackupCredential; +pub use error::BackupError; +pub use format::csv::{ + ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, ImportReport, +}; +pub use format::{csv, json}; +pub use key::BackupKey; +pub use model::{Backup, Card, Login, Passkey, Vault}; + +/// Version stamped into newly written backups. +pub const CURRENT_VERSION: u32 = 1; + +/// Oldest envelope version this build can still read. Backups are long-lived: a +/// file written by an old build may be restored by a much newer one. When +/// `CURRENT_VERSION` is bumped, keep older versions readable here (and preserve +/// their exact AAD/BCS layout - see [`encryption::BackupAad`]) instead of +/// rejecting them. Per-version decode branches belong in the format's `import`, +/// keyed off the envelope version. +pub const MIN_SUPPORTED_VERSION: u32 = 1; diff --git a/rust/rust-code/lib/src/backup/model.rs b/rust/rust-code/lib/src/backup/model.rs new file mode 100644 index 000000000..00a65e8df --- /dev/null +++ b/rust/rust-code/lib/src/backup/model.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Backup { + pub vaults: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Vault { + pub name: String, + /// Names an icon from the client's own set; the format assigns it no meaning beyond a label to + /// hand back on restore. `default` covers producers that have none to give - CSV imports, and + /// backups written before the field existed, including the frozen v1 goldens - which read as an + /// empty string and let the client fall back to its own default. + #[serde(default)] + pub icon: String, + pub logins: Vec, + pub cards: Vec, +} + +macro_rules! backup_item { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident : $field_ty:ty + ),*$(,)? + } + ) => { + $(#[$meta])* + + #[derive(Debug, Clone, Default, Serialize, Deserialize)] + $vis struct $name { + pub title: String, + pub notes: Option, + pub tags: Vec, + pub pinned: bool, + $( + $(#[$field_meta])* + $field_vis $field: $field_ty, + )* + } + }; +} + +backup_item! { + pub struct Login { + pub username: Option, + pub password: Option, + pub totp_secret: Option, + /// A login can be associated with several sites. `default` keeps pre-field backups - + /// including the frozen v1 goldens - readable. + #[serde(default)] + pub websites: Vec, + /// A login can hold several passkeys (one per RP). `default` keeps pre-field backups - + /// including the frozen v1 goldens - readable. + #[serde(default)] + pub passkeys: Vec, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Passkey { + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +backup_item! { + pub struct Card { + pub cardholder: Option, + pub number: String, + pub expiration_month: Option, + pub expiration_year: Option, + pub cvv: Option, + } +} diff --git a/rust/rust-code/lib/src/card/mod.rs b/rust/rust-code/lib/src/card/mod.rs index 1d706f6a6..ee128369c 100644 --- a/rust/rust-code/lib/src/card/mod.rs +++ b/rust/rust-code/lib/src/card/mod.rs @@ -1,12 +1,12 @@ //! Credit-card field helpers, split by concern: //! -//! - network detection — the issuing [`CardNetwork`] and its metadata (valid +//! - network detection: the issuing [`CardNetwork`] and its metadata (valid //! lengths, CVV length, grouping convention); -//! - number handling — parse, cap, group, and validate a PAN via [`Card`]; -//! - expiration formatting — render an `MM/YY` date as it is typed or deleted. +//! - number handling: parse, cap, group, and validate a PAN via [`Card`]; +//! - expiration formatting: render an `MM/YY` date as it is typed or deleted. //! -//! All entry points accept "dirty" input — spaces, dashes, and any other -//! non-digit characters are ignored — so they work equally well on a pasted +//! All entry points accept "dirty" input: spaces, dashes, and any other +//! non-digit characters are ignored, so they work equally well on a pasted //! value or on text being typed live into a field. //! //! ``` diff --git a/rust/rust-code/lib/src/card/network.rs b/rust/rust-code/lib/src/card/network.rs index 5e2eb3ca9..e3244ed32 100644 --- a/rust/rust-code/lib/src/card/network.rs +++ b/rust/rust-code/lib/src/card/network.rs @@ -39,11 +39,11 @@ impl CardNetwork { if matches!(prefix(digits, 2), Some(34 | 37)) { return Amex; } - // JCB: 3528–3589 (checked before the broader Diners "3" ranges) + // JCB: 3528-3589 (checked before the broader Diners "3" ranges) if matches!(prefix(digits, 4), Some(3528..=3589)) { return Jcb; } - // Diners Club: 300–305, 36, 38, 39 + // Diners Club: 300-305, 36, 38, 39 if matches!(prefix(digits, 3), Some(300..=305)) || matches!(prefix(digits, 2), Some(36 | 38 | 39)) { @@ -53,13 +53,13 @@ impl CardNetwork { if digits.starts_with('4') { return Visa; } - // Mastercard: 51–55, 2221–2720 + // Mastercard: 51-55, 2221-2720 if matches!(prefix(digits, 2), Some(51..=55)) || matches!(prefix(digits, 4), Some(2221..=2720)) { return Mastercard; } - // Discover: 6011, 644–649, 65, and the 622126–622925 co-brand range + // Discover: 6011, 644-649, 65, and the 622126-622925 co-brand range // (checked before UnionPay's broad "62"). if matches!(prefix(digits, 4), Some(6011)) || matches!(prefix(digits, 3), Some(644..=649)) diff --git a/rust/rust-code/lib/src/card/number.rs b/rust/rust-code/lib/src/card/number.rs index 73c3ed7b1..654cd847d 100644 --- a/rust/rust-code/lib/src/card/number.rs +++ b/rust/rust-code/lib/src/card/number.rs @@ -76,7 +76,7 @@ impl Card { /// Structurally valid: a length the (possibly [`Unknown`](CardNetwork::Unknown)) network /// accepts and a passing Luhn check. The network is deliberately *not* gated on being - /// recognised — an unrecognised IIN is still acceptable as long as its length is plausible + /// recognised: an unrecognised IIN is still acceptable as long as its length is plausible /// and the checksum holds. pub fn is_valid(&self) -> bool { self.is_length_valid() && self.is_luhn_valid() diff --git a/rust/rust-code/lib/src/crypto/error.rs b/rust/rust-code/lib/src/crypto/error.rs index 7e71444a4..c208d5ed6 100644 --- a/rust/rust-code/lib/src/crypto/error.rs +++ b/rust/rust-code/lib/src/crypto/error.rs @@ -4,12 +4,12 @@ use thiserror::Error; pub enum CryptoError { #[error("AEAD encryption failed")] EncryptionFailed, - #[error("AEAD decryption failed — ciphertext invalid or tampered")] + #[error("AEAD decryption failed: ciphertext invalid or tampered")] DecryptionFailed, #[error("Key wrap failed")] KeyWrapFailed, - #[error("Key unwrap failed — wrong key or corrupted data")] + #[error("Key unwrap failed: wrong key or corrupted data")] KeyUnwrapFailed, #[error("CBOR serialisation failed: {0}")] diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index 5a2c4343e..d52da3b67 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -1,29 +1,73 @@ use crate::crypto::error::{CryptoError, CryptoResult}; use argon2::{Algorithm, Argon2, Params, Version}; -/// Minimum salt length enforced for password-based derivation (RFC 9106 §3.1). pub const MIN_SALT_LEN: usize = 16; - -/// Length of the derived key material (bytes). pub const DERIVED_KEY_LEN: usize = 32; +pub(crate) const MAX_ARGON2_MEM_KIB: u32 = 256 * 1024; + +#[derive(Clone, Copy)] +pub(crate) struct Argon2Params { + /// Memory cost in KiB + pub mem_kib: u32, + /// Iterations / time cost + pub iters: u32, + /// Degree of parallelism + pub lanes: u32, +} + +impl Default for Argon2Params { + fn default() -> Self { + Self { + mem_kib: 64 * 1024, + iters: 3, + lanes: 4, + } + } +} -fn get_argon<'a>() -> Argon2<'a> { - let params = Params::new(64 * 1024, 3, 4, Some(DERIVED_KEY_LEN)).expect("valid default params"); - Argon2::new(Algorithm::Argon2id, Version::V0x13, params) +fn build_argon<'a>(params: Argon2Params) -> CryptoResult> { + if params.mem_kib > MAX_ARGON2_MEM_KIB { + return Err(CryptoError::KdfError(format!( + "argon2 mem_kib too large: {} > {}", + params.mem_kib, MAX_ARGON2_MEM_KIB, + ))); + } + let params = Params::new( + params.mem_kib, + params.iters, + params.lanes, + Some(DERIVED_KEY_LEN), + ) + .map_err(|e| CryptoError::KdfError(e.to_string()))?; + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) +} + +/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id with the +/// [`Argon2Params::default`] cost profile. See [`derive_argon2id_with_params`]. +pub(crate) fn derive_argon2id( + password: &[u8], + salt: &[u8], + domain: &[u8], +) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { + derive_argon2id_with_params(password, salt, domain, Argon2Params::default()) } -/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id. +/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id with explicit +/// cost [`Argon2Params`], so a derivation can be reproduced from costs recorded +/// at seal time. Invalid or over-limit params yield [`CryptoError::KdfError`] +/// rather than panicking. /// /// The salt must be caller-provided, at least `MIN_SALT_LEN` bytes, and persisted with the /// credential so the same KEK can be re-derived on later logins. `domain` is mixed into the salt /// as a label to separate otherwise-identical derivations (e.g. password-KEK vs recovery-KEK). /// -/// The password is not zeroized by this function — the caller owns the password buffer and must +/// The password is not zeroized by this function - the caller owns the password buffer and must /// wrap it in `Zeroizing` / a secret type at the FFI boundary. -pub(crate) fn derive_argon2id( +pub(crate) fn derive_argon2id_with_params( password: &[u8], salt: &[u8], domain: &[u8], + params: Argon2Params, ) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { if password.is_empty() { return Err(CryptoError::KdfError("empty password".into())); @@ -41,7 +85,7 @@ pub(crate) fn derive_argon2id( full_salt.extend_from_slice(salt); full_salt.extend_from_slice(domain); - let argon2 = get_argon(); + let argon2 = build_argon(params)?; let mut out = [0u8; DERIVED_KEY_LEN]; argon2 @@ -80,4 +124,58 @@ mod tests { fn rejects_short_salt() { assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); } + + #[test] + fn different_params_different_key() { + let a = derive_argon2id_with_params(PW, &SALT, b"pwd", Argon2Params::default()).unwrap(); + let b = derive_argon2id_with_params( + PW, + &SALT, + b"pwd", + Argon2Params { + iters: Argon2Params::default().iters + 1, + ..Argon2Params::default() + }, + ) + .unwrap(); + assert_ne!(a, b); + } + + #[test] + fn default_params_match_plain_derive() { + let plain = derive_argon2id(PW, &SALT, b"pwd").unwrap(); + let explicit = + derive_argon2id_with_params(PW, &SALT, b"pwd", Argon2Params::default()).unwrap(); + assert_eq!(plain, explicit); + } + + #[test] + fn rejects_excessive_memory() { + let err = derive_argon2id_with_params( + PW, + &SALT, + b"pwd", + Argon2Params { + mem_kib: MAX_ARGON2_MEM_KIB + 1, + ..Argon2Params::default() + }, + ) + .unwrap_err(); + assert!(matches!(err, CryptoError::KdfError(_))); + } + + #[test] + fn rejects_degenerate_params() { + let err = derive_argon2id_with_params( + PW, + &SALT, + b"pwd", + Argon2Params { + iters: 0, + ..Argon2Params::default() + }, + ) + .unwrap_err(); + assert!(matches!(err, CryptoError::KdfError(_))); + } } diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs new file mode 100644 index 000000000..ebe6856ed --- /dev/null +++ b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs @@ -0,0 +1,65 @@ +use crate::crypto::error::{CryptoError, CryptoResult}; +use hkdf::Hkdf; +use sha2::Sha256; + +/// Length of the derived key material (bytes). +pub const DERIVED_KEY_LEN: usize = 32; + +/// Derive `DERIVED_KEY_LEN` bytes from high-entropy keying material using +/// HKDF-SHA256 (RFC 5869). +/// +/// Unlike [`super::argon2::derive_argon2id`], this performs no memory-hard +/// stretch: it is for inputs that are already uniformly random (e.g. the ARK), +/// where a password KDF would add cost without security. `salt` is the HKDF +/// extract salt (a fresh per-use random value); `info` is the HKDF expand label +/// used to domain-separate otherwise-identical derivations. +/// +/// HKDF accepts any salt length (including empty), so - unlike the Argon2id +/// primitive - no minimum-salt check is enforced here. +pub(crate) fn derive_hkdf_sha256( + ikm: &[u8], + salt: &[u8], + info: &[u8], +) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { + let hk = Hkdf::::new(Some(salt), ikm); + let mut out = [0u8; DERIVED_KEY_LEN]; + hk.expand(info, &mut out) + .map_err(|e| CryptoError::KdfError(e.to_string()))?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IKM: &[u8] = &[7u8; 32]; + const SALT: &[u8] = &[0x11; 16]; + + #[test] + fn deterministic_for_same_inputs() { + let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); + let b = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_salt_different_key() { + let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); + let b = derive_hkdf_sha256(IKM, &[0x22; 16], b"info").unwrap(); + assert_ne!(a, b); + } + + #[test] + fn different_info_different_key() { + let a = derive_hkdf_sha256(IKM, SALT, b"info-a").unwrap(); + let b = derive_hkdf_sha256(IKM, SALT, b"info-b").unwrap(); + assert_ne!(a, b); + } + + #[test] + fn different_ikm_different_key() { + let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); + let b = derive_hkdf_sha256(&[8u8; 32], SALT, b"info").unwrap(); + assert_ne!(a, b); + } +} diff --git a/rust/rust-code/lib/src/crypto/primitive/mod.rs b/rust/rust-code/lib/src/crypto/primitive/mod.rs index aa628bdb2..deae068a2 100644 --- a/rust/rust-code/lib/src/crypto/primitive/mod.rs +++ b/rust/rust-code/lib/src/crypto/primitive/mod.rs @@ -1,3 +1,4 @@ pub mod aead_data; pub mod argon2; +pub mod hkdf; pub mod wrap_key; diff --git a/rust/rust-code/lib/src/lib.rs b/rust/rust-code/lib/src/lib.rs index c41bab606..8e72d71d6 100644 --- a/rust/rust-code/lib/src/lib.rs +++ b/rust/rust-code/lib/src/lib.rs @@ -1,3 +1,5 @@ +mod b64; +pub mod backup; pub mod card; pub mod crypto; pub mod item; diff --git a/rust/rust-code/lib/src/totp.rs b/rust/rust-code/lib/src/totp.rs index cf57563c5..1d63351bc 100644 --- a/rust/rust-code/lib/src/totp.rs +++ b/rust/rust-code/lib/src/totp.rs @@ -65,3 +65,7 @@ pub fn get_totp_url( .map_err(TotpError::Url)?; Ok(totp.get_url()) } + +pub(crate) fn is_valid_totp_secret(s: &str) -> bool { + base32::decode(base32::Alphabet::Rfc4648 { padding: false }, s).is_some() +} diff --git a/rust/rust-code/lib/src/url.rs b/rust/rust-code/lib/src/url.rs index c13886166..ff4f6dbce 100644 --- a/rust/rust-code/lib/src/url.rs +++ b/rust/rust-code/lib/src/url.rs @@ -46,7 +46,7 @@ pub fn sanitize_to_https_url(input: &str) -> Result { if input.starts_with("//") { input = format!("https:{input}"); } else if !input.starts_with("http://") && !input.starts_with("https://") { - // 4) No scheme — default to https + // 4) No scheme: default to https input = format!("https://{input}"); } } diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt b/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt new file mode 100644 index 000000000..a48cc8575 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.rust.backup + +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.BackupException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Runs a UniFFI backup call off the caller's thread and folds it into a [Result]. + * + * Every backup entry point in the Rust bindings throws [BackupException] and nothing else, so the + * cast is total for anything the FFI itself raises. + */ +internal suspend inline fun backupResult( + crossinline block: () -> T, +): Result = withContext(Dispatchers.Default) { + runCatching { block() }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) }, + ) +} diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt new file mode 100644 index 000000000..3590f2a2e --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.rust.backup + +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface +import de.davisalessandro.keygo.rust.CsvImportResult +import de.davisalessandro.keygo.rust.ExportPreset + +suspend fun CsvBackupManagerInterface.analyzeWithResult( + data: String, +): Result = backupResult { analyze(data) } + +suspend fun CsvBackupManagerInterface.importWithResult( + data: String, + mapping: ColumnMapping, +): Result = backupResult { import(data, mapping) } + +suspend fun CsvBackupManagerInterface.exportWithResult( + backup: Backup, + preset: ExportPreset, +): Result = backupResult { export(backup, preset) } diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt new file mode 100644 index 000000000..96fe249cf --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.rust.backup + +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.JsonBackupManagerInterface +import de.davisalessandro.keygo.rust.JsonEncryption + +suspend fun JsonBackupManagerInterface.exportWithResult( + backup: Backup, + credential: BackupCredential, +): Result = backupResult { export(backup, credential) } + +suspend fun JsonBackupManagerInterface.importWithResult( + data: String, + credential: BackupCredential, +): Result = backupResult { import(data, credential) } + +suspend fun JsonBackupManagerInterface.inspectWithResult( + data: String, +): Result = backupResult { inspect(data) } diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt index eba638fbe..db7ebb225 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt @@ -4,8 +4,12 @@ import de.davisalessandro.keygo.rust.AccountManager import de.davisalessandro.keygo.rust.AccountManagerInterface import de.davisalessandro.keygo.rust.CardFormatter import de.davisalessandro.keygo.rust.CardFormatterInterface +import de.davisalessandro.keygo.rust.CsvBackupManager +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface import de.davisalessandro.keygo.rust.ItemManager import de.davisalessandro.keygo.rust.ItemManagerInterface +import de.davisalessandro.keygo.rust.JsonBackupManager +import de.davisalessandro.keygo.rust.JsonBackupManagerInterface import de.davisalessandro.keygo.rust.KeyDeriver import de.davisalessandro.keygo.rust.KeyDeriverInterface import de.davisalessandro.keygo.rust.KeyWrapper @@ -24,6 +28,12 @@ import org.koin.core.annotation.Single @Configuration object RustModule { + @Single + internal fun provideBackupManager(): JsonBackupManagerInterface = JsonBackupManager() + + @Single + internal fun provideCsvManager(): CsvBackupManagerInterface = CsvBackupManager() + @Single internal fun providePasskeyManager(): RustPasskeyInterface = RustPasskey() diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt new file mode 100644 index 000000000..ba94c4a8f --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt @@ -0,0 +1,54 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.ColumnMapping +import de.davisalessandro.keygo.rust.CsvAnalysis +import de.davisalessandro.keygo.rust.CsvBackupManagerInterface +import de.davisalessandro.keygo.rust.CsvImportResult +import de.davisalessandro.keygo.rust.ExportPreset +import de.davisalessandro.keygo.rust.FieldConfidence +import de.davisalessandro.keygo.rust.ImportReport + +class FakeCsvBackupManager : CsvBackupManagerInterface { + + data class AnalyzeCall(val data: String) + data class ImportCall(val data: String, val mapping: ColumnMapping) + data class ExportCall(val backup: Backup, val preset: ExportPreset) + + val analyzeCalls = mutableListOf() + val importCalls = mutableListOf() + val exportCalls = mutableListOf() + + var analyzeResult: CsvAnalysis = CsvAnalysis( + columns = emptyList(), + suggested = ColumnMapping(null, null, null, null, null, null), + confidence = FieldConfidence(null, null, null, null, null, null), + ) + var importResult: CsvImportResult = CsvImportResult( + backup = Backup(emptyList()), + report = ImportReport(imported = 0u, skipped = 0u), + ) + var exportResult: String = "" + var analyzeException: BackupException? = null + var importException: BackupException? = null + var exportException: BackupException? = null + + override fun analyze(data: String): CsvAnalysis { + analyzeCalls += AnalyzeCall(data) + analyzeException?.let { throw it } + return analyzeResult + } + + override fun import(data: String, mapping: ColumnMapping): CsvImportResult { + importCalls += ImportCall(data, mapping) + importException?.let { throw it } + return importResult + } + + override fun export(backup: Backup, preset: ExportPreset): String { + exportCalls += ExportCall(backup, preset) + exportException?.let { throw it } + return exportResult + } +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt index 5b2b60a9a..51e8874bf 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt @@ -10,7 +10,7 @@ import java.security.SecureRandom * In-memory [ItemManagerInterface] for tests. * * Encryption XORs the plaintext with a stream derived from (item key, AAD, nonce). Decryption - * reproduces the same stream and must match a recorded plaintext — passing the wrong key or AAD + * reproduces the same stream and must match a recorded plaintext; passing the wrong key or AAD * throws [ItemCryptoException.DecryptionFailed], exercising the authentication-failure path. */ class FakeItemManager : ItemManagerInterface { diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt new file mode 100644 index 000000000..b60e29316 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.Backup +import de.davisalessandro.keygo.rust.BackupCredential +import de.davisalessandro.keygo.rust.BackupException +import de.davisalessandro.keygo.rust.JsonBackupManagerInterface +import de.davisalessandro.keygo.rust.JsonEncryption + +class FakeJsonBackupManager : JsonBackupManagerInterface { + + data class ExportCall(val backup: Backup, val credential: BackupCredential) + data class ImportCall(val data: String, val credential: BackupCredential) + + val exportCalls = mutableListOf() + val importCalls = mutableListOf() + val inspectCalls = mutableListOf() + + var exportResult: String = "{}" + var importResult: Backup = Backup(emptyList()) + var inspectResult: JsonEncryption = JsonEncryption.PASSPHRASE + var exportException: BackupException? = null + var importException: BackupException? = null + var inspectException: BackupException? = null + + /** + * Receives the live credential rather than the recorded snapshot, so a test can hold the very + * array the caller passed in and assert it was zeroed after the call returned. + */ + var onImport: ((data: String, credential: BackupCredential) -> Unit)? = null + + override fun export(backup: Backup, credential: BackupCredential): String { + exportCalls += ExportCall(backup, credential.snapshot()) + exportException?.let { throw it } + return exportResult + } + + override fun import(data: String, credential: BackupCredential): Backup { + onImport?.invoke(data, credential) + importCalls += ImportCall(data, credential.snapshot()) + importException?.let { throw it } + return importResult + } + + // Callers zero secret key material as soon as the call returns (a recovered ARK, a decrypted + // passphrase), so record the bytes we were called with rather than a live reference to them. + private fun BackupCredential.snapshot(): BackupCredential = when (this) { + is BackupCredential.Ark -> BackupCredential.Ark(key.copyOf()) + is BackupCredential.Passphrase -> BackupCredential.Passphrase(bytes.copyOf()) + } + + override fun inspect(data: String): JsonEncryption { + inspectCalls += data + inspectException?.let { throw it } + return inspectResult + } +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt index afc322bf6..4cf26aea0 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt @@ -16,7 +16,7 @@ import java.util.UUID * * Wrapping XORs the plaintext key with a stream derived from (outer key, id, nonce) so that * wrap/unwrap round-trips correctly when the same outer key and id are supplied. Unwrapping - * with a different outer key or id yields garbage — every `unwrap*` call throws + * with a different outer key or id yields garbage; every `unwrap*` call throws * [KeyWrapException.UnwrapFailed] when the result does not match a recorded ciphertext, which * is sufficient to exercise the wrong-password / wrong-key paths in use case tests. * diff --git a/scripts/new-module.sh b/scripts/new-module.sh index 2bd8e9988..80c879e39 100644 --- a/scripts/new-module.sh +++ b/scripts/new-module.sh @@ -2,7 +2,7 @@ set -euo pipefail # --------------------------------------------------------------------------- -# new-module.sh — scaffold a new Gradle module in the KeyGo Android project. +# new-module.sh - scaffold a new Gradle module in the KeyGo Android project. # # Interactive (arrow-key menus): # scripts/new-module.sh @@ -68,7 +68,7 @@ USAGE } # --------------------------------------------------------------------------- -# Interactive widgets — drawn on stderr, result in $MENU_RESULT. +# Interactive widgets - drawn on stderr, result in $MENU_RESULT. # --------------------------------------------------------------------------- MENU_RESULT="" @@ -77,7 +77,7 @@ menu_abort() { exit 130 } -# read_key — reads one keypress into $KEY: up|down|enter|space|quit|other +# read_key - reads one keypress into $KEY: up|down|enter|space|quit|other read_key() { local key rest IFS= read -rsn1 key @@ -100,7 +100,7 @@ read_key() { esac } -# erase_menu — erase the last drawn lines. +# erase_menu - erase the last drawn lines. erase_menu() { local n=$1 i printf '\033[%dA' "$n" >&2 @@ -108,7 +108,7 @@ erase_menu() { printf '\033[%dA' "$n" >&2 } -# choose_one <default_idx> <item>... — single-select arrow menu. +# choose_one <title> <default_idx> <item>... - single-select arrow menu. choose_one() { local title=$1 current=$2 shift 2 @@ -138,12 +138,12 @@ choose_one() { for (( i=0; i<count; i++ )); do printf '\033[2K' >&2 if (( i == current )); then - printf ' %s▶ %s%s\n' "$C_CYAN" "${items[$i]}" "$C_RESET" >&2 + printf ' %s> %s%s\n' "$C_CYAN" "${items[$i]}" "$C_RESET" >&2 else printf ' %s\n' "${items[$i]}" >&2 fi done - printf '\033[2K%s ↑/↓ move · Enter select · q quit%s\n' "$C_DIM" "$C_RESET" >&2 + printf '\033[2K%s Up/Down move | Enter select | q quit%s\n' "$C_DIM" "$C_RESET" >&2 } draw @@ -166,7 +166,7 @@ choose_one() { done } -# choose_multi <title> <checked_csv> <item>... — multi-select; result is CSV. +# choose_multi <title> <checked_csv> <item>... - multi-select; result is CSV. choose_multi() { local title=$1 checked_csv=$2 shift 2 @@ -211,12 +211,12 @@ choose_multi() { if (( checked[j] )); then box="[x]"; else box="[ ]"; fi printf '\033[2K' >&2 if (( j == current )); then - printf ' %s▶ %s %s%s\n' "$C_CYAN" "$box" "${items[$j]}" "$C_RESET" >&2 + printf ' %s> %s %s%s\n' "$C_CYAN" "$box" "${items[$j]}" "$C_RESET" >&2 else printf ' %s %s\n' "$box" "${items[$j]}" >&2 fi done - printf '\033[2K%s ↑/↓ move · Space toggle · Enter confirm · q quit%s\n' "$C_DIM" "$C_RESET" >&2 + printf '\033[2K%s Up/Down move | Space toggle | Enter confirm | q quit%s\n' "$C_DIM" "$C_RESET" >&2 } draw @@ -240,7 +240,7 @@ choose_multi() { done } -# confirm <question> <default y|n> — result in $MENU_RESULT (1 or 0). +# confirm <question> <default y|n> - result in $MENU_RESULT (1 or 0). confirm() { local question=$1 default=$2 hint answer if [[ "$default" == y ]]; then hint="Y/n"; else hint="y/N"; fi @@ -295,10 +295,10 @@ if [[ -z "$LOCATION" ]]; then for d in core feature; do [[ -d "$REPO_ROOT/$d" ]] && LOCATION_OPTS+=("$d") done - LOCATION_OPTS+=("custom…") + LOCATION_OPTS+=("custom...") choose_one "Module location:" 0 "${LOCATION_OPTS[@]}" LOCATION="$MENU_RESULT" - if [[ "$LOCATION" == "custom…" ]]; then + if [[ "$LOCATION" == "custom..." ]]; then printf '%sLocation path (e.g. feature/item):%s ' "$C_BOLD" "$C_RESET" >&2 read -r LOCATION fi @@ -325,9 +325,9 @@ MODULE_DIR="$REPO_ROOT/$LOCATION/$NAME" # Resolve convention plugin # --------------------------------------------------------------------------- PLUGIN_LABELS=( - "keygo.android.compose — Android library + Compose" - "keygo.android.library — Android library (no Compose)" - "keygo.kotlin.jvm — pure Kotlin/JVM" + "keygo.android.compose - Android library + Compose" + "keygo.android.library - Android library (no Compose)" + "keygo.kotlin.jvm - pure Kotlin/JVM" ) if [[ -z "$PLUGIN_ARG" ]]; then @@ -392,7 +392,7 @@ PKG_PATH="${NAMESPACE//.//}" GRADLE_PATH=":${LOCATION//\//:}:${NAME}" PLUGIN_ACCESSOR="libs.plugins.${PLUGIN//-/.}" -# DI class: PascalCase of location + name, e.g. feature/item + create → FeatureItemCreateModule +# DI class: PascalCase of location + name, e.g. feature/item + create -> FeatureItemCreateModule pascal_case() { local out="" part IFS='/-_' read -ra parts <<<"$1" @@ -447,7 +447,7 @@ dependencies { } GRADLE else - # keygo.kotlin.jvm doesn't wire Koin — add what the DI module needs. + # keygo.kotlin.jvm doesn't wire Koin - add what the DI module needs. cat > "$MODULE_DIR/build.gradle.kts" <<GRADLE plugins { ${PLUGINS_BLOCK} @@ -470,7 +470,7 @@ INCLUDE_LINE="include(\"${GRADLE_PATH}\")" if [[ ! -f "$SETTINGS" ]]; then die "settings.gradle.kts not found at $SETTINGS" elif grep -qF "$INCLUDE_LINE" "$SETTINGS"; then - printf '%sNote:%s %s already present in settings.gradle.kts — skipping.\n' \ + printf '%sNote:%s %s already present in settings.gradle.kts, skipping.\n' \ "$C_DIM" "$C_RESET" "$INCLUDE_LINE" >&2 else awk -v line="$INCLUDE_LINE" ' @@ -492,7 +492,7 @@ fi PKG_SUMMARY="di" [[ -n "$PACKAGES" ]] && PKG_SUMMARY="$PACKAGES,di" -printf '\n%s✔ Module created%s\n' "$C_GREEN" "$C_RESET" >&2 +printf '\n%s[OK] Module created%s\n' "$C_GREEN" "$C_RESET" >&2 printf ' Directory : %s/%s\n' "$LOCATION" "$NAME" >&2 printf ' Gradle path : %s\n' "$GRADLE_PATH" >&2 printf ' Namespace : %s\n' "$NAMESPACE" >&2 @@ -500,6 +500,6 @@ printf ' Plugin : %s%s\n' "$PLUGIN" "$( (( PROTOBUF )) && echo ' + keygo.a printf ' Packages : %s\n' "$PKG_SUMMARY" >&2 printf ' Koin module : %s\n' "$DI_CLASS" >&2 printf '\nNext steps:\n' >&2 -printf ' · add implementation(projects.%s) where the module is consumed\n' \ +printf ' - add implementation(projects.%s) where the module is consumed\n' \ "$(echo "${LOCATION//\//.}.${NAME}" | sed -E 's/[-_]([a-z])/\U\1/g')" >&2 -printf ' · sync Gradle (./gradlew %s:help)\n' "$GRADLE_PATH" >&2 +printf ' - sync Gradle (./gradlew %s:help)\n' "$GRADLE_PATH" >&2 diff --git a/scripts/test-new-module.py b/scripts/test-new-module.py index f48631ea8..d547bb246 100644 --- a/scripts/test-new-module.py +++ b/scripts/test-new-module.py @@ -244,7 +244,7 @@ def test_one_liner_jvm(): "core", "toolkit") check("di module generated", os.path.isfile(os.path.join(pkg, "di", "CoreToolkitModule.kt"))) - check("packages none → no domain/data", + check("packages none -> no domain/data", not os.path.isdir(os.path.join(pkg, "domain")) and not os.path.isdir(os.path.join(pkg, "data"))) finally: @@ -263,7 +263,7 @@ def test_one_liner_protobuf_and_naming(): "alias(libs.plugins.keygo.android.library)" in build, build) check("applies protobuf add-on", "alias(libs.plugins.keygo.android.protobuf)" in build, build) - check("dash → underscore in namespace", + check("dash -> underscore in namespace", 'namespace = "de.davis.keygo.core.proto_store"' in build, build) pkg = os.path.join(mod, "src", "main", "kotlin", "de", "davis", "keygo", "core", "proto_store") @@ -337,7 +337,7 @@ def test_errors(): def selected_line(term): for line in term.lines: - if "▶" in line: + if ">" in line: return line return None @@ -355,8 +355,8 @@ def test_interactive_visuals(): "Module location:" in screen and "core" in screen and "feature" in screen, screen) check("first item highlighted initially", - (selected_line(s.term) or "").strip().startswith("▶ core"), screen) - check("hint line shown", "↑/↓ move" in screen, screen) + (selected_line(s.term) or "").strip().startswith("> core"), screen) + check("hint line shown", "Up/Down move" in screen, screen) check("cursor hidden while menu open", b"\x1b[?25l" in s.term.raw and not s.term.cursor_visible) check("highlight uses color", @@ -365,18 +365,18 @@ def test_interactive_visuals(): s.send(ARROW_DOWN) sel = selected_line(s.term) or "" check("arrow down moves highlight to feature", - sel.strip().startswith("▶ feature"), s.term.screen()) + sel.strip().startswith("> feature"), s.term.screen()) check("only one item highlighted", - sum("▶" in l for l in s.term.lines) == 1, s.term.screen()) + sum(">" in l for l in s.term.lines) == 1, s.term.screen()) s.send(ARROW_UP) sel = selected_line(s.term) or "" check("arrow up moves highlight back to core", - sel.strip().startswith("▶ core"), s.term.screen()) + sel.strip().startswith("> core"), s.term.screen()) s.send(ARROW_UP) sel = selected_line(s.term) or "" check("arrow up clamps at top", - sel.strip().startswith("▶ core"), s.term.screen()) + sel.strip().startswith("> core"), s.term.screen()) s.send(ARROW_DOWN) s.send(ENTER) @@ -384,7 +384,7 @@ def test_interactive_visuals(): check("menu collapses to inline answer", "Module location: feature" in screen, screen) check("menu items cleared after select", - "▶" not in screen and "↑/↓ move" not in screen, screen) + ">" not in screen and "Up/Down move" not in screen, screen) check("cursor restored after select", s.term.cursor_visible) s.send(b"demo\r") @@ -406,7 +406,7 @@ def test_interactive_visuals(): check("compose defaults preselect presentation", "[x] presentation" in screen, screen) - s.send(ARROW_DOWN) # → data + s.send(ARROW_DOWN) # -> data s.send(SPACE) # untoggle sel = selected_line(s.term) or "" check("space untoggles item under cursor", @@ -420,11 +420,11 @@ def test_interactive_visuals(): code = s.finish() screen = s.term.screen() check("exits 0 after interactive flow", code == 0, screen) - check("success banner shown", "✔ Module created" in screen, screen) + check("success banner shown", "[OK] Module created" in screen, screen) check("summary shows gradle path", ":feature:demo" in screen, screen) check("summary shows Koin module", "FeatureDemoModule" in screen, screen) check("final screen has no menu artifacts", - "▶" not in screen and "Enter select" not in screen, screen) + ">" not in screen and "Enter select" not in screen, screen) check("cursor visible at exit", s.term.cursor_visible) di = os.path.join(root, "feature", "demo", "src", "main", "kotlin", "de", diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a41b2b99..a74bb3192 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,3 +48,4 @@ include(":feature:auth") include(":feature:autofill") include(":feature:credit-card") include(":feature:settings") +include(":feature:backup")