From 937f64f72cf9ab8c4cf494a0649f8e4d9f96ed36 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 12 Jun 2026 12:16:31 +0200 Subject: [PATCH 01/59] build(backup): add backup --- app/build.gradle.kts | 1 + feature/backup/build.gradle.kts | 15 +++++++++++++++ .../feature/backup/di/FeatureBackupModule.kt | 10 ++++++++++ settings.gradle.kts | 1 + 4 files changed, 27 insertions(+) create mode 100644 feature/backup/build.gradle.kts create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db1e5028e..2907a2f87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,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/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts new file mode 100644 index 000000000..e1c766b32 --- /dev/null +++ b/feature/backup/build.gradle.kts @@ -0,0 +1,15 @@ +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" +} + +dependencies { + implementation(libs.androidx.navigation.compose) + + implementation(projects.core.util) +} 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..3cdb72b45 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/FeatureBackupModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.backup") +object FeatureBackupModule 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") From b62da8203cef3c6d1cc1beea61df6d1f02754876 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 12 Jun 2026 12:18:43 +0200 Subject: [PATCH 02/59] feat(backup): add backup hub UI --- .../keygo/app/presentation/MainActivity.kt | 5 + .../keygo/feature/backup/BackupInterval.kt | 21 ++ .../backup/domain/model/BackupInterval.kt | 6 + .../feature/backup/domain/model/FileFormat.kt | 6 + .../backup/presentation/BackupGraph.kt | 20 ++ .../feature/backup/presentation/FileFormat.kt | 22 ++ .../backup/presentation/RouteDestination.kt | 9 + .../presentation/hub/BackupHubContent.kt | 221 ++++++++++++++++++ .../presentation/hub/BackupHubScreen.kt | 26 +++ .../presentation/hub/BackupHubViewModel.kt | 25 ++ .../presentation/hub/model/BackupHubEvent.kt | 5 + .../hub/model/BackupHubUiEvent.kt | 6 + .../hub/model/BackupHubUiState.kt | 21 ++ .../backup/src/main/res/values/strings.xml | 32 +++ .../settings/presentation/SettingsRoutes.kt | 3 +- 15 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupInterval.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubScreen.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModel.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiEvent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiState.kt create mode 100644 feature/backup/src/main/res/values/strings.xml 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..e386ffc25 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,8 @@ private fun App() { ) } } + + backupGraph(navigateToDestination = navController::navigate) } } } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt new file mode 100644 index 000000000..a0e6007f4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.backup + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import de.davis.keygo.feature.backup.domain.model.BackupInterval + +internal val BackupInterval.displayName + @Composable + get() = when (this) { + is BackupInterval.Day -> pluralStringResource( + R.plurals.backup_interval_days, + count = count, + count + ) + + is BackupInterval.Week -> pluralStringResource( + R.plurals.backup_interval_weeks, + count = count, + count + ) + } \ No newline at end of file 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..84290c96a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupInterval.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface BackupInterval { + data class Day(val count: Int) : BackupInterval + data class Week(val count: Int) : BackupInterval +} \ No newline at end of file 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..2749b1e70 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FileFormat.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.domain.model + +enum class FileFormat { + KDBX, + CSV, +} \ No newline at end of file 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..a3a76e685 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt @@ -0,0 +1,20 @@ +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 + +fun NavGraphBuilder.backupGraph(navigateToDestination: (Any) -> Unit) { + composable { + BackupHubScreen( + navigateToExport = { + navigateToDestination(BackupExportRoute) + } + ) + } + + composable { + ExportWizardScreen() + } +} \ No newline at end of file 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..c4c5715a4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt @@ -0,0 +1,22 @@ +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.filled.Lock +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.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.KDBX -> Icons.Default.Lock + } 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..cff45d01a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/RouteDestination.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.presentation + +import kotlinx.serialization.Serializable + +@Serializable +object BackupHubRoute + +@Serializable +object BackupExportRoute \ No newline at end of file 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..695d180c4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt @@ -0,0 +1,221 @@ +package de.davis.keygo.feature.backup.presentation.hub + +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.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +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.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.SettingsBackupRestore +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SplitButtonDefaults +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.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.displayName +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.displayName +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.ScheduledBackup +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEvent) -> Unit) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(horizontal = 8.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + ListItem( + onClick = {}, + // Workaround to disable clicking but remaining original color schema + colors = ListItemDefaults.colors( + disabledContainerColor = ListItemDefaults.colors().containerColor, + disabledContentColor = ListItemDefaults.colors().contentColor, + disabledOverlineContentColor = ListItemDefaults.colors().overlineContentColor, + disabledLeadingContentColor = ListItemDefaults.colors().leadingContentColor, + disabledSupportingContentColor = ListItemDefaults.colors().supportingContentColor, + ), + enabled = false, + overlineContent = { + Text(text = stringResource(R.string.last_backup)) + }, + leadingContent = { + Box(modifier = Modifier.wrapContentHeight()) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + ) + } + }, + supportingContent = state.lastBackupProvider?.let { { Text(text = it) } }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = state.lastBackupAt ?: stringResource(R.string.never_backed_up)) + } + + FilledTonalButton( + onClick = { onEvent(BackupHubUiEvent.OnRestoreBackup) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.SettingsBackupRestore, + modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), + contentDescription = null, + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.restore_backup), + ) + } + + FilledTonalButton( + onClick = { onEvent(BackupHubUiEvent.OnScheduleBackupClick) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Schedule, + modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), + contentDescription = null, + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.schedule_new_backup), + ) + } + + HorizontalDivider() + + Text( + text = stringResource(R.string.scheduled_backups), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier.weight(1f), + ) { + AnimatedContent(state.hasScheduledItems) { hasItems -> + when { + hasItems -> LazyColumn( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(items = state.scheduledBackups) { idx, item -> + SegmentedListItem( + onClick = {}, + shapes = ListItemDefaults.segmentedShapes( + idx, + state.scheduledBackups.size + ), + colors = ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + overlineContent = { + Text(text = item.type.displayName) + }, + supportingContent = { + Text(text = item.path) + }, + ) { + Text( + text = stringResource( + R.string.scheduled_title, + item.provider, + item.scheduleInterval.displayName + ) + ) + } + } + } + + else -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_scheduled_backups), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + + +@Preview +@Composable +private fun BackupHubContentPreview() { + val currentTime = { + val zonedDateTime = Instant.now().atZone(ZoneId.systemDefault()) + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + .withLocale(Locale.getDefault()) + zonedDateTime.format(formatter) + } + + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + BackupHubContent( + state = BackupHubUiState( + lastBackupAt = currentTime(), + lastBackupProvider = "Nextcloud", + scheduledBackups = listOf( + ScheduledBackup( + provider = "Nextcloud", + type = FileFormat.CSV, + scheduleInterval = BackupInterval.Week(1), + path = "/path/to/backup" + ), + ScheduledBackup( + provider = "Google Drive", + type = FileFormat.KDBX, + scheduleInterval = BackupInterval.Day(1), + path = "/path/to/drive/backup" + ) + ) + ), + onEvent = {}, + ) + } + } +} \ No newline at end of file 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..673abef6b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubScreen.kt @@ -0,0 +1,26 @@ +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) { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(flow = viewModel.event) { + when (it) { + BackupHubEvent.NavigateToExport -> navigateToExport() + } + } + + + 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..0ed53e819 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModel.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import androidx.lifecycle.ViewModel +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.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class BackupHubViewModel : ViewModel() { + + private val _event = Channel() + val event = _event.receiveAsFlow() + + private val _state = MutableStateFlow(BackupHubUiState()) + val state = _state.asStateFlow() + + fun onEvent(event: BackupHubUiEvent) { + + } +} \ No newline at end of file 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..08a162dfa --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +internal sealed interface BackupHubEvent { + data object NavigateToExport : 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..6592aec9a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiEvent.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +internal sealed interface BackupHubUiEvent { + data object OnScheduleBackupClick : BackupHubUiEvent + data object OnRestoreBackup : BackupHubUiEvent +} \ No newline at end of file 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..84000a51d --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubUiState.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.backup.presentation.hub.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.FileFormat + +@Stable +internal data class BackupHubUiState( + val lastBackupAt: String? = null, + val lastBackupProvider: String? = null, + val scheduledBackups: List = emptyList(), +) { + val hasScheduledItems = scheduledBackups.isNotEmpty() +} + +internal data class ScheduledBackup( + val provider: String, + val type: FileFormat, + val scheduleInterval: BackupInterval, + val path: String, +) 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..67a9bfb0a --- /dev/null +++ b/feature/backup/src/main/res/values/strings.xml @@ -0,0 +1,32 @@ + + + Last Backup + + Schedule new Backup + Restore Backup + + Scheduled Backups + No Scheduled Backups + + Never Backed Up + + CSV + KDBX + + %1$s \u2022 %2$s + %1$s Backup + + + Daily + Every %1$d days + + + + Weekly + Every %1$d weeks + + + Continue + Passphrase + Confirm Passphrase + \ 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..24249205e 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,6 +23,7 @@ object ChangePasswordRoute : RouteDestination { fun NavGraphBuilder.settingsGraph( onOpenChangePassword: () -> Unit, onShowLibraries: () -> Unit, + onOpenBackup: () -> Unit, onUp: () -> Unit, ) = navigation(startDestination = SettingsHomeRoute) { composable { @@ -31,7 +32,7 @@ fun NavGraphBuilder.settingsGraph( onOpenChangePassword = onOpenChangePassword, // TODO: #51 - onExportDataClicked = {}, + onExportDataClicked = onOpenBackup, onImportDataClicked = {} ) } From 0b23781f6b73e0dd6190aa02483340ca8b1671a9 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 12 Jun 2026 16:22:08 +0200 Subject: [PATCH 03/59] feat(backup): add export wizard --- feature/backup/build.gradle.kts | 2 + .../feature/backup/domain/model/FileFormat.kt | 5 +- .../export/ExportWizardContent.kt | 302 ++++++++++++++++++ .../presentation/export/ExportWizardScreen.kt | 17 + .../export/ExportWizardViewModel.kt | 56 ++++ .../export/model/ExportWizardUiEvent.kt | 9 + .../export/model/ExportWizardUiState.kt | 30 ++ .../backup/src/main/res/values/strings.xml | 10 + 8 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardScreen.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiState.kt diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index e1c766b32..a5e3f9be0 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -11,5 +11,7 @@ android { dependencies { implementation(libs.androidx.navigation.compose) + implementation(projects.core.ui) implementation(projects.core.util) + implementation(projects.core.item) } 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 index 2749b1e70..38303f103 100644 --- 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 @@ -2,5 +2,8 @@ package de.davis.keygo.feature.backup.domain.model enum class FileFormat { KDBX, - CSV, + CSV; + + val recommented: Boolean + get() = this == KDBX } \ No newline at end of file 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..c51131f25 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt @@ -0,0 +1,302 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.animation.animateColorAsState +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +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.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.draw.clip +import androidx.compose.ui.focus.onFocusChanged +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.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.FileFormat +import de.davis.keygo.feature.backup.presentation.displayName +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.SelectFormatState +import de.davis.keygo.feature.backup.presentation.icon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ExportWizardContent( + state: ExportWizardUiState, + onEvent: (ExportWizardUiEvent) -> Unit +) { + val pagerState = rememberPagerState(state.step.ordinal) { ExportWizardStep.entries.size } + LaunchedEffect(state.step) { + if (pagerState.currentPage != state.step.ordinal) { + pagerState.animateScrollToPage(state.step.ordinal) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = state.step.title) + }, + navigationIcon = { + IconButton( + onClick = { onEvent(ExportWizardUiEvent.Back) } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null, + ) + } + }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(pagerState.pageCount) { page -> + val color by animateColorAsState( + targetValue = if (page <= pagerState.currentPage) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primaryContainer, + label = "indicator_page=$page", + ) + Box( + modifier = Modifier + .height(8.dp) + .weight(1f) + .clip(CircleShape) + .background(color), + ) + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + userScrollEnabled = false, + ) { page -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + when (ExportWizardStep.entries[page]) { + ExportWizardStep.SelectFormat -> SelectFileFormatContent(onEvent = onEvent) + ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( + state = state.providePassphraseState, + onEvent = onEvent + ) + } + } + } + } + } +} + +private val ExportWizardStep.title + @Composable + get() = when (this) { + ExportWizardStep.SelectFormat -> stringResource(R.string.select_file_format_title) + ExportWizardStep.ProvidePassphrase -> stringResource(R.string.provide_passphrase_title) + } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { + Surface { + Column( + 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 = if (type.recommented) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + ), + supportingContent = { + Text(text = type.description) + }, + leadingContent = { + Icon( + imageVector = type.icon, + contentDescription = null, + ) + }, + overlineContent = when { + type.recommented -> { + { Text(text = stringResource(R.string.recommented)) } + } + + else -> null + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = type.displayName) + } + } + } + } +} + + +private val FileFormat.description + @Composable + get() = when (this) { + FileFormat.KDBX -> stringResource(R.string.export_description_kdbx) + FileFormat.CSV -> stringResource(R.string.export_description_csv) + } + + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private 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) } + Surface { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.export_passphrase_instruction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedSecureTextField( + state = state.passphraseTextFieldState, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + forceCompact = !it.hasFocus + }, + label = { + Text(text = stringResource(R.string.passphrase)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Password, + contentDescription = null, + ) + }, + textObfuscationMode = if (passphraseHidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton( + isHidden = passphraseHidden, + onClick = { passphraseHidden = !passphraseHidden }, + ) + }, + ) + StrengthIndicator( + passwordScore = state.passphraseScore, + forceCompact = forceCompact, + ) + + OutlinedSecureTextField( + state = state.confirmPassphraseTextFieldState, + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(R.string.confirm_passphrase)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Password, + contentDescription = null, + ) + }, + textObfuscationMode = if (confirmPassphraseHidden) TextObfuscationMode.RevealLastTyped + else TextObfuscationMode.Visible, + trailingIcon = { + VisibilityButton( + isHidden = confirmPassphraseHidden, + onClick = { confirmPassphraseHidden = !confirmPassphraseHidden }, + ) + }, + ) + + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { onEvent(ExportWizardUiEvent.Continue) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.continue_step)) + } + } + } +} + +private class ExportWizardUiStateProvider : PreviewParameterProvider { + + override val values = ExportWizardStep.entries.asSequence().map { + ExportWizardUiState( + formatState = SelectFormatState(), + providePassphraseState = ProvidePassphraseState( + passphraseTextFieldState = TextFieldState(), + confirmPassphraseTextFieldState = TextFieldState(), + ), + step = it, + ) + } +} + +@Preview +@Composable +private fun BackupHubContentPreview(@PreviewParameter(ExportWizardUiStateProvider::class) state: ExportWizardUiState) { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + ExportWizardContent( + state = state, + onEvent = {} + ) + } + } +} \ 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..286c0b99a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardScreen.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ExportWizardScreen() { + val viewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + ExportWizardContent( + 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/export/ExportWizardViewModel.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt new file mode 100644 index 000000000..c5771a132 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModel.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.SelectFormatState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class ExportWizardViewModel : ViewModel() { + + private val passphraseTextFieldState = TextFieldState() + private val confirmPassphraseTextFieldState = TextFieldState() + + private val _formatState = MutableStateFlow(SelectFormatState()) + private val _providePassphraseState = MutableStateFlow( + ProvidePassphraseState( + passphraseTextFieldState = passphraseTextFieldState, + confirmPassphraseTextFieldState = confirmPassphraseTextFieldState, + ) + ) + + private val _step = MutableStateFlow(ExportWizardStep.SelectFormat) + + val state = combine( + _formatState, + _providePassphraseState, + _step + ) { formatState, providePassphraseState, step -> + ExportWizardUiState( + formatState = formatState, + providePassphraseState = providePassphraseState, + step = step, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ExportWizardUiState( + formatState = _formatState.value, + providePassphraseState = _providePassphraseState.value, + step = _step.value, + ) + ) + + fun onEvent(event: ExportWizardUiEvent) { + + } +} \ 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..49e994c7f --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal sealed interface ExportWizardUiEvent { + data object Back : ExportWizardUiEvent + data object Continue : ExportWizardUiEvent + data class FileFormatSelected(val format: FileFormat) : 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..f65771ae4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiState.kt @@ -0,0 +1,30 @@ +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.FileFormat + +@Stable +internal data class ExportWizardUiState( + val formatState: SelectFormatState, + val providePassphraseState: ProvidePassphraseState, + val step: ExportWizardStep = ExportWizardStep.SelectFormat, +) + +@Stable +internal data class SelectFormatState( + val format: FileFormat? = null +) + +@Stable +internal data class ProvidePassphraseState( + val passphraseTextFieldState: TextFieldState, + val confirmPassphraseTextFieldState: TextFieldState, + val passphraseScore: PasswordScore = PasswordScore.None, +) + +internal enum class ExportWizardStep { + SelectFormat, + ProvidePassphrase +} \ No newline at end of file diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 67a9bfb0a..20e7c48a9 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -2,6 +2,8 @@ Last Backup + Recommended + Schedule new Backup Restore Backup @@ -29,4 +31,12 @@ Continue Passphrase Confirm Passphrase + + Export all data, including vaults, logins, cards, and TOTP, to a secure, encrypted KDBX 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. + + Select File Format + Provide Passphrase \ No newline at end of file From 362c05b3f4fefe0fa1aac26f05084403fd17b30f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 15 Jun 2026 19:08:55 +0200 Subject: [PATCH 04/59] feat(backup): handle UI events in BackupHubViewModel Update BackupHubViewModel to process incoming UI events, wiring the schedule backup click to navigate to the export screen and adding a placeholder for the restore backup event. --- .../feature/backup/presentation/hub/BackupHubViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 0ed53e819..1ff9326e6 100644 --- 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 @@ -20,6 +20,9 @@ internal class BackupHubViewModel : ViewModel() { val state = _state.asStateFlow() fun onEvent(event: BackupHubUiEvent) { - + when (event) { + BackupHubUiEvent.OnRestoreBackup -> {} //TODO + BackupHubUiEvent.OnScheduleBackupClick -> _event.trySend(BackupHubEvent.NavigateToExport) + } } } \ No newline at end of file From f8ba142d08c755e97e064e6e1d9ceebc7ec14f61 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 15 Jun 2026 19:17:11 +0200 Subject: [PATCH 05/59] feat(backup): add steps --- .../keygo/feature/backup/BackupInterval.kt | 29 ++ .../export/ExportWizardContent.kt | 199 ++------- .../export/ExportWizardViewModel.kt | 61 ++- .../export/ProvidePassphraseContent.kt | 112 +++++ .../export/ReviewBackupContent.kt | 352 +++++++++++++++ .../presentation/export/ScheduleComponents.kt | 415 ++++++++++++++++++ .../export/SelectDestinationContent.kt | 268 +++++++++++ .../export/SelectFileFormatContent.kt | 74 ++++ .../export/SelectScheduleContent.kt | 127 ++++++ .../presentation/export/WizardComponents.kt | 62 +++ .../export/model/ExportWizardUiEvent.kt | 7 + .../export/model/ExportWizardUiState.kt | 37 +- .../backup/src/main/res/values/strings.xml | 69 +++ 13 files changed, 1659 insertions(+), 153 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ProvidePassphraseContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ScheduleComponents.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectFileFormatContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectScheduleContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt index a0e6007f4..71990718c 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt @@ -2,7 +2,9 @@ package de.davis.keygo.feature.backup import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.presentation.export.model.IntervalUnit internal val BackupInterval.displayName @Composable @@ -18,4 +20,31 @@ internal val BackupInterval.displayName count = count, count ) + } + +internal val BackupInterval.intervalCount: Int + get() = when (this) { + is BackupInterval.Day -> count + is BackupInterval.Week -> count + } + +internal val BackupInterval.intervalUnit: IntervalUnit + get() = when (this) { + is BackupInterval.Day -> IntervalUnit.Days + is BackupInterval.Week -> IntervalUnit.Weeks + } + +internal fun backupInterval(unit: IntervalUnit, count: Int): BackupInterval { + val safeCount = count.coerceAtLeast(1) + return when (unit) { + IntervalUnit.Days -> BackupInterval.Day(safeCount) + IntervalUnit.Weeks -> BackupInterval.Week(safeCount) + } +} + +internal val IntervalUnit.label + @Composable + get() = when (this) { + IntervalUnit.Days -> stringResource(R.string.interval_unit_days) + IntervalUnit.Weeks -> stringResource(R.string.interval_unit_weeks) } \ No newline at end of file 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 index c51131f25..9555da971 100644 --- 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 @@ -6,58 +6,48 @@ 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.Spacer +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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape 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.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Password -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedListItem 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.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.draw.clip -import androidx.compose.ui.focus.onFocusChanged 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.core.item.presentation.StrengthIndicator -import de.davis.keygo.core.ui.components.VisibilityButton +import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.BackupInterval import de.davis.keygo.feature.backup.domain.model.FileFormat -import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination 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.icon +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -94,7 +84,9 @@ internal fun ExportWizardContent( Column( modifier = Modifier .padding(innerPadding) - .padding(horizontal = 4.dp), + .consumeWindowInsets(innerPadding) + .padding(start = 4.dp, end = 4.dp, bottom = 16.dp) + .imePadding(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { @@ -123,10 +115,32 @@ internal fun ExportWizardContent( ) { when (ExportWizardStep.entries[page]) { 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.Review -> state.formatState.format?.let { format -> + ReviewBackupContent( + format = format, + scheduleState = state.scheduleState, + destinationState = state.destinationState, + passphraseState = state.providePassphraseState, + onEvent = onEvent, + ) + } } } } @@ -138,148 +152,31 @@ 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.Review -> stringResource(R.string.review_backup_title) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { - Surface { - Column( - 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 = if (type.recommented) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHigh, - ), - supportingContent = { - Text(text = type.description) - }, - leadingContent = { - Icon( - imageVector = type.icon, - contentDescription = null, - ) - }, - overlineContent = when { - type.recommented -> { - { Text(text = stringResource(R.string.recommented)) } - } - - else -> null - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = type.displayName) - } - } - } - } -} - - -private val FileFormat.description - @Composable - get() = when (this) { - FileFormat.KDBX -> stringResource(R.string.export_description_kdbx) - FileFormat.CSV -> stringResource(R.string.export_description_csv) - } - - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private 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) } - Surface { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.export_passphrase_instruction), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - OutlinedSecureTextField( - state = state.passphraseTextFieldState, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - forceCompact = !it.hasFocus - }, - label = { - Text(text = stringResource(R.string.passphrase)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Password, - contentDescription = null, - ) - }, - textObfuscationMode = if (passphraseHidden) TextObfuscationMode.RevealLastTyped - else TextObfuscationMode.Visible, - trailingIcon = { - VisibilityButton( - isHidden = passphraseHidden, - onClick = { passphraseHidden = !passphraseHidden }, - ) - }, - ) - StrengthIndicator( - passwordScore = state.passphraseScore, - forceCompact = forceCompact, - ) - - OutlinedSecureTextField( - state = state.confirmPassphraseTextFieldState, - modifier = Modifier.fillMaxWidth(), - label = { - Text(text = stringResource(R.string.confirm_passphrase)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Password, - contentDescription = null, - ) - }, - textObfuscationMode = if (confirmPassphraseHidden) TextObfuscationMode.RevealLastTyped - else TextObfuscationMode.Visible, - trailingIcon = { - VisibilityButton( - isHidden = confirmPassphraseHidden, - onClick = { confirmPassphraseHidden = !confirmPassphraseHidden }, - ) - }, - ) - - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { onEvent(ExportWizardUiEvent.Continue) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(R.string.continue_step)) - } - } - } -} - private class ExportWizardUiStateProvider : PreviewParameterProvider { override val values = ExportWizardStep.entries.asSequence().map { ExportWizardUiState( - formatState = SelectFormatState(), + formatState = SelectFormatState(format = FileFormat.KDBX), + scheduleState = SelectScheduleState( + mode = ScheduleMode.Recurring, + interval = BackupInterval.Day(3), + ), + destinationState = SelectDestinationState( + destination = BackupDestination( + providerLabel = "Nextcloud", + displayPath = "Backups / KeyGo", + ), + ), providePassphraseState = ProvidePassphraseState( passphraseTextFieldState = TextFieldState(), confirmPassphraseTextFieldState = TextFieldState(), + passphraseScore = PasswordScore.Strong, ), step = it, ) 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 index c5771a132..b6e6c1e7b 100644 --- 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 @@ -3,15 +3,21 @@ package de.davis.keygo.feature.backup.presentation.export import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.feature.backup.backupInterval +import de.davis.keygo.feature.backup.intervalCount +import de.davis.keygo.feature.backup.intervalUnit 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.SelectDestinationState import de.davis.keygo.feature.backup.presentation.export.model.SelectFormatState +import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import org.koin.core.annotation.KoinViewModel @KoinViewModel @@ -21,6 +27,8 @@ internal class ExportWizardViewModel : ViewModel() { 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, @@ -32,11 +40,15 @@ internal class ExportWizardViewModel : ViewModel() { val state = combine( _formatState, + _scheduleState, + _destinationState, _providePassphraseState, _step - ) { formatState, providePassphraseState, step -> + ) { formatState, scheduleState, destinationState, providePassphraseState, step -> ExportWizardUiState( formatState = formatState, + scheduleState = scheduleState, + destinationState = destinationState, providePassphraseState = providePassphraseState, step = step, ) @@ -45,12 +57,59 @@ internal class ExportWizardViewModel : ViewModel() { started = SharingStarted.WhileSubscribed(5_000), initialValue = ExportWizardUiState( formatState = _formatState.value, + scheduleState = _scheduleState.value, + destinationState = _destinationState.value, providePassphraseState = _providePassphraseState.value, step = _step.value, ) ) fun onEvent(event: ExportWizardUiEvent) { + when (event) { + ExportWizardUiEvent.Back -> previousStep() + ExportWizardUiEvent.Continue -> nextStep() + + // TODO: open the system file picker once destination selection is wired up + ExportWizardUiEvent.ChooseDestination -> Unit + + // TODO: trigger the actual export once the export use case is wired up + ExportWizardUiEvent.Export -> Unit + + is ExportWizardUiEvent.FileFormatSelected -> { + _formatState.update { + it.copy(format = event.format) + } + nextStep() + } + + is ExportWizardUiEvent.ScheduleModeSelected -> _scheduleState.update { + it.copy(mode = event.mode) + } + + is ExportWizardUiEvent.IntervalUnitSelected -> _scheduleState.update { + it.copy(interval = backupInterval(event.unit, it.interval.intervalCount)) + } + + is ExportWizardUiEvent.IntervalCountChanged -> _scheduleState.update { + it.copy(interval = backupInterval(it.interval.intervalUnit, event.count)) + } + + is ExportWizardUiEvent.KeepCountChanged -> _scheduleState.update { + it.copy(keepCount = event.count.coerceAtLeast(1)) + } + + is ExportWizardUiEvent.KeepAllChanged -> _scheduleState.update { + it.copy(keepAll = event.keepAll) + } + } + } + + private fun nextStep() = _step.update { + ExportWizardStep.entries[(it.ordinal + 1).coerceAtMost(ExportWizardStep.entries.size - 1)] + } + + private fun previousStep() = _step.update { + ExportWizardStep.entries[(it.ordinal - 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..371b27f3e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ProvidePassphraseContent.kt @@ -0,0 +1,112 @@ +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.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.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@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) } + Surface { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + 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 }, + ) + } + + ContinueButton(onEvent = onEvent) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@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..4eff79639 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt @@ -0,0 +1,352 @@ +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.Spacer +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.layout.width +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.filled.Backup +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.Schedule +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.BackupInterval +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination +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.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.displayName as intervalDisplayName + +@Composable +internal fun ReviewBackupContent( + format: FileFormat, + scheduleState: SelectScheduleState, + destinationState: SelectDestinationState, + passphraseState: ProvidePassphraseState, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + val encrypted = format == FileFormat.KDBX + val recurring = scheduleState.mode == ScheduleMode.Recurring + Surface { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ReviewHeroCard(format = format) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + ReviewRow( + icon = format.icon, + label = stringResource(R.string.review_section_format), + ) { + Text(text = format.displayName) + } + + ReviewDivider() + + ReviewRow( + 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) { + ReviewDivider() + ReviewRow( + 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, + ), + ) + } + } + + ReviewDivider() + + ReviewRow( + icon = Icons.Default.Folder, + label = stringResource(R.string.review_section_destination), + ) { + destinationState.destination?.let { destination -> + Text(text = destination.displayPath) + } + } + + ReviewDivider() + + ReviewRow( + 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, + ), + ) + } + + ReviewDivider() + + ReviewRow( + 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) { + ReviewDivider() + ReviewRow( + icon = Icons.Default.Password, + label = stringResource(R.string.review_section_passphrase), + ) { + StrengthIndicator(passwordScore = passphraseState.passphraseScore) + } + } + } + } + + if (!encrypted) ReviewPlaintextWarning() + } + + Button( + onClick = { onEvent(ExportWizardUiEvent.Export) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Icon( + imageVector = if (recurring) Icons.Default.Schedule else Icons.Default.Backup, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource( + if (recurring) R.string.schedule_backup + else R.string.create_backup, + ), + ) + } + } + } +} + +@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, + ) + } + } +} + +@Composable +private fun ReviewRow( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + value: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + IconBadge( + icon = icon, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = iconTint, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodyLarge + ) { + value() + } + } + } +} + +@Composable +private fun ReviewDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 72.dp, end = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) +} + +@Composable +private fun ReviewPlaintextWarning() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.review_plaintext_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +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.Day(3), + ), + destinationState = SelectDestinationState( + destination = BackupDestination( + providerLabel = "Nextcloud", + displayPath = "Backups / KeyGo", + ), + ), + passphraseState = ProvidePassphraseState( + passphraseTextFieldState = TextFieldState(), + confirmPassphraseTextFieldState = TextFieldState(), + passphraseScore = PasswordScore.Strong, + ), + onEvent = {}, + ) + } + } +} 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..bc13667ab --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ScheduleComponents.kt @@ -0,0 +1,415 @@ +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.displayName +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.intervalCount +import de.davis.keygo.feature.backup.intervalUnit +import de.davis.keygo.feature.backup.label +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.IntervalUnit +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +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.intervalCount + val unit = interval.intervalUnit + 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, + enabled: Boolean = true, + min: Int = 1, + supporting: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + StepperButton( + onStep = { onValueChange((value - 1).coerceAtLeast(min)) }, + imageVector = Icons.Default.Remove, + enabled = enabled && value > min, + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + StepperValueField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + min = min, + ) + + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodySmall, + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, + ) { + supporting?.invoke() + } + } + + StepperButton( + onStep = { onValueChange(value + 1) }, + imageVector = Icons.Default.Add, + enabled = enabled, + ) + } +} + +@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, + enabled: Boolean, + min: Int, + 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(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() + } + + val numberColor = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + + 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 + }, + enabled = enabled, + textStyle = MaterialTheme.typography.headlineMedium.copy( + color = numberColor, + textAlign = TextAlign.Center, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + ) +} + +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/SelectDestinationContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt new file mode 100644 index 000000000..219f66d6b --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectDestinationContent.kt @@ -0,0 +1,268 @@ +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.material.icons.filled.Folder +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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 androidx.compose.ui.unit.dp +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination +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 + +@Composable +internal fun SelectDestinationContent( + state: SelectDestinationState, + scheduleState: SelectScheduleState, + format: FileFormat?, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Surface { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + when (val destination = state.destination) { + null -> DestinationChooserCard( + onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, + ) + + else -> DestinationSelectedCard( + destination = destination, + fileName = format.previewFileName(), + onChange = { onEvent(ExportWizardUiEvent.ChooseDestination) }, + ) + } + + BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) + } + + ContinueButton(onEvent = onEvent) + } + } +} + +@Composable +private fun DestinationChooserCard(onChoose: () -> Unit) { + Card( + 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 = Icons.Default.CreateNewFolder, + contentDescription = null, + ) + Text( + text = stringResource(R.string.destination_choose_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.destination_choose_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + FilledTonalButton( + onClick = onChoose, + modifier = Modifier.padding(top = 4.dp), + ) { + Text(text = stringResource(R.string.destination_choose_action)) + } + } + } +} + +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())), + ), + ) +} + +@Composable +private fun DestinationSelectedCard( + destination: BackupDestination, + fileName: String, + onChange: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconBadge( + icon = Icons.Default.Folder, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + size = 44.dp, + iconSize = 24.dp, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = destination.providerLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = destination.displayPath, + style = MaterialTheme.typography.titleSmall, + ) + } + TextButton(onClick = onChange) { + Text(text = stringResource(R.string.destination_change)) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.destination_filename_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = fileName, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@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 fun FileFormat?.previewFileName(): String = when (this) { + FileFormat.KDBX -> "keygo-backup.kdbx" + FileFormat.CSV -> "keygo-backup.csv" + null -> "keygo-backup" +} + +private class SelectDestinationStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + SelectDestinationState(), + SelectDestinationState( + destination = BackupDestination( + providerLabel = "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.KDBX, + 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..12958a5d2 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectFileFormatContent.kt @@ -0,0 +1,74 @@ +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.ExperimentalMaterial3ExpressiveApi +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 de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.domain.model.FileFormat +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 + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { + Surface { + 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 = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), + ), + supportingContent = { + Text(text = type.description) + }, + leadingContent = { + Icon( + imageVector = type.icon, + contentDescription = null, + ) + }, + overlineContent = when { + type.recommented -> { + { Text(text = stringResource(R.string.recommented)) } + } + + else -> null + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = type.displayName) + } + } + } + } +} + +private val FileFormat.description + @Composable + get() = when (this) { + FileFormat.KDBX -> stringResource(R.string.export_description_kdbx) + 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..84d718dd3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectScheduleContent.kt @@ -0,0 +1,127 @@ +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.ExperimentalMaterial3ExpressiveApi +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.domain.model.BackupInterval +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 + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SelectScheduleContent( + state: SelectScheduleState, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Surface { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + ScheduleMode.entries.forEachIndexed { index, mode -> + val selected = state.mode == mode + SegmentedListItem( + checked = selected, + onCheckedChange = { onEvent(ExportWizardUiEvent.ScheduleModeSelected(mode)) }, + shapes = ListItemDefaults.segmentedShapes(index, ScheduleMode.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), + ), + leadingContent = { + Icon( + imageVector = mode.icon, + contentDescription = null, + ) + }, + supportingContent = { + Text(text = stringResource(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 + ) + } + } + } + } + + ContinueButton(onEvent = onEvent) + } + } +} + +private class SelectScheduleStateProvider : PreviewParameterProvider { + override val values = sequenceOf( + SelectScheduleState(mode = ScheduleMode.OneTime), + SelectScheduleState( + mode = ScheduleMode.Recurring, + interval = BackupInterval.Day(3), + ), + ) +} + +@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/WizardComponents.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt new file mode 100644 index 000000000..28b963054 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt @@ -0,0 +1,62 @@ +package de.davis.keygo.feature.backup.presentation.export + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +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.presentation.export.model.ExportWizardUiEvent + +@Composable +internal fun ContinueButton( + onEvent: (ExportWizardUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = { onEvent(ExportWizardUiEvent.Continue) }, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + Text(text = stringResource(R.string.continue_step)) + } +} + +@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/export/model/ExportWizardUiEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardUiEvent.kt index 49e994c7f..f2598bc4a 100644 --- 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 @@ -5,5 +5,12 @@ import de.davis.keygo.feature.backup.domain.model.FileFormat internal sealed interface ExportWizardUiEvent { data object Back : ExportWizardUiEvent data object Continue : ExportWizardUiEvent + data object ChooseDestination : ExportWizardUiEvent + data object Export : 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 } \ 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 index f65771ae4..97ae223c0 100644 --- 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 @@ -3,11 +3,14 @@ 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.BackupInterval import de.davis.keygo.feature.backup.domain.model.FileFormat @Stable internal data class ExportWizardUiState( val formatState: SelectFormatState, + val scheduleState: SelectScheduleState, + val destinationState: SelectDestinationState, val providePassphraseState: ProvidePassphraseState, val step: ExportWizardStep = ExportWizardStep.SelectFormat, ) @@ -17,6 +20,35 @@ internal data class SelectFormatState( val format: FileFormat? = null ) +@Stable +internal data class SelectDestinationState( + val destination: BackupDestination? = null, +) + +@Stable +internal data class BackupDestination( + val providerLabel: String, + val displayPath: String, +) + +@Stable +internal data class SelectScheduleState( + val mode: ScheduleMode = ScheduleMode.OneTime, + val interval: BackupInterval = BackupInterval.Day(1), + val keepCount: Int = 5, + val keepAll: Boolean = false, +) + +internal enum class ScheduleMode { + OneTime, + Recurring, +} + +internal enum class IntervalUnit { + Days, + Weeks, +} + @Stable internal data class ProvidePassphraseState( val passphraseTextFieldState: TextFieldState, @@ -26,5 +58,8 @@ internal data class ProvidePassphraseState( internal enum class ExportWizardStep { SelectFormat, - ProvidePassphrase + Schedule, + SelectDestination, + ProvidePassphrase, + Review, } \ No newline at end of file diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 20e7c48a9..14f3349c9 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -38,5 +38,74 @@ 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. Select File Format + Backup Schedule + Backup Location Provide Passphrase + Review Backup Info + + Choose where to save + Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. + Choose location + Change + Saved as + + 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. + + 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 + + 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 \ No newline at end of file From d28d0e9cf51dc8f50607d7cff4ab034f52beb922 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 15 Jun 2026 19:33:38 +0200 Subject: [PATCH 06/59] fix(backup): skip passphrase for csv --- .../feature/backup/domain/model/FileFormat.kt | 3 +++ .../presentation/export/ExportWizardContent.kt | 12 +++++++----- .../presentation/export/ExportWizardViewModel.kt | 11 +++++++---- .../presentation/export/ReviewBackupContent.kt | 2 +- .../export/model/ExportWizardUiState.kt | 16 ++++++++++++++-- 5 files changed, 32 insertions(+), 12 deletions(-) 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 index 38303f103..4139d64e9 100644 --- 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 @@ -6,4 +6,7 @@ enum class FileFormat { val recommented: Boolean get() = this == KDBX + + val encrypted: Boolean + get() = this == KDBX } \ No newline at end of file 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 index 9555da971..51252b736 100644 --- 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 @@ -55,10 +55,12 @@ internal fun ExportWizardContent( state: ExportWizardUiState, onEvent: (ExportWizardUiEvent) -> Unit ) { - val pagerState = rememberPagerState(state.step.ordinal) { ExportWizardStep.entries.size } - LaunchedEffect(state.step) { - if (pagerState.currentPage != state.step.ordinal) { - pagerState.animateScrollToPage(state.step.ordinal) + val steps = state.steps + val currentPage = steps.indexOf(state.step).coerceAtLeast(0) + val pagerState = rememberPagerState(currentPage) { steps.size } + LaunchedEffect(currentPage) { + if (pagerState.currentPage != currentPage) { + pagerState.animateScrollToPage(currentPage) } } @@ -113,7 +115,7 @@ internal fun ExportWizardContent( Surface( modifier = Modifier.fillMaxSize(), ) { - when (ExportWizardStep.entries[page]) { + when (steps[page]) { ExportWizardStep.SelectFormat -> SelectFileFormatContent(onEvent = onEvent) ExportWizardStep.Schedule -> SelectScheduleContent( state = state.scheduleState, 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 index b6e6c1e7b..77a27269b 100644 --- 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 @@ -13,6 +13,7 @@ import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphrase 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.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -105,11 +106,13 @@ internal class ExportWizardViewModel : ViewModel() { } } - private fun nextStep() = _step.update { - ExportWizardStep.entries[(it.ordinal + 1).coerceAtMost(ExportWizardStep.entries.size - 1)] + 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 { - ExportWizardStep.entries[(it.ordinal - 1).coerceAtLeast(0)] + 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/ReviewBackupContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ReviewBackupContent.kt index 4eff79639..9c8491a81 100644 --- 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 @@ -68,7 +68,7 @@ internal fun ReviewBackupContent( passphraseState: ProvidePassphraseState, onEvent: (ExportWizardUiEvent) -> Unit, ) { - val encrypted = format == FileFormat.KDBX + val encrypted = format.encrypted val recurring = scheduleState.mode == ScheduleMode.Recurring Surface { Column(modifier = Modifier.fillMaxSize()) { 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 index 97ae223c0..68a2e48a4 100644 --- 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 @@ -13,7 +13,9 @@ internal data class ExportWizardUiState( val destinationState: SelectDestinationState, val providePassphraseState: ProvidePassphraseState, val step: ExportWizardStep = ExportWizardStep.SelectFormat, -) +) { + val steps: List = exportStepsFor(formatState.format) +} @Stable internal data class SelectFormatState( @@ -62,4 +64,14 @@ internal enum class ExportWizardStep { SelectDestination, ProvidePassphrase, Review, -} \ No newline at end of file +} + +/** + * The wizard steps that apply to the chosen [format]. The passphrase step only makes sense for + * encrypted formats, so it is dropped for plaintext exports (e.g. CSV). While no format is chosen + * yet the full path is shown. + */ +internal fun exportStepsFor(format: FileFormat?): List = + ExportWizardStep.entries.filter { step -> + step != ExportWizardStep.ProvidePassphrase || (format?.encrypted ?: true) + } \ No newline at end of file From 8a9683556a0fd6602ee2cf277367b98007a2f319 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 15 Jun 2026 21:04:32 +0200 Subject: [PATCH 07/59] fix(backup): centralize continue button --- .../export/ExportWizardContent.kt | 41 +++- .../export/ExportWizardViewModel.kt | 44 +++- .../export/ProvidePassphraseContent.kt | 60 +++--- .../export/ReviewBackupContent.kt | 198 ++++++++---------- .../export/SelectDestinationContent.kt | 36 ++-- .../export/SelectScheduleContent.kt | 100 +++++---- .../presentation/export/WizardComponents.kt | 22 -- .../export/model/ExportWizardUiState.kt | 7 + 8 files changed, 256 insertions(+), 252 deletions(-) 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 index 51252b736..84036001b 100644 --- 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 @@ -1,5 +1,6 @@ package de.davis.keygo.feature.backup.presentation.export +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -8,15 +9,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Schedule +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 @@ -140,12 +147,44 @@ internal fun ExportWizardContent( scheduleState = state.scheduleState, destinationState = state.destinationState, passphraseState = state.providePassphraseState, - onEvent = onEvent, ) } } } } + + AnimatedVisibility(visible = state.step != ExportWizardStep.SelectFormat) { + Button( + onClick = { onEvent(ExportWizardUiEvent.Continue) }, + enabled = state.canContinue, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + 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 + } + ), + ) + } + } } } } 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 index 77a27269b..4e42f54c5 100644 --- 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 @@ -1,6 +1,7 @@ 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.feature.backup.backupInterval @@ -14,12 +15,19 @@ import de.davis.keygo.feature.backup.presentation.export.model.SelectDestination 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.FlowPreview 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.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class ExportWizardViewModel : ViewModel() { @@ -53,17 +61,33 @@ internal class ExportWizardViewModel : ViewModel() { providePassphraseState = providePassphraseState, step = step, ) - }.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, + } + .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, + ) ) - ) + + @OptIn(FlowPreview::class) + private fun observePassphrase() { + snapshotFlow { + val passphrase = passphraseTextFieldState.text + passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text) + } + .debounce(150.milliseconds) + .distinctUntilChanged() + .onEach { valid -> _providePassphraseState.update { it.copy(valid = valid) } } + .launchIn(viewModelScope) + } fun onEvent(event: ExportWizardUiEvent) { when (event) { 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 index 371b27f3e..2dbddaced 100644 --- 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 @@ -41,40 +41,36 @@ internal fun ProvidePassphraseContent( var confirmPassphraseHidden by rememberSaveable { mutableStateOf(true) } var forceCompact by rememberSaveable { mutableStateOf(false) } Surface { - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - 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, - ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.export_passphrase_instruction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - PassphraseField( - state = state.confirmPassphraseTextFieldState, - label = stringResource(R.string.confirm_passphrase), - hidden = confirmPassphraseHidden, - onToggleHidden = { confirmPassphraseHidden = !confirmPassphraseHidden }, - ) - } + 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, + ) - ContinueButton(onEvent = onEvent) + PassphraseField( + state = state.confirmPassphraseTextFieldState, + label = stringResource(R.string.confirm_passphrase), + hidden = confirmPassphraseHidden, + onToggleHidden = { confirmPassphraseHidden = !confirmPassphraseHidden }, + ) } } } 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 index 9c8491a81..e96a2ad8a 100644 --- 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 @@ -3,27 +3,20 @@ 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.Spacer 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.layout.width 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.filled.Backup 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.Schedule import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.WarningAmber -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -52,7 +45,6 @@ import de.davis.keygo.feature.backup.domain.model.BackupInterval import de.davis.keygo.feature.backup.domain.model.FileFormat import de.davis.keygo.feature.backup.presentation.displayName import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination -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.export.model.ScheduleMode import de.davis.keygo.feature.backup.presentation.export.model.SelectDestinationState @@ -66,140 +58,117 @@ internal fun ReviewBackupContent( scheduleState: SelectScheduleState, destinationState: SelectDestinationState, passphraseState: ProvidePassphraseState, - onEvent: (ExportWizardUiEvent) -> Unit, ) { val encrypted = format.encrypted val recurring = scheduleState.mode == ScheduleMode.Recurring Surface { - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ReviewHeroCard(format = format) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { - ReviewHeroCard(format = format) + Column(modifier = Modifier.padding(vertical = 8.dp)) { + ReviewRow( + icon = format.icon, + label = stringResource(R.string.review_section_format), + ) { + Text(text = format.displayName) + } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - ReviewRow( - icon = format.icon, - label = stringResource(R.string.review_section_format), - ) { - Text(text = format.displayName) - } + ReviewDivider() - ReviewDivider() + ReviewRow( + 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) { + ReviewDivider() ReviewRow( - icon = scheduleState.mode.icon, - label = stringResource(R.string.review_section_schedule), + icon = Icons.Default.DeleteSweep, + label = stringResource(R.string.review_section_retention), ) { Text( - text = if (recurring) scheduleState.interval.intervalDisplayName - else stringResource(R.string.review_schedule_one_time), + text = if (scheduleState.keepAll) stringResource(R.string.review_retention_all) + else pluralStringResource( + R.plurals.review_retention, + scheduleState.keepCount, + scheduleState.keepCount, + ), ) } + } + + ReviewDivider() - if (recurring) { - ReviewDivider() - ReviewRow( - 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, - ), - ) - } + ReviewRow( + icon = Icons.Default.Folder, + label = stringResource(R.string.review_section_destination), + ) { + destinationState.destination?.let { destination -> + Text(text = destination.displayPath) } + } - ReviewDivider() + ReviewDivider() - ReviewRow( - icon = Icons.Default.Folder, - label = stringResource(R.string.review_section_destination), - ) { - destinationState.destination?.let { destination -> - Text(text = destination.displayPath) - } - } + ReviewRow( + 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, + ), + ) + } - ReviewDivider() + ReviewDivider() - ReviewRow( - 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, - ), - ) - } + ReviewRow( + 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) { ReviewDivider() - ReviewRow( - 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, + icon = Icons.Default.Password, + label = stringResource(R.string.review_section_passphrase), ) { - 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) { - ReviewDivider() - ReviewRow( - icon = Icons.Default.Password, - label = stringResource(R.string.review_section_passphrase), - ) { - StrengthIndicator(passwordScore = passphraseState.passphraseScore) - } + StrengthIndicator(passwordScore = passphraseState.passphraseScore) } } } - - if (!encrypted) ReviewPlaintextWarning() } - Button( - onClick = { onEvent(ExportWizardUiEvent.Export) }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - ) { - Icon( - imageVector = if (recurring) Icons.Default.Schedule else Icons.Default.Backup, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text( - text = stringResource( - if (recurring) R.string.schedule_backup - else R.string.create_backup, - ), - ) - } + if (!encrypted) ReviewPlaintextWarning() } } } @@ -345,7 +314,6 @@ private fun ReviewBackupContentPreview( confirmPassphraseTextFieldState = TextFieldState(), passphraseScore = PasswordScore.Strong, ), - onEvent = {}, ) } } 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 index 219f66d6b..8853e9998 100644 --- 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 @@ -52,29 +52,25 @@ internal fun SelectDestinationContent( onEvent: (ExportWizardUiEvent) -> Unit, ) { Surface { - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - when (val destination = state.destination) { - null -> DestinationChooserCard( - onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, - ) - - else -> DestinationSelectedCard( - destination = destination, - fileName = format.previewFileName(), - onChange = { onEvent(ExportWizardUiEvent.ChooseDestination) }, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + when (val destination = state.destination) { + null -> DestinationChooserCard( + onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, + ) - BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) + else -> DestinationSelectedCard( + destination = destination, + fileName = format.previewFileName(), + onChange = { onEvent(ExportWizardUiEvent.ChooseDestination) }, + ) } - ContinueButton(onEvent = onEvent) + BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) } } } 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 index 84d718dd3..8b7c6c140 100644 --- 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 @@ -40,63 +40,59 @@ internal fun SelectScheduleContent( onEvent: (ExportWizardUiEvent) -> Unit, ) { Surface { - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), - ) { - ScheduleMode.entries.forEachIndexed { index, mode -> - val selected = state.mode == mode - SegmentedListItem( - checked = selected, - onCheckedChange = { onEvent(ExportWizardUiEvent.ScheduleModeSelected(mode)) }, - shapes = ListItemDefaults.segmentedShapes(index, ScheduleMode.entries.size), - colors = ListItemDefaults.segmentedColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), - ), - leadingContent = { - Icon( - imageVector = mode.icon, - contentDescription = null, - ) - }, - supportingContent = { - Text(text = stringResource(mode.descriptionRes)) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = stringResource(mode.titleRes)) - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + ScheduleMode.entries.forEachIndexed { index, mode -> + val selected = state.mode == mode + SegmentedListItem( + checked = selected, + onCheckedChange = { onEvent(ExportWizardUiEvent.ScheduleModeSelected(mode)) }, + shapes = ListItemDefaults.segmentedShapes(index, ScheduleMode.entries.size), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), + ), + leadingContent = { + Icon( + imageVector = mode.icon, + contentDescription = null, + ) + }, + supportingContent = { + Text(text = stringResource(mode.descriptionRes)) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(mode.titleRes)) + } - AnimatedVisibility( - visible = selected && mode == ScheduleMode.Recurring, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), + AnimatedVisibility( + visible = selected && mode == ScheduleMode.Recurring, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - 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 - ) - } + IntervalPicker( + interval = state.interval, + onEvent = onEvent, + shape = SegmentTopShape + ) + RetentionPicker( + keepCount = state.keepCount, + keepAll = state.keepAll, + onEvent = onEvent, + shape = SegmentBottomShape + ) } } } - - ContinueButton(onEvent = onEvent) } } } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt index 28b963054..6538ff501 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/WizardComponents.kt @@ -2,39 +2,17 @@ package de.davis.keygo.feature.backup.presentation.export import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button import androidx.compose.material3.Icon -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.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource 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.presentation.export.model.ExportWizardUiEvent - -@Composable -internal fun ContinueButton( - onEvent: (ExportWizardUiEvent) -> Unit, - modifier: Modifier = Modifier, -) { - Button( - onClick = { onEvent(ExportWizardUiEvent.Continue) }, - modifier = modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - Text(text = stringResource(R.string.continue_step)) - } -} @Composable internal fun IconBadge( 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 index 68a2e48a4..2a9637e09 100644 --- 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 @@ -15,6 +15,12 @@ internal data class ExportWizardUiState( val step: ExportWizardStep = ExportWizardStep.SelectFormat, ) { val steps: List = exportStepsFor(formatState.format) + + val canContinue: Boolean = when (step) { + ExportWizardStep.SelectDestination -> destinationState.destination != null + ExportWizardStep.ProvidePassphrase -> providePassphraseState.valid + else -> true + } } @Stable @@ -56,6 +62,7 @@ internal data class ProvidePassphraseState( val passphraseTextFieldState: TextFieldState, val confirmPassphraseTextFieldState: TextFieldState, val passphraseScore: PasswordScore = PasswordScore.None, + val valid: Boolean = false, ) internal enum class ExportWizardStep { From d48a2150475927687b738dd8932bf819ad05bb48 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 16 Jun 2026 18:14:13 +0200 Subject: [PATCH 08/59] feat(backup): enhance UI --- .../keygo/app/presentation/MainActivity.kt | 5 +- .../keygo/feature/backup/BackupInterval.kt | 30 +--- .../backup/domain/model/BackupInterval.kt | 13 +- .../backup/presentation/BackupGraph.kt | 7 +- .../export/ExportWizardContent.kt | 145 +++++++++++------- .../presentation/export/ExportWizardScreen.kt | 3 +- .../export/ExportWizardViewModel.kt | 7 +- .../export/ProvidePassphraseContent.kt | 2 - .../export/ReviewBackupContent.kt | 4 +- .../presentation/export/ScheduleComponents.kt | 8 +- .../export/SelectDestinationContent.kt | 1 + .../export/SelectScheduleContent.kt | 3 +- .../IconBadge.kt} | 2 +- .../export/model/BackupDestination.kt | 9 ++ .../export/model/ExportWizardStep.kt | 16 ++ .../export/model/ExportWizardUiEvent.kt | 1 + .../export/model/ExportWizardUiState.kt | 59 ------- .../export/model/ProvidePassphraseState.kt | 13 ++ .../export/model/SelectDestinationState.kt | 8 + .../export/model/SelectFormatState.kt | 9 ++ .../export/model/SelectScheduleState.kt | 13 ++ .../presentation/hub/BackupHubContent.kt | 5 +- 22 files changed, 197 insertions(+), 166 deletions(-) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/{WizardComponents.kt => component/IconBadge.kt} (94%) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupDestination.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStep.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ProvidePassphraseState.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectDestinationState.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectScheduleState.kt 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 e386ffc25..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 @@ -189,7 +189,10 @@ private fun App() { } } - backupGraph(navigateToDestination = navController::navigate) + backupGraph( + navigateToDestination = navController::navigate, + navigateUp = { navController.navigateUp() }, + ) } } } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt index 71990718c..18c9049e8 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt @@ -4,47 +4,27 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.davis.keygo.feature.backup.domain.model.BackupInterval -import de.davis.keygo.feature.backup.presentation.export.model.IntervalUnit +import de.davis.keygo.feature.backup.domain.model.IntervalUnit internal val BackupInterval.displayName @Composable - get() = when (this) { - is BackupInterval.Day -> pluralStringResource( + get() = when (unit) { + IntervalUnit.Days -> pluralStringResource( R.plurals.backup_interval_days, count = count, count ) - is BackupInterval.Week -> pluralStringResource( + IntervalUnit.Weeks -> pluralStringResource( R.plurals.backup_interval_weeks, count = count, count ) } -internal val BackupInterval.intervalCount: Int - get() = when (this) { - is BackupInterval.Day -> count - is BackupInterval.Week -> count - } - -internal val BackupInterval.intervalUnit: IntervalUnit - get() = when (this) { - is BackupInterval.Day -> IntervalUnit.Days - is BackupInterval.Week -> IntervalUnit.Weeks - } - -internal fun backupInterval(unit: IntervalUnit, count: Int): BackupInterval { - val safeCount = count.coerceAtLeast(1) - return when (unit) { - IntervalUnit.Days -> BackupInterval.Day(safeCount) - IntervalUnit.Weeks -> BackupInterval.Week(safeCount) - } -} - internal val IntervalUnit.label @Composable get() = when (this) { IntervalUnit.Days -> stringResource(R.string.interval_unit_days) IntervalUnit.Weeks -> stringResource(R.string.interval_unit_weeks) - } \ No newline at end of file + } 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 index 84290c96a..8d37a9db6 100644 --- 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 @@ -1,6 +1,11 @@ package de.davis.keygo.feature.backup.domain.model -sealed interface BackupInterval { - data class Day(val count: Int) : BackupInterval - data class Week(val count: Int) : BackupInterval -} \ No newline at end of file +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/presentation/BackupGraph.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupGraph.kt index a3a76e685..73966727e 100644 --- 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 @@ -5,7 +5,10 @@ import androidx.navigation.compose.composable import de.davis.keygo.feature.backup.presentation.export.ExportWizardScreen import de.davis.keygo.feature.backup.presentation.hub.BackupHubScreen -fun NavGraphBuilder.backupGraph(navigateToDestination: (Any) -> Unit) { +fun NavGraphBuilder.backupGraph( + navigateToDestination: (Any) -> Unit, + navigateUp: () -> Unit, +) { composable { BackupHubScreen( navigateToExport = { @@ -15,6 +18,6 @@ fun NavGraphBuilder.backupGraph(navigateToDestination: (Any) -> Unit) { } composable { - ExportWizardScreen() + ExportWizardScreen(navigateUp = navigateUp) } } \ No newline at end of file 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 index 84036001b..8f5b495b2 100644 --- 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 @@ -1,7 +1,15 @@ package de.davis.keygo.feature.backup.presentation.export +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 @@ -14,8 +22,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons @@ -35,6 +41,8 @@ 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.res.stringResource @@ -44,7 +52,6 @@ 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.feature.backup.R -import de.davis.keygo.feature.backup.domain.model.BackupInterval import de.davis.keygo.feature.backup.domain.model.FileFormat import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardStep @@ -55,19 +62,38 @@ 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 kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExportWizardContent( state: ExportWizardUiState, - onEvent: (ExportWizardUiEvent) -> Unit + onEvent: (ExportWizardUiEvent) -> Unit, + navigateUp: () -> Unit, ) { val steps = state.steps - val currentPage = steps.indexOf(state.step).coerceAtLeast(0) - val pagerState = rememberPagerState(currentPage) { steps.size } - LaunchedEffect(currentPage) { - if (pagerState.currentPage != currentPage) { - pagerState.animateScrollToPage(currentPage) + val currentStep = state.step + val currentIndex = steps.indexOf(currentStep).coerceAtLeast(0) + + val transitionState = remember { SeekableTransitionState(currentStep) } + val transition = rememberTransition(transitionState, label = "export_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) + } + onEvent(ExportWizardUiEvent.Back) + } catch (_: CancellationException) { + resetScope.launch { transitionState.animateTo(transitionState.currentState) } } } @@ -79,7 +105,10 @@ internal fun ExportWizardContent( }, navigationIcon = { IconButton( - onClick = { onEvent(ExportWizardUiEvent.Back) } + onClick = { + if (currentIndex == 0) navigateUp() + else onEvent(ExportWizardUiEvent.Back) + } ) { Icon( imageVector = Icons.AutoMirrored.Default.ArrowBack, @@ -88,20 +117,56 @@ internal fun ExportWizardContent( } }, ) + }, + bottomBar = { + Box( + modifier = Modifier + .padding(vertical = 12.dp, horizontal = 4.dp) + .imePadding(), + ) { + Button( + onClick = { onEvent(ExportWizardUiEvent.Continue) }, + enabled = state.canContinue, + modifier = Modifier.fillMaxWidth() + ) { + 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 + } + ), + ) + } + } } ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding) - .padding(start = 4.dp, end = 4.dp, bottom = 16.dp) + .padding(start = 4.dp, end = 4.dp) .imePadding(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - repeat(pagerState.pageCount) { page -> + repeat(steps.size) { page -> val color by animateColorAsState( - targetValue = if (page <= pagerState.currentPage) MaterialTheme.colorScheme.primary + targetValue = if (page <= currentIndex) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primaryContainer, label = "indicator_page=$page", ) @@ -114,15 +179,17 @@ internal fun ExportWizardContent( ) } } - HorizontalPager( - state = pagerState, + transition.AnimatedContent( modifier = Modifier.weight(1f), - userScrollEnabled = false, - ) { page -> + transitionSpec = { + fadeIn(animationSpec = tween(700)) togetherWith + fadeOut(animationSpec = tween(700)) + }, + ) { step -> Surface( modifier = Modifier.fillMaxSize(), ) { - when (steps[page]) { + when (step) { ExportWizardStep.SelectFormat -> SelectFileFormatContent(onEvent = onEvent) ExportWizardStep.Schedule -> SelectScheduleContent( state = state.scheduleState, @@ -138,7 +205,6 @@ internal fun ExportWizardContent( ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( state = state.providePassphraseState, - onEvent = onEvent ) ExportWizardStep.Review -> state.formatState.format?.let { format -> @@ -152,39 +218,6 @@ internal fun ExportWizardContent( } } } - - AnimatedVisibility(visible = state.step != ExportWizardStep.SelectFormat) { - Button( - onClick = { onEvent(ExportWizardUiEvent.Continue) }, - enabled = state.canContinue, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - 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 - } - ), - ) - } - } } } } @@ -204,10 +237,7 @@ private class ExportWizardUiStateProvider : PreviewParameterProvider Unit) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() ExportWizardContent( state = state, onEvent = viewModel::onEvent, + navigateUp = navigateUp, ) } \ No newline at end of file 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 index 4e42f54c5..a5a1898c2 100644 --- 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 @@ -4,9 +4,6 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import de.davis.keygo.feature.backup.backupInterval -import de.davis.keygo.feature.backup.intervalCount -import de.davis.keygo.feature.backup.intervalUnit 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 @@ -113,11 +110,11 @@ internal class ExportWizardViewModel : ViewModel() { } is ExportWizardUiEvent.IntervalUnitSelected -> _scheduleState.update { - it.copy(interval = backupInterval(event.unit, it.interval.intervalCount)) + it.copy(interval = it.interval.copy(unit = event.unit)) } is ExportWizardUiEvent.IntervalCountChanged -> _scheduleState.update { - it.copy(interval = backupInterval(it.interval.intervalUnit, event.count)) + it.copy(interval = it.interval.copy(count = event.count.coerceAtLeast(1))) } is ExportWizardUiEvent.KeepCountChanged -> _scheduleState.update { 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 index 2dbddaced..2817d9678 100644 --- 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 @@ -28,14 +28,12 @@ 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.presentation.export.model.ExportWizardUiEvent import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun ProvidePassphraseContent( state: ProvidePassphraseState, - onEvent: (ExportWizardUiEvent) -> Unit ) { var passphraseHidden by rememberSaveable { mutableStateOf(true) } var confirmPassphraseHidden by rememberSaveable { mutableStateOf(true) } 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 index e96a2ad8a..28de4ef51 100644 --- 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 @@ -43,7 +43,9 @@ import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.feature.backup.R import de.davis.keygo.feature.backup.domain.model.BackupInterval 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.displayName +import de.davis.keygo.feature.backup.presentation.export.component.IconBadge import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode @@ -301,7 +303,7 @@ private fun ReviewBackupContentPreview( format = format, scheduleState = SelectScheduleState( mode = ScheduleMode.Recurring, - interval = BackupInterval.Day(3), + interval = BackupInterval(count = 3, unit = IntervalUnit.Days), ), destinationState = SelectDestinationState( destination = BackupDestination( 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 index bc13667ab..ec7dc6b3a 100644 --- 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 @@ -71,11 +71,9 @@ import de.davis.keygo.core.ui.components.KeyGoSwitch import de.davis.keygo.feature.backup.R import de.davis.keygo.feature.backup.displayName import de.davis.keygo.feature.backup.domain.model.BackupInterval -import de.davis.keygo.feature.backup.intervalCount -import de.davis.keygo.feature.backup.intervalUnit +import de.davis.keygo.feature.backup.domain.model.IntervalUnit import de.davis.keygo.feature.backup.label import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent -import de.davis.keygo.feature.backup.presentation.export.model.IntervalUnit import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -88,8 +86,8 @@ internal fun IntervalPicker( shape: Shape, modifier: Modifier = Modifier, ) { - val count = interval.intervalCount - val unit = interval.intervalUnit + val count = interval.count + val unit = interval.unit ScheduleCard( title = stringResource(R.string.schedule_repeat_every_label), footerText = interval.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 index 8853e9998..52eb5ef17 100644 --- 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 @@ -38,6 +38,7 @@ 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.FileFormat +import de.davis.keygo.feature.backup.presentation.export.component.IconBadge import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode 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 index 8b7c6c140..2203c6ee1 100644 --- 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 @@ -29,6 +29,7 @@ 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.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit 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 @@ -102,7 +103,7 @@ private class SelectScheduleStateProvider : PreviewParameterProvider = + ExportWizardStep.entries.filter { step -> + step != ExportWizardStep.ProvidePassphrase || (format?.encrypted ?: 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 index f2598bc4a..7728b2573 100644 --- 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 @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup.presentation.export.model 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 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 index 2a9637e09..70e2bc583 100644 --- 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 @@ -1,10 +1,6 @@ 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.BackupInterval -import de.davis.keygo.feature.backup.domain.model.FileFormat @Stable internal data class ExportWizardUiState( @@ -23,62 +19,7 @@ internal data class ExportWizardUiState( } } -@Stable -internal data class SelectFormatState( - val format: FileFormat? = null -) - -@Stable -internal data class SelectDestinationState( - val destination: BackupDestination? = null, -) - -@Stable -internal data class BackupDestination( - val providerLabel: String, - val displayPath: String, -) - -@Stable -internal data class SelectScheduleState( - val mode: ScheduleMode = ScheduleMode.OneTime, - val interval: BackupInterval = BackupInterval.Day(1), - val keepCount: Int = 5, - val keepAll: Boolean = false, -) - internal enum class ScheduleMode { OneTime, Recurring, } - -internal enum class IntervalUnit { - Days, - Weeks, -} - -@Stable -internal data class ProvidePassphraseState( - val passphraseTextFieldState: TextFieldState, - val confirmPassphraseTextFieldState: TextFieldState, - val passphraseScore: PasswordScore = PasswordScore.None, - val valid: Boolean = false, -) - -internal enum class ExportWizardStep { - SelectFormat, - Schedule, - SelectDestination, - ProvidePassphrase, - Review, -} - -/** - * The wizard steps that apply to the chosen [format]. The passphrase step only makes sense for - * encrypted formats, so it is dropped for plaintext exports (e.g. CSV). While no format is chosen - * yet the full path is shown. - */ -internal fun exportStepsFor(format: FileFormat?): List = - ExportWizardStep.entries.filter { step -> - step != ExportWizardStep.ProvidePassphrase || (format?.encrypted ?: true) - } \ No newline at end of file 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..a467dea27 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ProvidePassphraseState.kt @@ -0,0 +1,13 @@ +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 + +@Stable +internal data class ProvidePassphraseState( + val passphraseTextFieldState: TextFieldState, + val confirmPassphraseTextFieldState: TextFieldState, + val passphraseScore: PasswordScore = PasswordScore.None, + val valid: Boolean = false, +) \ 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..15dffad11 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectDestinationState.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable + +@Stable +internal data class SelectDestinationState( + val destination: BackupDestination? = 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..460b5888e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.FileFormat + +@Stable +internal data class SelectFormatState( + val format: FileFormat? = null +) \ 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..0b70a8fab --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectScheduleState.kt @@ -0,0 +1,13 @@ +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, +) \ No newline at end of file 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 index 695d180c4..2e17a49f5 100644 --- 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 @@ -40,6 +40,7 @@ import de.davis.keygo.feature.backup.R import de.davis.keygo.feature.backup.displayName import de.davis.keygo.feature.backup.domain.model.BackupInterval 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.displayName import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiEvent import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubUiState @@ -203,13 +204,13 @@ private fun BackupHubContentPreview() { ScheduledBackup( provider = "Nextcloud", type = FileFormat.CSV, - scheduleInterval = BackupInterval.Week(1), + scheduleInterval = BackupInterval(count = 1, unit = IntervalUnit.Weeks), path = "/path/to/backup" ), ScheduledBackup( provider = "Google Drive", type = FileFormat.KDBX, - scheduleInterval = BackupInterval.Day(1), + scheduleInterval = BackupInterval(count = 1, unit = IntervalUnit.Days), path = "/path/to/drive/backup" ) ) From f87064d690505c98fcfec350d03eda76d088a2f3 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 16 Jun 2026 20:06:19 +0200 Subject: [PATCH 09/59] wip(backup): checkpoint in-progress destination UI Snapshot of the backup destination resolver + export wizard changes before layering the one-time file-picker work on top. Co-Authored-By: Claude Opus 4.8 --- .../data/BackupDestinationResolverImpl.kt | 78 +++++++++++++++++++ .../domain/BackupDestinationResolver.kt | 9 +++ .../backup/domain/model/BackupDestination.kt | 13 ++++ .../domain/model/BackupDestinationUri.kt | 4 + .../export/ExportWizardContent.kt | 1 - .../presentation/export/ExportWizardScreen.kt | 18 +++++ .../export/ExportWizardViewModel.kt | 32 +++++++- .../export/ReviewBackupContent.kt | 1 - .../export/SelectDestinationContent.kt | 26 ++++--- .../export/model/BackupDestination.kt | 9 --- .../export/model/ExportWizardEvent.kt | 6 ++ .../export/model/SelectDestinationState.kt | 1 + .../backup/src/main/res/values/strings.xml | 3 + 13 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupDestinationResolver.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestination.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestinationUri.kt delete mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupDestination.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardEvent.kt 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..0a97811ba --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt @@ -0,0 +1,78 @@ +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 + ): BackupDestination = withContext(Dispatchers.IO) { + val uri = uri.value.toUri() + + BackupDestination( + provider = uri.providerLabel(), + displayPath = uri.displayName() + ) + } + + 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) + } + + fun Uri.displayName(): String { + val docId = DocumentsContract.getTreeDocumentId(this) + + + return if (authority == EXTERNAL_STORAGE) { + val (volume, path) = docId.split(":", limit = 2) + .let { it[0] to it.getOrElse(1) { "" } } + + val root = if (volume == "primary") "Internal storage" else volume + if (path.isBlank()) root else "$root/$path" + } else { + queryDisplayName() ?: DocumentsContract.getTreeDocumentId(this) + } + } + + private fun Uri.queryDisplayName(): String? { + val documentUri = DocumentsContract.buildDocumentUriUsingTree( + this, + DocumentsContract.getTreeDocumentId(this), + ) + return context.contentResolver.query( + documentUri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } + + companion object { + private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" + } +} \ No newline at end of file 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..269c317d4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupDestinationResolver.kt @@ -0,0 +1,9 @@ +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 { + + suspend fun resolve(uri: BackupDestinationUri): BackupDestination +} \ No newline at end of file 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..6662d6b36 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupDestination.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupDestination( + val provider: Provider, + val displayPath: String, +) { + + 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/presentation/export/ExportWizardContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardContent.kt index 8f5b495b2..5965b22e9 100644 --- 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 @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.backup.R import de.davis.keygo.feature.backup.domain.model.FileFormat -import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination 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 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 index 4c0f70902..290c0dab9 100644 --- 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 @@ -1,8 +1,12 @@ 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.presentation.export.model.ExportWizardEvent import org.koin.androidx.compose.koinViewModel @Composable @@ -10,6 +14,20 @@ fun ExportWizardScreen(navigateUp: () -> Unit) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val destinationPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + viewModel.onDestinationPicked(uri) + } + + ObserveAsEvents(flow = viewModel.event) { + when (it) { + ExportWizardEvent.OpenDestinationPicker -> { + destinationPicker.launch(null) + } + } + } + ExportWizardContent( state = state, onEvent = viewModel::onEvent, 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 index a5a1898c2..90876bf61 100644 --- 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 @@ -1,9 +1,13 @@ package de.davis.keygo.feature.backup.presentation.export +import android.net.Uri import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +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 @@ -13,6 +17,7 @@ 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.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -21,13 +26,17 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn 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 : ViewModel() { +internal class ExportWizardViewModel( + private val backupDestinationResolver: BackupDestinationResolver +) : ViewModel() { private val passphraseTextFieldState = TextFieldState() private val confirmPassphraseTextFieldState = TextFieldState() @@ -74,6 +83,9 @@ internal class ExportWizardViewModel : ViewModel() { ) ) + private val _event = Channel() + val event = _event.receiveAsFlow() + @OptIn(FlowPreview::class) private fun observePassphrase() { snapshotFlow { @@ -92,8 +104,9 @@ internal class ExportWizardViewModel : ViewModel() { ExportWizardUiEvent.Continue -> nextStep() - // TODO: open the system file picker once destination selection is wired up - ExportWizardUiEvent.ChooseDestination -> Unit + ExportWizardUiEvent.ChooseDestination -> { + _event.trySend(ExportWizardEvent.OpenDestinationPicker) + } // TODO: trigger the actual export once the export use case is wired up ExportWizardUiEvent.Export -> Unit @@ -127,6 +140,19 @@ internal class ExportWizardViewModel : ViewModel() { } } + fun onDestinationPicked(uri: Uri?) { + if (uri == null) return + + viewModelScope.launch { + val destination = + backupDestinationResolver.resolve(BackupDestinationUri(uri.toString())) + + _destinationState.update { + it.copy(destination = destination) + } + } + } + private fun nextStep() = _step.update { current -> val steps = exportStepsFor(_formatState.value.format) steps[(steps.indexOf(current) + 1).coerceAtMost(steps.lastIndex)] 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 index 28de4ef51..35c49cdce 100644 --- 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 @@ -46,7 +46,6 @@ 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.displayName import de.davis.keygo.feature.backup.presentation.export.component.IconBadge -import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination 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 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 index 52eb5ef17..0ac8bd34e 100644 --- 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 @@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -37,9 +36,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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 import de.davis.keygo.feature.backup.domain.model.FileFormat import de.davis.keygo.feature.backup.presentation.export.component.IconBadge -import de.davis.keygo.feature.backup.presentation.export.model.BackupDestination 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 @@ -79,6 +78,7 @@ internal fun SelectDestinationContent( @Composable private fun DestinationChooserCard(onChoose: () -> Unit) { Card( + onClick = onChoose, modifier = Modifier .fillMaxWidth() .dashedBorder( @@ -111,12 +111,10 @@ private fun DestinationChooserCard(onChoose: () -> Unit) { color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), textAlign = TextAlign.Center, ) - FilledTonalButton( - onClick = onChoose, - modifier = Modifier.padding(top = 4.dp), - ) { - Text(text = stringResource(R.string.destination_choose_action)) - } + Text( + text = stringResource(R.string.destination_choose_action), + style = MaterialTheme.typography.labelLarge + ) } } } @@ -168,7 +166,7 @@ private fun DestinationSelectedCard( ) Column(modifier = Modifier.weight(1f)) { Text( - text = destination.providerLabel, + text = destination.provider.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -235,12 +233,20 @@ private fun FileFormat?.previewFileName(): String = when (this) { null -> "keygo-backup" } +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 + } + private class SelectDestinationStateProvider : PreviewParameterProvider { override val values = sequenceOf( SelectDestinationState(), SelectDestinationState( destination = BackupDestination( - providerLabel = "Nextcloud", + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), displayPath = "Backups / KeyGo", ), ), diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupDestination.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupDestination.kt deleted file mode 100644 index d055938ce..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupDestination.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.davis.keygo.feature.backup.presentation.export.model - -import androidx.compose.runtime.Stable - -@Stable -internal data class BackupDestination( - val providerLabel: String, - val displayPath: String, -) \ No newline at end of file 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..128ead172 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardEvent.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +internal sealed interface ExportWizardEvent { + + data object OpenDestinationPicker : ExportWizardEvent +} \ 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 index 15dffad11..072868b73 100644 --- 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 @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup.presentation.export.model import androidx.compose.runtime.Stable +import de.davis.keygo.feature.backup.domain.model.BackupDestination @Stable internal data class SelectDestinationState( diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 14f3349c9..461c0eb04 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -43,6 +43,9 @@ Provide Passphrase 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 From 14367a6285af7234de9241f1a9c9b1f85cf1879a Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 13:18:58 +0200 Subject: [PATCH 10/59] feat(backup): allow file and folder picker Co-Authored-By: Claude Opus 4.8 --- feature/backup/consumer-rules.pro | 0 .../data/BackupDestinationResolverImpl.kt | 77 ++++++++---- .../backup/domain/model/BackupDestination.kt | 1 + .../export/ExportWizardContent.kt | 3 +- .../presentation/export/ExportWizardScreen.kt | 23 ++-- .../export/ExportWizardViewModel.kt | 20 +-- .../export/ReviewBackupContent.kt | 3 +- .../export/SelectDestinationContent.kt | 32 +++-- .../presentation/export/model/BackupFile.kt | 11 ++ .../export/model/ExportWizardEvent.kt | 8 +- .../backup/src/main/res/values/strings.xml | 2 + .../data/FakeBackupDestinationResolver.kt | 20 +++ .../export/ExportWizardViewModelTest.kt | 114 ++++++++++++++++++ .../export/model/BackupFileTest.kt | 23 ++++ 14 files changed, 280 insertions(+), 57 deletions(-) create mode 100644 feature/backup/consumer-rules.pro create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt 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/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImpl.kt index 0a97811ba..808c7dbdc 100644 --- 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 @@ -13,56 +13,82 @@ import org.koin.core.annotation.Single @Single internal class BackupDestinationResolverImpl( - private val context: Context + private val context: Context, ) : BackupDestinationResolver { override suspend fun resolve( - uri: BackupDestinationUri + uri: BackupDestinationUri, ): BackupDestination = withContext(Dispatchers.IO) { - val uri = uri.value.toUri() + val parsed = uri.value.toUri() - BackupDestination( - provider = uri.providerLabel(), - displayPath = uri.displayName() - ) + if (DocumentsContract.isTreeUri(parsed)) parsed.asFolderDestination() + else parsed.asFileDestination() } + private fun Uri.asFolderDestination() = BackupDestination( + provider = providerLabel(), + displayPath = treeDisplayPath(), + fileName = null, + ) + + private fun Uri.asFileDestination() = BackupDestination( + provider = providerLabel(), + displayPath = documentDisplayPath(), + fileName = 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 info = pm.resolveContentProvider(authority, 0) + ?: return BackupDestination.Provider.ThirdParty(authority) val label = pm.getApplicationLabel(info.applicationInfo).toString() return BackupDestination.Provider.ThirdParty(label) } - fun Uri.displayName(): String { + 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 ?: "" - return if (authority == EXTERNAL_STORAGE) { - val (volume, path) = docId.split(":", limit = 2) - .let { it[0] to it.getOrElse(1) { "" } } + // 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("/") + } - val root = if (volume == "primary") "Internal storage" else volume - if (path.isBlank()) root else "$root/$path" - } else { - queryDisplayName() ?: DocumentsContract.getTreeDocumentId(this) - } + 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 Uri.queryDisplayName(): String? { - val documentUri = DocumentsContract.buildDocumentUriUsingTree( + 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), - ) - return context.contentResolver.query( - documentUri, + ).queryDisplayName() + + private fun Uri.queryDisplayName(): String? = + context.contentResolver.query( + this, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, @@ -70,9 +96,8 @@ internal class BackupDestinationResolverImpl( )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } - } companion object { private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" } -} \ No newline at end of file +} 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 index 6662d6b36..1935bab27 100644 --- 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 @@ -3,6 +3,7 @@ package de.davis.keygo.feature.backup.domain.model data class BackupDestination( val provider: Provider, val displayPath: String, + val fileName: String? = null, ) { sealed interface Provider { 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 index 5965b22e9..ea17914b4 100644 --- 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 @@ -52,6 +52,7 @@ 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.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.export.model.ExportWizardStep import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent @@ -239,7 +240,7 @@ private class ExportWizardUiStateProvider : PreviewParameterProvider Unit) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val destinationPicker = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocumentTree() + val folderPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), ) { uri -> - viewModel.onDestinationPicked(uri) + viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) + } + + // CreateDocument fixes its MIME type at construction. We use a generic binary + // type for both formats and let the suggested file name carry the extension + // (.kdbx / .csv); the user can still rename in the system dialog. + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) } ObserveAsEvents(flow = viewModel.event) { when (it) { - ExportWizardEvent.OpenDestinationPicker -> { - destinationPicker.launch(null) - } + ExportWizardEvent.PickFolder -> folderPicker.launch(null) + is ExportWizardEvent.CreateFile -> filePicker.launch(it.suggestedName) } } @@ -33,4 +42,4 @@ fun ExportWizardScreen(navigateUp: () -> Unit) { onEvent = viewModel::onEvent, navigateUp = navigateUp, ) -} \ No newline at end of file +} 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 index 90876bf61..ec0650ad8 100644 --- 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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.backup.presentation.export -import android.net.Uri import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel @@ -14,7 +13,9 @@ import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiSta import de.davis.keygo.feature.backup.presentation.export.model.ProvidePassphraseState 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.ScheduleMode import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState +import de.davis.keygo.feature.backup.presentation.export.model.backupFileName import de.davis.keygo.feature.backup.presentation.export.model.exportStepsFor import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -83,7 +84,7 @@ internal class ExportWizardViewModel( ) ) - private val _event = Channel() + private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() @OptIn(FlowPreview::class) @@ -105,7 +106,13 @@ internal class ExportWizardViewModel( ExportWizardUiEvent.Continue -> nextStep() ExportWizardUiEvent.ChooseDestination -> { - _event.trySend(ExportWizardEvent.OpenDestinationPicker) + val pickerEvent = when (_scheduleState.value.mode) { + ScheduleMode.Recurring -> ExportWizardEvent.PickFolder + ScheduleMode.OneTime -> ExportWizardEvent.CreateFile( + suggestedName = _formatState.value.format.backupFileName(), + ) + } + _event.trySend(pickerEvent) } // TODO: trigger the actual export once the export use case is wired up @@ -140,13 +147,12 @@ internal class ExportWizardViewModel( } } - fun onDestinationPicked(uri: Uri?) { + fun onDestinationPicked(uri: BackupDestinationUri?) { if (uri == null) return viewModelScope.launch { - val destination = - backupDestinationResolver.resolve(BackupDestinationUri(uri.toString())) - + val destination = backupDestinationResolver.resolve(uri) + _destinationState.update { it.copy(destination = destination) } 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 index 35c49cdce..a0dd0c4d0 100644 --- 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 @@ -41,6 +41,7 @@ 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.FileFormat import de.davis.keygo.feature.backup.domain.model.IntervalUnit @@ -306,7 +307,7 @@ private fun ReviewBackupContentPreview( ), destinationState = SelectDestinationState( destination = BackupDestination( - providerLabel = "Nextcloud", + provider = BackupDestination.Provider.ThirdParty("Nextcloud"), displayPath = "Backups / KeyGo", ), ), 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 index 0ac8bd34e..3a4170d7b 100644 --- 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 @@ -11,6 +11,8 @@ 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.material.icons.filled.Description +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -43,6 +45,7 @@ import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEve 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( @@ -58,14 +61,16 @@ internal fun SelectDestinationContent( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + val isFile = scheduleState.mode == ScheduleMode.OneTime when (val destination = state.destination) { null -> DestinationChooserCard( + isFile = isFile, onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, ) else -> DestinationSelectedCard( destination = destination, - fileName = format.previewFileName(), + fileName = destination.fileName ?: format.backupFileName(), onChange = { onEvent(ExportWizardUiEvent.ChooseDestination) }, ) } @@ -76,7 +81,7 @@ internal fun SelectDestinationContent( } @Composable -private fun DestinationChooserCard(onChoose: () -> Unit) { +private fun DestinationChooserCard(isFile: Boolean, onChoose: () -> Unit) { Card( onClick = onChoose, modifier = Modifier @@ -98,7 +103,7 @@ private fun DestinationChooserCard(onChoose: () -> Unit) { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Default.CreateNewFolder, + imageVector = if (isFile) Icons.Default.FileUpload else Icons.Default.CreateNewFolder, contentDescription = null, ) Text( @@ -106,14 +111,20 @@ private fun DestinationChooserCard(onChoose: () -> Unit) { style = MaterialTheme.typography.titleMedium, ) Text( - text = stringResource(R.string.destination_choose_subtitle), + text = stringResource( + if (isFile) R.string.destination_choose_file_subtitle + else R.string.destination_choose_subtitle, + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), textAlign = TextAlign.Center, ) Text( - text = stringResource(R.string.destination_choose_action), - style = MaterialTheme.typography.labelLarge + text = stringResource( + if (isFile) R.string.destination_choose_file_action + else R.string.destination_choose_action, + ), + style = MaterialTheme.typography.labelLarge, ) } } @@ -158,7 +169,8 @@ private fun DestinationSelectedCard( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { IconBadge( - icon = Icons.Default.Folder, + icon = if (destination.fileName != null) Icons.Default.Description + else Icons.Default.Folder, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, size = 44.dp, @@ -227,12 +239,6 @@ private fun BehaviorHint(mode: ScheduleMode, keepAll: Boolean) { } } -private fun FileFormat?.previewFileName(): String = when (this) { - FileFormat.KDBX -> "keygo-backup.kdbx" - FileFormat.CSV -> "keygo-backup.csv" - null -> "keygo-backup" -} - private val BackupDestination.Provider.label @Composable get() = when (this) { 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..88ae9bcc3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal const val BACKUP_BASE_NAME = "keygo-backup" + +internal fun FileFormat?.backupFileName(): String = when (this) { + FileFormat.KDBX -> "$BACKUP_BASE_NAME.kdbx" + FileFormat.CSV -> "$BACKUP_BASE_NAME.csv" + null -> BACKUP_BASE_NAME +} 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 index 128ead172..efc552b40 100644 --- 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 @@ -2,5 +2,9 @@ package de.davis.keygo.feature.backup.presentation.export.model internal sealed interface ExportWizardEvent { - data object OpenDestinationPicker : ExportWizardEvent -} \ No newline at end of file + /** Recurring backups: pick a folder the scheduler writes into over time. */ + data object PickFolder : ExportWizardEvent + + /** One-time backups: a "Save As" dialog seeded with [suggestedName]. */ + data class CreateFile(val suggestedName: String) : ExportWizardEvent +} diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 461c0eb04..e1188835d 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -49,6 +49,8 @@ Choose where to save Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. Choose location + Pick a file location on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. + Choose file Change Saved as diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt new file mode 100644 index 000000000..783dc9c78 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt @@ -0,0 +1,20 @@ +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 + + override suspend fun resolve(uri: BackupDestinationUri): BackupDestination { + lastUri = uri + return result + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt new file mode 100644 index 000000000..777a352c4 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt @@ -0,0 +1,114 @@ +package de.davis.keygo.feature.backup.presentation.export + +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.FileFormat +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardUiEvent +import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +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 ExportWizardViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val resolver = FakeBackupDestinationResolver() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = ExportWizardViewModel(backupDestinationResolver = resolver) + + @Test + fun `choosing a destination with the default schedule mode opens the folder picker`() = + runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(ExportWizardUiEvent.ChooseDestination) + + assertEquals(ExportWizardEvent.PickFolder, vm.event.first()) + } + + @Test + fun `choosing a destination for a recurring backup opens the folder picker`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.Recurring)) + + vm.onEvent(ExportWizardUiEvent.ChooseDestination) + + assertEquals(ExportWizardEvent.PickFolder, vm.event.first()) + } + + @Test + fun `choosing a destination for a one-time kdbx backup creates a kdbx file`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.KDBX)) + vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.OneTime)) + + vm.onEvent(ExportWizardUiEvent.ChooseDestination) + + assertEquals( + ExportWizardEvent.CreateFile(suggestedName = "keygo-backup.kdbx"), + vm.event.first(), + ) + } + + @Test + fun `choosing a destination for a one-time csv backup creates a csv file`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) + vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.OneTime)) + + vm.onEvent(ExportWizardUiEvent.ChooseDestination) + + assertEquals( + ExportWizardEvent.CreateFile(suggestedName = "keygo-backup.csv"), + vm.event.first(), + ) + } + + @Test + fun `picking a destination resolves it and stores it in state`() = + runTest(dispatcher) { + val resolved = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Download", + fileName = "keygo-backup.kdbx", + ) + resolver.result = resolved + val vm = viewModel() + + vm.onDestinationPicked(BackupDestinationUri("content://example/document/1")) + + val state = vm.state.first { it.destinationState.destination != null } + assertEquals(resolved, state.destinationState.destination) + } + + @Test + fun `picking a null destination leaves state unchanged`() = + runTest(dispatcher) { + val vm = viewModel() + + vm.onDestinationPicked(null) + + assertEquals(null, resolver.lastUri) + assertEquals(null, vm.state.first().destinationState.destination) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt new file mode 100644 index 000000000..5a3f371f3 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.backup.presentation.export.model + +import de.davis.keygo.feature.backup.domain.model.FileFormat +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackupFileTest { + + @Test + fun `kdbx format maps to a kdbx file name`() { + assertEquals("keygo-backup.kdbx", FileFormat.KDBX.backupFileName()) + } + + @Test + fun `csv format maps to a csv file name`() { + assertEquals("keygo-backup.csv", FileFormat.CSV.backupFileName()) + } + + @Test + fun `null format falls back to the base name`() { + assertEquals("keygo-backup", (null as FileFormat?).backupFileName()) + } +} From 31b1dbf8bd069fb58579ec232c32899cf8e120a8 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 14:59:49 +0200 Subject: [PATCH 11/59] feat(backup): integrate password strength estimation in export wizard --- .../export/ExportWizardViewModel.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 index ec0650ad8..baad7779a 100644 --- 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 @@ -4,6 +4,7 @@ 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.feature.backup.domain.BackupDestinationResolver import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent @@ -11,12 +12,14 @@ 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.ScheduleMode import de.davis.keygo.feature.backup.presentation.export.model.SelectScheduleState import de.davis.keygo.feature.backup.presentation.export.model.backupFileName 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 @@ -24,7 +27,9 @@ 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 @@ -36,7 +41,8 @@ import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class ExportWizardViewModel( - private val backupDestinationResolver: BackupDestinationResolver + private val backupDestinationResolver: BackupDestinationResolver, + private val passwordStrengthEstimator: PasswordStrengthEstimator, ) : ViewModel() { private val passphraseTextFieldState = TextFieldState() @@ -87,15 +93,26 @@ internal class ExportWizardViewModel( private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observePassphrase() { snapshotFlow { val passphrase = passphraseTextFieldState.text - passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text) + val valid = + passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text) + passphrase to valid } .debounce(150.milliseconds) .distinctUntilChanged() - .onEach { valid -> _providePassphraseState.update { it.copy(valid = valid) } } + .mapLatest { (pwd, valid) -> passwordStrengthEstimator(pwd.toString()) to valid } + .onEach { (score, valid) -> + _providePassphraseState.update { + it.copy( + passphraseScore = score, + valid = valid + ) + } + } + .flowOn(Dispatchers.Default) .launchIn(viewModelScope) } From 238d9f5345271d358728c8c2a39d06944a4ee974 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 20:58:49 +0200 Subject: [PATCH 12/59] refactor(backup): move ScheduleMode to standalone file Relocate the `ScheduleMode` enum from `ExportWizardUiState.kt` to its own dedicated file. This change also updates the visibility of the enum and fixes the corresponding imports across the backup presentation layer. --- .../backup/presentation/export/ExportWizardViewModel.kt | 2 ++ .../backup/presentation/export/model/ExportWizardUiState.kt | 5 ----- .../backup/presentation/export/model/ScheduleMode.kt | 6 ++++++ 3 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ScheduleMode.kt 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 index baad7779a..1ee840751 100644 --- 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 @@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator 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.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 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 index 70e2bc583..6539f19b4 100644 --- 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 @@ -18,8 +18,3 @@ internal data class ExportWizardUiState( else -> true } } - -internal enum class ScheduleMode { - OneTime, - Recurring, -} 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 From bcd99162b1748a76a3dff94d4342c6d2d6473417 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 21:37:19 +0200 Subject: [PATCH 13/59] feat(security): allow optional authentication for KeyStore keys Update `KeyId` to include a `needsAuthentication` flag and add a new `BackupPassphraseKey` constant. Modify `KeyStoreManagerImpl` to respect this flag during key creation and require an unlocked device on supported Android versions. --- .../keygo/core/security/data/KeyStoreManagerImpl.kt | 11 ++++++----- .../davis/keygo/core/security/domain/model/KeyId.kt | 8 ++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) 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..740dd136b 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,17 @@ internal class KeyStoreManagerImpl( return cipher } - private fun createKeyFor(alias: String): SecretKey { + 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/domain/model/KeyId.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt index e39388c07..1464d522a 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,12 @@ package de.davis.keygo.core.security.domain.model -data class KeyId(val id: String) { +data class KeyId( + val id: String, + val needsAuthentication: Boolean, +) { companion object { - val BiometricVaultKek = KeyId("biometric_vault_kek") + val BiometricVaultKek = KeyId("biometric_vault_kek", true) + val BackupPassphraseKey = KeyId("backup_passphrase_key", false) } } \ No newline at end of file From 87f26aa4caab56c9167fa7531d558e32186b7ad7 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 21:39:24 +0200 Subject: [PATCH 14/59] feat(backup): set up WorkManager and DataStore infrastructure --- app/build.gradle.kts | 1 + .../de/davis/keygo/app/KeyGoApplication.kt | 2 ++ feature/backup/build.gradle.kts | 4 ++++ feature/backup/src/main/AndroidManifest.xml | 17 +++++++++++++++++ .../backup/src/main/proto/backup_schedule.proto | 12 ++++++++++++ gradle/libs.versions.toml | 4 ++++ 6 files changed, 40 insertions(+) create mode 100644 feature/backup/src/main/AndroidManifest.xml create mode 100644 feature/backup/src/main/proto/backup_schedule.proto diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2907a2f87..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) 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/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index a5e3f9be0..702f9e612 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -10,6 +10,10 @@ android { 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) 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/proto/backup_schedule.proto b/feature/backup/src/main/proto/backup_schedule.proto new file mode 100644 index 000000000..323b47027 --- /dev/null +++ b/feature/backup/src/main/proto/backup_schedule.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package de.davis.keygo.feature.backup.data.local.model; +option java_multiple_files = true; + +message ProtoBackupSchedule { + string uri = 1; + string format = 2; + + bytes passphraseCipherText = 3; + bytes passphraseIV = 4; +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a9871c1c..7c0010631 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" } @@ -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" } From c48510cf0fc661fcfb5bb5858fc8adaf2cc52ed4 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 21:39:57 +0200 Subject: [PATCH 15/59] feat(backup): include destination URI in SelectDestinationState Add the BackupDestinationUri field to SelectDestinationState and update ExportWizardViewModel to populate it when a destination is resolved. --- .../feature/backup/presentation/export/ExportWizardViewModel.kt | 2 +- .../backup/presentation/export/model/SelectDestinationState.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 1ee840751..3e042a6fd 100644 --- 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 @@ -173,7 +173,7 @@ internal class ExportWizardViewModel( val destination = backupDestinationResolver.resolve(uri) _destinationState.update { - it.copy(destination = destination) + it.copy(destination = destination, uri = uri) } } } 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 index 072868b73..f7872843a 100644 --- 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 @@ -2,8 +2,10 @@ 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 From e57ea830fe1cbedd0d5d7408d0349412431c15fb Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 21:40:08 +0200 Subject: [PATCH 16/59] refactor(backup): remove Export event from ExportWizardUiEvent --- .../backup/presentation/export/model/ExportWizardUiEvent.kt | 1 - 1 file changed, 1 deletion(-) 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 index 7728b2573..6dd104694 100644 --- 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 @@ -7,7 +7,6 @@ internal sealed interface ExportWizardUiEvent { data object Back : ExportWizardUiEvent data object Continue : ExportWizardUiEvent data object ChooseDestination : ExportWizardUiEvent - data object Export : ExportWizardUiEvent data class FileFormatSelected(val format: FileFormat) : ExportWizardUiEvent data class ScheduleModeSelected(val mode: ScheduleMode) : ExportWizardUiEvent data class IntervalUnitSelected(val unit: IntervalUnit) : ExportWizardUiEvent From 5663c6c50fde47736b9bfd67768d0378d4a96ee7 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 21:52:25 +0200 Subject: [PATCH 17/59] feat(backup): implement scheduler --- feature/backup/build.gradle.kts | 1 + .../backup/data/BackupSchedulerImpl.kt | 60 ++++++++++++++++ .../backup/data/PersistableUriManagerImpl.kt | 30 ++++++++ .../data/mapper/BackupScheduleMapper.kt | 25 +++++++ .../reository/BackupScheduleRepositoryImpl.kt | 30 ++++++++ .../feature/backup/di/FeatureBackupModule.kt | 47 ++++++++++++- .../di/annotation/BackupScheduleQualifier.kt | 6 ++ .../feature/backup/domain/BackupScheduler.kt | 10 +++ .../backup/domain/PersistableUriManager.kt | 9 +++ .../backup/domain/model/BackupSchedule.kt | 9 +++ .../backup/domain/model/ExportDetails.kt | 8 +++ .../domain/model/FinishExportWizardError.kt | 7 ++ .../repository/BackupScheduleRepository.kt | 10 +++ .../usecase/FinishExportWizardUseCase.kt | 68 +++++++++++++++++++ .../presentation/export/ExportWizardScreen.kt | 1 + .../export/ExportWizardViewModel.kt | 32 +++++++-- .../export/model/ExportWizardEvent.kt | 4 +- .../feature/backup/worker/BackupWorker.kt | 20 ++++++ 18 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSchedulerImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/PersistableUriManagerImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/PersistableUriManager.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportDetails.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FinishExportWizardError.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index 702f9e612..feef87e04 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.util) implementation(projects.core.item) + implementation(projects.core.security) } 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..e1bdd45a6 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSchedulerImpl.kt @@ -0,0 +1,60 @@ +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 de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.worker.BackupWorker +import org.koin.core.annotation.Single +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration + +@Single +internal class BackupSchedulerImpl( + private val workManager: WorkManager, +) : BackupScheduler { + + private val constraints by lazy { + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build() + } + + override fun scheduleRecurringBackup(interval: BackupInterval) { + val repeat = when (interval.unit) { + IntervalUnit.Days -> interval.count.days + IntervalUnit.Weeks -> interval.count.days * 7 + }.toJavaDuration() + + val request = PeriodicWorkRequestBuilder(repeat) + .setConstraints(constraints) + .build() + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = UNIQUE_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = request + ) + } + + override fun scheduleOneTimeBackup() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + workManager.enqueue(request = request) + } + + override fun cancel() { + workManager.cancelUniqueWork(UNIQUE_WORK_NAME) + } + + companion object { + private const val UNIQUE_WORK_NAME = "backup_worker" + } +} \ No newline at end of file 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/BackupScheduleMapper.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt new file mode 100644 index 000000000..791efeaa9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt @@ -0,0 +1,25 @@ +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.ProtoBackupSchedule +import de.davis.keygo.feature.backup.data.local.model.protoBackupSchedule +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import de.davis.keygo.feature.backup.domain.model.BackupSchedule +import de.davis.keygo.feature.backup.domain.model.FileFormat + +internal fun ProtoBackupSchedule.toDomain() = BackupSchedule( + uri = BackupDestinationUri(uri), + format = FileFormat.valueOf(this@toDomain.format), + passphrase = CryptographicData( + data = passphraseCipherText.toByteArray(), + iv = passphraseIV.toByteArray() + ), +) + +internal fun BackupSchedule.toProto() = protoBackupSchedule { + uri = this@toProto.uri.value + format = this@toProto.format.name + passphraseCipherText = passphrase.data.toByteString() + passphraseIV = passphrase.iv.toByteString() +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt new file mode 100644 index 000000000..b7bc3d871 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.backup.data.reository + +import androidx.datastore.core.DataStore +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupSchedule +import de.davis.keygo.feature.backup.data.mapper.toDomain +import de.davis.keygo.feature.backup.data.mapper.toProto +import de.davis.keygo.feature.backup.di.annotation.BackupScheduleQualifier +import de.davis.keygo.feature.backup.domain.model.BackupSchedule +import de.davis.keygo.feature.backup.domain.repository.BackupScheduleRepository +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class BackupScheduleRepositoryImpl( + @param:BackupScheduleQualifier + private val dataStore: DataStore, +) : BackupScheduleRepository { + + override suspend fun getSchedule(): BackupSchedule? = + dataStore.data.map { it.toDomain() }.firstOrNull() + + override suspend fun setSchedule(schedule: BackupSchedule): Result = runCatching { + dataStore.updateData { schedule.toProto() } + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(Unit) } + ) +} \ 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 index 3cdb72b45..9179b6662 100644 --- 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 @@ -1,10 +1,55 @@ package de.davis.keygo.feature.backup.di +import android.content.Context +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import androidx.work.WorkManager +import com.google.protobuf.MessageLite +import com.google.protobuf.Parser +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupSchedule +import de.davis.keygo.feature.backup.di.annotation.BackupScheduleQualifier import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import java.io.InputStream +import java.io.OutputStream @Module @Configuration @ComponentScan("de.davis.keygo.feature.backup") -object FeatureBackupModule +object FeatureBackupModule { + + private val Context.backupScheduleDataStore by dataStore( + "backup_schedule.pb", + DefaultProtoSerializer( + defaultInstance = ProtoBackupSchedule.getDefaultInstance(), + parser = ProtoBackupSchedule.parser() + ) + ) + + @Single + @BackupScheduleQualifier + internal fun provideBackupScheduleDataStore(context: Context) = + context.backupScheduleDataStore + + @Single + internal fun provideWorkManager(context: Context): WorkManager = + WorkManager.getInstance(context) +} + +internal class DefaultProtoSerializer( + private val defaultInstance: T, + private val parser: Parser +) : Serializer { + + override val defaultValue: T + get() = defaultInstance + + override suspend fun readFrom(input: InputStream): T = + parser.parseFrom(input) + + override suspend fun writeTo(t: T, output: OutputStream) { + t.writeTo(output) + } +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt new file mode 100644 index 000000000..088e73bab --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.di.annotation + +import org.koin.core.annotation.Named + +@Named +internal annotation class BackupScheduleQualifier 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..5aa65663a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.feature.backup.domain.model.BackupInterval + +interface BackupScheduler { + + fun scheduleRecurringBackup(interval: BackupInterval) + fun scheduleOneTimeBackup() + fun cancel() +} \ 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/model/BackupSchedule.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt new file mode 100644 index 000000000..3befd982c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData + +data class BackupSchedule( + val uri: BackupDestinationUri, + val passphrase: CryptographicData, + val format: FileFormat, +) 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..1215c0423 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportDetails.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +data class ExportDetails( + val format: FileFormat, + val interval: BackupInterval?, + val passphrase: String, + val uri: BackupDestinationUri, +) 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..2935f6f3f --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/FinishExportWizardError.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.domain.model + +sealed interface FinishExportWizardError { + data object PassphraseEmpty : FinishExportWizardError + data object CryptoFailed : FinishExportWizardError + data object SchedulePersistenceFailed : FinishExportWizardError +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt new file mode 100644 index 000000000..ac0cf914e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.model.BackupSchedule + +interface BackupScheduleRepository { + + suspend fun getSchedule(): BackupSchedule? + suspend fun setSchedule(schedule: BackupSchedule): Result +} \ No newline at end of file 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..1b9a30bc9 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt @@ -0,0 +1,68 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.core.security.domain.KeyStoreManager +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.core.util.asResult +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.backup.domain.BackupScheduler +import de.davis.keygo.feature.backup.domain.PersistableUriManager +import de.davis.keygo.feature.backup.domain.model.BackupSchedule +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.BackupScheduleRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +@Single +class FinishExportWizardUseCase( + private val backupScheduler: BackupScheduler, + private val keyStoreManager: KeyStoreManager, + private val persistableUriManager: PersistableUriManager, + private val backupScheduleRepository: BackupScheduleRepository, +) { + + suspend operator fun invoke(details: ExportDetails): Result = + resultBinding { + if (details.format.encrypted && details.passphrase.isBlank()) + return Result.Failure(FinishExportWizardError.PassphraseEmpty) + + when (val interval = details.interval) { + null -> backupScheduler.scheduleOneTimeBackup() + else -> { + setupRecurringSchedule(details).bind() + persistableUriManager.takePersistableUriPermission(details.uri) + backupScheduler.scheduleRecurringBackup(interval) + } + } + + return Result.Success(Unit) + } + + private suspend fun setupRecurringSchedule(details: ExportDetails) = resultBinding { + val encrypted = withContext(Dispatchers.Default) { + val cipher = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupPassphraseKey, + cryptographicMode = CryptographicMode.Encrypt, + ) + + runCatching { + CryptographicData( + data = cipher.doFinal(details.passphrase.encodeToByteArray()), + iv = cipher.iv + ) + }.getOrNull().asResult(FinishExportWizardError.CryptoFailed).bind() + } + + backupScheduleRepository.setSchedule( + BackupSchedule( + uri = details.uri, + passphrase = encrypted, + format = details.format, + ) + ).bind { FinishExportWizardError.SchedulePersistenceFailed } + } +} 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 index 2fa0da494..70851b78c 100644 --- 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 @@ -32,6 +32,7 @@ fun ExportWizardScreen(navigateUp: () -> Unit) { ObserveAsEvents(flow = viewModel.event) { when (it) { + ExportWizardEvent.Finished -> navigateUp() ExportWizardEvent.PickFolder -> folderPicker.launch(null) is ExportWizardEvent.CreateFile -> filePicker.launch(it.suggestedName) } 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 index 3e042a6fd..3bef50c3e 100644 --- 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 @@ -5,6 +5,8 @@ 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.onFailure +import de.davis.keygo.core.util.onSuccess 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 @@ -45,6 +47,7 @@ import kotlin.time.Duration.Companion.milliseconds internal class ExportWizardViewModel( private val backupDestinationResolver: BackupDestinationResolver, private val passwordStrengthEstimator: PasswordStrengthEstimator, + private val finishExportWizard: FinishExportWizardUseCase, ) : ViewModel() { private val passphraseTextFieldState = TextFieldState() @@ -122,7 +125,12 @@ internal class ExportWizardViewModel( when (event) { ExportWizardUiEvent.Back -> previousStep() - ExportWizardUiEvent.Continue -> nextStep() + ExportWizardUiEvent.Continue -> { + when { + _step.value == ExportWizardStep.Review -> finishExport() + else -> nextStep() + } + } ExportWizardUiEvent.ChooseDestination -> { val pickerEvent = when (_scheduleState.value.mode) { @@ -134,9 +142,6 @@ internal class ExportWizardViewModel( _event.trySend(pickerEvent) } - // TODO: trigger the actual export once the export use case is wired up - ExportWizardUiEvent.Export -> Unit - is ExportWizardUiEvent.FileFormatSelected -> { _formatState.update { it.copy(format = event.format) @@ -178,6 +183,25 @@ internal class ExportWizardViewModel( } } + private fun finishExport() { + viewModelScope.launch { + finishExportWizard( + ExportDetails( + uri = _destinationState.value.uri!!, // Uri is not null, when the user reached review + format = _formatState.value.format!!, + interval = _scheduleState.value + .takeIf { it.mode == ScheduleMode.Recurring } + ?.interval, + passphrase = passphraseTextFieldState.text.toString() + ) + ).onSuccess { + _event.trySend(ExportWizardEvent.Finished) + }.onFailure { + // TODO: handle failure + } + } + } + private fun nextStep() = _step.update { current -> val steps = exportStepsFor(_formatState.value.format) steps[(steps.indexOf(current) + 1).coerceAtMost(steps.lastIndex)] 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 index efc552b40..8125f4bfe 100644 --- 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 @@ -2,9 +2,7 @@ package de.davis.keygo.feature.backup.presentation.export.model internal sealed interface ExportWizardEvent { - /** Recurring backups: pick a folder the scheduler writes into over time. */ + data object Finished : ExportWizardEvent data object PickFolder : ExportWizardEvent - - /** One-time backups: a "Save As" dialog seeded with [suggestedName]. */ data class CreateFile(val suggestedName: String) : ExportWizardEvent } 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..89a0ebcc1 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.feature.backup.worker + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import org.koin.android.annotation.KoinWorker + +@KoinWorker +internal class BackupWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + // TODO: backup + Log.d("BackupWorker", "doing work") + return Result.success() + } +} \ No newline at end of file From 8ecb7e6a56a914b5e92aa51767871c595d85de7a Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 22:33:56 +0200 Subject: [PATCH 18/59] feat(backup): restrict recurring schedule to encrypted formats Recurring backups now require an encrypted format (KDBX). Selecting a non-encrypted format coerces the schedule to one-time and disables the recurring option with an explanation; the ViewModel also guards against direct recurring selection when it is not allowed. Adds SelectScheduleState.recurringAllowed, the disabled/explanatory UI in SelectScheduleContent, the schedule_recurring_requires_encryption string, and gating tests. Also fixes the broken test baseline (missing testFixtures(core:item) dependency and passwordStrengthEstimator arg). Co-Authored-By: Claude Opus 4.8 --- feature/backup/build.gradle.kts | 2 + .../export/ExportWizardViewModel.kt | 11 +++- .../export/SelectScheduleContent.kt | 10 +++- .../export/model/SelectScheduleState.kt | 1 + .../backup/src/main/res/values/strings.xml | 1 + .../export/ExportWizardViewModelTest.kt | 50 ++++++++++++++++++- 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index a5e3f9be0..0e9b258fc 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -14,4 +14,6 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.util) implementation(projects.core.item) + + testImplementation(testFixtures(projects.core.item)) } 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 index baad7779a..51ee6f048 100644 --- 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 @@ -139,11 +139,20 @@ internal class ExportWizardViewModel( _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 { - it.copy(mode = event.mode) + if (event.mode == ScheduleMode.Recurring && !it.recurringAllowed) it + else it.copy(mode = event.mode) } is ExportWizardUiEvent.IntervalUnitSelected -> _scheduleState.update { 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 index 2203c6ee1..85d852894 100644 --- 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 @@ -28,6 +28,7 @@ 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.export.model.ExportWizardUiEvent @@ -49,8 +50,10 @@ internal fun SelectScheduleContent( ) { 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( @@ -64,7 +67,12 @@ internal fun SelectScheduleContent( ) }, supportingContent = { - Text(text = stringResource(mode.descriptionRes)) + Text( + text = stringResource( + if (recurringDisabled) R.string.schedule_recurring_requires_encryption + else mode.descriptionRes, + ), + ) }, verticalAlignment = Alignment.CenterVertically, ) { 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 index 0b70a8fab..e96c71156 100644 --- 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 @@ -10,4 +10,5 @@ internal data class SelectScheduleState( 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/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index e1188835d..faab32625 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ 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 KDBX to schedule automatic backups. Repeat every Auto-delete old backups diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt index 777a352c4..0f529cc7d 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.backup.presentation.export +import de.davis.keygo.core.item.FakePasswordStrengthEstimator 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 @@ -24,6 +25,7 @@ class ExportWizardViewModelTest { private val dispatcher = StandardTestDispatcher() private val resolver = FakeBackupDestinationResolver() + private val estimator = FakePasswordStrengthEstimator() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -31,7 +33,10 @@ class ExportWizardViewModelTest { @AfterTest fun tearDown() = Dispatchers.resetMain() - private fun viewModel() = ExportWizardViewModel(backupDestinationResolver = resolver) + private fun viewModel() = ExportWizardViewModel( + backupDestinationResolver = resolver, + passwordStrengthEstimator = estimator, + ) @Test fun `choosing a destination with the default schedule mode opens the folder picker`() = @@ -111,4 +116,47 @@ class ExportWizardViewModelTest { assertEquals(null, resolver.lastUri) assertEquals(null, vm.state.first().destinationState.destination) } + + @Test + fun `selecting a non-encrypted format forces one-time and disables recurring`() = + runTest(dispatcher) { + val vm = viewModel() + + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) + + val schedule = vm.state + .first { it.scheduleState.mode == ScheduleMode.OneTime } + .scheduleState + assertEquals(ScheduleMode.OneTime, schedule.mode) + assertEquals(false, schedule.recurringAllowed) + } + + @Test + fun `re-selecting an encrypted format re-enables recurring without overriding one-time`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) + + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.KDBX)) + + val schedule = vm.state + .first { it.scheduleState.mode == ScheduleMode.OneTime } + .scheduleState + assertEquals(true, schedule.recurringAllowed) + assertEquals(ScheduleMode.OneTime, schedule.mode) + } + + @Test + fun `selecting recurring mode is ignored when recurring is not allowed`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) + + vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.Recurring)) + + val schedule = vm.state + .first { it.scheduleState.mode == ScheduleMode.OneTime } + .scheduleState + assertEquals(ScheduleMode.OneTime, schedule.mode) + } } From 022e931305347663489ee878d32277004d13b440 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 17 Jun 2026 23:48:29 +0200 Subject: [PATCH 19/59] fix(backup): hide continue button for SelectFormat --- .../export/ExportWizardContent.kt | 58 ++--- .../export/model/ExportWizardUiState.kt | 2 + .../export/ExportWizardViewModelTest.kt | 208 ------------------ .../export/model/BackupFileTest.kt | 23 -- 4 files changed, 32 insertions(+), 259 deletions(-) delete mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt delete mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt 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 index ea17914b4..a718aaee0 100644 --- 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 @@ -119,38 +119,40 @@ internal fun ExportWizardContent( ) }, bottomBar = { - Box( - modifier = Modifier - .padding(vertical = 12.dp, horizontal = 4.dp) - .imePadding(), - ) { - Button( - onClick = { onEvent(ExportWizardUiEvent.Continue) }, - enabled = state.canContinue, - modifier = Modifier.fillMaxWidth() + AnimatedVisibility(visible = state.showsContinueButton) { + Box( + modifier = Modifier + .padding(vertical = 12.dp, horizontal = 4.dp) + .imePadding(), ) { - val recurring = state.scheduleState.mode == ScheduleMode.Recurring - val isReview = state.step == ExportWizardStep.Review - AnimatedVisibility( - visible = isReview + Button( + onClick = { onEvent(ExportWizardUiEvent.Continue) }, + enabled = state.canContinue, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = if (recurring) Icons.Default.Schedule else Icons.Default.Backup, - contentDescription = null, - modifier = Modifier - .padding(end = ButtonDefaults.IconSpacing) - .size(ButtonDefaults.IconSize), + 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 + } + ), ) } - Text( - text = stringResource( - when { - isReview && recurring -> R.string.schedule_backup - isReview -> R.string.create_backup - else -> R.string.continue_step - } - ), - ) } } } 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 index 6539f19b4..5a74706b2 100644 --- 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 @@ -17,4 +17,6 @@ internal data class ExportWizardUiState( ExportWizardStep.ProvidePassphrase -> providePassphraseState.valid else -> true } + + val showsContinueButton: Boolean = step != ExportWizardStep.SelectFormat } diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt deleted file mode 100644 index 60f2a3f86..000000000 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/ExportWizardViewModelTest.kt +++ /dev/null @@ -1,208 +0,0 @@ -package de.davis.keygo.feature.backup.presentation.export - -import de.davis.keygo.core.item.FakePasswordStrengthEstimator -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 de.davis.keygo.core.util.Result -import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver -import de.davis.keygo.feature.backup.domain.BackupScheduler -import de.davis.keygo.feature.backup.domain.PersistableUriManager -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.BackupSchedule -import de.davis.keygo.feature.backup.domain.model.FileFormat -import de.davis.keygo.feature.backup.domain.repository.BackupScheduleRepository -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.ExportWizardUiEvent -import de.davis.keygo.feature.backup.presentation.export.model.ScheduleMode -import javax.crypto.Cipher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.StandardTestDispatcher -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 ExportWizardViewModelTest { - - private val dispatcher = StandardTestDispatcher() - private val resolver = FakeBackupDestinationResolver() - private val estimator = FakePasswordStrengthEstimator() - private val finishExportWizard = FinishExportWizardUseCase( - backupScheduler = FakeBackupScheduler(), - keyStoreManager = FakeKeyStoreManager(), - persistableUriManager = FakePersistableUriManager(), - backupScheduleRepository = FakeBackupScheduleRepository(), - ) - - @BeforeTest - fun setUp() = Dispatchers.setMain(dispatcher) - - @AfterTest - fun tearDown() = Dispatchers.resetMain() - - private fun viewModel() = ExportWizardViewModel( - backupDestinationResolver = resolver, - passwordStrengthEstimator = estimator, - finishExportWizard = finishExportWizard, - ) - - @Test - fun `choosing a destination with the default schedule mode opens the folder picker`() = - runTest(dispatcher) { - val vm = viewModel() - - vm.onEvent(ExportWizardUiEvent.ChooseDestination) - - assertEquals(ExportWizardEvent.PickFolder, vm.event.first()) - } - - @Test - fun `choosing a destination for a recurring backup opens the folder picker`() = - runTest(dispatcher) { - val vm = viewModel() - vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.Recurring)) - - vm.onEvent(ExportWizardUiEvent.ChooseDestination) - - assertEquals(ExportWizardEvent.PickFolder, vm.event.first()) - } - - @Test - fun `choosing a destination for a one-time kdbx backup creates a kdbx file`() = - runTest(dispatcher) { - val vm = viewModel() - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.KDBX)) - vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.OneTime)) - - vm.onEvent(ExportWizardUiEvent.ChooseDestination) - - assertEquals( - ExportWizardEvent.CreateFile(suggestedName = "keygo-backup.kdbx"), - vm.event.first(), - ) - } - - @Test - fun `choosing a destination for a one-time csv backup creates a csv file`() = - runTest(dispatcher) { - val vm = viewModel() - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) - vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.OneTime)) - - vm.onEvent(ExportWizardUiEvent.ChooseDestination) - - assertEquals( - ExportWizardEvent.CreateFile(suggestedName = "keygo-backup.csv"), - vm.event.first(), - ) - } - - @Test - fun `picking a destination resolves it and stores it in state`() = - runTest(dispatcher) { - val resolved = BackupDestination( - provider = BackupDestination.Provider.OnDevice, - displayPath = "Internal storage/Download", - fileName = "keygo-backup.kdbx", - ) - resolver.result = resolved - val vm = viewModel() - - vm.onDestinationPicked(BackupDestinationUri("content://example/document/1")) - - val state = vm.state.first { it.destinationState.destination != null } - assertEquals(resolved, state.destinationState.destination) - } - - @Test - fun `picking a null destination leaves state unchanged`() = - runTest(dispatcher) { - val vm = viewModel() - - vm.onDestinationPicked(null) - - assertEquals(null, resolver.lastUri) - assertEquals(null, vm.state.first().destinationState.destination) - } - - @Test - fun `selecting a non-encrypted format forces one-time and disables recurring`() = - runTest(dispatcher) { - val vm = viewModel() - - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) - - val schedule = vm.state - .first { it.scheduleState.mode == ScheduleMode.OneTime } - .scheduleState - assertEquals(ScheduleMode.OneTime, schedule.mode) - assertEquals(false, schedule.recurringAllowed) - } - - @Test - fun `re-selecting an encrypted format re-enables recurring without overriding one-time`() = - runTest(dispatcher) { - val vm = viewModel() - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) - - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.KDBX)) - - val schedule = vm.state - .first { it.scheduleState.mode == ScheduleMode.OneTime } - .scheduleState - assertEquals(true, schedule.recurringAllowed) - assertEquals(ScheduleMode.OneTime, schedule.mode) - } - - @Test - fun `selecting recurring mode is ignored when recurring is not allowed`() = - runTest(dispatcher) { - val vm = viewModel() - vm.onEvent(ExportWizardUiEvent.FileFormatSelected(FileFormat.CSV)) - - vm.onEvent(ExportWizardUiEvent.ScheduleModeSelected(ScheduleMode.Recurring)) - - val schedule = vm.state - .first { it.scheduleState.mode == ScheduleMode.OneTime } - .scheduleState - assertEquals(ScheduleMode.OneTime, schedule.mode) - } -} - -// Placeholder fakes: these wizard tests never reach the finish step, so the use -// case is constructed but never invoked. Replace with shared testFixtures fakes -// once the worker layer provides them. -private class FakeBackupScheduler : BackupScheduler { - override fun scheduleRecurringBackup(interval: BackupInterval) = Unit - override fun scheduleOneTimeBackup() = Unit - override fun cancel() = Unit -} - -private class FakePersistableUriManager : PersistableUriManager { - override fun takePersistableUriPermission(uri: BackupDestinationUri) = Unit - override fun releasePersistableUriPermission(uri: BackupDestinationUri) = Unit -} - -private class FakeBackupScheduleRepository : BackupScheduleRepository { - override suspend fun getSchedule(): BackupSchedule? = null - override suspend fun setSchedule(schedule: BackupSchedule): Result = - Result.Success(Unit) -} - -private class FakeKeyStoreManager : KeyStoreManager { - override fun getOrCreateCipherFor( - keyId: KeyId, - cryptographicMode: CryptographicMode, - iv: ByteArray?, - ): Cipher = throw UnsupportedOperationException("not used in these tests") -} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt deleted file mode 100644 index 5a3f371f3..000000000 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFileTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package de.davis.keygo.feature.backup.presentation.export.model - -import de.davis.keygo.feature.backup.domain.model.FileFormat -import kotlin.test.Test -import kotlin.test.assertEquals - -class BackupFileTest { - - @Test - fun `kdbx format maps to a kdbx file name`() { - assertEquals("keygo-backup.kdbx", FileFormat.KDBX.backupFileName()) - } - - @Test - fun `csv format maps to a csv file name`() { - assertEquals("keygo-backup.csv", FileFormat.CSV.backupFileName()) - } - - @Test - fun `null format falls back to the base name`() { - assertEquals("keygo-backup", (null as FileFormat?).backupFileName()) - } -} From 1206c42480d8949a38ebe8573ea2697d1f1d93e1 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 18 Jun 2026 16:34:30 +0200 Subject: [PATCH 20/59] feat(backup): store backup jobs for one-time backupss --- .../backup/data/BackupSchedulerImpl.kt | 43 ++++++++++----- ...upScheduleMapper.kt => BackupJobMapper.kt} | 26 +++++---- .../data/reository/BackupJobRepositoryImpl.kt | 33 ++++++++++++ .../reository/BackupScheduleRepositoryImpl.kt | 30 ----------- .../feature/backup/di/FeatureBackupModule.kt | 18 +++---- ...uleQualifier.kt => BackupJobsQualifier.kt} | 2 +- .../feature/backup/domain/BackupScheduler.kt | 10 +++- .../model/{BackupSchedule.kt => BackupJob.kt} | 4 +- .../domain/repository/BackupJobRepository.kt | 10 ++++ .../repository/BackupScheduleRepository.kt | 10 ---- .../usecase/FinishExportWizardUseCase.kt | 54 ++++++++++--------- .../feature/backup/worker/BackupWorker.kt | 23 ++++++-- .../backup/src/main/proto/backup_jobs.proto | 18 +++++++ .../src/main/proto/backup_schedule.proto | 12 ----- 14 files changed, 174 insertions(+), 119 deletions(-) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/{BackupScheduleMapper.kt => BackupJobMapper.kt} (54%) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt delete mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/{BackupScheduleQualifier.kt => BackupJobsQualifier.kt} (66%) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/{BackupSchedule.kt => BackupJob.kt} (74%) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt delete mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt create mode 100644 feature/backup/src/main/proto/backup_jobs.proto delete mode 100644 feature/backup/src/main/proto/backup_schedule.proto 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 index e1bdd45a6..4b04eaa1a 100644 --- 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 @@ -5,9 +5,13 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +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.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 org.koin.core.annotation.Single import kotlin.time.Duration.Companion.days @@ -16,6 +20,7 @@ import kotlin.time.toJavaDuration @Single internal class BackupSchedulerImpl( private val workManager: WorkManager, + private val backupJobRepository: BackupJobRepository, ) : BackupScheduler { private val constraints by lazy { @@ -25,7 +30,10 @@ internal class BackupSchedulerImpl( .build() } - override fun scheduleRecurringBackup(interval: BackupInterval) { + 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 @@ -33,28 +41,37 @@ internal class BackupSchedulerImpl( val request = PeriodicWorkRequestBuilder(repeat) .setConstraints(constraints) + .addTag(BackupWorker.TAG) + .addTag(BackupWorker.TAG_RECURRING) .build() - workManager.enqueueUniquePeriodicWork( - uniqueWorkName = UNIQUE_WORK_NAME, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = request - ) + // 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, + ) + } } - override fun scheduleOneTimeBackup() { + override suspend fun scheduleOneTimeBackup(job: BackupJob): Result { val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) + .addTag(BackupWorker.TAG) + .addTag(BackupWorker.TAG_ONE_TIME) .build() - workManager.enqueue(request = request) + // Persist before enqueue so the worker can never start ahead of its own record. + return backupJobRepository.putJob(request.id.toString(), job).onSuccess { + workManager.enqueue(request) + } } override fun cancel() { - workManager.cancelUniqueWork(UNIQUE_WORK_NAME) - } - - companion object { - private const val UNIQUE_WORK_NAME = "backup_worker" + workManager.cancelUniqueWork(BackupWorker.UNIQUE_WORK_NAME) } } \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt similarity index 54% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt index 791efeaa9..c25e430f6 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupScheduleMapper.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt @@ -2,24 +2,28 @@ 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.ProtoBackupSchedule -import de.davis.keygo.feature.backup.data.local.model.protoBackupSchedule +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJob +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.BackupSchedule +import de.davis.keygo.feature.backup.domain.model.BackupJob import de.davis.keygo.feature.backup.domain.model.FileFormat -internal fun ProtoBackupSchedule.toDomain() = BackupSchedule( +internal fun ProtoBackupJob.toDomain() = BackupJob( uri = BackupDestinationUri(uri), format = FileFormat.valueOf(this@toDomain.format), - passphrase = CryptographicData( - data = passphraseCipherText.toByteArray(), - iv = passphraseIV.toByteArray() - ), + passphrase = if (hasPassphraseCt() && hasPassphraseIv()) + CryptographicData( + data = passphraseCt.toByteArray(), + iv = passphraseIv.toByteArray() + ) + else null ) -internal fun BackupSchedule.toProto() = protoBackupSchedule { +internal fun BackupJob.toProto() = protoBackupJob { uri = this@toProto.uri.value format = this@toProto.format.name - passphraseCipherText = passphrase.data.toByteString() - passphraseIV = passphrase.iv.toByteString() + passphrase?.let { + passphraseCt = it.data.toByteString() + passphraseIv = it.iv.toByteString() + } } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt new file mode 100644 index 000000000..3c77ea860 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.backup.data.reository + +import androidx.datastore.core.DataStore +import de.davis.keygo.core.util.Result +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.di.annotation.BackupJobsQualifier +import de.davis.keygo.feature.backup.domain.model.BackupJob +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository +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: String): BackupJob? = + dataStore.data.map { it.jobsMap[workId]?.toDomain() }.firstOrNull() + + override suspend fun putJob(workId: String, job: BackupJob): Result = runCatching { + dataStore.updateData { current -> + current.copy { jobs[workId] = job.toProto() } + } + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(Unit) } + ) +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt deleted file mode 100644 index b7bc3d871..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupScheduleRepositoryImpl.kt +++ /dev/null @@ -1,30 +0,0 @@ -package de.davis.keygo.feature.backup.data.reository - -import androidx.datastore.core.DataStore -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.backup.data.local.model.ProtoBackupSchedule -import de.davis.keygo.feature.backup.data.mapper.toDomain -import de.davis.keygo.feature.backup.data.mapper.toProto -import de.davis.keygo.feature.backup.di.annotation.BackupScheduleQualifier -import de.davis.keygo.feature.backup.domain.model.BackupSchedule -import de.davis.keygo.feature.backup.domain.repository.BackupScheduleRepository -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import org.koin.core.annotation.Single - -@Single -internal class BackupScheduleRepositoryImpl( - @param:BackupScheduleQualifier - private val dataStore: DataStore, -) : BackupScheduleRepository { - - override suspend fun getSchedule(): BackupSchedule? = - dataStore.data.map { it.toDomain() }.firstOrNull() - - override suspend fun setSchedule(schedule: BackupSchedule): Result = runCatching { - dataStore.updateData { schedule.toProto() } - }.fold( - onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Failure(Unit) } - ) -} \ 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 index 9179b6662..3e8aee42e 100644 --- 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 @@ -6,8 +6,8 @@ import androidx.datastore.dataStore import androidx.work.WorkManager import com.google.protobuf.MessageLite import com.google.protobuf.Parser -import de.davis.keygo.feature.backup.data.local.model.ProtoBackupSchedule -import de.davis.keygo.feature.backup.di.annotation.BackupScheduleQualifier +import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJobs +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 @@ -20,18 +20,18 @@ import java.io.OutputStream @ComponentScan("de.davis.keygo.feature.backup") object FeatureBackupModule { - private val Context.backupScheduleDataStore by dataStore( - "backup_schedule.pb", + private val Context.backupJobsDataStore by dataStore( + "backup_jobs.pb", DefaultProtoSerializer( - defaultInstance = ProtoBackupSchedule.getDefaultInstance(), - parser = ProtoBackupSchedule.parser() + defaultInstance = ProtoBackupJobs.getDefaultInstance(), + parser = ProtoBackupJobs.parser() ) ) @Single - @BackupScheduleQualifier - internal fun provideBackupScheduleDataStore(context: Context) = - context.backupScheduleDataStore + @BackupJobsQualifier + internal fun provideBackupJobsDataStore(context: Context) = + context.backupJobsDataStore @Single internal fun provideWorkManager(context: Context): WorkManager = diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt similarity index 66% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt index 088e73bab..3c1879822 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupScheduleQualifier.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupJobsQualifier.kt @@ -3,4 +3,4 @@ package de.davis.keygo.feature.backup.di.annotation import org.koin.core.annotation.Named @Named -internal annotation class BackupScheduleQualifier +internal annotation class BackupJobsQualifier 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 index 5aa65663a..e8fa6e22e 100644 --- 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 @@ -1,10 +1,16 @@ package de.davis.keygo.feature.backup.domain +import de.davis.keygo.core.util.Result import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.BackupJob interface BackupScheduler { - fun scheduleRecurringBackup(interval: BackupInterval) - fun scheduleOneTimeBackup() + suspend fun scheduleRecurringBackup( + job: BackupJob, + interval: BackupInterval + ): Result + + suspend fun scheduleOneTimeBackup(job: BackupJob): Result fun cancel() } \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt similarity index 74% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt index 3befd982c..70562f556 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupSchedule.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt @@ -2,8 +2,8 @@ package de.davis.keygo.feature.backup.domain.model import de.davis.keygo.core.security.domain.crypto.model.CryptographicData -data class BackupSchedule( +data class BackupJob( val uri: BackupDestinationUri, - val passphrase: CryptographicData, + val passphrase: CryptographicData?, val format: FileFormat, ) 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..bec887335 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.model.BackupJob + +interface BackupJobRepository { + + suspend fun getJob(workId: String): BackupJob? + suspend fun putJob(workId: String, job: BackupJob): Result +} \ No newline at end of file diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt deleted file mode 100644 index ac0cf914e..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupScheduleRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.feature.backup.domain.repository - -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.backup.domain.model.BackupSchedule - -interface BackupScheduleRepository { - - suspend fun getSchedule(): BackupSchedule? - suspend fun setSchedule(schedule: BackupSchedule): Result -} \ No newline at end of file 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 index 1b9a30bc9..43219d00f 100644 --- 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 @@ -9,10 +9,9 @@ import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.backup.domain.BackupScheduler import de.davis.keygo.feature.backup.domain.PersistableUriManager -import de.davis.keygo.feature.backup.domain.model.BackupSchedule +import de.davis.keygo.feature.backup.domain.model.BackupJob 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.BackupScheduleRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.core.annotation.Single @@ -22,7 +21,6 @@ class FinishExportWizardUseCase( private val backupScheduler: BackupScheduler, private val keyStoreManager: KeyStoreManager, private val persistableUriManager: PersistableUriManager, - private val backupScheduleRepository: BackupScheduleRepository, ) { suspend operator fun invoke(details: ExportDetails): Result = @@ -30,39 +28,43 @@ class FinishExportWizardUseCase( if (details.format.encrypted && details.passphrase.isBlank()) return Result.Failure(FinishExportWizardError.PassphraseEmpty) + val passphrase = if (details.format.encrypted) + wrapPassphrase(details.passphrase).bind() + else null + + val job = BackupJob( + uri = details.uri, + passphrase = passphrase, + format = details.format, + ) + when (val interval = details.interval) { - null -> backupScheduler.scheduleOneTimeBackup() + null -> backupScheduler.scheduleOneTimeBackup(job) + .bind { FinishExportWizardError.SchedulePersistenceFailed } + else -> { - setupRecurringSchedule(details).bind() persistableUriManager.takePersistableUriPermission(details.uri) - backupScheduler.scheduleRecurringBackup(interval) + backupScheduler.scheduleRecurringBackup(job, interval) + .bind { FinishExportWizardError.SchedulePersistenceFailed } } } return Result.Success(Unit) } - private suspend fun setupRecurringSchedule(details: ExportDetails) = resultBinding { - val encrypted = withContext(Dispatchers.Default) { - val cipher = keyStoreManager.getOrCreateCipherFor( - keyId = KeyId.BackupPassphraseKey, - cryptographicMode = CryptographicMode.Encrypt, - ) - - runCatching { - CryptographicData( - data = cipher.doFinal(details.passphrase.encodeToByteArray()), - iv = cipher.iv - ) - }.getOrNull().asResult(FinishExportWizardError.CryptoFailed).bind() - } + private suspend fun wrapPassphrase( + passphrase: String, + ): Result = withContext(Dispatchers.Default) { + val cipher = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupPassphraseKey, + cryptographicMode = CryptographicMode.Encrypt, + ) - backupScheduleRepository.setSchedule( - BackupSchedule( - uri = details.uri, - passphrase = encrypted, - format = details.format, + runCatching { + CryptographicData( + data = cipher.doFinal(passphrase.encodeToByteArray()), + iv = cipher.iv, ) - ).bind { FinishExportWizardError.SchedulePersistenceFailed } + }.getOrNull().asResult(FinishExportWizardError.CryptoFailed) } } 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 index 89a0ebcc1..573867a95 100644 --- 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 @@ -4,17 +4,34 @@ import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import de.davis.keygo.feature.backup.domain.repository.BackupJobRepository import org.koin.android.annotation.KoinWorker @KoinWorker internal class BackupWorker( appContext: Context, - params: WorkerParameters + params: WorkerParameters, + private val backupJobRepository: BackupJobRepository, ) : CoroutineWorker(appContext, params) { + private val isRecurring = TAG_RECURRING in tags + override suspend fun doWork(): Result { - // TODO: backup - Log.d("BackupWorker", "doing work") + // Recurring is a singleton stored under a stable key; one-time records are keyed by workId. + val workId = if (isRecurring) RECURRING_WORK_ID else id.toString() + val job = backupJobRepository.getJob(workId) ?: return Result.failure() + + // TODO: perform the actual export using job.uri / job.format / job.passphrase + Log.d("BackupWorker", "doing work: $job") return Result.success() } + + companion object { + const val UNIQUE_WORK_NAME = "backup_worker" + const val RECURRING_WORK_ID = "recurring_backup" + + const val TAG = "backup" + const val TAG_RECURRING = "backup_recurring" + const val TAG_ONE_TIME = "backup_one_time" + } } \ No newline at end of file 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..90f7fa750 --- /dev/null +++ b/feature/backup/src/main/proto/backup_jobs.proto @@ -0,0 +1,18 @@ +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; +} + +message ProtoBackupJobs { + map jobs = 1; +} diff --git a/feature/backup/src/main/proto/backup_schedule.proto b/feature/backup/src/main/proto/backup_schedule.proto deleted file mode 100644 index 323b47027..000000000 --- a/feature/backup/src/main/proto/backup_schedule.proto +++ /dev/null @@ -1,12 +0,0 @@ -syntax = "proto3"; - -package de.davis.keygo.feature.backup.data.local.model; -option java_multiple_files = true; - -message ProtoBackupSchedule { - string uri = 1; - string format = 2; - - bytes passphraseCipherText = 3; - bytes passphraseIV = 4; -} From d3916149f7914e4fc02e2c15ec456802ec822b20 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 18 Jun 2026 19:02:37 +0200 Subject: [PATCH 21/59] feat(backup): surface export wizard failures via snackbar --- .../domain/model/FinishExportWizardError.kt | 1 + .../usecase/FinishExportWizardUseCase.kt | 6 ++++- .../export/ExportWizardViewModel.kt | 22 +++++++++++++++++-- .../backup/src/main/res/values/strings.xml | 7 +++++- 4 files changed, 32 insertions(+), 4 deletions(-) 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 index 2935f6f3f..e9845d619 100644 --- 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 @@ -4,4 +4,5 @@ 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/usecase/FinishExportWizardUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt index 43219d00f..d8578c5a3 100644 --- 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 @@ -43,7 +43,11 @@ class FinishExportWizardUseCase( .bind { FinishExportWizardError.SchedulePersistenceFailed } else -> { - persistableUriManager.takePersistableUriPermission(details.uri) + runCatching { persistableUriManager.takePersistableUriPermission(details.uri) } + .getOrNull() + .asResult(FinishExportWizardError.DestinationPermissionDenied) + .bind() + backupScheduler.scheduleRecurringBackup(job, interval) .bind { FinishExportWizardError.SchedulePersistenceFailed } } 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 index 047f539b8..2248ac0c6 100644 --- 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 @@ -5,9 +5,14 @@ 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.FinishExportWizardError 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.usecase.FinishExportWizardUseCase @@ -48,6 +53,7 @@ internal class ExportWizardViewModel( private val backupDestinationResolver: BackupDestinationResolver, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val finishExportWizard: FinishExportWizardUseCase, + private val snackbarManager: SnackbarManager, ) : ViewModel() { private val passphraseTextFieldState = TextFieldState() @@ -205,8 +211,20 @@ internal class ExportWizardViewModel( ) ).onSuccess { _event.trySend(ExportWizardEvent.Finished) - }.onFailure { - // TODO: handle failure + }.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)), + ) } } } diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index faab32625..803ff7a1b 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -114,4 +114,9 @@ Create Backup Enable - \ No newline at end of file + + 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 + From 796ebc49a3937a93b37317402e58991fb2e43251 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 18 Jun 2026 20:06:10 +0200 Subject: [PATCH 22/59] refactor(backup): ExportDetails construction --- .../export/ExportWizardViewModel.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) 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 index 2248ac0c6..0854a4fef 100644 --- 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 @@ -12,9 +12,9 @@ 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.FinishExportWizardError 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.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 @@ -199,17 +199,10 @@ internal class ExportWizardViewModel( } private fun finishExport() { + val details = currentExportDetails() ?: return + viewModelScope.launch { - finishExportWizard( - ExportDetails( - uri = _destinationState.value.uri!!, // Uri is not null, when the user reached review - format = _formatState.value.format!!, - interval = _scheduleState.value - .takeIf { it.mode == ScheduleMode.Recurring } - ?.interval, - passphrase = passphraseTextFieldState.text.toString() - ) - ).onSuccess { + finishExportWizard(details).onSuccess { _event.trySend(ExportWizardEvent.Finished) }.onFailure { error -> if (error == FinishExportWizardError.PassphraseEmpty) @@ -229,6 +222,20 @@ internal class ExportWizardViewModel( } } + private fun currentExportDetails(): ExportDetails? { + val format = _formatState.value.format ?: return null + val uri = _destinationState.value.uri ?: return null + + return ExportDetails( + uri = uri, + format = format, + interval = _scheduleState.value + .takeIf { it.mode == ScheduleMode.Recurring } + ?.interval, + passphrase = passphraseTextFieldState.text.toString(), + ) + } + private fun nextStep() = _step.update { current -> val steps = exportStepsFor(_formatState.value.format) steps[(steps.indexOf(current) + 1).coerceAtMost(steps.lastIndex)] From 48d1fde540a2150607ef931bbc36a19e7feed40d Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 24 Jun 2026 13:15:30 +0200 Subject: [PATCH 23/59] refactor(backup): replace kdbx with json --- .../feature/backup/domain/model/FileFormat.kt | 10 +++---- .../feature/backup/presentation/FileFormat.kt | 2 +- .../contract/CreateDynamicDocument.kt | 29 +++++++++++++++++++ .../export/ExportWizardContent.kt | 2 +- .../presentation/export/ExportWizardScreen.kt | 10 ++----- .../export/ExportWizardViewModel.kt | 9 ++++-- .../export/SelectDestinationContent.kt | 2 +- .../export/SelectFileFormatContent.kt | 2 +- .../presentation/export/model/BackupFile.kt | 2 +- .../export/model/ExportWizardEvent.kt | 2 +- .../presentation/hub/BackupHubContent.kt | 2 +- .../backup/src/main/res/values/strings.xml | 6 ++-- 12 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt 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 index 4139d64e9..ba807ddb9 100644 --- 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 @@ -1,12 +1,12 @@ package de.davis.keygo.feature.backup.domain.model -enum class FileFormat { - KDBX, - CSV; +enum class FileFormat(val mimeType: String) { + JSON("application/json"), + CSV("text/csv"); val recommented: Boolean - get() = this == KDBX + get() = this == JSON val encrypted: Boolean - get() = this == KDBX + get() = this == JSON } \ No newline at end of file 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 index c4c5715a4..b0daae3d6 100644 --- 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 @@ -18,5 +18,5 @@ internal val FileFormat.displayName internal val FileFormat.icon get() = when (this) { FileFormat.CSV -> Icons.AutoMirrored.Default.List - FileFormat.KDBX -> Icons.Default.Lock + FileFormat.JSON -> Icons.Default.Lock } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt new file mode 100644 index 000000000..80e8c5108 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.feature.backup.presentation.contract + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.CallSuper +import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent + +internal class CreateDynamicDocument : + ActivityResultContract() { + + @CallSuper + override fun createIntent(context: Context, input: ExportWizardEvent.CreateFile): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT) + .setType(input.mimeType) + .putExtra(Intent.EXTRA_TITLE, input.suggestedName) + } + + override fun getSynchronousResult( + context: Context, + input: ExportWizardEvent.CreateFile, + ): SynchronousResult? = null + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return intent.takeIf { resultCode == Activity.RESULT_OK }?.data + } +} \ No newline at end of file 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 index a718aaee0..df0875835 100644 --- 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 @@ -238,7 +238,7 @@ private class ExportWizardUiStateProvider : PreviewParameterProvider Unit) { viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) } - // CreateDocument fixes its MIME type at construction. We use a generic binary - // type for both formats and let the suggested file name carry the extension - // (.kdbx / .csv); the user can still rename in the system dialog. - val filePicker = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/octet-stream"), - ) { uri -> + val filePicker = rememberLauncherForActivityResult(CreateDynamicDocument()) { uri -> viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) } @@ -34,7 +30,7 @@ fun ExportWizardScreen(navigateUp: () -> Unit) { when (it) { ExportWizardEvent.Finished -> navigateUp() ExportWizardEvent.PickFolder -> folderPicker.launch(null) - is ExportWizardEvent.CreateFile -> filePicker.launch(it.suggestedName) + is ExportWizardEvent.CreateFile -> filePicker.launch(it) } } 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 index 0854a4fef..1179ea46c 100644 --- 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 @@ -141,9 +141,12 @@ internal class ExportWizardViewModel( ExportWizardUiEvent.ChooseDestination -> { val pickerEvent = when (_scheduleState.value.mode) { ScheduleMode.Recurring -> ExportWizardEvent.PickFolder - ScheduleMode.OneTime -> ExportWizardEvent.CreateFile( - suggestedName = _formatState.value.format.backupFileName(), - ) + ScheduleMode.OneTime -> _formatState.value.format?.let { format -> + ExportWizardEvent.CreateFile( + suggestedName = format.backupFileName(), + mimeType = format.mimeType + ) + } ?: return } _event.trySend(pickerEvent) } 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 index 3a4170d7b..72a96cb3b 100644 --- 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 @@ -269,7 +269,7 @@ private fun SelectDestinationContentPreview( SelectDestinationContent( state = state, scheduleState = SelectScheduleState(mode = ScheduleMode.Recurring), - format = FileFormat.KDBX, + 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 index 12958a5d2..e1377b46a 100644 --- 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 @@ -69,6 +69,6 @@ internal fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { private val FileFormat.description @Composable get() = when (this) { - FileFormat.KDBX -> stringResource(R.string.export_description_kdbx) + 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/model/BackupFile.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/BackupFile.kt index 88ae9bcc3..a1b1b8112 100644 --- 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 @@ -5,7 +5,7 @@ import de.davis.keygo.feature.backup.domain.model.FileFormat internal const val BACKUP_BASE_NAME = "keygo-backup" internal fun FileFormat?.backupFileName(): String = when (this) { - FileFormat.KDBX -> "$BACKUP_BASE_NAME.kdbx" + FileFormat.JSON -> "$BACKUP_BASE_NAME.json" FileFormat.CSV -> "$BACKUP_BASE_NAME.csv" null -> BACKUP_BASE_NAME } 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 index 8125f4bfe..bf621bed4 100644 --- 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 @@ -4,5 +4,5 @@ internal sealed interface ExportWizardEvent { data object Finished : ExportWizardEvent data object PickFolder : ExportWizardEvent - data class CreateFile(val suggestedName: String) : ExportWizardEvent + data class CreateFile(val suggestedName: String, val mimeType: String) : ExportWizardEvent } 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 index 2e17a49f5..36cd7f95b 100644 --- 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 @@ -209,7 +209,7 @@ private fun BackupHubContentPreview() { ), ScheduledBackup( provider = "Google Drive", - type = FileFormat.KDBX, + type = FileFormat.JSON, scheduleInterval = BackupInterval(count = 1, unit = IntervalUnit.Days), path = "/path/to/drive/backup" ) diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 803ff7a1b..5e46da5d9 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Never Backed Up CSV - KDBX + JSON %1$s \u2022 %2$s %1$s Backup @@ -32,7 +32,7 @@ Passphrase Confirm Passphrase - Export all data, including vaults, logins, cards, and TOTP, to a secure, encrypted KDBX file. + 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. @@ -62,7 +62,7 @@ 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 KDBX to schedule automatic backups. + Recurring backups require an encrypted format. Choose JSON to schedule automatic backups. Repeat every Auto-delete old backups From 4bf7c30f1b8c9be3ad50c2a27948a66bf872a60b Mon Sep 17 00:00:00 2001 From: Davis <42292083+OffRange@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:20:50 +0200 Subject: [PATCH 24/59] Feat/backup rust (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backup): implement basic structs * feat(backup): implement json backup * refactor(backup): split json module into encryption/key/model/format Break the monolithic json.rs into focused modules: encryption (seal/open envelope), key (BackupKey derivation), model (backup data types), and format/json (JSON framing). No behavior change; full backup suite green. Co-Authored-By: Claude Opus 4.8 * feat(crypto): add HKDF-SHA256 primitive A fast KDF for high-entropy keying material, mirroring the argon2 primitive. Used by the ARK backup path where a memory-hard password stretch buys nothing over a uniformly-random 256-bit key. Co-Authored-By: Claude Opus 4.8 * feat(backup): derive ARK backup key with HKDF-SHA256 Make the Kdf header per-source: Argon2id for the low-entropy passphrase path, HkdfSha256 for the full-entropy ARK path (a memory-hard stretch over a random 256-bit key buys nothing). open() now pairs (source, kdf, cred) and rejects a source<->kdf mismatch with MalformedHeader before deriving any key. Co-Authored-By: Claude Opus 4.8 * test(backup): freeze v1 ARK golden backup Mirror golden_v1_passphrase_still_decrypts for the ARK path so the HKDF derivation and ARK wire format fail loudly if they ever drift. Also add wrong_ark_fails to assert that importing an ARK backup with a different ARK fails opaquely with Crypto(DecryptionFailed), matching the wrong_passphrase_fails behavior and proving no panic on bad credential. Co-Authored-By: Claude Opus 4.8 * refactor(backup): cleanup * fix(backup): use argon params * refactor(backup): modify aad * refactor(backup): vaults hold logins/cards lists; re-freeze v1 goldens Vault now holds `logins: Vec` and `cards: Vec` (was a single `login`/`card`), and backup items derive Default — prerequisites for CSV import, which yields many logins per vault. The v1 passphrase and ARK golden backups are re-frozen at the new payload schema; version is unchanged since the backup format is not yet shipped. Co-Authored-By: Claude Opus 4.8 * feat(backup): csv format Co-Authored-By: Claude Opus 4.8 * feat(backup): csv export * feat(backup): add bindings --------- Co-authored-by: Claude Opus 4.8 --- rust/rust-code/Cargo.lock | 59 + rust/rust-code/bindings/Cargo.toml | 3 + rust/rust-code/bindings/src/backup.rs | 451 +++++++ rust/rust-code/bindings/src/lib.rs | 1 + rust/rust-code/lib/Cargo.toml | 8 +- rust/rust-code/lib/src/b64.rs | 43 + rust/rust-code/lib/src/backup/encryption.rs | 341 ++++++ rust/rust-code/lib/src/backup/error.rs | 27 + rust/rust-code/lib/src/backup/format/csv.rs | 1088 +++++++++++++++++ rust/rust-code/lib/src/backup/format/json.rs | 367 ++++++ rust/rust-code/lib/src/backup/format/mod.rs | 2 + rust/rust-code/lib/src/backup/key.rs | 86 ++ rust/rust-code/lib/src/backup/mod.rs | 25 + rust/rust-code/lib/src/backup/model.rs | 68 ++ .../lib/src/crypto/primitive/argon2.rs | 116 +- .../lib/src/crypto/primitive/hkdf.rs | 65 + .../rust-code/lib/src/crypto/primitive/mod.rs | 1 + rust/rust-code/lib/src/lib.rs | 2 + rust/rust-code/lib/src/totp.rs | 4 + .../keygo/rust/backup/CsvBackupManager.kt | 49 + .../keygo/rust/backup/JsonBackupManager.kt | 35 + .../de/davis/keygo/rust/di/RustModule.kt | 10 + .../davis/keygo/rust/FakeCsvBackupManager.kt | 43 + .../davis/keygo/rust/FakeJsonBackupManager.kt | 32 + 24 files changed, 2916 insertions(+), 10 deletions(-) create mode 100644 rust/rust-code/bindings/src/backup.rs create mode 100644 rust/rust-code/lib/src/b64.rs create mode 100644 rust/rust-code/lib/src/backup/encryption.rs create mode 100644 rust/rust-code/lib/src/backup/error.rs create mode 100644 rust/rust-code/lib/src/backup/format/csv.rs create mode 100644 rust/rust-code/lib/src/backup/format/json.rs create mode 100644 rust/rust-code/lib/src/backup/format/mod.rs create mode 100644 rust/rust-code/lib/src/backup/key.rs create mode 100644 rust/rust-code/lib/src/backup/mod.rs create mode 100644 rust/rust-code/lib/src/backup/model.rs create mode 100644 rust/rust-code/lib/src/crypto/primitive/hkdf.rs create mode 100644 rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt create mode 100644 rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt create mode 100644 rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt create mode 100644 rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock index 9fd9a4cc6..755d4fb7a 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/rust-code/Cargo.lock @@ -154,6 +154,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" @@ -440,6 +446,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.0" @@ -613,6 +640,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" @@ -803,6 +839,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" @@ -990,6 +1035,7 @@ name = "keygo-bindings" version = "0.1.0" dependencies = [ "lib", + "serde_json", "thiserror", "tokio", "uniffi", @@ -1016,16 +1062,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.1", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "totp-rs", "url", @@ -1490,6 +1542,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[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" @@ -1868,6 +1926,7 @@ dependencies = [ "base32", "constant_time_eq", "hmac", + "serde", "sha1", "sha2 0.10.9", "url", diff --git a/rust/rust-code/bindings/Cargo.toml b/rust/rust-code/bindings/Cargo.toml index ffcf045eb..07acafe8d 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.31.1", 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..207dca239 --- /dev/null +++ b/rust/rust-code/bindings/src/backup.rs @@ -0,0 +1,451 @@ +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 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 website: Option, + pub passkey: Option, +} + +#[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, + website: l.website, + passkey: l.passkey.map(Into::into), + } + } +} + +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, + website: l.website, + passkey: l.passkey.map(Into::into), + } + } +} + +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, + 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, + 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, +} + +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("encryption header and payload disagree")] + EncryptionMismatch, + #[error("a credential is required to read this encrypted backup")] + MissingCredential, + #[error("a credential was supplied for a plaintext backup")] + UnexpectedCredential, + #[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::EncryptionMismatch => Self::EncryptionMismatch, + E::MissingCredential => Self::MissingCredential, + E::UnexpectedCredential => Self::UnexpectedCredential, + 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: Option, + ) -> Result { + let backup: core::Backup = backup.into(); + let json = match credential { + None => core::json::export(&backup, None), + Some(BackupCredential::Passphrase { bytes }) => { + core::json::export(&backup, Some(core::BackupCredential::Passphrase(&bytes))) + } + Some(BackupCredential::Ark { key }) => { + core::json::export(&backup, Some(core::BackupCredential::Ark(&key))) + } + }?; + Ok(json) + } + + pub fn import( + &self, + data: String, + credential: Option, + ) -> Result { + let backup = match credential { + None => core::json::import(&data, None), + Some(BackupCredential::Passphrase { bytes }) => { + core::json::import(&data, Some(core::BackupCredential::Passphrase(&bytes))) + } + Some(BackupCredential::Ark { key }) => { + core::json::import(&data, Some(core::BackupCredential::Ark(&key))) + } + }?; + Ok(backup.into()) + } +} + +#[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/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 1517a7ce9..a21221f80 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..36017045d --- /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::backup::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..5ffbc21d6 --- /dev/null +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -0,0 +1,341 @@ +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)] +#[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: Option>, + version: u32, +) -> Result, BackupError> { + if header.nonce.len() != NONCE_LEN { + return Err(BackupError::MalformedHeader); + } + + let key = match (header.source, &header.kdf, cred) { + // No credential supplied for an encrypted backup. + (_, _, None) => return Err(BackupError::MissingCredential), + + // Credential's source disagrees with the header's declared source. + (KeySource::Passphrase, _, Some(BackupCredential::Ark(_))) + | (KeySource::Ark, _, Some(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, + }, + Some(BackupCredential::Passphrase(passphrase)), + ) => BackupKey::from_passphrase( + passphrase, + salt, + Argon2Params { + mem_kib: *mem_kib, + iters: *iters, + lanes: *lanes, + }, + )?, + (KeySource::Ark, Kdf::HkdfSha256 { salt }, Some(BackupCredential::Ark(ark))) => { + BackupKey::from_ark(ark, salt)? + } + + // Source matches credential but the KDF disagrees with the source. + (KeySource::Passphrase, _, Some(BackupCredential::Passphrase(_))) + | (KeySource::Ark, _, Some(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, + Some(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, + Some(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, + Some(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, + Some(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, + Some(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, + Some(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..1d57f79ad --- /dev/null +++ b/rust/rust-code/lib/src/backup/error.rs @@ -0,0 +1,27 @@ +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("encryption header and payload disagree")] + EncryptionMismatch, + #[error("a credential is required to read this encrypted backup")] + MissingCredential, + #[error("a credential was supplied for a plaintext backup")] + UnexpectedCredential, + #[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..f7891619b --- /dev/null +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -0,0 +1,1088 @@ +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.website.as_deref(), + 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',', b';', b'\t', b'|']; + +/// 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 = 7; +const DISPLAY_SAMPLES: usize = 3; + +/// 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, + website: url, + 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"] + ); + // 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].website.as_deref(), Some("https://mail.example")); + 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()), + website: Some("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()), + website: Some("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()), + website: Some("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.website.as_deref(), Some("https://mail.example")); + 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..15cfb0f98 --- /dev/null +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -0,0 +1,367 @@ +use crate::b64; +use crate::backup::encryption::{self, BackupCredential, EncryptionHeader}; +use crate::backup::{Backup, BackupError, CURRENT_VERSION, MIN_SUPPORTED_VERSION}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Serialize, Deserialize)] +pub struct BackupEnvelope<'a> { + pub version: u32, + pub encryption: Option, + pub payload: Payload<'a>, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum Payload<'a> { + Encrypted(String), + Plain(Cow<'a, Backup>), +} + +pub fn export(backup: &Backup, cred: Option>) -> Result { + let env = match cred { + None => BackupEnvelope { + version: CURRENT_VERSION, + encryption: None, + payload: Payload::Plain(Cow::Borrowed(backup)), + }, + Some(cred) => { + let payload_bytes = serde_json::to_vec(backup)?; + let sealed = encryption::seal(&payload_bytes, cred, CURRENT_VERSION)?; + BackupEnvelope { + version: CURRENT_VERSION, + encryption: Some(sealed.header), + payload: Payload::Encrypted(b64::encode(sealed.ciphertext)), + } + } + }; + Ok(serde_json::to_string(&env)?) +} + +pub fn import(data: &str, cred: Option>) -> Result { + let env: BackupEnvelope = serde_json::from_str(data)?; + let version = env.version; + if !(MIN_SUPPORTED_VERSION..=CURRENT_VERSION).contains(&version) { + return Err(BackupError::UnsupportedVersion(version)); + } + match (env.encryption, env.payload) { + (None, Payload::Plain(backup)) => { + if cred.is_some() { + return Err(BackupError::UnexpectedCredential); + } + Ok(backup.into_owned()) + } + (Some(header), Payload::Encrypted(ciphertext_b64)) => { + let ciphertext = b64::decode(&ciphertext_b64).map_err(|_| BackupError::Base64)?; + let payload_bytes = encryption::open(&header, &ciphertext, cred, version)?; + Ok(serde_json::from_slice(&payload_bytes)?) + } + _ => Err(BackupError::EncryptionMismatch), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::encryption::{Kdf, KeySource}; + use crate::backup::{Card, Login, 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(), + 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, + website: Some("https://mail.example".into()), + passkey: None, + }], + 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, + Some(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 version_below_minimum_fails() { + let json = export(&sample_backup(), None).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(), None).unwrap_err(); + assert!(matches!(err, BackupError::UnsupportedVersion(0))); + } + + #[test] + fn plaintext_round_trip() { + let original = sample_backup(); + let json = export(&original, None).unwrap(); + let restored = import(&json, None).unwrap(); + json_eq(&restored, &original); + } + + #[test] + fn passphrase_round_trip() { + let original = sample_backup(); + let json = export(&original, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let restored = import(&json, Some(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, Some(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, Some(BackupCredential::Ark(&ark))).unwrap(); + let restored = import(&json, Some(BackupCredential::Ark(&ark))).unwrap(); + json_eq(&restored, &original); + } + + #[test] + fn wrong_passphrase_fails() { + let json = export( + &sample_backup(), + Some(BackupCredential::Passphrase(b"right")), + ) + .unwrap(); + let err = import(&json, Some(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(), Some(BackupCredential::Ark(&right))).unwrap(); + let err = import(&json, Some(BackupCredential::Ark(&wrong))).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed), + )); + } + + #[test] + fn empty_passphrase_is_rejected() { + let err = export(&sample_backup(), Some(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(), Some(BackupCredential::Ark(&ark))).unwrap(); + let err = import(&json, Some(BackupCredential::Passphrase(b"x"))).unwrap_err(); + assert!(matches!(err, BackupError::CredentialMismatch)); + } + + #[test] + fn missing_credential_fails() { + let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let err = import(&json, None).unwrap_err(); + assert!(matches!(err, BackupError::MissingCredential)); + } + + #[test] + fn unexpected_credential_fails() { + let json = export(&sample_backup(), None).unwrap(); + let err = import(&json, Some(BackupCredential::Passphrase(b"x"))).unwrap_err(); + assert!(matches!(err, BackupError::UnexpectedCredential)); + } + + #[test] + fn tampered_ciphertext_fails() { + let json = export(&sample_backup(), Some(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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn tampered_header_salt_fails() { + let json = export(&sample_backup(), Some(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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn guard_rejects_null_encryption_with_string_payload() { + let v = serde_json::json!({ "version": 1, "encryption": null, "payload": "AAAA" }); + let err = import(&v.to_string(), None).unwrap_err(); + assert!(matches!(err, BackupError::EncryptionMismatch)); + } + + #[test] + fn guard_rejects_encryption_with_object_payload() { + let v = serde_json::json!({ + "version": 1, + "encryption": { + "source": "passphrase", + "kdf": { "type": "argon2id", "salt": b64::encode([1u8; 16]), "mem_kib": 65536, "iters": 3, "lanes": 4 }, + "nonce": b64::encode([2u8; 12]), + }, + "payload": { "vaults": [] }, + }); + let err = import(&v.to_string(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + assert!(matches!(err, BackupError::EncryptionMismatch)); + } + + #[test] + fn unknown_version_fails() { + let json = export(&sample_backup(), None).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(), None).unwrap_err(); + assert!(matches!(err, BackupError::UnsupportedVersion(2))); + } + + #[test] + fn malformed_base64_payload_fails() { + let json = export(&sample_backup(), Some(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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + assert!(matches!(err, BackupError::Base64)); + } + + #[test] + fn plaintext_envelope_shape() { + let json = export(&sample_backup(), None).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["version"], serde_json::json!(1)); + assert!(v["encryption"].is_null()); + assert!(v["payload"].is_object()); + } + + #[test] + fn encrypted_envelope_hides_plaintext() { + let json = export(&sample_backup(), Some(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 plaintext_envelope_serde_round_trip() { + let env = BackupEnvelope { + version: CURRENT_VERSION, + encryption: None, + payload: Payload::Plain(Cow::Owned(Backup { vaults: vec![] })), + }; + let json = serde_json::to_string(&env).unwrap(); + let back: BackupEnvelope = serde_json::from_str(&json).unwrap(); + assert!(back.encryption.is_none()); + assert!(matches!(back.payload, Payload::Plain(_))); + } + + #[test] + fn encrypted_envelope_serde_round_trip() { + let env = BackupEnvelope { + version: CURRENT_VERSION, + encryption: Some(EncryptionHeader { + source: KeySource::Passphrase, + kdf: Kdf::Argon2id { + salt: vec![1u8; 16], + mem_kib: 65536, + iters: 3, + lanes: 4, + }, + nonce: vec![2u8; 12], + }), + payload: Payload::Encrypted("AAAA".into()), + }; + let json = serde_json::to_string(&env).unwrap(); + let back: BackupEnvelope = serde_json::from_str(&json).unwrap(); + assert!(matches!(back.payload, Payload::Encrypted(_))); + let header = back.encryption.unwrap(); + assert!(matches!(header.source, KeySource::Passphrase)); + assert!(matches!(header.kdf, Kdf::Argon2id { .. })); + } +} 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..84bbb3d34 --- /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..73877ebf4 --- /dev/null +++ b/rust/rust-code/lib/src/backup/model.rs @@ -0,0 +1,68 @@ +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, + 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, + pub website: Option, + pub passkey: Option, + } +} + +#[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/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index 5a2c4343e..5624528a6 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -1,18 +1,61 @@ 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 @@ -20,10 +63,11 @@ fn get_argon<'a>() -> Argon2<'a> { /// /// 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..15d154070 --- /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/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..e8561122b --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt @@ -0,0 +1,49 @@ +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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +typealias CsvManager = CsvBackupManagerInterface + +suspend fun CsvBackupManagerInterface.analyzeWithResult( + data: String, +): Result = withContext(Dispatchers.Default) { + runCatching { + analyze(data) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} + +suspend fun CsvBackupManagerInterface.importWithResult( + data: String, + mapping: ColumnMapping, +): Result = withContext(Dispatchers.Default) { + runCatching { + import(data, mapping) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} + +suspend fun CsvBackupManagerInterface.exportWithResult( + backup: Backup, + preset: ExportPreset, +): Result = withContext(Dispatchers.Default) { + runCatching { + export(backup, preset) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} 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..dea26dc00 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -0,0 +1,35 @@ +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +typealias BackupManager = JsonBackupManagerInterface + +suspend fun JsonBackupManagerInterface.exportWithResult( + backup: Backup, + credential: BackupCredential?, +): Result = withContext(Dispatchers.Default) { + runCatching { + export(backup, credential) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} + +suspend fun JsonBackupManagerInterface.importWithResult( + data: String, + credential: BackupCredential?, +): Result = withContext(Dispatchers.Default) { + runCatching { + import(data, credential) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} 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..8e4b8f0f0 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt @@ -0,0 +1,43 @@ +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 { + + 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 { + analyzeException?.let { throw it } + return analyzeResult + } + + override fun import(data: String, mapping: ColumnMapping): CsvImportResult { + importException?.let { throw it } + return importResult + } + + override fun export(backup: Backup, preset: ExportPreset): String { + exportException?.let { throw it } + return exportResult + } +} 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..df676aa77 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt @@ -0,0 +1,32 @@ +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 + +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() + + var exportResult: String = "{}" + var importResult: Backup = Backup(emptyList()) + var exportException: BackupException? = null + var importException: BackupException? = null + + override fun export(backup: Backup, credential: BackupCredential?): String { + exportCalls += ExportCall(backup, credential) + exportException?.let { throw it } + return exportResult + } + + override fun import(data: String, credential: BackupCredential?): Backup { + importCalls += ImportCall(data, credential) + importException?.let { throw it } + return importResult + } +} From 34ecbdbd969d408cba34f87d51e031bf8340d920 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 24 Jun 2026 15:06:06 +0200 Subject: [PATCH 25/59] refactor(backup): rename wrappedPassphrase --- .../davis/keygo/feature/backup/data/mapper/BackupJobMapper.kt | 4 ++-- .../de/davis/keygo/feature/backup/domain/model/BackupJob.kt | 2 +- .../backup/domain/usecase/FinishExportWizardUseCase.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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 index c25e430f6..0394ba30a 100644 --- 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 @@ -11,7 +11,7 @@ import de.davis.keygo.feature.backup.domain.model.FileFormat internal fun ProtoBackupJob.toDomain() = BackupJob( uri = BackupDestinationUri(uri), format = FileFormat.valueOf(this@toDomain.format), - passphrase = if (hasPassphraseCt() && hasPassphraseIv()) + wrappedPassphrase = if (hasPassphraseCt() && hasPassphraseIv()) CryptographicData( data = passphraseCt.toByteArray(), iv = passphraseIv.toByteArray() @@ -22,7 +22,7 @@ internal fun ProtoBackupJob.toDomain() = BackupJob( internal fun BackupJob.toProto() = protoBackupJob { uri = this@toProto.uri.value format = this@toProto.format.name - passphrase?.let { + wrappedPassphrase?.let { passphraseCt = it.data.toByteString() passphraseIv = it.iv.toByteString() } 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 index 70562f556..ee678f4ce 100644 --- 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 @@ -4,6 +4,6 @@ import de.davis.keygo.core.security.domain.crypto.model.CryptographicData data class BackupJob( val uri: BackupDestinationUri, - val passphrase: CryptographicData?, + val wrappedPassphrase: CryptographicData?, val format: FileFormat, ) 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 index d8578c5a3..548665da9 100644 --- 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 @@ -28,13 +28,13 @@ class FinishExportWizardUseCase( if (details.format.encrypted && details.passphrase.isBlank()) return Result.Failure(FinishExportWizardError.PassphraseEmpty) - val passphrase = if (details.format.encrypted) + val wrappedPassphrase = if (details.format.encrypted) wrapPassphrase(details.passphrase).bind() else null val job = BackupJob( uri = details.uri, - passphrase = passphrase, + wrappedPassphrase = wrappedPassphrase, format = details.format, ) From 88d5c43514d1837e2951281808216ee702a50696 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 24 Jun 2026 15:06:20 +0200 Subject: [PATCH 26/59] refactor(backup): rename managers --- .../main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt | 2 +- .../main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index e8561122b..c1e18d97c 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt @@ -11,7 +11,7 @@ import de.davisalessandro.keygo.rust.ExportPreset import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -typealias CsvManager = CsvBackupManagerInterface +typealias CsvBackupManager = CsvBackupManagerInterface suspend fun CsvBackupManagerInterface.analyzeWithResult( data: String, 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 index dea26dc00..3502fd18f 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -8,7 +8,7 @@ import de.davisalessandro.keygo.rust.JsonBackupManagerInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -typealias BackupManager = JsonBackupManagerInterface +typealias JsonBackupManager = JsonBackupManagerInterface suspend fun JsonBackupManagerInterface.exportWithResult( backup: Backup, From 7f48a9e4839afccedd6e9b165ead71e4f0d9d27d Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 17 Jul 2026 16:23:03 +0200 Subject: [PATCH 27/59] feat(backup): improve export data flow Co-authored-by: Claude Opus 4.8 --- .../core/item/data/local/dao/CreditCardDao.kt | 7 +- .../core/item/data/local/dao/PasskeyDao.kt | 4 + .../repository/CreditCardRepositoryImpl.kt | 7 +- .../data/repository/PasskeyRepositoryImpl.kt | 5 + .../domain/repository/CreditCardRepository.kt | 2 + .../domain/repository/PasskeyRepository.kt | 3 + .../core/item/FakeCreditCardRepositoryTest.kt | 38 +++ .../core/item/FakePasskeyRepositoryTest.kt | 42 +++ .../core/item/FakeCreditCardRepository.kt | 4 + .../keygo/core/item/FakePasskeyRepository.kt | 4 + .../core/security/data/KeyStoreManagerImpl.kt | 4 + .../CryptographicScopeProviderFactoryImpl.kt | 20 ++ .../core/security/domain/KeyStoreManager.kt | 2 + .../core/security/domain/crypto/CipherExt.kt | 16 + .../CryptographicScopeProviderFactory.kt | 11 + .../keygo/core/security/domain/model/KeyId.kt | 1 + .../usecase/ItemWithCryptoScopeUseCase.kt | 6 +- .../crypto/FakeKeyStoreManagerTest.kt | 65 ++++ .../usecase/ItemWithCryptoScopeUseCaseTest.kt | 17 + .../FakeCryptographicScopeProviderFactory.kt | 18 ++ .../security/crypto/FakeKeyStoreManager.kt | 46 +++ feature/backup/build.gradle.kts | 10 + .../backup/data/BackupArkKeyStoreImpl.kt | 37 +++ .../data/BackupDestinationResolverImpl.kt | 2 +- .../feature/backup/data/BackupSession.kt | 17 + .../data/ContentResolverBackupFileStore.kt | 109 +++++++ .../data/DispatchedBackupRepositoryImpl.kt | 25 ++ .../backup/data/mapper/BackupJobMapper.kt | 48 ++- .../data/mapper/DispatchedBackupMapper.kt | 58 ++++ .../data/reository/BackupJobRepositoryImpl.kt | 53 ++- .../feature/backup/di/FeatureBackupModule.kt | 15 + .../di/annotation/BackupArkQualifier.kt | 6 + .../backup/domain/BackupArkUnlocker.kt | 86 +++++ .../feature/backup/domain/BackupCollector.kt | 88 +++++ .../feature/backup/domain/BackupFileStore.kt | 32 ++ .../backup/domain/BackupProvisioningLock.kt | 18 ++ .../feature/backup/domain/BackupRestorer.kt | 99 ++++++ .../domain/DispatchedBackupRepository.kt | 9 + .../backup/domain/mapper/ExportMappers.kt | 54 ++++ .../backup/domain/mapper/ImportMappers.kt | 38 +++ .../backup/domain/model/BackupEntry.kt | 7 + .../backup/domain/model/BackupFileName.kt | 12 + .../feature/backup/domain/model/BackupJob.kt | 10 + .../backup/domain/model/BackupResult.kt | 3 + .../backup/domain/model/BackupWorkStatus.kt | 8 + .../backup/domain/model/CollectedBackup.kt | 8 + .../feature/backup/domain/model/CsvPreset.kt | 10 + .../backup/domain/model/DispatchedBackup.kt | 14 + .../backup/domain/model/EncryptionMethod.kt | 10 + .../backup/domain/model/ExportDetails.kt | 4 + .../backup/domain/model/ExportError.kt | 20 ++ .../backup/domain/model/ExportProgress.kt | 10 + .../feature/backup/domain/model/FileFormat.kt | 6 +- .../backup/domain/model/ImportError.kt | 13 + .../backup/domain/model/ImportProgress.kt | 9 + .../backup/domain/model/ImportRequest.kt | 7 + .../backup/domain/model/ImportSummary.kt | 8 + .../feature/backup/domain/model/LastBackup.kt | 6 + .../domain/repository/BackupArkKeyStore.kt | 9 + .../domain/repository/BackupJobRepository.kt | 9 +- .../domain/usecase/CancelBackupUseCase.kt | 28 ++ .../usecase/CleanupBackupResourcesUseCase.kt | 66 ++++ .../domain/usecase/ExportBackupUseCase.kt | 134 ++++++++ .../usecase/FinishExportWizardUseCase.kt | 101 ++++-- .../domain/usecase/ImportBackupUseCase.kt | 111 +++++++ .../ObserveDispatchedBackupsUseCase.kt | 39 +++ .../usecase/ObserveLastBackupUseCase.kt | 23 ++ .../usecase/RecordBackupOutcomeUseCase.kt | 30 ++ .../feature/backup/presentation/FileFormat.kt | 46 +++ .../contract/CreateDynamicDocument.kt | 29 -- .../export/ExportWizardContent.kt | 12 +- .../presentation/export/ExportWizardScreen.kt | 6 - .../export/ExportWizardViewModel.kt | 32 +- .../export/ProvidePassphraseContent.kt | 87 +++-- .../export/ReviewBackupContent.kt | 29 +- .../export/SelectCsvPresetContent.kt | 59 ++++ .../export/SelectDestinationContent.kt | 17 +- .../presentation/export/model/BackupFile.kt | 4 +- .../export/model/ExportWizardEvent.kt | 1 - .../export/model/ExportWizardStep.kt | 7 +- .../export/model/ExportWizardUiEvent.kt | 4 + .../export/model/ExportWizardUiState.kt | 5 +- .../export/model/ProvidePassphraseState.kt | 2 + .../export/model/SelectFormatState.kt | 4 +- .../backup/presentation/hub/BackupGrouping.kt | 20 ++ .../presentation/hub/BackupHubContent.kt | 231 ++++++++----- .../presentation/hub/BackupHubViewModel.kt | 38 ++- .../hub/DispatchedBackupDisplay.kt | 40 +++ .../presentation/hub/model/BackupGroup.kt | 10 + .../hub/model/BackupHubUiEvent.kt | 5 +- .../hub/model/BackupHubUiState.kt | 17 +- .../feature/backup/worker/BackupWorker.kt | 33 +- .../src/main/proto/backup_ark_data.proto | 11 + .../backup/src/main/proto/backup_jobs.proto | 13 + .../backup/src/main/res/values/strings.xml | 31 +- .../keygo/feature/backup/BackupTestData.kt | 102 ++++++ .../feature/backup/FakeBackupArkKeyStore.kt | 23 ++ .../backup/FakeBackupArkKeyStoreTest.kt | 31 ++ .../feature/backup/FakeBackupFileStore.kt | 61 ++++ .../feature/backup/FakeBackupFileStoreTest.kt | 59 ++++ .../feature/backup/FakeBackupJobRepository.kt | 40 +++ .../feature/backup/FakeBackupScheduler.kt | 55 ++++ .../backup/FakeDispatchedBackupRepository.kt | 15 + .../backup/FakePersistableUriManager.kt | 20 ++ .../keygo/feature/backup/RestorerTestEnv.kt | 59 ++++ .../backup/data/mapper/BackupJobMapperTest.kt | 164 ++++++++++ .../data/mapper/DispatchedBackupMapperTest.kt | 89 +++++ .../backup/domain/BackupArkUnlockerTest.kt | 155 +++++++++ .../backup/domain/BackupCollectorTest.kt | 233 +++++++++++++ .../backup/domain/BackupRestorerTest.kt | 111 +++++++ .../BackupProvisioningSerializationTest.kt | 115 +++++++ .../usecase/BackupWorkActionsUseCaseTest.kt | 70 ++++ .../CleanupBackupResourcesUseCaseTest.kt | 224 +++++++++++++ .../domain/usecase/ExportBackupUseCaseTest.kt | 306 ++++++++++++++++++ .../usecase/FinishExportWizardUseCaseTest.kt | 173 ++++++++++ .../domain/usecase/ImportBackupUseCaseTest.kt | 287 ++++++++++++++++ .../ObserveDispatchedBackupsUseCaseTest.kt | 126 ++++++++ .../usecase/ObserveLastBackupUseCaseTest.kt | 58 ++++ .../usecase/RecordBackupOutcomeUseCaseTest.kt | 141 ++++++++ .../export/model/ExportWizardStepTest.kt | 70 ++++ .../presentation/hub/BackupGroupingTest.kt | 51 +++ .../hub/BackupHubViewModelTest.kt | 103 ++++++ .../backup/worker/BackupWorkerResultTest.kt | 35 ++ rust/rust-code/bindings/src/backup.rs | 21 +- rust/rust-code/lib/src/backup/encryption.rs | 4 +- rust/rust-code/lib/src/backup/format/json.rs | 111 ++++++- rust/rust-code/lib/src/backup/mod.rs | 2 +- rust/rust-code/lib/src/backup/model.rs | 5 +- .../lib/src/crypto/primitive/argon2.rs | 2 +- .../lib/src/crypto/primitive/hkdf.rs | 4 +- .../keygo/rust/backup/JsonBackupManager.kt | 12 + .../davis/keygo/rust/FakeCsvBackupManager.kt | 11 + .../davis/keygo/rust/FakeJsonBackupManager.kt | 22 +- 133 files changed, 5525 insertions(+), 279 deletions(-) create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CipherExt.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProviderFactory.kt create mode 100644 core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt create mode 100644 core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProviderFactory.kt create mode 100644 core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManager.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupArkKeyStoreImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/BackupSession.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/ContentResolverBackupFileStore.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapper.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/di/annotation/BackupArkQualifier.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupFileStore.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupProvisioningLock.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportMappers.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFileName.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupWorkStatus.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CollectedBackup.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/DispatchedBackup.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/EncryptionMethod.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportProgress.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportError.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportProgress.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportSummary.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupArkKeyStore.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt delete mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectCsvPresetContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupGroup.kt create mode 100644 feature/backup/src/main/proto/backup_ark_data.proto create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapperTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapperTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlockerTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorerTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupWorkActionsUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/export/model/ExportWizardStepTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/worker/BackupWorkerResultTest.kt 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/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/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/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt new file mode 100644 index 000000000..963359a35 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt @@ -0,0 +1,38 @@ +package de.davis.keygo.core.item + +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.KeyInformation +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FakeCreditCardRepositoryTest { + + private fun card(vaultId: java.util.UUID, name: String) = CreditCard( + id = newItemId(), + vaultId = vaultId, + name = name, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = null, + cardNumber = null, + cvv = null, + expirationDate = null, + ) + + @Test + fun `getCreditCardsByVault returns only cards in that vault`() = runTest { + val vaultA = newVaultId() + val vaultB = newVaultId() + val repo = FakeCreditCardRepository() + repo.seed(card(vaultA, "A1"), card(vaultA, "A2"), card(vaultB, "B1")) + + val result = repo.getCreditCardsByVault(vaultA) + + assertEquals(setOf("A1", "A2"), result.map { it.name }.toSet()) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt new file mode 100644 index 000000000..9521d10a4 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.model.PasskeyUser +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FakePasskeyRepositoryTest { + + private val repository = FakePasskeyRepository() + + private fun passkey(loginId: de.davis.keygo.core.item.domain.alias.ItemId, rp: String) = Passkey( + credentialId = rp.encodeToByteArray(), + rp = rp, + privateKey = Passkey.PrivateKey(EncryptedPayload(byteArrayOf(1), byteArrayOf(2))), + loginId = loginId, + user = PasskeyUser(name = "alice", displayName = "Alice"), + ) + + @Test + fun `returns every passkey of the requested login`() = runTest { + val login = newItemId() + val other = newItemId() + repository.seed( + passkey(login, "example.com"), + passkey(login, "example.org"), + passkey(other, "elsewhere.test"), + ) + + val passkeys = repository.getPasskeysByLogin(login) + + assertEquals(listOf("example.com", "example.org"), passkeys.map { it.rp }) + } + + @Test + fun `returns nothing for a login without passkeys`() = runTest { + assertEquals(emptyList(), repository.getPasskeysByLogin(newItemId())) + } +} 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/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 740dd136b..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 @@ -55,6 +55,10 @@ internal class KeyStoreManagerImpl( return cipher } + override fun deleteKey(keyId: KeyId) { + if (keyStore.containsAlias(keyId.id)) keyStore.deleteEntry(keyId.id) + } + private fun createKeyFor(keyId: KeyId): SecretKey { val spec = KeyGenParameterSpec.Builder( keyId.id, 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 1464d522a..9a33a8380 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 @@ -8,5 +8,6 @@ data class KeyId( companion object { val BiometricVaultKek = KeyId("biometric_vault_kek", true) val BackupPassphraseKey = KeyId("backup_passphrase_key", false) + val BackupArkKey = KeyId("backup_ark_key", false) } } \ No newline at end of file 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/crypto/FakeKeyStoreManagerTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt new file mode 100644 index 000000000..9784e3fc5 --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt @@ -0,0 +1,65 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.domain.model.CryptographicMode +import de.davis.keygo.core.security.domain.model.KeyId +import javax.crypto.AEADBadTagException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class FakeKeyStoreManagerTest { + + private val keyId = KeyId.BackupArkKey + + @Test + fun `encrypt then decrypt round-trips under the same key id`() { + val ks = FakeKeyStoreManager() + val plaintext = ByteArray(32) { it.toByte() } + + val enc = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt) + val ct = enc.doFinal(plaintext) + val iv = enc.iv + + val dec = ks.getOrCreateCipherFor(keyId, CryptographicMode.Decrypt, iv) + assertContentEquals(plaintext, dec.doFinal(ct)) + } + + @Test + fun `nonce is randomized per encryption`() { + val ks = FakeKeyStoreManager() + val a = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt).iv + val b = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt).iv + assertFalse(a.contentEquals(b)) + } + + @Test + fun `device locked makes cipher access throw`() { + val ks = FakeKeyStoreManager(deviceLocked = true) + assertFailsWith { + ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt) + } + } + + @Test + fun `deleting a key drops the alias and records it`() { + val keyStoreManager = FakeKeyStoreManager() + val cipher = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupArkKey, + cryptographicMode = CryptographicMode.Encrypt, + ) + val ciphertext = cipher.doFinal("ark".encodeToByteArray()) + + keyStoreManager.deleteKey(KeyId.BackupArkKey) + + assertFalse(keyStoreManager.keys.keys.contains(KeyId.BackupArkKey)) + // The alias is gone: the next cipher is backed by a brand-new key, so the old + // ciphertext no longer opens. + val fresh = keyStoreManager.getOrCreateCipherFor( + keyId = KeyId.BackupArkKey, + cryptographicMode = CryptographicMode.Decrypt, + iv = cipher.iv, + ) + assertFailsWith { fresh.doFinal(ciphertext) } + } +} 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/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/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index a8635f215..698ca0c66 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -6,6 +6,10 @@ plugins { android { namespace = "de.davis.keygo.feature.backup" + + defaultConfig { + missingDimensionStrategy("store", "playStore") + } } dependencies { @@ -19,6 +23,12 @@ dependencies { 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.io.mockk) } 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 index 808c7dbdc..3b9707033 100644 --- 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 @@ -62,7 +62,7 @@ internal class BackupDestinationResolverImpl( // 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 + // 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() 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..be4114834 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/ContentResolverBackupFileStore.kt @@ -0,0 +1,109 @@ +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 + +@Single +internal class ContentResolverBackupFileStore( + private val context: Context, +) : BackupFileStore { + + override suspend fun read(uri: BackupDestinationUri): Result = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri.value.toUri()) + ?.use { it.readBytes().decodeToString() } + ?: error("Unable to open input stream for ${uri.value}") + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it) }, + ) + } + + override suspend fun writeNewDocument( + folder: BackupDestinationUri, + fileName: String, + mimeType: String, + text: String, + ): Result = withContext(Dispatchers.IO) { + runCatching { + 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") + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(it) }, + ) + } + + override suspend fun listBackups( + folder: BackupDestinationUri, + baseName: String, + ): Result, Throwable> = withContext(Dispatchers.IO) { + runCatching { + 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() + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it) }, + ) + } + + override suspend fun delete(uri: BackupDestinationUri): Result = + withContext(Dispatchers.IO) { + runCatching { + val deleted = DocumentsContract.deleteDocument( + context.contentResolver, + uri.value.toUri(), + ) + if (!deleted) error("Unable to delete ${uri.value}") + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(it) }, + ) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt new file mode 100644 index 000000000..0578ca07e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.backup.data + +import androidx.work.WorkManager +import de.davis.keygo.feature.backup.data.mapper.toStatus +import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +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)) + } +} 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 index 0394ba30a..f26c56e95 100644 --- 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 @@ -6,18 +6,39 @@ import de.davis.keygo.feature.backup.data.local.model.ProtoBackupJob 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.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( - uri = BackupDestinationUri(uri), - format = FileFormat.valueOf(this@toDomain.format), - wrappedPassphrase = if (hasPassphraseCt() && hasPassphraseIv()) - CryptographicData( - data = passphraseCt.toByteArray(), - iv = passphraseIv.toByteArray() - ) - else null -) +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 = if (hasLastResult()) + runCatching { BackupResult.valueOf(lastResult) }.getOrNull() + else null, + cancelled = this.cancelled, + ) +} internal fun BackupJob.toProto() = protoBackupJob { uri = this@toProto.uri.value @@ -26,4 +47,11 @@ internal fun BackupJob.toProto() = protoBackupJob { 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 { lastResult = it.name } + cancelled = this@toProto.cancelled } 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..958e17f49 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapper.kt @@ -0,0 +1,58 @@ +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), + 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): DispatchedBackup.State = when (state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> DispatchedBackup.State.Enqueued + WorkInfo.State.RUNNING -> DispatchedBackup.State.Running + 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/reository/BackupJobRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt index 3c77ea860..a1e1100ef 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt @@ -8,7 +8,10 @@ import de.davis.keygo.feature.backup.data.mapper.toDomain import de.davis.keygo.feature.backup.data.mapper.toProto import de.davis.keygo.feature.backup.di.annotation.BackupJobsQualifier 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 @@ -22,12 +25,56 @@ internal class BackupJobRepositoryImpl( override suspend fun getJob(workId: String): 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: String, job: BackupJob): Result = runCatching { dataStore.updateData { current -> - current.copy { jobs[workId] = job.toProto() } + current.copy { + jobs[workId] = job.copy(createdAt = System.currentTimeMillis()).toProto() + } } }.fold( onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Failure(Unit) } + onFailure = { Result.Failure(Unit) }, ) -} \ No newline at end of file + + override suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) { + dataStore.updateData { current -> + val existing = current.jobsMap[workId] ?: return@updateData current + current.copy { + jobs[workId] = existing.copy { + this.finishedAt = finishedAt + lastResult = result.name + } + } + } + } + + override suspend fun markCancelled(workId: String, cancelledAt: Long) { + dataStore.updateData { current -> + val existing = current.jobsMap[workId] ?: return@updateData current + current.copy { + jobs[workId] = existing.copy { + cancelled = true + finishedAt = cancelledAt + } + } + } + } + + override suspend fun clearPassphrase(workId: String) { + dataStore.updateData { current -> + val existing = current.jobsMap[workId] ?: return@updateData current + current.copy { + jobs[workId] = existing.copy { + clearPassphraseCt() + clearPassphraseIv() + } + } + } + } +} 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 index 3e8aee42e..c473a80e4 100644 --- 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 @@ -6,7 +6,9 @@ import androidx.datastore.dataStore import androidx.work.WorkManager import com.google.protobuf.MessageLite import com.google.protobuf.Parser +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 @@ -33,6 +35,19 @@ object FeatureBackupModule { 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) 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/domain/BackupArkUnlocker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupArkUnlocker.kt new file mode 100644 index 000000000..715d285e9 --- /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 = runCatching { session.ark }.getOrNull() + 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 = runCatching { session.ark }.getOrNull() + 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..fdbd3a5da --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupCollector.kt @@ -0,0 +1,88 @@ +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.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.coroutineScope +import kotlinx.coroutines.flow.first +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 -> + val logins = async { loginRepository.getLoginsByVault(meta.vaultId) } + val cards = async { creditCardRepository.getCreditCardsByVault(meta.vaultId) } + VaultItems( + meta = meta, + logins = logins.await(), + cards = cards.await(), + ) + } + } + + val total = perVault.sumOf { it.items } + (total > 0).asResult(ExportError.NothingToExport).bind() + + var processed = 0 + suspend fun I.export(map: suspend CryptographicScope.(I) -> R): R = + scope.withItem(this, map) + .bind { ExportError.CryptoFailed } + .also { onProgress(++processed, total) } + + val backupVaults = perVault.map { (meta, logins, cards) -> + BackupVault( + name = meta.name, + logins = logins.map { login -> + val passkeys = passkeyRepository.getPasskeysByLogin(login.id) + login.export { it.toBackupLogin(passkeys) } + }, + cards = cards.map { it.export { card -> card.toBackupCard() } }, + ) + } + + CollectedBackup(Backup(backupVaults), total) + } +} 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..cca894aa4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupFileStore.kt @@ -0,0 +1,32 @@ +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 + + /** Lists documents inside [folder] whose display name starts with [baseName]. */ + suspend fun listBackups( + folder: BackupDestinationUri, + baseName: String, + ): Result, Throwable> + + /** Deletes the document at [uri]. */ + 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..0c670f60a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupProvisioningLock.kt @@ -0,0 +1,18 @@ +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. + * + * Provisioning (FinishExportWizardUseCase) writes the escrow + auth-less key aliases to one + * DataStore and the job record that marks a job "live" to another, with no cross-store transaction. + * CleanupBackupResourcesUseCase decides what to tear down solely from the job records. Holding this + * single lock across the whole of each section means a cleanup can never observe a half-provisioned + * job (escrow written, record not yet) and destroy 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..6aee12f93 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorer.kt @@ -0,0 +1,99 @@ +package de.davis.keygo.feature.backup.domain + +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.model.ImportError +import de.davis.keygo.feature.backup.domain.model.ImportSummary +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, +) { + + suspend fun restore( + backup: Backup, + 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() + + var imported = 0 + var skipped = 0 + var failed = 0 + var vaultsCreated = 0 + var processed = 0 + + for (bvault in backup.vaults) { + val vaultId = + existingByName[bvault.name] ?: createVault(bvault.name, Vault.Icon.Default) + .getOrNull()?.also { existingByName[bvault.name] = it; vaultsCreated++ } + + if (vaultId == null) { + repeat(bvault.logins.size + bvault.cards.size) { + failed++ + processed++ + onProgress(processed, total) + } + 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() + + for (login in bvault.logins) { + val key = login.title to login.username + when { + key in loginKeys -> skipped++ + createLogin(login.toUpsertLogin(vaultId)) is Result.Success -> { + imported++ + loginKeys += key + } + + else -> failed++ + } + processed++ + onProgress(processed, total) + } + + for (card in bvault.cards) { + val key = card.title to card.cardholder + when { + key in cardKeys -> skipped++ + createCard(card.toUpsertCreditCard(vaultId)) is Result.Success -> { + imported++ + cardKeys += key + } + + else -> failed++ + } + processed++ + onProgress(processed, total) + } + } + + return Result.Success(ImportSummary(imported, skipped, failed, vaultsCreated)) + } +} diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt new file mode 100644 index 000000000..5fe02b06c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +import kotlinx.coroutines.flow.Flow + +interface DispatchedBackupRepository { + fun observe(): Flow> + suspend fun cancel(id: String) +} 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..6dd022878 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ExportMappers.kt @@ -0,0 +1,54 @@ +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.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 + +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(), + website = domainInfos.firstOrNull()?.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/ImportMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt new file mode 100644 index 000000000..8b1b05d5c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportMappers.kt @@ -0,0 +1,38 @@ +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.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 + +internal fun BackupLogin.toUpsertLogin(vaultId: VaultId): UpsertLogin = UpsertLogin.create( + vaultId = vaultId, + name = title, + password = password, + totpUriOrSecret = totpSecret, + username = username, + domains = website?.let { setOf(DomainInfo(value = it, eTLD1 = null)) }.orEmpty(), + 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/BackupEntry.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt new file mode 100644 index 000000000..4b9a7e844 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupEntry.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.domain.model + +/** An existing backup document discovered inside a destination folder. */ +data class BackupEntry( + val uri: BackupDestinationUri, + val name: String, +) 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..79b94f1f3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFileName.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.backup.domain.model + +/** Shared prefix for every backup document this app writes into a destination folder. */ +const val BACKUP_BASE_NAME = "keygo-backup" + +/** + * The concrete document name for a single export, 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/BackupJob.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt index ee678f4ce..1cebb23ef 100644 --- 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 @@ -6,4 +6,14 @@ 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, + val lastResult: BackupResult? = null, + val cancelled: Boolean = false, ) 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..0c5171114 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt @@ -0,0 +1,3 @@ +package de.davis.keygo.feature.backup.domain.model + +enum class BackupResult { Success, Failure } 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..3dc818f6a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupWorkStatus.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +data class BackupWorkStatus( + val id: String, + val kind: DispatchedBackup.Kind, + val state: DispatchedBackup.State, + val progress: ExportProgress.InFlight?, +) 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/CsvPreset.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt new file mode 100644 index 000000000..dd9bcb386 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.backup.domain.model + +/** Column layout for CSV exports. */ +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..8074b85c1 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/DispatchedBackup.kt @@ -0,0 +1,14 @@ +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 progress: ExportProgress.InFlight?, + val timestamp: Long = 0L, +) { + enum class Kind { OneTime, Recurring } + enum class State { Enqueued, Running, Succeeded, Failed, Cancelled } +} 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..b214bbaf7 --- /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 + +/** How a JSON backup is sealed. Irrelevant for CSV (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 index 1215c0423..62bc335d8 100644 --- 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 @@ -5,4 +5,8 @@ data class ExportDetails( 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..62aaba61c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt @@ -0,0 +1,20 @@ +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 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 index ba807ddb9..95d990604 100644 --- 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 @@ -1,8 +1,8 @@ package de.davis.keygo.feature.backup.domain.model -enum class FileFormat(val mimeType: String) { - JSON("application/json"), - CSV("text/csv"); +enum class FileFormat(val mimeType: String, val extension: String) { + JSON("application/json", "json"), + CSV("text/csv", "csv"); val recommented: Boolean get() = this == JSON 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..d5ea62ede --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.backup.domain.model + +data class ImportRequest( + val uri: BackupDestinationUri, + val format: FileFormat, + val passphrase: String?, +) 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/LastBackup.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt new file mode 100644 index 000000000..2ccba6d18 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/LastBackup.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.backup.domain.model + +data class LastBackup( + val finishedAt: Long, + val destination: BackupDestination?, +) 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 index bec887335..ccf7c6fa3 100644 --- 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 @@ -2,9 +2,16 @@ package de.davis.keygo.feature.backup.domain.repository import de.davis.keygo.core.util.Result 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: String): BackupJob? + suspend fun getJobs(): Map suspend fun putJob(workId: String, job: BackupJob): Result -} \ No newline at end of file + suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) + suspend fun markCancelled(workId: String, cancelledAt: Long) + suspend fun clearPassphrase(workId: String) + fun observeJobs(): Flow> +} 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..ac3c3ad3e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt @@ -0,0 +1,28 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +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 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) + + // Recurring work is a singleton stored under a stable key; one-time records are keyed by + // the WorkManager id. + val workId = when (kind) { + DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID + DispatchedBackup.Kind.OneTime -> id + } + jobRepository.markCancelled(workId, System.currentTimeMillis()) + 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..ab53d099e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt @@ -0,0 +1,66 @@ +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.PersistableUriManager +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. Calling this on a live job is + * a programming error the caller must avoid; doing nothing is the safe response here. + * + * Runs after the backup file is already written, so every step is best-effort: a failure here must + * never fail a backup. + */ +@Single +internal class CleanupBackupResourcesUseCase( + private val jobRepository: BackupJobRepository, + private val arkKeyStore: BackupArkKeyStore, + private val keyStoreManager: KeyStoreManager, + private val persistableUriManager: PersistableUriManager, + private val provisioningLock: BackupProvisioningLock, +) { + + suspend operator fun invoke(workId: String): Unit = provisioningLock.mutex.withLock { + val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return + + // Calling this on a job that is still live would strip credentials its next run needs. + if (current[workId]?.isLive(workId) == true) return + + runCatching { jobRepository.clearPassphrase(workId) } + + // 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 + val done = jobs[workId] + val live = jobs.filter { (id, job) -> job.isLive(id) } + + if (done != null && live.values.none { it.uri == done.uri }) + runCatching { persistableUriManager.releasePersistableUriPermission(done.uri) } + + 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) } + } + + // 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. + private fun BackupJob.isLive(workId: String): Boolean = + !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..fe884360a --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt @@ -0,0 +1,134 @@ +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.flow +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 = flow { + resultBinding { + val collected = collector.collect { p, t -> emit(ExportProgress.Running(p, t)) }.bind() + + emit(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) + emit(ExportProgress.Succeeded(count)) + }.onFailure { failure -> + emit(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 + .filter { it.name.endsWith(".${job.format.extension}") } + .sortedByDescending { it.timestamp(job.format) } + .drop(keep) + .forEach { fileStore.delete(it.uri) } + } + + private fun BackupEntry.timestamp(format: FileFormat): Long = + name.removePrefix("$BACKUP_BASE_NAME-") + .removeSuffix(".${format.extension}") + .toLongOrNull() ?: Long.MIN_VALUE + + 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). + else -> { + 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 index 548665da9..8edba5223 100644 --- 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 @@ -1,19 +1,24 @@ 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.BackupProvisioningLock import de.davis.keygo.feature.backup.domain.BackupScheduler import de.davis.keygo.feature.backup.domain.PersistableUriManager 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore +import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Single @Single @@ -21,54 +26,88 @@ class FinishExportWizardUseCase( private val backupScheduler: BackupScheduler, 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 { - if (details.format.encrypted && details.passphrase.isBlank()) - return Result.Failure(FinishExportWizardError.PassphraseEmpty) + // 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 (details.format.encrypted) - wrapPassphrase(details.passphrase).bind() - else null + val wrappedPassphrase = if (passphraseRequired) + wrapPassphrase(details.passphrase).bind() + else null - val job = BackupJob( - uri = details.uri, - wrappedPassphrase = wrappedPassphrase, - format = details.format, - ) + val job = BackupJob( + uri = details.uri, + wrappedPassphrase = wrappedPassphrase, + format = details.format, + encryption = details.encryption, + csvPreset = details.csvPreset, + keepCount = details.keepCount, + ) - when (val interval = details.interval) { - null -> backupScheduler.scheduleOneTimeBackup(job) - .bind { FinishExportWizardError.SchedulePersistenceFailed } + // 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() - else -> { - runCatching { persistableUriManager.takePersistableUriPermission(details.uri) } - .getOrNull() - .asResult(FinishExportWizardError.DestinationPermissionDenied) - .bind() + provisionBackupArk().bind() - backupScheduler.scheduleRecurringBackup(job, interval) - .bind { FinishExportWizardError.SchedulePersistenceFailed } + 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) + return Result.Success(Unit) + } } private suspend fun wrapPassphrase( passphrase: String, - ): Result = withContext(Dispatchers.Default) { + ): Result = resultBinding { val cipher = keyStoreManager.getOrCreateCipherFor( keyId = KeyId.BackupPassphraseKey, cryptographicMode = CryptographicMode.Encrypt, ) - runCatching { - CryptographicData( - data = cipher.doFinal(passphrase.encodeToByteArray()), - iv = cipher.iv, - ) - }.getOrNull().asResult(FinishExportWizardError.CryptoFailed) + CryptographicData( + data = cipher.suspendDoFinal(passphrase.encodeToByteArray()) + .bind { FinishExportWizardError.CryptoFailed }, + iv = cipher.iv, + ) + } + + private suspend fun provisionBackupArk() = resultBinding { + val ark = runCatching { session.ark }.getOrNull() + .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..7312fce01 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt @@ -0,0 +1,111 @@ +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.resultBinding +import de.davis.keygo.feature.backup.domain.BackupFileStore +import de.davis.keygo.feature.backup.domain.BackupRestorer +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.BackupException +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.flow +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, +) { + + operator fun invoke(request: ImportRequest): Flow = flow { + if (runCatching { session.ark }.isFailure) { + emit(ImportProgress.Failed(ImportError.SessionLocked)) + return@flow + } + + emit(ImportProgress.Reading) + val text = when (val read = fileStore.read(request.uri)) { + is Result.Success -> read.success + is Result.Failure -> { + emit(ImportProgress.Failed(ImportError.FileUnreadable)) + return@flow + } + } + if (text.isBlank()) { + emit(ImportProgress.Failed(ImportError.EmptyFile)) + return@flow + } + + emit(ImportProgress.Parsing) + val backup = when (val parsed = parse(request, text)) { + is Result.Success -> parsed.success + is Result.Failure -> { + emit(ImportProgress.Failed(parsed.error)) + return@flow + } + } + + when (val restored = + restorer.restore(backup) { p, t -> emit(ImportProgress.Running(p, t)) }) { + is Result.Success -> emit(ImportProgress.Succeeded(restored.success)) + is Result.Failure -> emit(ImportProgress.Failed(restored.error)) + } + } + + 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.NONE -> null + + JsonEncryption.PASSPHRASE -> request.passphrase + ?.takeIf(String::isNotBlank) + ?.let { BackupCredential.Passphrase(it.encodeToByteArray()) } + ?: return Result.Failure(ImportError.PassphraseRequired) + + JsonEncryption.ARK -> BackupCredential.Ark( + runCatching { session.ark }.getOrNull() + ?: return Result.Failure(ImportError.SessionLocked), + ) + } + jsonBackupManager.importWithResult(text, credential).bind { it.toImportError() } + } + + FileFormat.CSV -> { + val analysis = csvBackupManager.analyzeWithResult(text) + .bind { it.toImportError() } + + csvBackupManager.importWithResult(text, analysis.suggested) + .bind { it.toImportError() } + .backup + } + } + } + + private fun BackupException.toImportError(): ImportError = when (this) { + is BackupException.Crypto, + is BackupException.CredentialMismatch, + is BackupException.MissingCredential, + is BackupException.UnexpectedCredential, + is BackupException.EncryptionMismatch -> ImportError.WrongCredential + + else -> ImportError.ParseFailed(this) + } +} 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..4c5bacf60 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +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.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 -> statuses.map { it.enrich() } } + + private suspend fun BackupWorkStatus.enrich(): DispatchedBackup { + val workId = when (kind) { + DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID + DispatchedBackup.Kind.OneTime -> id + } + val job = jobRepository.getJob(workId) + return DispatchedBackup( + id = id, + kind = kind, + state = state, + format = job?.format, + destination = job?.let { destinationResolver.resolve(it.uri) }, + progress = progress, + timestamp = job?.let { it.finishedAt ?: it.createdAt } ?: 0L, + ) + } +} 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..00c028ba0 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCase.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.domain.BackupDestinationResolver +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 +internal class ObserveLastBackupUseCase( + private val jobRepository: BackupJobRepository, + private val destinationResolver: BackupDestinationResolver, +) { + + operator fun invoke(): Flow = + jobRepository.observeJobs().map { jobs -> + jobs.filter { it.lastResult == BackupResult.Success && it.finishedAt != null } + .maxByOrNull { it.finishedAt!! } + ?.let { LastBackup(it.finishedAt!!, destinationResolver.resolve(it.uri)) } + } +} 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..d5b15a640 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt @@ -0,0 +1,30 @@ +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.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.worker.BackupWorker +import org.koin.core.annotation.Single + +@Single +internal class RecordBackupOutcomeUseCase( + private val jobRepository: BackupJobRepository, + private val cleanupBackupResources: CleanupBackupResourcesUseCase, +) { + + suspend operator fun invoke(workId: String, terminal: ExportProgress?) { + val result = when (terminal) { + is ExportProgress.Succeeded -> BackupResult.Success + is ExportProgress.Failed -> + if (terminal.error.retryable) return + else BackupResult.Failure + + else -> return + } + jobRepository.markFinished(workId, System.currentTimeMillis(), result) + + // A recurring schedule still needs its credentials for the next run. + if (workId != BackupWorker.RECURRING_WORK_ID) cleanupBackupResources(workId) + } +} 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 index b0daae3d6..a9d7ef515 100644 --- 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 @@ -3,9 +3,13 @@ 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.filled.Lock +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.PhoneAndroid 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.CsvPreset +import de.davis.keygo.feature.backup.domain.model.EncryptionMethod import de.davis.keygo.feature.backup.domain.model.FileFormat internal val FileFormat.displayName @@ -20,3 +24,45 @@ internal val FileFormat.icon 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 + } + ) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt deleted file mode 100644 index 80e8c5108..000000000 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/contract/CreateDynamicDocument.kt +++ /dev/null @@ -1,29 +0,0 @@ -package de.davis.keygo.feature.backup.presentation.contract - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContract -import androidx.annotation.CallSuper -import de.davis.keygo.feature.backup.presentation.export.model.ExportWizardEvent - -internal class CreateDynamicDocument : - ActivityResultContract() { - - @CallSuper - override fun createIntent(context: Context, input: ExportWizardEvent.CreateFile): Intent { - return Intent(Intent.ACTION_CREATE_DOCUMENT) - .setType(input.mimeType) - .putExtra(Intent.EXTRA_TITLE, input.suggestedName) - } - - override fun getSynchronousResult( - context: Context, - input: ExportWizardEvent.CreateFile, - ): SynchronousResult? = null - - override fun parseResult(resultCode: Int, intent: Intent?): Uri? { - return intent.takeIf { resultCode == Activity.RESULT_OK }?.data - } -} \ No newline at end of file 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 index df0875835..857d51e39 100644 --- 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 @@ -207,6 +207,12 @@ internal fun ExportWizardContent( ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( state = state.providePassphraseState, + onEvent = onEvent, + ) + + ExportWizardStep.SelectCsvPreset -> SelectCsvPresetContent( + preset = state.formatState.csvPreset, + onEvent = onEvent, ) ExportWizardStep.Review -> state.formatState.format?.let { format -> @@ -215,6 +221,7 @@ internal fun ExportWizardContent( scheduleState = state.scheduleState, destinationState = state.destinationState, passphraseState = state.providePassphraseState, + csvPreset = state.formatState.csvPreset, ) } } @@ -231,6 +238,7 @@ private val ExportWizardStep.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) } @@ -238,7 +246,9 @@ private class ExportWizardUiStateProvider : PreviewParameterProvider Unit) { viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) } - val filePicker = rememberLauncherForActivityResult(CreateDynamicDocument()) { uri -> - viewModel.onDestinationPicked(uri?.let { BackupDestinationUri(it.toString()) }) - } - ObserveAsEvents(flow = viewModel.event) { when (it) { ExportWizardEvent.Finished -> navigateUp() ExportWizardEvent.PickFolder -> folderPicker.launch(null) - is ExportWizardEvent.CreateFile -> filePicker.launch(it) } } 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 index 1179ea46c..5b7395d4e 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -25,7 +26,6 @@ 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.backupFileName import de.davis.keygo.feature.backup.presentation.export.model.exportStepsFor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -138,18 +138,7 @@ internal class ExportWizardViewModel( } } - ExportWizardUiEvent.ChooseDestination -> { - val pickerEvent = when (_scheduleState.value.mode) { - ScheduleMode.Recurring -> ExportWizardEvent.PickFolder - ScheduleMode.OneTime -> _formatState.value.format?.let { format -> - ExportWizardEvent.CreateFile( - suggestedName = format.backupFileName(), - mimeType = format.mimeType - ) - } ?: return - } - _event.trySend(pickerEvent) - } + ExportWizardUiEvent.ChooseDestination -> _event.trySend(ExportWizardEvent.PickFolder) is ExportWizardUiEvent.FileFormatSelected -> { _formatState.update { @@ -186,6 +175,14 @@ internal class ExportWizardViewModel( 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) + } } } @@ -228,14 +225,17 @@ internal class ExportWizardViewModel( 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 = _scheduleState.value - .takeIf { it.mode == ScheduleMode.Recurring } - ?.interval, + 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, ) } 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 index 2817d9678..bdafb3a5b 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -12,15 +13,19 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Password import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.Surface 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 @@ -28,12 +33,18 @@ 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.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 @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun ProvidePassphraseContent( state: ProvidePassphraseState, + onEvent: (ExportWizardUiEvent) -> Unit, ) { var passphraseHidden by rememberSaveable { mutableStateOf(true) } var confirmPassphraseHidden by rememberSaveable { mutableStateOf(true) } @@ -45,30 +56,62 @@ internal fun ProvidePassphraseContent( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = stringResource(R.string.export_passphrase_instruction), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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 MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColorFor( + if (state.method == method) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh + ), + ), + supportingContent = { + Text(text = method.description) + }, + leadingContent = { + Icon( + imageVector = method.icon, + contentDescription = null, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = method.displayName) + } + } - 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, - ) + AnimatedVisibility(visible = state.method == EncryptionMethod.Passphrase) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(R.string.export_passphrase_instruction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - PassphraseField( - state = state.confirmPassphraseTextFieldState, - label = stringResource(R.string.confirm_passphrase), - hidden = confirmPassphraseHidden, - onToggleHidden = { confirmPassphraseHidden = !confirmPassphraseHidden }, - ) + 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 }, + ) + } + } } } } 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 index a0dd0c4d0..8f6d2044c 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -43,6 +44,8 @@ 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.displayName @@ -60,6 +63,7 @@ internal fun ReviewBackupContent( scheduleState: SelectScheduleState, destinationState: SelectDestinationState, passphraseState: ProvidePassphraseState, + csvPreset: CsvPreset, ) { val encrypted = format.encrypted val recurring = scheduleState.mode == ScheduleMode.Recurring @@ -159,12 +163,30 @@ internal fun ReviewBackupContent( } if (encrypted) { + ReviewDivider() + if (passphraseState.method == EncryptionMethod.Passphrase) + ReviewRow( + icon = Icons.Default.Password, + label = stringResource(R.string.review_section_passphrase), + ) { + StrengthIndicator(passwordScore = passphraseState.passphraseScore) + } + else + ReviewRow( + icon = EncryptionMethod.Ark.icon, + label = stringResource(R.string.review_section_encryption), + ) { + Text(text = EncryptionMethod.Ark.displayName) + } + } + + if (format == FileFormat.CSV) { ReviewDivider() ReviewRow( - icon = Icons.Default.Password, - label = stringResource(R.string.review_section_passphrase), + icon = Icons.AutoMirrored.Default.List, + label = stringResource(R.string.review_section_csv_preset), ) { - StrengthIndicator(passwordScore = passphraseState.passphraseScore) + Text(text = csvPreset.displayName) } } } @@ -316,6 +338,7 @@ private fun ReviewBackupContentPreview( confirmPassphraseTextFieldState = TextFieldState(), passphraseScore = PasswordScore.Strong, ), + csvPreset = CsvPreset.Browser, ) } } 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..40559fc28 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/SelectCsvPresetContent.kt @@ -0,0 +1,59 @@ +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.ExperimentalMaterial3ExpressiveApi +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 de.davis.keygo.feature.backup.domain.model.CsvPreset +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 + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SelectCsvPresetContent( + preset: CsvPreset, + onEvent: (ExportWizardUiEvent) -> Unit, +) { + Surface { + 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 MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColorFor( + if (preset == candidate) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh + ), + ), + 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 index 72a96cb3b..8fddbf6ae 100644 --- 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -61,10 +60,8 @@ internal fun SelectDestinationContent( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - val isFile = scheduleState.mode == ScheduleMode.OneTime when (val destination = state.destination) { null -> DestinationChooserCard( - isFile = isFile, onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, ) @@ -81,7 +78,7 @@ internal fun SelectDestinationContent( } @Composable -private fun DestinationChooserCard(isFile: Boolean, onChoose: () -> Unit) { +private fun DestinationChooserCard(onChoose: () -> Unit) { Card( onClick = onChoose, modifier = Modifier @@ -103,7 +100,7 @@ private fun DestinationChooserCard(isFile: Boolean, onChoose: () -> Unit) { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = if (isFile) Icons.Default.FileUpload else Icons.Default.CreateNewFolder, + imageVector = Icons.Default.CreateNewFolder, contentDescription = null, ) Text( @@ -111,19 +108,13 @@ private fun DestinationChooserCard(isFile: Boolean, onChoose: () -> Unit) { style = MaterialTheme.typography.titleMedium, ) Text( - text = stringResource( - if (isFile) R.string.destination_choose_file_subtitle - else R.string.destination_choose_subtitle, - ), + text = stringResource(R.string.destination_choose_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), textAlign = TextAlign.Center, ) Text( - text = stringResource( - if (isFile) R.string.destination_choose_file_action - else R.string.destination_choose_action, - ), + text = stringResource(R.string.destination_choose_action), style = MaterialTheme.typography.labelLarge, ) } 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 index a1b1b8112..8cd6c8463 100644 --- 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 @@ -1,9 +1,9 @@ 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 -internal const val BACKUP_BASE_NAME = "keygo-backup" - +/** An illustrative document name shown on the destination card (actual files are timestamped). */ internal fun FileFormat?.backupFileName(): String = when (this) { FileFormat.JSON -> "$BACKUP_BASE_NAME.json" FileFormat.CSV -> "$BACKUP_BASE_NAME.csv" 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 index bf621bed4..0dc8b630a 100644 --- 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 @@ -4,5 +4,4 @@ internal sealed interface ExportWizardEvent { data object Finished : ExportWizardEvent data object PickFolder : ExportWizardEvent - data class CreateFile(val suggestedName: String, val mimeType: String) : 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 index fb955e413..b17c8c1d5 100644 --- 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 @@ -7,10 +7,15 @@ internal enum class ExportWizardStep { Schedule, SelectDestination, ProvidePassphrase, + SelectCsvPreset, Review, } internal fun exportStepsFor(format: FileFormat?): List = ExportWizardStep.entries.filter { step -> - step != ExportWizardStep.ProvidePassphrase || (format?.encrypted ?: true) + 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 index 6dd104694..b31071267 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -13,4 +15,6 @@ internal sealed interface 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 index 5a74706b2..8ebd148f7 100644 --- 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 @@ -1,6 +1,7 @@ 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( @@ -14,7 +15,9 @@ internal data class ExportWizardUiState( val canContinue: Boolean = when (step) { ExportWizardStep.SelectDestination -> destinationState.destination != null - ExportWizardStep.ProvidePassphrase -> providePassphraseState.valid + ExportWizardStep.ProvidePassphrase -> + providePassphraseState.method == EncryptionMethod.Ark || providePassphraseState.valid + else -> true } 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 index a467dea27..0ca6bb950 100644 --- 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 @@ -3,6 +3,7 @@ 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( @@ -10,4 +11,5 @@ internal data class ProvidePassphraseState( 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/SelectFormatState.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/model/SelectFormatState.kt index 460b5888e..a7830f878 100644 --- 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 @@ -1,9 +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 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/hub/BackupGrouping.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt new file mode 100644 index 000000000..084e517f4 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGrouping.kt @@ -0,0 +1,20 @@ +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 + +private val GROUP_ORDER = listOf( + DispatchedBackup.State.Running, + DispatchedBackup.State.Failed, + DispatchedBackup.State.Enqueued, + DispatchedBackup.State.Cancelled, + DispatchedBackup.State.Succeeded, +) + +internal fun List.toGroups(): List { + val byState = groupBy { it.state } + return GROUP_ORDER.mapNotNull { state -> + val items = byState[state]?.sortedByDescending { it.timestamp } ?: return@mapNotNull null + BackupGroup(state, 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 index 36cd7f95b..4f9bb9948 100644 --- 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 @@ -1,6 +1,8 @@ package de.davis.keygo.feature.backup.presentation.hub +import android.text.format.DateUtils import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.wrapContentHeight 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.History import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.SettingsBackupRestore @@ -22,6 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -37,21 +42,17 @@ import androidx.compose.ui.res.stringResource 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.displayName -import de.davis.keygo.feature.backup.domain.model.BackupInterval +import de.davis.keygo.feature.backup.domain.model.BackupDestination +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.domain.model.IntervalUnit +import de.davis.keygo.feature.backup.domain.model.LastBackup 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.ScheduledBackup -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEvent) -> Unit) { Scaffold { innerPadding -> @@ -74,21 +75,17 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven disabledSupportingContentColor = ListItemDefaults.colors().supportingContentColor, ), enabled = false, - overlineContent = { - Text(text = stringResource(R.string.last_backup)) - }, + overlineContent = { Text(text = stringResource(R.string.last_backup)) }, leadingContent = { Box(modifier = Modifier.wrapContentHeight()) { - Icon( - imageVector = Icons.Default.History, - contentDescription = null, - ) + Icon(imageVector = Icons.Default.History, contentDescription = null) } }, - supportingContent = state.lastBackupProvider?.let { { Text(text = it) } }, - verticalAlignment = Alignment.CenterVertically + supportingContent = state.lastBackup?.let { { Text(text = it.destination.displayText()) } }, + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = state.lastBackupAt ?: stringResource(R.string.never_backed_up)) + Text(text = state.lastBackup?.let { relativeTime(it.finishedAt) } + ?: stringResource(R.string.never_backed_up)) } FilledTonalButton( @@ -101,9 +98,7 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven contentDescription = null, ) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.restore_backup), - ) + Text(text = stringResource(R.string.restore_backup)) } FilledTonalButton( @@ -116,49 +111,40 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven contentDescription = null, ) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.schedule_new_backup), - ) + Text(text = stringResource(R.string.schedule_new_backup)) } HorizontalDivider() Text( - text = stringResource(R.string.scheduled_backups), + text = stringResource(R.string.dispatched_backups), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(), ) - Surface( - modifier = Modifier.weight(1f), - ) { - AnimatedContent(state.hasScheduledItems) { hasItems -> + Surface(modifier = Modifier.weight(1f)) { + AnimatedContent(state.hasItems) { hasItems -> when { hasItems -> LazyColumn( verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { - itemsIndexed(items = state.scheduledBackups) { idx, item -> - SegmentedListItem( - onClick = {}, - shapes = ListItemDefaults.segmentedShapes( - idx, - state.scheduledBackups.size - ), - colors = ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - overlineContent = { - Text(text = item.type.displayName) - }, - supportingContent = { - Text(text = item.path) - }, - ) { - Text( - text = stringResource( - R.string.scheduled_title, - item.provider, - item.scheduleInterval.displayName - ) + state.groups.forEach { group -> + stickyHeader(key = "header-${group.state}") { + BackupGroupHeader(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)) + }, ) } } @@ -169,7 +155,7 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven contentAlignment = Alignment.Center, ) { Text( - text = stringResource(R.string.no_scheduled_backups), + text = stringResource(R.string.no_dispatched_backups), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -181,42 +167,127 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven } } +@Composable +private fun BackupGroupHeader(group: BackupGroup) { + Surface(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(group.state.label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 4.dp), + ) + } +} -@Preview +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun BackupHubContentPreview() { - val currentTime = { - val zonedDateTime = Instant.now().atZone(ZoneId.systemDefault()) - val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) - .withLocale(Locale.getDefault()) - zonedDateTime.format(formatter) +private fun DispatchedBackupRow( + item: DispatchedBackup, + index: Int, + count: Int, + onCancel: () -> Unit, +) { + val active = item.state == DispatchedBackup.State.Enqueued || + item.state == DispatchedBackup.State.Running + + SegmentedListItem( + onClick = {}, + shapes = ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + overlineContent = { Text(text = item.destination.displayText()) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + item.format?.let { Text(text = it.displayName) } + if (item.state == DispatchedBackup.State.Running) + BackupProgress(progress = item.progress) + } + }, + trailingContent = if (active) { + { + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel_backup), + ) + } + } + } else null, + ) { + Text(text = stringResource(item.kind.label)) } +} +@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, null -> + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +private fun relativeTime(epochMillis: Long): String = + DateUtils.getRelativeTimeSpanString( + epochMillis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + +@Preview +@Composable +private fun BackupHubContentPreview() { MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize() - ) { + Surface(modifier = Modifier.fillMaxSize()) { BackupHubContent( state = BackupHubUiState( - lastBackupAt = currentTime(), - lastBackupProvider = "Nextcloud", - scheduledBackups = listOf( - ScheduledBackup( - provider = "Nextcloud", - type = FileFormat.CSV, - scheduleInterval = BackupInterval(count = 1, unit = IntervalUnit.Weeks), - path = "/path/to/backup" + lastBackup = LastBackup( + finishedAt = System.currentTimeMillis(), + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", ), - ScheduledBackup( - provider = "Google Drive", - type = FileFormat.JSON, - scheduleInterval = BackupInterval(count = 1, unit = IntervalUnit.Days), - path = "/path/to/drive/backup" - ) - ) + ), + groups = listOf( + DispatchedBackup( + id = "1", + kind = DispatchedBackup.Kind.Recurring, + state = DispatchedBackup.State.Running, + format = FileFormat.JSON, + destination = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", + ), + progress = ExportProgress.Running(2, 5), + timestamp = 2L, + ), + DispatchedBackup( + id = "2", + kind = DispatchedBackup.Kind.OneTime, + state = DispatchedBackup.State.Succeeded, + format = FileFormat.CSV, + destination = BackupDestination( + provider = BackupDestination.Provider.OnDevice, + displayPath = "Internal storage/Backups", + ), + progress = null, + timestamp = 1L, + ), + ).toGroups(), ), 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 index 1ff9326e6..f8c90e7ef 100644 --- 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 @@ -1,28 +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.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +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 : ViewModel() { +internal class BackupHubViewModel( + observeDispatchedBackups: ObserveDispatchedBackupsUseCase, + observeLastBackup: ObserveLastBackupUseCase, + private val cancelBackup: CancelBackupUseCase, +) : ViewModel() { private val _event = Channel() val event = _event.receiveAsFlow() - private val _state = MutableStateFlow(BackupHubUiState()) - val state = _state.asStateFlow() + 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.OnRestoreBackup -> {} //TODO - BackupHubUiEvent.OnScheduleBackupClick -> _event.trySend(BackupHubEvent.NavigateToExport) + BackupHubUiEvent.OnScheduleBackupClick -> + _event.trySend(BackupHubEvent.NavigateToExport) + + BackupHubUiEvent.OnRestoreBackup -> {} // TODO: restore flow (existing placeholder) + + is BackupHubUiEvent.OnCancelBackup -> + viewModelScope.launch { cancelBackup(event.id, event.kind) } } } -} \ No newline at end of file +} 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..ce58b4644 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import androidx.annotation.StringRes +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.BackupDestination +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup + +@get:StringRes +internal val DispatchedBackup.State.label: Int + get() = when (this) { + DispatchedBackup.State.Enqueued -> R.string.backup_state_enqueued + DispatchedBackup.State.Running -> R.string.backup_state_running + DispatchedBackup.State.Succeeded -> R.string.backup_state_succeeded + DispatchedBackup.State.Failed -> R.string.backup_state_failed + DispatchedBackup.State.Cancelled -> R.string.backup_state_cancelled + } + +@get:StringRes +internal val DispatchedBackup.Kind.label: Int + get() = when (this) { + DispatchedBackup.Kind.OneTime -> R.string.backup_kind_one_time + DispatchedBackup.Kind.Recurring -> R.string.backup_kind_recurring + } + +@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..53b56f99d --- /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 state: DispatchedBackup.State, + val items: List, +) 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 index 6592aec9a..43b1e334b 100644 --- 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 @@ -1,6 +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 -} \ No newline at end of file + 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 index 84000a51d..cb22b99ec 100644 --- 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 @@ -1,21 +1,12 @@ package de.davis.keygo.feature.backup.presentation.hub.model import androidx.compose.runtime.Stable -import de.davis.keygo.feature.backup.domain.model.BackupInterval -import de.davis.keygo.feature.backup.domain.model.FileFormat +import de.davis.keygo.feature.backup.domain.model.LastBackup @Stable internal data class BackupHubUiState( - val lastBackupAt: String? = null, - val lastBackupProvider: String? = null, - val scheduledBackups: List = emptyList(), + val lastBackup: LastBackup? = null, + val groups: List = emptyList(), ) { - val hasScheduledItems = scheduledBackups.isNotEmpty() + val hasItems: Boolean get() = groups.isNotEmpty() } - -internal data class ScheduledBackup( - val provider: String, - val type: FileFormat, - val scheduleInterval: BackupInterval, - val path: String, -) 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 index 573867a95..fea63c4cb 100644 --- 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 @@ -1,17 +1,33 @@ 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.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?): ListenableWorker.Result = when (terminal) { + is ExportProgress.Succeeded -> ListenableWorker.Result.success() + is ExportProgress.Failed -> + if (terminal.error.retryable) 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 @@ -21,9 +37,16 @@ internal class BackupWorker( val workId = if (isRecurring) RECURRING_WORK_ID else id.toString() val job = backupJobRepository.getJob(workId) ?: return Result.failure() - // TODO: perform the actual export using job.uri / job.format / job.passphrase - Log.d("BackupWorker", "doing work: $job") - return Result.success() + var terminal: ExportProgress? = null + exportBackup(job).collect { progress -> + if (progress is ExportProgress.InFlight) + setProgress(progress.toProgressData()) + terminal = progress + } + + recordOutcome(workId, terminal) + + return resultFor(terminal) } companion object { @@ -34,4 +57,4 @@ internal class BackupWorker( const val TAG_RECURRING = "backup_recurring" const val TAG_ONE_TIME = "backup_one_time" } -} \ No newline at end of file +} 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 index 90f7fa750..ace8a55d9 100644 --- a/feature/backup/src/main/proto/backup_jobs.proto +++ b/feature/backup/src/main/proto/backup_jobs.proto @@ -11,6 +11,19 @@ message ProtoBackupJob { 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; } message ProtoBackupJobs { diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 5e46da5d9..29b99bc5e 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -7,15 +7,24 @@ Schedule new Backup Restore Backup - Scheduled Backups - No Scheduled Backups - Never Backed Up CSV JSON - %1$s \u2022 %2$s + Backups + No backups dispatched + Cancel + %1$d / %2$d + + Queued + Running + Completed + Failed + Cancelled + + One-time + Recurring %1$s Backup @@ -37,10 +46,21 @@ 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. + + 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 @@ -49,8 +69,6 @@ Choose where to save Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. Choose location - Pick a file location on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. - Choose file Change Saved as @@ -94,6 +112,7 @@ Encryption Contents Passphrase strength + CSV layout One-time 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..2a55e05fc --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt @@ -0,0 +1,102 @@ +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 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) = Vault( + id = id, + name = name, + keyInformation = emptyKey, + icon = Vault.Icon.Default, +) + +fun testLogin( + vaultId: VaultId, + id: ItemId = newItemId(), + name: String, + username: String? = null, + password: String? = null, + totpSecret: String? = null, + website: String? = null, + tags: Set = emptySet(), + note: String? = null, + passkeyRPs: Set = emptySet(), +) = Login( + id = id, + vaultId = vaultId, + name = name, + username = username, + domainInfos = website?.let { setOf(DomainInfo(loginId = id, value = it, eTLD1 = null)) }.orEmpty(), + 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/FakeBackupArkKeyStore.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt new file mode 100644 index 000000000..c39247904 --- /dev/null +++ b/feature/backup/src/test/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/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt new file mode 100644 index 000000000..78df5f507 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FakeBackupArkKeyStoreTest { + + @Test + fun `load returns null before anything is saved`() = runTest { + assertNull(FakeBackupArkKeyStore().load()) + } + + @Test + fun `save then load round-trips the data`() = runTest { + val store = FakeBackupArkKeyStore() + val data = CryptographicData(byteArrayOf(1, 2, 3), byteArrayOf(9, 8)) + store.save(data) + assertEquals(data, store.load()) + } + + @Test + fun `clear removes stored data and counts`() = runTest { + val store = FakeBackupArkKeyStore(CryptographicData(byteArrayOf(1), byteArrayOf(2))) + store.clear() + assertNull(store.load()) + assertEquals(1, store.clearCount) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt new file mode 100644 index 000000000..04f6bb292 --- /dev/null +++ b/feature/backup/src/test/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/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt new file mode 100644 index 000000000..760bd8e6b --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt @@ -0,0 +1,59 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class FakeBackupFileStoreTest { + + private val folder = BackupDestinationUri("content://tree") + + @Test + fun `writeNewDocument records the call and lists the new document`() = runTest { + val store = FakeBackupFileStore() + + val result = + store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "hello") + + assertIs>(result) + assertEquals("hello", store.writtenText) + assertEquals("keygo-backup-1.json", store.writtenFileName) + assertEquals(listOf("keygo-backup-1.json"), store.backups.map { it.name }) + } + + @Test + fun `listBackups filters by base name`() = runTest { + val store = FakeBackupFileStore() + store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "a") + store.writeNewDocument(folder, "unrelated.txt", "text/plain", "b") + + val listed = + assertIs, Throwable>>(store.listBackups(folder, "keygo-backup")) + + assertEquals( + listOf("keygo-backup-1.json"), + store.backups.filter { it.name.startsWith("keygo-backup") }.map { it.name }) + assertEquals(1, listed.success.size) + } + + @Test + fun `delete removes the document`() = runTest { + val store = FakeBackupFileStore() + store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "a") + val entry = store.backups.single() + + store.delete(entry.uri) + + assertEquals(emptyList(), store.backups) + assertEquals(listOf(entry.uri), store.deleted) + } + + @Test + fun `read surfaces the configured error`() = runTest { + val store = FakeBackupFileStore().apply { readError = RuntimeException("boom") } + assertIs>(store.read(BackupDestinationUri("content://doc"))) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt new file mode 100644 index 000000000..ae3fdf952 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.core.util.Result +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: String): BackupJob? = jobs[workId] + + override suspend fun getJobs(): Map = jobs.toMap() + + override fun observeJobs(): Flow> = flow { emit(jobs.values.toList()) } + + override suspend fun putJob(workId: String, job: BackupJob): Result { + jobs[workId] = job.copy(createdAt = now()) + return Result.Success(Unit) + } + + override suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) { + val existing = jobs[workId] ?: return + jobs[workId] = existing.copy(finishedAt = finishedAt, lastResult = result) + } + + override suspend fun markCancelled(workId: String, cancelledAt: Long) { + val existing = jobs[workId] ?: return + jobs[workId] = existing.copy(cancelled = true, finishedAt = cancelledAt) + } + + override suspend fun clearPassphrase(workId: String) { + val existing = jobs[workId] ?: return + jobs[workId] = existing.copy(wrappedPassphrase = null) + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt new file mode 100644 index 000000000..d9e3b6378 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt @@ -0,0 +1,55 @@ +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.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: String = "one-time", +) : BackupScheduler { + + var recurringJob: BackupJob? = null + var recurringInterval: BackupInterval? = null + var oneTimeJob: BackupJob? = null + var cancelled = false + var result: Result = Result.Success(Unit) + + 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: String, job: BackupJob): Result { + gate?.await() + if (result is Result.Failure) return result + jobRepository?.putJob(workId, job) + return result + } +} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt new file mode 100644 index 000000000..389c7ce38 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.backup + +import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus +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/test/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt new file mode 100644 index 000000000..b08d01a9a --- /dev/null +++ b/feature/backup/src/test/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/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..88900bb78 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt @@ -0,0 +1,59 @@ +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.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() + 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, + ) +} 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..d243f38f5 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/BackupJobMapperTest.kt @@ -0,0 +1,164 @@ +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.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 `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) + } +} 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..d651d34b4 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/mapper/DispatchedBackupMapperTest.kt @@ -0,0 +1,89 @@ +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 `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/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..ce9eb0338 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt @@ -0,0 +1,233 @@ +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.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.test.runTest +import java.time.YearMonth +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", + website = "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("https://mail.example", login.website) + assertEquals(listOf("work"), login.tags) + assertEquals("remember", login.notes) + assertEquals(1, collected.itemCount) + } + + @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 `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) + } + + @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..0f627a11e --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupRestorerTest.kt @@ -0,0 +1,111 @@ +package de.davis.keygo.feature.backup.domain + +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.RestorerTestEnv +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.BackupCard +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) = BackupLogin( + title = title, + notes = null, + tags = emptyList(), + pinned = false, + username = username, + password = "pw", + totpSecret = null, + website = null, + passkeys = emptyList(), + ) + + private fun backup(vararg vaults: BackupVault) = Backup(vaults.toList()) + private fun vault(name: String, logins: List) = + BackupVault(name = name, logins = logins, cards = emptyList()) + + @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 `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 `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) + } +} + +private suspend fun FakeLoginRepository.observeLoginsCount(): Int = + observeLogins().first().size 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..fd6a30f08 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupProvisioningSerializationTest.kt @@ -0,0 +1,115 @@ +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.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() + + // 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", + ), + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + session = session, + arkKeyStore = arkKeyStore, + provisioningLock = lock, + ) + + private val cleanup = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = lock, + ) + + @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..256394690 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/BackupWorkActionsUseCaseTest.kt @@ -0,0 +1,70 @@ +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.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 cancelBackup = CancelBackupUseCase( + repository = repository, + jobRepository = jobRepository, + cleanupBackupResources = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = FakeBackupArkKeyStore(), + keyStoreManager = FakeKeyStoreManager(), + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + ), + ) + + 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..db8ad4580 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCaseTest.kt @@ -0,0 +1,224 @@ +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.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.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 useCase = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + ) + + 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 `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(), + ) + + 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(), + ) + 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: String): Unit = throw IOException("boom") + } + val useCase = CleanupBackupResourcesUseCase( + jobRepository = failingClear, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + ) + 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..4fbcd4c0d --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ExportBackupUseCaseTest.kt @@ -0,0 +1,306 @@ +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 `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..51245ccef --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCaseTest.kt @@ -0,0 +1,173 @@ +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.domain.BackupProvisioningLock +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 fun useCase() = FinishExportWizardUseCase( + backupScheduler = scheduler, + 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 `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..d88c54141 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt @@ -0,0 +1,287 @@ +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.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.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.BackupVault +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.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.assertNull + +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, + website = null, + 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 MissingCredential to WrongCredential`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importException = BackupException.MissingCredential() + + val emissions = useCase()(jsonRequest()).toList() + + assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) + } + + @Test + fun `json parse failure maps UnexpectedCredential to WrongCredential`() = runTest { + fileStore.contents = """{"vaults":[]}""" + json.importException = BackupException.UnexpectedCredential() + + 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")), emptyList()))) + + 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")), emptyList()))), + 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 analyze failure maps EncryptionMismatch to WrongCredential`() = runTest { + fileStore.contents = "name\nEmail\n" + csv.analyzeException = BackupException.EncryptionMismatch() + + 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")), emptyList())), + ) + + 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")), emptyList()))) + 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 `plaintext json imports without a credential`() = runTest { + fileStore.contents = "{}" + json.inspectResult = JsonEncryption.NONE + json.importResult = Backup(listOf(BackupVault("V", listOf(login("A")), emptyList()))) + + val emissions = useCase()(jsonRequest(passphrase = null)).toList() + + assertIs(emissions.last()) + assertNull(json.importCalls.single().credential) + } + + @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, + ) + } +} 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..21e7186e0 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt @@ -0,0 +1,126 @@ +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.BackupJob +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, + progress: ExportProgress.InFlight? = null, + ) = BackupWorkStatus(id, kind, state, progress) + + @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 `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 `progress passes through unchanged`() = runTest { + repository.statuses.value = listOf( + status(id = "w", progress = ExportProgress.Running(3, 7)), + ) + + val result = useCase().first().single() + + assertEquals(ExportProgress.Running(3, 7), result.progress) + } + + @Test + fun `writing progress passes through unchanged`() = runTest { + repository.statuses.value = listOf( + status(id = "w", progress = ExportProgress.Writing), + ) + + val result = useCase().first().single() + + assertEquals(ExportProgress.Writing, result.progress) + } + + @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) + } +} 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..a96f84db3 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveLastBackupUseCaseTest.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.feature.backup.domain.usecase + +import de.davis.keygo.feature.backup.FakeBackupJobRepository +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.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 destinationResolver = FakeBackupDestinationResolver() + private val useCase = ObserveLastBackupUseCase(jobRepository, destinationResolver) + + 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) + destinationResolver.result = BackupDestination( + provider = BackupDestination.Provider.ThirdParty("Drive"), + displayPath = "Drive/Backups", + ) + + val last = useCase().first() + + assertEquals(300L, last?.finishedAt) + assertEquals(BackupDestinationUri("content://rec.json"), destinationResolver.lastUri) + assertEquals(destinationResolver.result, last?.destination) + } + + @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..364aa3e1f --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCaseTest.kt @@ -0,0 +1,141 @@ +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.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.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 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 useCase = RecordBackupOutcomeUseCase( + jobRepository = jobRepository, + cleanupBackupResources = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = arkKeyStore, + keyStoreManager = keyStoreManager, + persistableUriManager = uriManager, + provisioningLock = BackupProvisioningLock(), + ), + ) + + 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, 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 `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 { + jobRepository.jobs[BackupWorker.RECURRING_WORK_ID] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 1L, + ) + + useCase(BackupWorker.RECURRING_WORK_ID, ExportProgress.Succeeded(itemCount = 1)) + + assertTrue(uriManager.released.isEmpty()) + } + + @Test + fun `a late outcome on a cancelled recurring schedule cleans nothing`() = 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, + cancelled = true, + ) + + useCase(BackupWorker.RECURRING_WORK_ID, ExportProgress.Succeeded(itemCount = 1)) + + assertTrue(uriManager.released.isEmpty()) + assertTrue(keyStoreManager.keys.isEmpty()) + val saved = jobRepository.jobs.getValue(BackupWorker.RECURRING_WORK_ID) + assertNotNull(saved.wrappedPassphrase) + assertContentEquals(wrappedPassphrase.data, saved.wrappedPassphrase.data) + assertContentEquals(wrappedPassphrase.iv, saved.wrappedPassphrase.iv) + } +} 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..89ef3cdce --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt @@ -0,0 +1,51 @@ +package de.davis.keygo.feature.backup.presentation.hub + +import de.davis.keygo.feature.backup.domain.model.DispatchedBackup +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackupGroupingTest { + + private fun item( + id: String, + state: DispatchedBackup.State, + timestamp: Long = 0L, + ) = DispatchedBackup( + id = id, + kind = DispatchedBackup.Kind.OneTime, + state = state, + format = null, + destination = null, + progress = null, + timestamp = timestamp, + ) + + @Test + fun `groups appear in fixed order and skip empties`() { + val groups = listOf( + item("a", DispatchedBackup.State.Succeeded), + item("b", DispatchedBackup.State.Running), + item("c", DispatchedBackup.State.Failed), + ).toGroups() + + assertEquals( + listOf( + DispatchedBackup.State.Running, + DispatchedBackup.State.Failed, + DispatchedBackup.State.Succeeded, + ), + groups.map { it.state }, + ) + } + + @Test + fun `items within a group 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..e8d5e0d89 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt @@ -0,0 +1,103 @@ +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.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.BackupHubUiEvent +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 fun viewModel() = BackupHubViewModel( + observeDispatchedBackups = + ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver), + observeLastBackup = ObserveLastBackupUseCase(jobRepository, destinationResolver), + cancelBackup = CancelBackupUseCase( + repository = repository, + jobRepository = jobRepository, + cleanupBackupResources = CleanupBackupResourcesUseCase( + jobRepository = jobRepository, + arkKeyStore = FakeBackupArkKeyStore(), + keyStoreManager = FakeKeyStoreManager(), + persistableUriManager = FakePersistableUriManager(), + provisioningLock = BackupProvisioningLock(), + ), + ), + ) + + @Test + fun `dispatched workers are grouped in the ui state`() = runTest(dispatcher) { + repository.statuses.value = listOf( + BackupWorkStatus("w1", DispatchedBackup.Kind.OneTime, DispatchedBackup.State.Running, null), + ) + val vm = viewModel() + + val state = vm.state.first { it.hasItems } + + assertEquals(DispatchedBackup.State.Running, state.groups.single().state) + 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) + } +} 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..0fcf411db --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/worker/BackupWorkerResultTest.kt @@ -0,0 +1,35 @@ +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))) + } + + @Test + fun `session locked retries`() { + assertIs(resultFor(ExportProgress.Failed(ExportError.SessionLocked))) + } + + @Test + fun `not provisioned fails`() { + assertIs(resultFor(ExportProgress.Failed(ExportError.NotProvisioned))) + } + + @Test + fun `succeeded returns success`() { + assertIs(resultFor(ExportProgress.Succeeded(1))) + } + + @Test + fun `null terminal fails`() { + assertIs(resultFor(null)) + } +} diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs index 207dca239..c75e738ed 100644 --- a/rust/rust-code/bindings/src/backup.rs +++ b/rust/rust-code/bindings/src/backup.rs @@ -25,7 +25,7 @@ pub struct BackupLogin { pub password: Option, pub totp_secret: Option, pub website: Option, - pub passkey: Option, + pub passkeys: Vec, } #[derive(uniffi::Record)] @@ -85,7 +85,7 @@ impl From for BackupLogin { password: l.password, totp_secret: l.totp_secret, website: l.website, - passkey: l.passkey.map(Into::into), + passkeys: l.passkeys.into_iter().map(Into::into).collect(), } } } @@ -101,7 +101,7 @@ impl From for core::Login { password: l.password, totp_secret: l.totp_secret, website: l.website, - passkey: l.passkey.map(Into::into), + passkeys: l.passkeys.into_iter().map(Into::into).collect(), } } } @@ -233,6 +233,13 @@ pub enum ExportPreset { Browser, } +#[derive(uniffi::Enum)] +pub enum JsonEncryption { + None, + Passphrase, + Ark, +} + impl From for Confidence { fn from(c: core::Confidence) -> Self { match c { @@ -415,6 +422,14 @@ impl JsonBackupManager { }?; Ok(backup.into()) } + + pub fn inspect(&self, data: String) -> Result { + Ok(match core::json::inspect(&data)? { + None => JsonEncryption::None, + Some(core::encryption::KeySource::Passphrase) => JsonEncryption::Passphrase, + Some(core::encryption::KeySource::Ark) => JsonEncryption::Ark, + }) + } } #[derive(uniffi::Object)] diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs index 5ffbc21d6..2eb52e21a 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -17,7 +17,7 @@ pub struct EncryptionHeader { pub nonce: Vec, } -#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] #[serde(rename_all = "snake_case")] pub enum KeySource { Passphrase, @@ -324,7 +324,7 @@ mod tests { 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 + // 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; diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 15cfb0f98..2f54bd107 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -1,5 +1,5 @@ use crate::b64; -use crate::backup::encryption::{self, BackupCredential, EncryptionHeader}; +use crate::backup::encryption::{self, BackupCredential, EncryptionHeader, KeySource}; use crate::backup::{Backup, BackupError, CURRENT_VERSION, MIN_SUPPORTED_VERSION}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; @@ -60,11 +60,25 @@ pub fn import(data: &str, cred: Option>) -> Result Result, BackupError> { + let env: BackupEnvelope = serde_json::from_str(data)?; + if !(MIN_SUPPORTED_VERSION..=CURRENT_VERSION).contains(&env.version) { + return Err(BackupError::UnsupportedVersion(env.version)); + } + match (&env.encryption, &env.payload) { + (None, Payload::Plain(_)) => Ok(None), + (Some(header), Payload::Encrypted(_)) => Ok(Some(header.source)), + _ => Err(BackupError::EncryptionMismatch), + } +} + #[cfg(test)] mod tests { use super::*; use crate::backup::encryption::{Kdf, KeySource}; - use crate::backup::{Card, Login, Vault}; + use crate::backup::{Card, Login, Passkey, Vault}; use crate::crypto::error::CryptoError; use crate::crypto::key::KeyMaterial; use crate::crypto::keys::AccountRootKey; @@ -82,7 +96,7 @@ mod tests { password: Some("s3cr3t-password".into()), totp_secret: None, website: Some("https://mail.example".into()), - passkey: None, + passkeys: vec![], }], cards: vec![Card { title: "Visa".into(), @@ -107,7 +121,7 @@ mod tests { } // 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 + // 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"; @@ -133,6 +147,47 @@ mod tests { 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, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let restored = import(&json, Some(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 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, + Some(BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)), + ) + .unwrap(); + assert!(backup.vaults[0].logins[0].passkeys.is_empty()); + } + #[test] fn version_below_minimum_fails() { let json = export(&sample_backup(), None).unwrap(); @@ -364,4 +419,52 @@ mod tests { assert!(matches!(header.source, KeySource::Passphrase)); assert!(matches!(header.kdf, Kdf::Argon2id { .. })); } + + #[test] + fn inspect_reports_plaintext() { + let json = export(&sample_backup(), None).unwrap(); + assert!(matches!(inspect(&json).unwrap(), None)); + } + + #[test] + fn inspect_reports_passphrase() { + let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + assert!(matches!(inspect(&json).unwrap(), Some(KeySource::Passphrase))); + } + + #[test] + fn inspect_reports_ark() { + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let json = export(&sample_backup(), Some(BackupCredential::Ark(&ark))).unwrap(); + assert!(matches!(inspect(&json).unwrap(), Some(KeySource::Ark))); + } + + #[test] + fn inspect_rejects_unsupported_version() { + let json = export(&sample_backup(), None).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(_))); + } + + #[test] + fn inspect_rejects_header_with_plain_payload() { + let sealed = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let plain = export(&sample_backup(), None).unwrap(); + let mut sealed_v: serde_json::Value = serde_json::from_str(&sealed).unwrap(); + let plain_v: serde_json::Value = serde_json::from_str(&plain).unwrap(); + sealed_v["payload"] = plain_v["payload"].clone(); + assert!(matches!( + inspect(&sealed_v.to_string()).unwrap_err(), + BackupError::EncryptionMismatch, + )); + } } diff --git a/rust/rust-code/lib/src/backup/mod.rs b/rust/rust-code/lib/src/backup/mod.rs index 84bbb3d34..6023e3ed0 100644 --- a/rust/rust-code/lib/src/backup/mod.rs +++ b/rust/rust-code/lib/src/backup/mod.rs @@ -19,7 +19,7 @@ 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 +/// 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 index 73877ebf4..07f95a4ff 100644 --- a/rust/rust-code/lib/src/backup/model.rs +++ b/rust/rust-code/lib/src/backup/model.rs @@ -44,7 +44,10 @@ backup_item! { pub password: Option, pub totp_secret: Option, pub website: Option, - pub passkey: Option, + /// 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, } } diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index 5624528a6..d52da3b67 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -61,7 +61,7 @@ pub(crate) fn derive_argon2id( /// 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_with_params( password: &[u8], diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs index 15d154070..ebe6856ed 100644 --- a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs +++ b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs @@ -14,8 +14,8 @@ pub const DERIVED_KEY_LEN: usize = 32; /// 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. +/// 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], 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 index 3502fd18f..e09c550ac 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -5,6 +5,7 @@ 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,3 +34,14 @@ suspend fun JsonBackupManagerInterface.importWithResult( onFailure = { Result.Failure(it as BackupException) } ) } + +suspend fun JsonBackupManagerInterface.inspectWithResult( + data: String, +): Result = withContext(Dispatchers.Default) { + runCatching { + inspect(data) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as BackupException) } + ) +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt index 8e4b8f0f0..ba94c4a8f 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt @@ -12,6 +12,14 @@ 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), @@ -27,16 +35,19 @@ class FakeCsvBackupManager : CsvBackupManagerInterface { 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/FakeJsonBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt index df676aa77..6fefa780f 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt @@ -4,6 +4,7 @@ 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 { @@ -12,21 +13,38 @@ class FakeJsonBackupManager : JsonBackupManagerInterface { 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 override fun export(backup: Backup, credential: BackupCredential?): String { - exportCalls += ExportCall(backup, credential) + exportCalls += ExportCall(backup, credential.snapshot()) exportException?.let { throw it } return exportResult } override fun import(data: String, credential: BackupCredential?): Backup { - importCalls += ImportCall(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) { + null -> null + 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 + } } From 70d364b9d8a9107354832edd7703056085d321cd Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 22 Jul 2026 23:52:32 +0200 Subject: [PATCH 28/59] feat(backup): improve UI fix(backup): don't show a failure reason on a cancelled schedule Co-Authored-By: Claude Opus 4.8 feat(backup): show why a backup failed on the hub row feat(backup): surface the failure reason on dispatched backups feat(backup): record why an export failed feat(backup): record and clear the failure reason on markFinished Co-Authored-By: Claude Opus 4.8 feat(backup): persist the failure reason on the job record feat(backup): add BackupFailureReason taxonomy feat(backup): group the hub into In progress, Scheduled and Recent Replace the flat dispatched-backup list with three sections derived from state + kind, rendered as Material 3 Expressive segmented list items. Co-Authored-By: Claude Opus 4.8 --- .../backup/data/mapper/BackupJobMapper.kt | 44 +- .../data/mapper/DispatchedBackupMapper.kt | 19 +- .../data/reository/BackupJobRepositoryImpl.kt | 38 +- .../data/reository/BackupJobRetention.kt | 24 ++ .../domain/model/BackupFailureReason.kt | 20 + .../feature/backup/domain/model/BackupJob.kt | 7 +- .../backup/domain/model/BackupResult.kt | 11 +- .../backup/domain/model/BackupWorkStatus.kt | 1 - .../backup/domain/model/DispatchedBackup.kt | 16 +- .../backup/domain/model/ExportError.kt | 22 + .../domain/repository/BackupJobRepository.kt | 9 +- .../domain/usecase/CancelBackupUseCase.kt | 2 +- .../ObserveDispatchedBackupsUseCase.kt | 9 +- .../usecase/RecordBackupOutcomeUseCase.kt | 5 +- .../backup/presentation/hub/BackupGrouping.kt | 29 +- .../presentation/hub/BackupHubContent.kt | 401 +++++++++++------- .../hub/DispatchedBackupDisplay.kt | 62 ++- .../presentation/hub/model/BackupGroup.kt | 2 +- .../presentation/hub/model/BackupSection.kt | 10 + .../feature/backup/worker/BackupWorker.kt | 7 + .../backup/src/main/proto/backup_jobs.proto | 3 + .../backup/src/main/res/values/strings.xml | 15 + .../feature/backup/FakeBackupJobRepository.kt | 13 +- .../backup/FakeBackupJobRepositoryTest.kt | 56 +++ .../backup/data/mapper/BackupJobMapperTest.kt | 41 ++ .../data/mapper/DispatchedBackupMapperTest.kt | 10 +- .../data/reository/BackupJobRetentionTest.kt | 39 ++ .../backup/domain/model/ExportErrorTest.kt | 43 ++ .../ObserveDispatchedBackupsUseCaseTest.kt | 92 +++- .../usecase/ObserveLastBackupUseCaseTest.kt | 4 +- .../usecase/RecordBackupOutcomeUseCaseTest.kt | 43 +- .../presentation/hub/BackupGroupingTest.kt | 64 ++- .../hub/BackupHubViewModelTest.kt | 5 +- 33 files changed, 934 insertions(+), 232 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFailureReason.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupSection.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/model/ExportErrorTest.kt 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 index f26c56e95..9ba87dad4 100644 --- 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 @@ -3,8 +3,10 @@ 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 @@ -33,9 +35,10 @@ internal fun ProtoBackupJob.toDomain(): BackupJob { keepCount = if (hasKeepCount()) keepCount else null, createdAt = createdAt, finishedAt = if (hasFinishedAt()) finishedAt else null, - lastResult = if (hasLastResult()) - runCatching { BackupResult.valueOf(lastResult) }.getOrNull() - else null, + lastResult = backupResultFromProto( + resultName = if (hasLastResult()) lastResult else null, + errorName = if (hasLastError()) lastError else null, + ), cancelled = this.cancelled, ) } @@ -52,6 +55,39 @@ internal fun BackupJob.toProto() = protoBackupJob { this@toProto.keepCount?.let { keepCount = it } createdAt = this@toProto.createdAt this@toProto.finishedAt?.let { finishedAt = it } - this@toProto.lastResult?.let { lastResult = it.name } + this@toProto.lastResult?.let { writeResult(it) } cancelled = this@toProto.cancelled } + +// 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 index 958e17f49..57fff9702 100644 --- 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 @@ -18,17 +18,22 @@ internal const val PROGRESS_PHASE_WRITING = "writing" internal fun WorkInfo.toStatus() = BackupWorkStatus( id = id.toString(), kind = toKind(tags), - state = toState(state), - progress = toProgress( - phase = progress.getString(PROGRESS_KEY_PHASE), - processed = progress.getInt(PROGRESS_KEY_PROCESSED, 0), - total = progress.getInt(PROGRESS_KEY_TOTAL, 0), + 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): DispatchedBackup.State = when (state) { +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 + 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 diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt index a1e1100ef..51694e94c 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt @@ -2,10 +2,12 @@ package de.davis.keygo.feature.backup.data.reository 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.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.model.BackupJob import de.davis.keygo.feature.backup.domain.model.BackupResult @@ -42,27 +44,25 @@ internal class BackupJobRepositoryImpl( onFailure = { Result.Failure(Unit) }, ) - override suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) { + override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) { dataStore.updateData { current -> val existing = current.jobsMap[workId] ?: return@updateData current - current.copy { - jobs[workId] = existing.copy { - this.finishedAt = finishedAt - lastResult = result.name - } + val updated = existing.copy { + this.finishedAt = finishedAt + writeResult(result) } + current.upsertAndPrune(workId, updated) } } override suspend fun markCancelled(workId: String, cancelledAt: Long) { dataStore.updateData { current -> val existing = current.jobsMap[workId] ?: return@updateData current - current.copy { - jobs[workId] = existing.copy { - cancelled = true - finishedAt = cancelledAt - } + val updated = existing.copy { + cancelled = true + finishedAt = cancelledAt } + current.upsertAndPrune(workId, updated) } } @@ -77,4 +77,20 @@ internal class BackupJobRepositoryImpl( } } } + + // 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: String, + 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/reository/BackupJobRetention.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt new file mode 100644 index 000000000..db1f0d8f5 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.feature.backup.data.reository + +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/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..c82746ced --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupFailureReason.kt @@ -0,0 +1,20 @@ +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]: retryable errors never reach a terminal record. It does + * not carry the Rust cause's free-form message (that would leak cryptic internals into the record + * and the UI); instead a serialization failure is split into the format-specific sub-cases that 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, +} 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 index 1cebb23ef..42bc05374 100644 --- 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 @@ -6,14 +6,15 @@ 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. + /** 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. + /** 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). + /** 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, ) 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 index 0c5171114..1001b7cce 100644 --- 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 @@ -1,3 +1,12 @@ package de.davis.keygo.feature.backup.domain.model -enum class BackupResult { Success, Failure } +sealed interface BackupResult { + data object Success : BackupResult + + /** + * Backup failed + * + * @param reason Carries why the run failed, null when the reason predates this field or wasn't 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 index 3dc818f6a..13ab9dacb 100644 --- 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 @@ -4,5 +4,4 @@ data class BackupWorkStatus( val id: String, val kind: DispatchedBackup.Kind, val state: DispatchedBackup.State, - val progress: ExportProgress.InFlight?, ) 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 index 8074b85c1..06ce40ebb 100644 --- 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 @@ -6,9 +6,21 @@ data class DispatchedBackup( val state: State, val format: FileFormat?, val destination: BackupDestination?, - val progress: ExportProgress.InFlight?, 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 } - enum class State { Enqueued, Running, Succeeded, Failed, Cancelled } + + 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/ExportError.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ExportError.kt index 62aaba61c..e740a2842 100644 --- 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 @@ -18,3 +18,25 @@ sealed interface ExportError { */ internal val ExportError.retryable: Boolean get() = this == ExportError.DeviceLocked || this == ExportError.SessionLocked + +/** + * The persistable, user-facing reason for a terminal failure, or null when the error is + * [retryable] and so never becomes one. + */ +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/repository/BackupJobRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt index ccf7c6fa3..afc04e73a 100644 --- 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 @@ -10,8 +10,13 @@ interface BackupJobRepository { suspend fun getJob(workId: String): BackupJob? suspend fun getJobs(): Map suspend fun putJob(workId: String, job: BackupJob): Result - suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) - suspend fun markCancelled(workId: String, cancelledAt: Long) + suspend fun markFinished( + workId: String, + result: BackupResult, + finishedAt: Long = System.currentTimeMillis() + ) + + suspend fun markCancelled(workId: String, cancelledAt: Long = System.currentTimeMillis()) suspend fun clearPassphrase(workId: String) fun observeJobs(): Flow> } 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 index ac3c3ad3e..1e4568f83 100644 --- 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 @@ -22,7 +22,7 @@ internal class CancelBackupUseCase( DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID DispatchedBackup.Kind.OneTime -> id } - jobRepository.markCancelled(workId, System.currentTimeMillis()) + jobRepository.markCancelled(workId) cleanupBackupResources(workId) } } 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 index 4c5bacf60..79e063155 100644 --- 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 @@ -2,6 +2,7 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.domain.BackupDestinationResolver import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +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 @@ -32,8 +33,14 @@ internal class ObserveDispatchedBackupsUseCase( state = state, format = job?.format, destination = job?.let { destinationResolver.resolve(it.uri) }, - progress = progress, 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/RecordBackupOutcomeUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt index d5b15a640..6f0e5b7c1 100644 --- 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 @@ -2,6 +2,7 @@ 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.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 @@ -18,11 +19,11 @@ internal class RecordBackupOutcomeUseCase( is ExportProgress.Succeeded -> BackupResult.Success is ExportProgress.Failed -> if (terminal.error.retryable) return - else BackupResult.Failure + else BackupResult.Failure(terminal.error.failureReason) else -> return } - jobRepository.markFinished(workId, System.currentTimeMillis(), result) + jobRepository.markFinished(workId, result) // A recurring schedule still needs its credentials for the next run. if (workId != BackupWorker.RECURRING_WORK_ID) cleanupBackupResources(workId) 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 index 084e517f4..7a5aee807 100644 --- 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 @@ -2,19 +2,26 @@ 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 -private val GROUP_ORDER = listOf( - DispatchedBackup.State.Running, - DispatchedBackup.State.Failed, - DispatchedBackup.State.Enqueued, - DispatchedBackup.State.Cancelled, - DispatchedBackup.State.Succeeded, -) +// 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 byState = groupBy { it.state } - return GROUP_ORDER.mapNotNull { state -> - val items = byState[state]?.sortedByDescending { it.timestamp } ?: return@mapNotNull null - BackupGroup(state, items) + 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 index 4f9bb9948..9f07378c2 100644 --- 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 @@ -1,165 +1,139 @@ 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.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row 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.layout.wrapContentHeight 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.History -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.SettingsBackupRestore +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.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem 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.SplitButtonDefaults 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.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 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.domain.model.LastBackup import de.davis.keygo.feature.backup.presentation.displayName +import de.davis.keygo.feature.backup.presentation.export.component.IconBadge 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 -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) +private const val DETAIL_SEPARATOR = " \u2022 " + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEvent) -> Unit) { - Scaffold { innerPadding -> + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + topBar = { + MediumFlexibleTopAppBar( + title = { + Text(text = "Backups") + }, + scrollBehavior = scrollBehavior, + ) + } + ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding) - .padding(horizontal = 8.dp) + .padding(8.dp) .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - ListItem( - onClick = {}, - // Workaround to disable clicking but remaining original color schema - colors = ListItemDefaults.colors( - disabledContainerColor = ListItemDefaults.colors().containerColor, - disabledContentColor = ListItemDefaults.colors().contentColor, - disabledOverlineContentColor = ListItemDefaults.colors().overlineContentColor, - disabledLeadingContentColor = ListItemDefaults.colors().leadingContentColor, - disabledSupportingContentColor = ListItemDefaults.colors().supportingContentColor, - ), - enabled = false, - overlineContent = { Text(text = stringResource(R.string.last_backup)) }, - leadingContent = { - Box(modifier = Modifier.wrapContentHeight()) { - Icon(imageVector = Icons.Default.History, contentDescription = null) - } - }, - supportingContent = state.lastBackup?.let { { Text(text = it.destination.displayText()) } }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = state.lastBackup?.let { relativeTime(it.finishedAt) } - ?: stringResource(R.string.never_backed_up)) - } - - FilledTonalButton( - onClick = { onEvent(BackupHubUiEvent.OnRestoreBackup) }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.SettingsBackupRestore, - modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), - contentDescription = null, - ) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.restore_backup)) - } - - FilledTonalButton( - onClick = { onEvent(BackupHubUiEvent.OnScheduleBackupClick) }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.Schedule, - modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), - contentDescription = null, - ) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.schedule_new_backup)) - } - - HorizontalDivider() - - Text( - text = stringResource(R.string.dispatched_backups), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth(), + HubActions( + onExport = { onEvent(BackupHubUiEvent.OnScheduleBackupClick) }, + onImport = { onEvent(BackupHubUiEvent.OnRestoreBackup) }, ) - Surface(modifier = Modifier.weight(1f)) { - AnimatedContent(state.hasItems) { hasItems -> - when { - hasItems -> LazyColumn( - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), - modifier = Modifier.fillMaxSize(), - ) { - state.groups.forEach { group -> - stickyHeader(key = "header-${group.state}") { - BackupGroupHeader(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)) - }, - ) - } + 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, - ) - } + else -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_dispatched_backups), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -167,14 +141,60 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun BackupGroupHeader(group: BackupGroup) { +private fun HubActions(onExport: () -> Unit, onImport: () -> Unit) { + val buttonSize = ButtonDefaults.MediumContainerHeight + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedButton( + onClick = onExport, + modifier = Modifier + .heightIn(buttonSize) + .weight(1f), + shapes = ButtonDefaults.shapesFor(buttonSize), + contentPadding = ButtonDefaults.contentPaddingFor(buttonSize), + ) { + Icon( + imageVector = Icons.Default.Upload, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), + ) + Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) + Text( + text = stringResource(R.string.export_backup), + style = ButtonDefaults.textStyleFor(buttonSize), + ) + } + Button( + onClick = onImport, + modifier = Modifier + .heightIn(buttonSize) + .weight(1f), + shapes = ButtonDefaults.shapesFor(buttonSize), + contentPadding = ButtonDefaults.contentPaddingFor(buttonSize), + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), + ) + Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) + Text( + text = stringResource(R.string.import_backup), + style = ButtonDefaults.textStyleFor(buttonSize), + ) + } + } +} + +@Composable +private fun BackupSectionHeader(group: BackupGroup) { Surface(modifier = Modifier.fillMaxWidth()) { Text( - text = stringResource(group.state.label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 4.dp), + text = stringResource(group.section.label), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), ) } } @@ -187,40 +207,69 @@ private fun DispatchedBackupRow( count: Int, onCancel: () -> Unit, ) { - val active = item.state == DispatchedBackup.State.Enqueued || - item.state == DispatchedBackup.State.Running + 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( onClick = {}, - shapes = ListItemDefaults.segmentedShapes(index, count), + shapes = ListItemDefaults.segmentedShapes( + index, + count, + defaultShapes = defaultShapes + ), colors = ListItemDefaults.segmentedColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), - overlineContent = { Text(text = item.destination.displayText()) }, + leadingContent = { + IconBadge( + icon = item.icon, + containerColor = statusColors.container, + contentColor = statusColors.content, + ) + }, supportingContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - item.format?.let { Text(text = it.displayName) } - if (item.state == DispatchedBackup.State.Running) - BackupProgress(progress = item.progress) + 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 (active) { - { - IconButton(onClick = onCancel) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cancel_backup), - ) - } + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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), + ) + } } - } else null, + }, + overlineContent = { + Text(text = item.kind.label) + }, + verticalAlignment = Alignment.CenterVertically ) { - Text(text = stringResource(item.kind.label)) + Text( + text = item.destination.displayText(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @Composable -private fun BackupProgress(progress: ExportProgress.InFlight?) { +private fun BackupProgress(progress: ExportProgress.InFlight) { when (progress) { is ExportProgress.Running -> { LinearProgressIndicator( @@ -233,11 +282,40 @@ private fun BackupProgress(progress: ExportProgress.InFlight?) { ) } - ExportProgress.Writing, null -> - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + 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, + ) +} + +// "Queued · JSON Backup" while it waits to dispatch, or "JSON Backup · 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, @@ -246,34 +324,50 @@ private fun relativeTime(epochMillis: Long): String = ).toString() @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun BackupHubContentPreview() { - MaterialTheme { + KeyGoTheme { Surface(modifier = Modifier.fillMaxSize()) { BackupHubContent( state = BackupHubUiState( - lastBackup = LastBackup( - finishedAt = System.currentTimeMillis(), - destination = BackupDestination( - provider = BackupDestination.Provider.ThirdParty("Drive"), - displayPath = "Drive/Backups", - ), - ), groups = listOf( DispatchedBackup( id = "1", kind = DispatchedBackup.Kind.Recurring, - state = DispatchedBackup.State.Running, + state = DispatchedBackup.State.Running(ExportProgress.Running(2, 5)), format = FileFormat.JSON, destination = BackupDestination( provider = BackupDestination.Provider.ThirdParty("Drive"), displayPath = "Drive/Backups", ), - progress = ExportProgress.Running(2, 5), - timestamp = 2L, + 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, @@ -281,8 +375,31 @@ private fun BackupHubContentPreview() { provider = BackupDestination.Provider.OnDevice, displayPath = "Internal storage/Backups", ), - progress = null, + 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(), ), 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 index ce58b4644..6e6722a7b 100644 --- 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 @@ -1,35 +1,69 @@ package de.davis.keygo.feature.backup.presentation.hub -import androidx.annotation.StringRes +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 -@get:StringRes -internal val DispatchedBackup.State.label: Int - get() = when (this) { - DispatchedBackup.State.Enqueued -> R.string.backup_state_enqueued - DispatchedBackup.State.Running -> R.string.backup_state_running - DispatchedBackup.State.Succeeded -> R.string.backup_state_succeeded - DispatchedBackup.State.Failed -> R.string.backup_state_failed - DispatchedBackup.State.Cancelled -> R.string.backup_state_cancelled +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 } -@get:StringRes -internal val DispatchedBackup.Kind.label: Int +/** Container/content pair tinting both the leading badge and the trailing status pill. */ +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 + }.let { stringResource(it) } @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.Unknown -> stringResource(R.string.destination_provider_unknown) BackupDestination.Provider.OnDevice -> destination.displayPath.ifBlank { stringResource(R.string.destination_provider_on_device) } 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 index 53b56f99d..0c99f67a5 100644 --- 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 @@ -5,6 +5,6 @@ import de.davis.keygo.feature.backup.domain.model.DispatchedBackup @Immutable internal data class BackupGroup( - val state: DispatchedBackup.State, + val section: BackupSection, val items: List, ) 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/worker/BackupWorker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt index fea63c4cb..5e0fd7a42 100644 --- 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 @@ -1,10 +1,12 @@ 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.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 @@ -44,6 +46,11 @@ internal class BackupWorker( 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) return resultFor(terminal) diff --git a/feature/backup/src/main/proto/backup_jobs.proto b/feature/backup/src/main/proto/backup_jobs.proto index ace8a55d9..648392bf0 100644 --- a/feature/backup/src/main/proto/backup_jobs.proto +++ b/feature/backup/src/main/proto/backup_jobs.proto @@ -24,6 +24,9 @@ message ProtoBackupJob { // 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; } message ProtoBackupJobs { diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 29b99bc5e..4b6625539 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -17,6 +17,13 @@ Cancel %1$d / %2$d + Export + Import + + In progress + Scheduled + Recent + Queued Running Completed @@ -25,6 +32,14 @@ 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 + + Last run failed. %1$s %1$s Backup diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt index ae3fdf952..86afb3144 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.backup.data.reository.retainedJobKeys 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 @@ -23,14 +24,20 @@ class FakeBackupJobRepository( return Result.Success(Unit) } - override suspend fun markFinished(workId: String, finishedAt: Long, result: BackupResult) { + override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) { val existing = jobs[workId] ?: return - jobs[workId] = existing.copy(finishedAt = finishedAt, lastResult = result) + upsertAndPrune(workId, existing.copy(finishedAt = finishedAt, lastResult = result)) } override suspend fun markCancelled(workId: String, cancelledAt: Long) { val existing = jobs[workId] ?: return - jobs[workId] = existing.copy(cancelled = true, finishedAt = cancelledAt) + upsertAndPrune(workId, existing.copy(cancelled = true, finishedAt = cancelledAt)) + } + + private fun upsertAndPrune(workId: String, job: BackupJob) { + jobs[workId] = job + val keep = retainedJobKeys(jobs.mapValues { it.value.finishedAt }) + jobs.keys.retainAll(keep) } override suspend fun clearPassphrase(workId: String) { diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt new file mode 100644 index 000000000..6f80a166b --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.backup + +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.FileFormat +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FakeBackupJobRepositoryTest { + + private val repository = FakeBackupJobRepository() + + private fun seed() { + repository.jobs["w"] = BackupJob( + uri = BackupDestinationUri("content://out.json"), + wrappedPassphrase = null, + format = FileFormat.JSON, + createdAt = 1L, + ) + } + + @Test + fun `a failure records its reason`() = runTest { + seed() + + repository.markFinished("w", BackupResult.Failure(BackupFailureReason.WriteFailed), 10L) + + val saved = repository.jobs.getValue("w") + assertEquals(BackupResult.Failure(BackupFailureReason.WriteFailed), saved.lastResult) + } + + @Test + fun `a later success clears the previous reason`() = runTest { + seed() + repository.markFinished("w", BackupResult.Failure(BackupFailureReason.WriteFailed), 10L) + + repository.markFinished("w", BackupResult.Success, 20L) + + assertEquals(BackupResult.Success, repository.jobs.getValue("w").lastResult) + } + + @Test + fun `marking an absent record is a no-op`() = runTest { + repository.markFinished( + "missing", + BackupResult.Failure(BackupFailureReason.CryptoFailed), + 10L + ) + + assertNull(repository.jobs["missing"]) + } +} 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 index d243f38f5..3d46ffeeb 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -161,4 +162,44 @@ class BackupJobMapperTest { 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 index d651d34b4..fd6a03d9b 100644 --- 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 @@ -14,12 +14,20 @@ class DispatchedBackupMapperTest { 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.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( diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt new file mode 100644 index 000000000..1d27aad61 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.backup.data.reository + +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/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/ObserveDispatchedBackupsUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCaseTest.kt index 21e7186e0..cd05340ec 100644 --- 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 @@ -5,7 +5,9 @@ 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 @@ -28,9 +30,8 @@ class ObserveDispatchedBackupsUseCaseTest { private fun status( id: String, kind: DispatchedBackup.Kind = DispatchedBackup.Kind.OneTime, - state: DispatchedBackup.State = DispatchedBackup.State.Running, - progress: ExportProgress.InFlight? = null, - ) = BackupWorkStatus(id, kind, state, progress) + state: DispatchedBackup.State = DispatchedBackup.State.Running(), + ) = BackupWorkStatus(id, kind, state) @Test fun `enriches a one-time worker from its persisted job`() = runTest { @@ -80,25 +81,25 @@ class ObserveDispatchedBackupsUseCaseTest { } @Test - fun `progress passes through unchanged`() = runTest { + fun `running progress passes through unchanged`() = runTest { repository.statuses.value = listOf( - status(id = "w", progress = ExportProgress.Running(3, 7)), + status(id = "w", state = DispatchedBackup.State.Running(ExportProgress.Running(3, 7))), ) val result = useCase().first().single() - assertEquals(ExportProgress.Running(3, 7), result.progress) + 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", progress = ExportProgress.Writing), + status(id = "w", state = DispatchedBackup.State.Running(ExportProgress.Writing)), ) val result = useCase().first().single() - assertEquals(ExportProgress.Writing, result.progress) + assertEquals(DispatchedBackup.State.Running(ExportProgress.Writing), result.state) } @Test @@ -123,4 +124,79 @@ class ObserveDispatchedBackupsUseCaseTest { 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 index a96f84db3..4b384b0f0 100644 --- 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 @@ -35,7 +35,7 @@ class ObserveLastBackupUseCaseTest { 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) + jobRepository.jobs["failed"] = job("content://bad.json", 999L, BackupResult.Failure()) destinationResolver.result = BackupDestination( provider = BackupDestination.Provider.ThirdParty("Drive"), displayPath = "Drive/Backups", @@ -50,7 +50,7 @@ class ObserveLastBackupUseCaseTest { @Test fun `null when no successful backup exists`() = runTest { - jobRepository.jobs["failed"] = job("content://bad.json", 5L, BackupResult.Failure) + 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 index 364aa3e1f..4a53db9bb 100644 --- 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 @@ -7,12 +7,14 @@ import de.davis.keygo.feature.backup.FakeBackupJobRepository 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 @@ -64,7 +66,7 @@ class RecordBackupOutcomeUseCaseTest { useCase("w", ExportProgress.Failed(ExportError.WriteFailed)) val saved = jobRepository.jobs.getValue("w") - assertEquals(BackupResult.Failure, saved.lastResult) + assertEquals(BackupResult.Failure(BackupFailureReason.WriteFailed), saved.lastResult) assertNotNull(saved.finishedAt) } @@ -138,4 +140,43 @@ class RecordBackupOutcomeUseCaseTest { assertContentEquals(wrappedPassphrase.data, saved.wrappedPassphrase.data) assertContentEquals(wrappedPassphrase.iv, saved.wrappedPassphrase.iv) } + + @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/hub/BackupGroupingTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupGroupingTest.kt index 89ef3cdce..e3c3bf533 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -9,37 +10,76 @@ class BackupGroupingTest { private fun item( id: String, state: DispatchedBackup.State, + kind: DispatchedBackup.Kind = DispatchedBackup.Kind.OneTime, timestamp: Long = 0L, ) = DispatchedBackup( id = id, - kind = DispatchedBackup.Kind.OneTime, + kind = kind, state = state, format = null, destination = null, - progress = null, timestamp = timestamp, ) @Test - fun `groups appear in fixed order and skip empties`() { + fun `sections appear in fixed order and skip empties`() { val groups = listOf( item("a", DispatchedBackup.State.Succeeded), - item("b", DispatchedBackup.State.Running), - item("c", DispatchedBackup.State.Failed), + item("b", DispatchedBackup.State.Enqueued, DispatchedBackup.Kind.Recurring), + item("c", DispatchedBackup.State.Running()), ).toGroups() assertEquals( - listOf( - DispatchedBackup.State.Running, - DispatchedBackup.State.Failed, - DispatchedBackup.State.Succeeded, - ), - groups.map { it.state }, + listOf(BackupSection.InProgress, BackupSection.Scheduled, BackupSection.Recent), + groups.map { it.section }, ) } @Test - fun `items within a group are newest first`() { + 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), 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 index e8d5e0d89..b3e6d1f20 100644 --- 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 @@ -18,6 +18,7 @@ import de.davis.keygo.feature.backup.domain.usecase.CleanupBackupResourcesUseCas 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.BackupHubUiEvent +import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -65,13 +66,13 @@ class BackupHubViewModelTest { @Test fun `dispatched workers are grouped in the ui state`() = runTest(dispatcher) { repository.statuses.value = listOf( - BackupWorkStatus("w1", DispatchedBackup.Kind.OneTime, DispatchedBackup.State.Running, null), + BackupWorkStatus("w1", DispatchedBackup.Kind.OneTime, DispatchedBackup.State.Running()), ) val vm = viewModel() val state = vm.state.first { it.hasItems } - assertEquals(DispatchedBackup.State.Running, state.groups.single().state) + assertEquals(BackupSection.InProgress, state.groups.single().section) assertEquals("w1", state.groups.single().items.single().id) } From ac8efe5319542ed148965601f89c33b5000a1d2c Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 24 Jul 2026 13:29:56 +0200 Subject: [PATCH 29/59] feat(backup): add CSV import with column mapping and vault targeting Squash-merge feat/backup-import. Adds the CSV analyze/map/import flow to the import wizard: column analysis domain models and AnalyzeCsvUseCase, a map-columns review step with a type picker, an import destination step that can target a specific vault, atomic restore to prevent partial imports, and the passphrase/progress/result/error screens. Includes accessibility fixes and tests for ImportWizardUiState. Co-Authored-By: Claude Opus 4.8 --- .../data/repository/RoomTransactionRunner.kt | 14 + .../core/item/domain/TransactionRunner.kt | 13 + .../keygo/core/item/FakeTransactionRunner.kt | 31 ++ .../feature/backup/domain/BackupRestorer.kt | 122 ++-- .../backup/domain/mapper/CsvMappingMappers.kt | 57 ++ .../domain/mapper/ImportErrorMappers.kt | 14 + .../backup/domain/model/CsvColumnAnalysis.kt | 13 + .../backup/domain/model/CsvColumnType.kt | 11 + .../backup/domain/model/ImportRequest.kt | 5 + .../backup/domain/model/ImportTarget.kt | 22 + .../backup/domain/model/MappingConfidence.kt | 8 + .../domain/usecase/AnalyzeCsvUseCase.kt | 29 + .../domain/usecase/ImportBackupUseCase.kt | 77 ++- .../backup/presentation/BackupGraph.kt | 10 +- .../feature/backup/presentation/FileFormat.kt | 32 ++ .../backup/presentation/RouteDestination.kt | 5 +- .../component/BackupFileChooser.kt | 196 +++++++ .../{export => }/component/IconBadge.kt | 2 +- .../backup/presentation/component/Wizard.kt | 184 ++++++ .../export/ExportWizardContent.kt | 239 ++------ .../export/ReviewBackupContent.kt | 2 +- .../export/SelectDestinationContent.kt | 169 +----- .../presentation/hub/BackupHubScreen.kt | 9 +- .../presentation/hub/BackupHubViewModel.kt | 4 +- .../presentation/hub/model/BackupHubEvent.kt | 1 + .../presentation/import/ImportPhaseContent.kt | 182 ++++++ .../import/ImportWizardContent.kt | 231 ++++++++ .../presentation/import/ImportWizardScreen.kt | 37 ++ .../import/ImportWizardViewModel.kt | 276 +++++++++ .../presentation/import/MapColumnsContent.kt | 475 ++++++++++++++++ .../import/ProvidePassphraseContent.kt | 63 +++ .../presentation/import/SelectVaultContent.kt | 236 ++++++++ .../import/model/ColumnMappingRow.kt | 34 ++ .../import/model/ImportWizardEvent.kt | 5 + .../import/model/ImportWizardStep.kt | 27 + .../import/model/ImportWizardUiEvent.kt | 13 + .../import/model/ImportWizardUiState.kt | 66 +++ .../backup/src/main/res/values/strings.xml | 63 ++- .../keygo/feature/backup/RestorerTestEnv.kt | 3 + .../backup/domain/BackupRestorerTest.kt | 97 ++++ .../domain/mapper/CsvMappingMappersTest.kt | 67 +++ .../domain/usecase/AnalyzeCsvUseCaseTest.kt | 85 +++ .../domain/usecase/ImportBackupUseCaseTest.kt | 90 +++ .../hub/BackupHubViewModelTest.kt | 10 + .../import/ImportWizardViewModelTest.kt | 522 ++++++++++++++++++ .../import/model/ImportWizardStepTest.kt | 35 ++ .../import/model/ImportWizardUiStateTest.kt | 82 +++ rust/rust-code/lib/src/backup/format/csv.rs | 6 +- 48 files changed, 3538 insertions(+), 436 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/RoomTransactionRunner.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/TransactionRunner.kt create mode 100644 core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeTransactionRunner.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnAnalysis.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvColumnType.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/MappingConfidence.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCase.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/{export => }/component/IconBadge.kt (94%) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardScreen.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/MapColumnsContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ProvidePassphraseContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/SelectVaultContent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ColumnMappingRow.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardEvent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappersTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/AnalyzeCsvUseCaseTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStepTest.kt create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt 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/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/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 index 6aee12f93..c31dd036d 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -10,6 +11,7 @@ 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.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 @@ -25,10 +27,12 @@ internal class BackupRestorer( 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 } @@ -38,62 +42,98 @@ internal class BackupRestorer( .associate { it.name to it.vaultId } .toMutableMap() - var imported = 0 - var skipped = 0 - var failed = 0 - var vaultsCreated = 0 - var processed = 0 - - for (bvault in backup.vaults) { - val vaultId = - existingByName[bvault.name] ?: createVault(bvault.name, Vault.Icon.Default) - .getOrNull()?.also { existingByName[bvault.name] = it; vaultsCreated++ } - - if (vaultId == null) { - repeat(bvault.logins.size + bvault.cards.size) { - failed++ - processed++ - onProgress(processed, total) - } - continue + // 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. + val targetVaultId = when (target) { + null -> null + is ImportTarget.Existing -> target.vaultId + is ImportTarget.New -> createVault(target.name, Vault.Icon.Default) + .getOrNull() + ?.also { acc.vaultCreated() } } - val loginKeys = loginRepository.getLoginsByVault(vaultId) - .map { it.name to it.username }.toMutableSet() - val cardKeys = creditCardRepository.getCreditCardsByVault(vaultId) - .map { it.name to it.holder }.toMutableSet() + for (bvault in backup.vaults) { + val vaultId = if (target != null) targetVaultId + else existingByName[bvault.name] + ?: createVault(bvault.name, Vault.Icon.Default) + .getOrNull() + ?.also { existingByName[bvault.name] = it; acc.vaultCreated() } - for (login in bvault.logins) { - val key = login.title to login.username - when { - key in loginKeys -> skipped++ - createLogin(login.toUpsertLogin(vaultId)) is Result.Success -> { - imported++ - loginKeys += key - } + if (vaultId == null) { + acc.failAll(bvault.logins.size + bvault.cards.size) + continue + } - else -> failed++ + 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)) } - processed++ - onProgress(processed, total) } - for (card in bvault.cards) { - val key = card.title to card.cardholder + 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 cardKeys -> skipped++ - createCard(card.toUpsertCreditCard(vaultId)) is Result.Success -> { + key in existingKeys -> skipped++ + create(item) is Result.Success -> { imported++ - cardKeys += key + existingKeys += key } else -> failed++ } - processed++ - onProgress(processed, total) + tick() } } - return Result.Success(ImportSummary(imported, skipped, failed, vaultsCreated)) + 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/mapper/CsvMappingMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt new file mode 100644 index 000000000..7fee3c3d0 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt @@ -0,0 +1,57 @@ +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 +} + +/** Turn a columnIndex -> type assignment into the Rust field-centric [ColumnMapping]. */ +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/ImportErrorMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt new file mode 100644 index 000000000..efbbc0bec --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/ImportErrorMappers.kt @@ -0,0 +1,14 @@ +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, + is BackupException.MissingCredential, + is BackupException.UnexpectedCredential, + is BackupException.EncryptionMismatch -> ImportError.WrongCredential + + else -> ImportError.ParseFailed(this) +} 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/ImportRequest.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportRequest.kt index d5ea62ede..c0c565c65 100644 --- 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 @@ -1,7 +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/ImportTarget.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt new file mode 100644 index 000000000..2bba25efd --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/ImportTarget.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.feature.backup.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId + +/** + * 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] 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) : ImportTarget +} 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..7738cd3d3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/MappingConfidence.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.backup.domain.model + +/** How strongly the CSV analyzer believes a column's auto-detected type is correct. */ +enum class MappingConfidence { + High, + Medium, + Low, +} 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/ImportBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCase.kt index 7312fce01..3e4633b99 100644 --- 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 @@ -2,9 +2,11 @@ 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.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 @@ -14,12 +16,11 @@ 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.BackupException 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.flow +import kotlinx.coroutines.flow.channelFlow import org.koin.core.annotation.Single @Single @@ -31,39 +32,35 @@ internal class ImportBackupUseCase( private val session: Session, ) { - operator fun invoke(request: ImportRequest): Flow = flow { - if (runCatching { session.ark }.isFailure) { - emit(ImportProgress.Failed(ImportError.SessionLocked)) - return@flow - } + /** + * [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 (runCatching { session.ark }.isFailure) + Result.Failure(ImportError.SessionLocked).bind() - emit(ImportProgress.Reading) - val text = when (val read = fileStore.read(request.uri)) { - is Result.Success -> read.success - is Result.Failure -> { - emit(ImportProgress.Failed(ImportError.FileUnreadable)) - return@flow - } - } - if (text.isBlank()) { - emit(ImportProgress.Failed(ImportError.EmptyFile)) - return@flow - } + send(ImportProgress.Reading) + val text = fileStore.read(request.uri).bind { ImportError.FileUnreadable } + if (text.isBlank()) + Result.Failure(ImportError.EmptyFile).bind() - emit(ImportProgress.Parsing) - val backup = when (val parsed = parse(request, text)) { - is Result.Success -> parsed.success - is Result.Failure -> { - emit(ImportProgress.Failed(parsed.error)) - return@flow - } - } + send(ImportProgress.Parsing) + val backup = parse(request, text).bind() - when (val restored = - restorer.restore(backup) { p, t -> emit(ImportProgress.Running(p, t)) }) { - is Result.Success -> emit(ImportProgress.Succeeded(restored.success)) - is Result.Failure -> emit(ImportProgress.Failed(restored.error)) + 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 = @@ -89,23 +86,13 @@ internal class ImportBackupUseCase( } FileFormat.CSV -> { - val analysis = csvBackupManager.analyzeWithResult(text) - .bind { it.toImportError() } - - csvBackupManager.importWithResult(text, analysis.suggested) + val mapping = request.csvMapping ?: csvBackupManager.analyzeWithResult(text) + .bind { it.toImportError() }.suggested + + csvBackupManager.importWithResult(text, mapping) .bind { it.toImportError() } .backup } } } - - private fun BackupException.toImportError(): ImportError = when (this) { - is BackupException.Crypto, - is BackupException.CredentialMismatch, - is BackupException.MissingCredential, - is BackupException.UnexpectedCredential, - is BackupException.EncryptionMismatch -> ImportError.WrongCredential - - else -> ImportError.ParseFailed(this) - } } 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 index 73966727e..eb3571e04 100644 --- 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 @@ -4,6 +4,7 @@ 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, @@ -13,11 +14,18 @@ fun NavGraphBuilder.backupGraph( 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/FileFormat.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/FileFormat.kt index a9d7ef515..603b8699a 100644 --- 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 @@ -2,12 +2,19 @@ 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 @@ -66,3 +73,28 @@ internal val CsvPreset.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 index cff45d01a..c09b00c0e 100644 --- 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 @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable object BackupHubRoute @Serializable -object BackupExportRoute \ No newline at end of file +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..890a518a1 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupFileChooser.kt @@ -0,0 +1,196 @@ +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.Row +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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?, + fileName: String, + 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, + fileName = fileName, + 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, + fileName: String, + changeLabel: String, + fileNameLabel: String, + onChange: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconBadge( + icon = if (destination.fileName != null) Icons.Default.Description + else Icons.Default.Folder, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + size = 44.dp, + iconSize = 24.dp, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = destination.provider.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = destination.displayPath, + style = MaterialTheme.typography.titleSmall, + ) + } + TextButton(onClick = onChange) { + Text(text = changeLabel) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = fileNameLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = fileName, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +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/export/component/IconBadge.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt similarity index 94% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/component/IconBadge.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt index 0815c1a20..45e5588a9 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/export/component/IconBadge.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/IconBadge.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.backup.presentation.export.component +package de.davis.keygo.feature.backup.presentation.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box 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..d79241c99 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt @@ -0,0 +1,184 @@ +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 T the type identifying a step, typically an enum. + * @param steps ordered list of steps the wizard walks through. + * @param currentStep the currently displayed step; must be an element of [steps]. + * @param title the app-bar title for [currentStep], resolved by the caller. + * @param onBack invoked when the user requests the previous step (nav icon or predictive back). + * @param onContinue invoked when the continue button is tapped. + * @param navigateUp invoked when back is requested while on the first step. + * @param showContinueButton whether the bottom continue button is visible for the current step. + * @param canContinue whether the continue button is enabled. + * @param continueButtonContent the content (label, optional icon) rendered inside the button. + * @param content the body for a given step. + */ +@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 index 857d51e39..eb955ad01 100644 --- 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 @@ -1,59 +1,29 @@ package de.davis.keygo.feature.backup.presentation.export -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.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.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.Schedule -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.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.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.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 @@ -62,170 +32,79 @@ 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 kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExportWizardContent( state: ExportWizardUiState, onEvent: (ExportWizardUiEvent) -> Unit, navigateUp: () -> Unit, ) { - val steps = state.steps - val currentStep = state.step - val currentIndex = steps.indexOf(currentStep).coerceAtLeast(0) - - val transitionState = remember { SeekableTransitionState(currentStep) } - val transition = rememberTransition(transitionState, label = "export_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) + 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), + ) } - onEvent(ExportWizardUiEvent.Back) - } catch (_: CancellationException) { - resetScope.launch { transitionState.animateTo(transitionState.currentState) } - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Text(text = state.step.title) - }, - navigationIcon = { - IconButton( - onClick = { - if (currentIndex == 0) navigateUp() - else onEvent(ExportWizardUiEvent.Back) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = null, - ) + Text( + text = stringResource( + when { + isReview && recurring -> R.string.schedule_backup + isReview -> R.string.create_backup + else -> R.string.continue_step } - }, + ), ) }, - bottomBar = { - AnimatedVisibility(visible = state.showsContinueButton) { - Box( - modifier = Modifier - .padding(vertical = 12.dp, horizontal = 4.dp) - .imePadding(), - ) { - Button( - onClick = { onEvent(ExportWizardUiEvent.Continue) }, - enabled = state.canContinue, - modifier = Modifier.fillMaxWidth() - ) { - 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 - } - ), - ) - } - } - } - } - ) { 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=$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(), - ) { - when (step) { - ExportWizardStep.SelectFormat -> SelectFileFormatContent(onEvent = onEvent) - ExportWizardStep.Schedule -> SelectScheduleContent( - state = state.scheduleState, - onEvent = onEvent, - ) + ) { 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.SelectDestination -> SelectDestinationContent( + state = state.destinationState, + scheduleState = state.scheduleState, + format = state.formatState.format, + onEvent = onEvent, + ) - ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( - state = state.providePassphraseState, - onEvent = onEvent, - ) + ExportWizardStep.ProvidePassphrase -> ProvidePassphraseContent( + state = state.providePassphraseState, + onEvent = onEvent, + ) - ExportWizardStep.SelectCsvPreset -> SelectCsvPresetContent( - preset = state.formatState.csvPreset, - 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, - ) - } - } - } + ExportWizardStep.Review -> state.formatState.format?.let { format -> + ReviewBackupContent( + format = format, + scheduleState = state.scheduleState, + destinationState = state.destinationState, + passphraseState = state.providePassphraseState, + csvPreset = state.formatState.csvPreset, + ) } } } 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 index 8f6d2044c..5fded506b 100644 --- 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 @@ -49,7 +49,7 @@ 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.displayName -import de.davis.keygo.feature.backup.presentation.export.component.IconBadge +import de.davis.keygo.feature.backup.presentation.component.IconBadge 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 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 index 8fddbf6ae..85e1e812c 100644 --- 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 @@ -11,35 +11,22 @@ 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.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.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton 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.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 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.export.component.IconBadge +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 @@ -60,143 +47,19 @@ internal fun SelectDestinationContent( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - when (val destination = state.destination) { - null -> DestinationChooserCard( - onChoose = { onEvent(ExportWizardUiEvent.ChooseDestination) }, - ) - - else -> DestinationSelectedCard( - destination = destination, - fileName = destination.fileName ?: format.backupFileName(), - onChange = { onEvent(ExportWizardUiEvent.ChooseDestination) }, - ) - } - - BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) - } - } -} - -@Composable -private fun DestinationChooserCard(onChoose: () -> Unit) { - 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 = Icons.Default.CreateNewFolder, - contentDescription = null, - ) - Text( - text = stringResource(R.string.destination_choose_title), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.destination_choose_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), - textAlign = TextAlign.Center, - ) - Text( - text = stringResource(R.string.destination_choose_action), - style = MaterialTheme.typography.labelLarge, + BackupFileChooser( + destination = state.destination, + fileName = state.destination?.fileName ?: format.backupFileName(), + 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), ) - } - } -} - -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())), - ), - ) -} - -@Composable -private fun DestinationSelectedCard( - destination: BackupDestination, - fileName: String, - onChange: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - IconBadge( - icon = if (destination.fileName != null) Icons.Default.Description - else Icons.Default.Folder, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - size = 44.dp, - iconSize = 24.dp, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = destination.provider.label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = destination.displayPath, - style = MaterialTheme.typography.titleSmall, - ) - } - TextButton(onClick = onChange) { - Text(text = stringResource(R.string.destination_change)) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.destination_filename_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = fileName, - style = MaterialTheme.typography.bodyMedium, - ) - } + BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) } } } @@ -230,14 +93,6 @@ private fun BehaviorHint(mode: ScheduleMode, keepAll: Boolean) { } } -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 - } - private class SelectDestinationStateProvider : PreviewParameterProvider { override val values = sequenceOf( SelectDestinationState(), 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 index 673abef6b..ce454e0d9 100644 --- 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 @@ -8,19 +8,22 @@ import de.davis.keygo.feature.backup.presentation.hub.model.BackupHubEvent import org.koin.androidx.compose.koinViewModel @Composable -fun BackupHubScreen(navigateToExport: () -> Unit) { +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 + 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 index f8c90e7ef..3ea3a28a2 100644 --- 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 @@ -24,7 +24,7 @@ internal class BackupHubViewModel( private val cancelBackup: CancelBackupUseCase, ) : ViewModel() { - private val _event = Channel() + private val _event = Channel(Channel.BUFFERED) val event = _event.receiveAsFlow() val state: StateFlow = @@ -41,7 +41,7 @@ internal class BackupHubViewModel( BackupHubUiEvent.OnScheduleBackupClick -> _event.trySend(BackupHubEvent.NavigateToExport) - BackupHubUiEvent.OnRestoreBackup -> {} // TODO: restore flow (existing placeholder) + 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/model/BackupHubEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/model/BackupHubEvent.kt index 08a162dfa..95d8b5726 100644 --- 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 @@ -2,4 +2,5 @@ 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/import/ImportPhaseContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt new file mode 100644 index 000000000..3b2cfc0cc --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportPhaseContent.kt @@ -0,0 +1,182 @@ +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.Row +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.ErrorOutline +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.text.style.TextAlign +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 + +@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) + is ImportProgress.Running -> stringResource(R.string.import_running) + 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(8.dp), + ) { + SummaryRow(stringResource(R.string.import_result_imported), summary.imported) + SummaryRow(stringResource(R.string.import_result_skipped), summary.skipped) + SummaryRow(stringResource(R.string.import_result_failed), summary.failed) + SummaryRow(stringResource(R.string.import_result_vaults), summary.vaultsCreated) + } + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.import_done)) + } + } +} + +@Composable +private fun SummaryRow(label: String, value: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@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)) + } + } +} 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..f84de9cc3 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt @@ -0,0 +1,231 @@ +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, + fileName = state.backupDestination?.fileName.orEmpty(), + 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), + ) + } + + 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, + onSelectVault = { onEvent(ImportWizardUiEvent.SelectVault(it)) }, + onCreateNewVault = { onEvent(ImportWizardUiEvent.CreateNewVault) }, + ) + } + } +} + +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..d9224d236 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModel.kt @@ -0,0 +1,276 @@ +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) + } + } + } + + 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() + } + + /** + * Seeds the destination on entry rather than in [ImportWizardUiState]'s initialiser: the file + * name is only known once a file has been picked, and seeding on every state update would + * overwrite a name the user has since typed. Falling back to a new vault when the context is + * [de.davis.keygo.core.item.domain.model.VaultContext.NoSpecific] matters — that is the "all + * vaults" view, which is not somewhere an import can land. + * + * Seeding itself only ever 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..345dff02e --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/MapColumnsContent.kt @@ -0,0 +1,475 @@ +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.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +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.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 + +/** + * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, + * which resolves to `colorScheme.surface` — identical to the wizard Scaffold's background in both + * KeyGo themes, so the groups would have no visible edge at all. Every other `SegmentedListItem` + * call site in the app pins this for the same reason. + */ +private val segmentContainerColor + @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh + +/** How many segments make up one column's group; drives [ListItemDefaults.segmentedShapes]. */ +private const val SEGMENTS_PER_COLUMN = 3 + +/** + * Colours for a segment that exists only to display something. + * + * [SegmentedListItem] has no non-interactive overload — every overload carrying `ListItemShapes` + * takes a click — so a display-only segment has to be a disabled one, and the disabled palette is + * mapped back onto the enabled colours so it does not read as greyed out. Same workaround as + * `BackupHubContent`. + * + * The cost is that TalkBack still announces these rows as *disabled*: the clickable node emits its + * `OnClick` action regardless of `enabled` and merely marks itself unavailable, so the row leaves + * the actionable set but keeps announcing as a control. It stays readable, and removing the + * announcement entirely would mean hand-drawing the container this refactor exists to stop drawing. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun displaySegmentColors(): ListItemColors { + val base = ListItemDefaults.segmentedColors() + return ListItemDefaults.segmentedColors( + containerColor = segmentContainerColor, + disabledContainerColor = segmentContainerColor, + disabledContentColor = base.contentColor, + disabledOverlineContentColor = base.overlineContentColor, + disabledLeadingContentColor = base.leadingContentColor, + disabledSupportingContentColor = base.supportingContentColor, + disabledTrailingContentColor = base.trailingContentColor, + ) +} + +/** + * 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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MapColumnsIntroCard(columnCount: Int, fileName: String?) { + val container = MaterialTheme.colorScheme.secondaryContainer + val content = MaterialTheme.colorScheme.onSecondaryContainer + + ListItem( + onClick = {}, + enabled = false, + shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large), + colors = ListItemDefaults.colors( + disabledContainerColor = container, + disabledContentColor = 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, + ), + color = content.copy(alpha = 0.8f), + ) + }, + ) { + 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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ColumnMappingGroup( + column: ColumnMappingRow, + isDuplicate: Boolean, + onTypeChange: (CsvColumnType?) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SourceSegment(index = 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 badge 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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SourceSegment(index: Int, header: String) { + SegmentedListItem( + onClick = {}, + enabled = false, + shapes = ListItemDefaults.segmentedShapes(0, SEGMENTS_PER_COLUMN), + colors = displaySegmentColors(), + leadingContent = { + IconBadge( + icon = Icons.Default.TableChart, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + overlineContent = { + Text(text = stringResource(R.string.csv_column_source_label, index + 1)) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = header, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** + * The KeyGo side of the group: a primary-coloured badge and the chosen type, tappable to open the + * type menu. The colour contrast against [SourceSegment] is what tells the two apart at a glance, + * and this is the only segment of the three that is genuinely interactive. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, 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 = { + IconBadge( + icon = selectedType.icon, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + 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 a badge it does not have. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SamplesSegment(samples: List) { + SegmentedListItem( + onClick = {}, + enabled = false, + shapes = ListItemDefaults.segmentedShapes(2, SEGMENTS_PER_COLUMN), + colors = displaySegmentColors(), + overlineContent = { Text(text = stringResource(R.string.csv_samples_label)) }, + verticalAlignment = Alignment.CenterVertically, + ) { + // Toned down from the headline slot's default rather than shrunk: these are raw file + // values, and they should not outrank the CSV header or the chosen type. onSurfaceVariant + // carries that on its own, so the size stays at bodyMedium — monospace runs visually + // smaller than the sans face at the same size, and these are the values the whole step + // asks the user to read. + 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, + ), +) + +/** + * Covers the cases this screen exists to communicate: a settled high-confidence column, a + * low-confidence one showing the verify hint, a corrected column whose hint is therefore + * suppressed, an over-long sample value, a duplicate type in its error state, a column with no + * sample values at all, and a header long enough to exercise the single-line ellipsis. + */ +@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..a9526a6a6 --- /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.Companion, +) { + 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..42e5da474 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/SelectVaultContent.kt @@ -0,0 +1,236 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +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.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.ExperimentalMaterial3ExpressiveApi +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.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.selected +import androidx.compose.ui.semantics.semantics +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.toImageVector +import de.davis.keygo.feature.backup.R +import de.davis.keygo.feature.backup.presentation.component.IconBadge + +/** + * 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, + onSelectVault: (VaultId) -> Unit, + onCreateNewVault: () -> Unit, + modifier: Modifier = Modifier, +) { + // One segment per vault plus the "new vault" row, so the group's rounded ends land correctly. + 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, + shapes = ListItemDefaults.segmentedShapes(vaults.size, segmentCount), + onClick = onCreateNewVault, + ) + } + } +} + +/** + * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, + * which resolves to `colorScheme.surface` — identical to the wizard Scaffold's background in both + * KeyGo themes, so the rows would have no visible edge at all. Same pin as `MapColumnsContent`. + */ +private val segmentContainerColor + @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh + +@Composable +private fun VaultSegment( + vault: VaultMetadata, + selected: Boolean, + shapes: ListItemShapes, + onClick: () -> Unit, +) { + SegmentedListItem( + onClick = onClick, + shapes = shapes, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + IconBadge( + icon = vault.icon.toImageVector(), + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + supportingContent = { + Text( + text = pluralStringResource( + R.plurals.select_vault_item_count, + vault.count, + vault.count, + ), + ) + }, + trailingContent = { + if (selected) Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + verticalAlignment = Alignment.CenterVertically, + // The row is a one-of-many choice, not a button; role announces that, selected announces + // whether this particular row is the one currently chosen. + modifier = Modifier.semantics { + role = Role.RadioButton + this.selected = selected + }, + ) { + Text(text = vault.name) + } +} + +@Composable +private fun NewVaultSegment( + selected: Boolean, + nameState: TextFieldState, + shapes: ListItemShapes, + onClick: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SegmentedListItem( + onClick = onClick, + shapes = shapes, + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + IconBadge( + icon = Icons.Default.Add, + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { + if (selected) Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.semantics { + role = Role.RadioButton + this.selected = selected + }, + ) { + Text(text = stringResource(R.string.select_vault_new)) + } + + 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), + ) + } +} + +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(), + onSelectVault = {}, + onCreateNewVault = {}, + ) + } + } +} + +@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"), + onSelectVault = {}, + onCreateNewVault = {}, + ) + } + } +} 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..2b174f92c --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardStep.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +internal enum class ImportWizardStep { + SelectFile, + MapColumns, + SelectVault, + ProvidePassphrase, +} + +internal fun importStepsFor(step: ImportWizardStep): List = when (step) { + ImportWizardStep.SelectFile -> listOf(ImportWizardStep.SelectFile) + ImportWizardStep.MapColumns -> listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns, + ) + + ImportWizardStep.SelectVault -> listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault, + ) + + ImportWizardStep.ProvidePassphrase -> listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.ProvidePassphrase, + ) +} 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..ae8842773 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import de.davis.keygo.core.item.domain.alias.VaultId +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 +} 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..29fe6e4d7 --- /dev/null +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiState.kt @@ -0,0 +1,66 @@ +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.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, + /** 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) + else selectedVaultId?.let(ImportTarget::Existing) +} diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 4b6625539..ed5687cda 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -84,9 +84,36 @@ Choose where to save Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox. Choose location - Change + Change Saved as + 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 + 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. @@ -153,4 +180,38 @@ 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 + + %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/RestorerTestEnv.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/RestorerTestEnv.kt index 88900bb78..6c0c5cf17 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,7 @@ 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) @@ -55,5 +57,6 @@ internal class RestorerTestEnv { createVault = createVault, createLogin = createLogin, createCard = createCard, + transactionRunner = transactionRunner, ) } 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 index 0f627a11e..90dba1836 100644 --- 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 @@ -3,6 +3,7 @@ package de.davis.keygo.feature.backup.domain import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.util.Result import de.davis.keygo.feature.backup.RestorerTestEnv +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 @@ -105,6 +106,102 @@ class BackupRestorerTest { 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 = 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/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/ImportBackupUseCaseTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/usecase/ImportBackupUseCaseTest.kt index d88c54141..ff425ad59 100644 --- 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 @@ -10,6 +10,8 @@ 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 @@ -23,6 +25,7 @@ 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 @@ -173,6 +176,33 @@ class ImportBackupUseCaseTest { 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")), emptyList()))), + 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 EncryptionMismatch to WrongCredential`() = runTest { fileStore.contents = "name\nEmail\n" @@ -284,4 +314,64 @@ class ImportBackupUseCaseTest { 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")), emptyList()))), + 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")), emptyList()))), + 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/presentation/hub/BackupHubViewModelTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubViewModelTest.kt index b3e6d1f20..1cd0ee994 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -101,4 +102,13 @@ class BackupHubViewModelTest { 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..4040507a1 --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardViewModelTest.kt @@ -0,0 +1,522 @@ +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.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.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.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.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.BackupVault +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, + website = null, + 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 { + json.inspectResult = JsonEncryption.NONE + fileStore.contents = """{"vaults":[]}""" + json.importResult = Backup(listOf(BackupVault("Imported", listOf(login("Email")), emptyList()))) + 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) + assertNull(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.NONE + 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")), emptyList()))), + 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")), emptyList()))), + 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")), emptyList()))), + 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 `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..c4f6eab8c --- /dev/null +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiStateTest.kt @@ -0,0 +1,82 @@ +package de.davis.keygo.feature.backup.presentation.import.model + +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.feature.backup.domain.model.BackupDestination +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 `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/rust/rust-code/lib/src/backup/format/csv.rs b/rust/rust-code/lib/src/backup/format/csv.rs index f7891619b..adc25a4df 100644 --- a/rust/rust-code/lib/src/backup/format/csv.rs +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -444,8 +444,8 @@ fn build_mapping(headers: &[String], samples: &[StringRecord]) -> (ColumnMapping } /// must be `>= DISPLAY_SAMPLES`. -const SAMPLE_ROWS: usize = 7; -const DISPLAY_SAMPLES: usize = 3; +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. @@ -740,7 +740,7 @@ mod tests { // Display samples are the first rows, in file order. assert_eq!( a1.columns[0].sample_values, - vec!["Site 0", "Site 1", "Site 2"] + 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)); From 7abf7a20382f6778cbdea843dbf5d9bbbc8a454a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 25 Jul 2026 15:39:42 +0200 Subject: [PATCH 30/59] chore: replace chars --- README.md | 4 +- .../domain/usecase/CreateAccessUseCase.kt | 2 +- .../local/entity/credential/PasswordEntity.kt | 2 +- .../core/item/data/mapper/ItemMapperTest.kt | 8 ++-- .../BindingCryptographicScopeProvider.kt | 2 +- .../core/util/domain/usecase/SortUseCase.kt | 2 +- .../snackbar/ObserveSnackbarEvents.kt | 4 +- .../autofill/src/main/res/values/strings.xml | 4 +- .../autofill/presentation/ClassifierTest.kt | 8 ++-- .../autofill/FakeSignatureInfoProvider.kt | 2 +- .../backup/domain/model/ImportTarget.kt | 2 +- .../presentation/hub/BackupHubContent.kt | 10 +++-- .../import/ImportWizardViewModel.kt | 2 +- .../presentation/import/MapColumnsContent.kt | 14 +++--- .../presentation/import/SelectVaultContent.kt | 2 +- .../create/activity/CreatePasskeyActivity.kt | 2 +- .../activity/ProvidePasskeyActivity.kt | 4 +- .../credit_card/presentation/NfcInfoCard.kt | 2 +- .../src/main/res/values/strings.xml | 2 +- .../transformation/CvvInputTransformation.kt | 2 +- .../CreateNewOrUpdateLoginUseCaseTest.kt | 12 ++--- .../item/create/presentation/ItemViewModel.kt | 2 +- .../creditcard/CreditCardViewModel.kt | 2 +- .../view/creditcard/ViewCreditCardContent.kt | 2 +- .../item/view/login/ViewLoginContent.kt | 6 +-- .../item/view/login/model/ObfuscatedString.kt | 2 +- .../creditcard/ViewCreditCardViewModelTest.kt | 2 +- .../view/login/model/ObfuscatedStringTest.kt | 13 +++--- .../domain/usecase/FilterUseCaseTest.kt | 6 +-- .../presentation/SettingsViewModel.kt | 2 +- .../totp/presentation/component/QRScanner.kt | 2 +- .../domain/usecase/MoveItemsToVaultUseCase.kt | 2 +- rust/rust-code/.cargo/config.toml | 2 +- rust/rust-code/bindings/src/item.rs | 2 +- rust/rust-code/bindings/src/key_wrap.rs | 2 +- rust/rust-code/lib/src/card/mod.rs | 10 ++--- rust/rust-code/lib/src/card/network.rs | 8 ++-- rust/rust-code/lib/src/card/number.rs | 2 +- rust/rust-code/lib/src/crypto/error.rs | 4 +- rust/rust-code/lib/src/url.rs | 2 +- .../de/davis/keygo/rust/FakeItemManager.kt | 2 +- .../de/davis/keygo/rust/FakeKeyWrapper.kt | 2 +- scripts/new-module.sh | 44 +++++++++---------- scripts/test-new-module.py | 26 +++++------ 44 files changed, 120 insertions(+), 119 deletions(-) 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/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/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/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/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/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/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 index 2bba25efd..5fb80b353 100644 --- 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 @@ -5,7 +5,7 @@ import de.davis.keygo.core.item.domain.alias.VaultId /** * Where an import should put its items. * - * A `null` target — the absence of this type — means "use the vaults described by the backup", which + * 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. */ 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 index 9f07378c2..90e076442 100644 --- 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 @@ -53,7 +53,7 @@ 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.displayName -import de.davis.keygo.feature.backup.presentation.export.component.IconBadge +import de.davis.keygo.feature.backup.presentation.component.IconBadge 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 @@ -303,9 +303,11 @@ private fun FailureLine(item: DispatchedBackup, reason: BackupFailureReason) { ) } -// "Queued · JSON Backup" while it waits to dispatch, or "JSON Backup · 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. +/** + * 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 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 index d9224d236..026703d2b 100644 --- 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 @@ -165,7 +165,7 @@ internal class ImportWizardViewModel( * Seeds the destination on entry rather than in [ImportWizardUiState]'s initialiser: the file * name is only known once a file has been picked, and seeding on every state update would * overwrite a name the user has since typed. Falling back to a new vault when the context is - * [de.davis.keygo.core.item.domain.model.VaultContext.NoSpecific] matters — that is the "all + * [de.davis.keygo.core.item.domain.model.VaultContext.NoSpecific] matters: that is the "all * vaults" view, which is not somewhere an import can land. * * Seeding itself only ever happens once per file, guarded by [vaultStepSeeded]: re-entering 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 index 345dff02e..32134087e 100644 --- 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 @@ -87,14 +87,14 @@ 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 + * [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 /** * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, - * which resolves to `colorScheme.surface` — identical to the wizard Scaffold's background in both + * which resolves to `colorScheme.surface`, identical to the wizard Scaffold's background in both * KeyGo themes, so the groups would have no visible edge at all. Every other `SegmentedListItem` * call site in the app pins this for the same reason. */ @@ -107,8 +107,8 @@ private const val SEGMENTS_PER_COLUMN = 3 /** * Colours for a segment that exists only to display something. * - * [SegmentedListItem] has no non-interactive overload — every overload carrying `ListItemShapes` - * takes a click — so a display-only segment has to be a disabled one, and the disabled palette is + * [SegmentedListItem] has no non-interactive overload: every overload carrying `ListItemShapes` + * takes a click, so a display-only segment has to be a disabled one, and the disabled palette is * mapped back onto the enabled colours so it does not read as greyed out. Same workaround as * `BackupHubContent`. * @@ -329,8 +329,8 @@ private fun TypeSegment( * 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 + * - [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 @@ -365,7 +365,7 @@ private fun SamplesSegment(samples: List) { ) { // Toned down from the headline slot's default rather than shrunk: these are raw file // values, and they should not outrank the CSV header or the chosen type. onSurfaceVariant - // carries that on its own, so the size stays at bodyMedium — monospace runs visually + // carries that on its own, so the size stays at bodyMedium: monospace runs visually // smaller than the sans face at the same size, and these are the values the whole step // asks the user to read. if (samples.isEmpty()) Text( 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 index 42e5da474..e380921f1 100644 --- 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 @@ -92,7 +92,7 @@ internal fun SelectVaultContent( /** * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, - * which resolves to `colorScheme.surface` — identical to the wizard Scaffold's background in both + * which resolves to `colorScheme.surface`, identical to the wizard Scaffold's background in both * KeyGo themes, so the rows would have no visible edge at all. Same pin as `MapColumnsContent`. */ private val segmentContainerColor 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/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 f5ef2ee57..4f079f95d 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 @@ -70,7 +70,7 @@ internal class SettingsViewModel( autofillServiceRepository.disable() // disableAutofillServices() propagates through the system server // asynchronously, so re-polling isEnabled() here can still read true. - // The outcome is deterministic — update the snapshot directly. + // The outcome is deterministic, so update the snapshot directly. osState.update { it.copy(autofillEnabled = false) } } } 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/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/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/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/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/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/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", From fc10580d68ced0b7dc2fb98b8c626db32f59a2f8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 25 Jul 2026 16:17:17 +0200 Subject: [PATCH 31/59] feat(settings): merge backup entries into one hub entrance Export and Import were separate rows even though the backup hub already offers both, and Import was wired to an empty callback. A single "Backup & Restore" row now opens the hub, with a subtitle showing how stale the newest backup is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../usecase/ObserveLastBackupUseCase.kt | 17 ++++++-- .../usecase/ObserveLastBackupUseCaseTest.kt | 2 +- .../hub/BackupHubViewModelTest.kt | 4 +- feature/settings/build.gradle.kts | 1 + .../settings/presentation/SettingsContent.kt | 39 ++++++++++++------- .../settings/presentation/SettingsEvent.kt | 3 +- .../settings/presentation/SettingsRoutes.kt | 5 +-- .../settings/presentation/SettingsScreen.kt | 6 +-- .../settings/presentation/SettingsUiEvent.kt | 3 +- .../settings/presentation/SettingsUiState.kt | 2 + .../presentation/SettingsViewModel.kt | 9 +++-- .../presentation/component/SettingsDsl.kt | 7 ++-- .../presentation/component/SettingsEntry.kt | 11 +++--- .../presentation/component/SettingsList.kt | 5 ++- .../settings/src/main/res/values/strings.xml | 7 ++-- .../presentation/SettingsViewModelTest.kt | 30 ++++++++++++++ 16 files changed, 102 insertions(+), 49 deletions(-) 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 index 00c028ba0..db8bf23f4 100644 --- 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 @@ -8,13 +8,24 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single +/** + * The most recent successful backup, or `null` while none has ever completed. + * + * An interface rather than a plain use case because other feature modules observe it to show backup + * health: the implementation's collaborators are module-internal, so an interface is what their + * tests can substitute. + */ +fun interface ObserveLastBackupUseCase { + operator fun invoke(): Flow<LastBackup?> +} + @Single -internal class ObserveLastBackupUseCase( +internal class ObserveLastBackupUseCaseImpl( private val jobRepository: BackupJobRepository, private val destinationResolver: BackupDestinationResolver, -) { +) : ObserveLastBackupUseCase { - operator fun invoke(): Flow<LastBackup?> = + override operator fun invoke(): Flow<LastBackup?> = jobRepository.observeJobs().map { jobs -> jobs.filter { it.lastResult == BackupResult.Success && it.finishedAt != null } .maxByOrNull { it.finishedAt!! } 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 index 4b384b0f0..6cbddad42 100644 --- 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 @@ -17,7 +17,7 @@ class ObserveLastBackupUseCaseTest { private val jobRepository = FakeBackupJobRepository() private val destinationResolver = FakeBackupDestinationResolver() - private val useCase = ObserveLastBackupUseCase(jobRepository, destinationResolver) + private val useCase = ObserveLastBackupUseCaseImpl(jobRepository, destinationResolver) private fun job( uri: String, 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 index 1cd0ee994..c3ad3a5ca 100644 --- 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 @@ -16,7 +16,7 @@ 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.domain.usecase.ObserveLastBackupUseCaseImpl 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 @@ -50,7 +50,7 @@ class BackupHubViewModelTest { private fun viewModel() = BackupHubViewModel( observeDispatchedBackups = ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver), - observeLastBackup = ObserveLastBackupUseCase(jobRepository, destinationResolver), + observeLastBackup = ObserveLastBackupUseCaseImpl(jobRepository, destinationResolver), cancelBackup = CancelBackupUseCase( repository = repository, jobRepository = jobRepository, diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 85508c572..597747a71 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) 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 24249205e..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 @@ -30,10 +30,7 @@ fun NavGraphBuilder.settingsGraph( SettingsScreen( showLibraries = onShowLibraries, onOpenChangePassword = onOpenChangePassword, - - // TODO: #51 - onExportDataClicked = onOpenBackup, - onImportDataClicked = {} + onOpenBackup = onOpenBackup, ) } composable<ChangePasswordRoute> { 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<SettingsViewModel>() @@ -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 4f079f95d..00517bb6e 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.model.OsState import de.davis.keygo.feature.settings.domain.repository.AppVersionRepository import kotlinx.coroutines.channels.Channel @@ -22,6 +23,7 @@ internal class SettingsViewModel( private val autofillServiceRepository: AutofillServiceRepository, accountRepository: AccountRepository, appVersionRepository: AppVersionRepository, + observeLastBackup: ObserveLastBackupUseCase, ) : ViewModel() { private val versionName = appVersionRepository.versionName @@ -39,12 +41,14 @@ internal class SettingsViewModel( val state = combine( accountRepository.observe(), osState, - ) { account, os -> + observeLastBackup(), + ) { account, os, lastBackup -> SettingsUiState( autofillEnabled = os.autofillEnabled, biometricsAvailable = os.biometricsAvailable, biometricsEnabled = os.biometricsAvailable && 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..0d88d0a7e 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 @@ -96,8 +97,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 @@ <string name="settings_biometric_update_failed">Could not update biometric unlock</string> <string name="settings_backup">Backup</string> - <string name="settings_export_data">Export Data</string> - <string name="settings_export_data_description">Save an encrypted backup of your vaults to a file</string> - <string name="settings_import_data">Import Data</string> - <string name="settings_import_data_description">Restore your vaults from a backup file</string> + <string name="settings_backup_and_restore">Backup & Restore</string> + <string name="settings_backup_last">Last backup %1$s</string> + <string name="settings_backup_none">No backups yet</string> <string name="settings_about">About</string> <string name="settings_report_issue">Report an issue</string> 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 da0fb6652..1275707bf 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,8 +4,11 @@ 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.domain.model.LastBackup +import de.davis.keygo.feature.backup.domain.usecase.ObserveLastBackupUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -16,6 +19,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 +31,7 @@ class SettingsViewModelTest { private val biometricAvailability = FakeBiometricAvailabilityRepository() private val autofillServiceRepository = FakeAutofillServiceRepository() private val appVersionRepository = FakeAppVersionRepository() + private val lastBackup = MutableStateFlow<LastBackup?>(null) @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -39,6 +44,7 @@ class SettingsViewModelTest { autofillServiceRepository = autofillServiceRepository, accountRepository = accountRepository, appVersionRepository = appVersionRepository, + observeLastBackup = ObserveLastBackupUseCase { lastBackup }, ) @Test @@ -76,4 +82,28 @@ 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) { + lastBackup.value = LastBackup(finishedAt = 1_700_000_000_000L, destination = null) + 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) + } } From d0cb411a607b1df5c04453f151bffc55c427e22a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 25 Jul 2026 16:26:12 +0200 Subject: [PATCH 32/59] feat(backup): carry the vault icon through JSON backups Vaults restored from a backup all came back with the default icon. The JSON schema now carries an icon name per vault, applied when the import creates a vault. Vaults matched by name, and an explicit target vault, keep the icon they already have. Format stays v1: the field defaults to empty, so backups written before it - including the frozen goldens - still read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../feature/backup/domain/BackupCollector.kt | 2 + .../feature/backup/domain/BackupRestorer.kt | 8 ++- .../backup/domain/mapper/ExportMappers.kt | 4 ++ .../backup/domain/mapper/ImportMappers.kt | 9 +++ .../keygo/feature/backup/BackupTestData.kt | 19 +++++- .../backup/domain/BackupCollectorTest.kt | 18 +++++ .../backup/domain/BackupRestorerTest.kt | 67 ++++++++++++++++++- .../domain/usecase/ImportBackupUseCaseTest.kt | 18 ++--- .../import/ImportWizardViewModelTest.kt | 10 +-- rust/rust-code/bindings/src/backup.rs | 3 + rust/rust-code/lib/src/backup/format/json.rs | 23 +++++++ rust/rust-code/lib/src/backup/model.rs | 6 ++ 12 files changed, 167 insertions(+), 20 deletions(-) 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 index fdbd3a5da..5d958a579 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -75,6 +76,7 @@ internal class BackupCollector( val backupVaults = perVault.map { (meta, logins, cards) -> BackupVault( name = meta.name, + icon = meta.icon.toBackupIcon(), logins = logins.map { login -> val passkeys = passkeyRepository.getPasskeysByLogin(login.id) login.export { it.toBackupLogin(passkeys) } 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 index c31dd036d..49333f4ab 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -51,7 +52,8 @@ internal class BackupRestorer( // 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. + // exactly one vault no matter how many vaults the backup describes. That collapse is + // also why it keeps the default icon: the sources it absorbs may disagree on one. val targetVaultId = when (target) { null -> null is ImportTarget.Existing -> target.vaultId @@ -60,10 +62,12 @@ internal class BackupRestorer( ?.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, Vault.Icon.Default) + ?: createVault(bvault.name, bvault.toVaultIcon()) .getOrNull() ?.also { existingByName[bvault.name] = it; acc.vaultCreated() } 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 index 6dd022878..91cdc89de 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -11,6 +12,9 @@ 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<Passkey>): BackupLogin = BackupLogin( title = name, 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 index 8b1b05d5c..110902d4f 100644 --- 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 @@ -3,10 +3,19 @@ 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, 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 index 2a55e05fc..0c1164594 100644 --- 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 @@ -18,6 +18,9 @@ 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). */ @@ -28,13 +31,25 @@ fun secretPayload(plaintext: String) = EncryptedPayload( private val emptyKey get() = KeyInformation(byteArrayOf(), byteArrayOf()) -fun testVault(id: VaultId = newVaultId(), name: String) = Vault( +fun testVault( + id: VaultId = newVaultId(), + name: String, + icon: Vault.Icon = Vault.Icon.Default, +) = Vault( id = id, name = name, keyInformation = emptyKey, - icon = Vault.Icon.Default, + 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<BackupLogin> = emptyList(), + cards: List<BackupCard> = emptyList(), + icon: String = "", +) = BackupVault(name = name, icon = icon, logins = logins, cards = cards) + fun testLogin( vaultId: VaultId, id: ItemId = newItemId(), 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 index ce9eb0338..75ca95ad0 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -136,6 +137,23 @@ class BackupCollectorTest { 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") 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 index 90dba1836..68a410a7b 100644 --- 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 @@ -1,8 +1,10 @@ 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 @@ -31,8 +33,8 @@ class BackupRestorerTest { ) private fun backup(vararg vaults: BackupVault) = Backup(vaults.toList()) - private fun vault(name: String, logins: List<BackupLogin>) = - BackupVault(name = name, logins = logins, cards = emptyList()) + private fun vault(name: String, logins: List<BackupLogin>, icon: String = "") = + backupVault(name = name, logins = logins, icon = icon) @Test fun `empty backup fails with NothingImported`() = runTest { @@ -67,6 +69,67 @@ class BackupRestorerTest { 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 keeps the default icon rather than 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"), + ) { _, _ -> } + + assertEquals( + Vault.Icon.Default, + env.vaultRepo.observeAllVaultMetadata().first().single().icon, + ) + } + @Test fun `skips a login that already exists by name and username`() = runTest { val env = RestorerTestEnv() 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 index ff425ad59..8ae970ed8 100644 --- 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 @@ -3,6 +3,7 @@ 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.backupVault import de.davis.keygo.feature.backup.RestorerTestEnv import de.davis.keygo.feature.backup.domain.BackupFileStore import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri @@ -18,7 +19,6 @@ 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.BackupVault import de.davisalessandro.keygo.rust.ColumnMapping import de.davisalessandro.keygo.rust.CsvAnalysis import de.davisalessandro.keygo.rust.CsvImportResult @@ -144,7 +144,7 @@ class ImportBackupUseCaseTest { @Test fun `json import restores and succeeds`() = runTest { fileStore.contents = """{"vaults":[]}""" - json.importResult = Backup(listOf(BackupVault("Imported", listOf(login("Email")), emptyList()))) + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) val emissions = useCase()(jsonRequest()).toList() @@ -163,7 +163,7 @@ class ImportBackupUseCaseTest { confidence = FieldConfidence(null, null, null, null, null, null), ) csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) @@ -185,7 +185,7 @@ class ImportBackupUseCaseTest { confidence = FieldConfidence(null, null, null, null, null, null), ) csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + 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 @@ -229,7 +229,7 @@ class ImportBackupUseCaseTest { fun `running progress is emitted during restore`() = runTest { fileStore.contents = """{"vaults":[]}""" json.importResult = Backup( - listOf(BackupVault("V", listOf(login("A"), login("B")), emptyList())), + listOf(backupVault("V", listOf(login("A"), login("B")))), ) val emissions = useCase()(jsonRequest()).toList() @@ -244,7 +244,7 @@ class ImportBackupUseCaseTest { 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")), emptyList()))) + json.importResult = Backup(listOf(backupVault("V", listOf(login("A"))))) val session = FakeSession(startOnConstruct = true) val emissions = useCase(session)(jsonRequest(passphrase = null)).toList() @@ -258,7 +258,7 @@ class ImportBackupUseCaseTest { fun `plaintext json imports without a credential`() = runTest { fileStore.contents = "{}" json.inspectResult = JsonEncryption.NONE - json.importResult = Backup(listOf(BackupVault("V", listOf(login("A")), emptyList()))) + json.importResult = Backup(listOf(backupVault("V", listOf(login("A"))))) val emissions = useCase()(jsonRequest(passphrase = null)).toList() @@ -326,7 +326,7 @@ class ImportBackupUseCaseTest { confidence = FieldConfidence(null, null, null, null, null, null), ) csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) @@ -354,7 +354,7 @@ class ImportBackupUseCaseTest { confidence = FieldConfidence(null, null, null, null, null, null), ) csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) 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 index 4040507a1..32f5bc05f 100644 --- 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 @@ -6,6 +6,7 @@ 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.backupVault import de.davis.keygo.feature.backup.RestorerTestEnv import de.davis.keygo.feature.backup.data.FakeBackupDestinationResolver import de.davis.keygo.feature.backup.domain.model.BackupDestination @@ -27,7 +28,6 @@ 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.BackupVault import de.davisalessandro.keygo.rust.ColumnMapping import de.davisalessandro.keygo.rust.Confidence import de.davisalessandro.keygo.rust.CsvAnalysis @@ -163,7 +163,7 @@ class ImportWizardViewModelTest { fun `Continue on selected JSON runs import and surfaces the summary`() = runTest { json.inspectResult = JsonEncryption.NONE fileStore.contents = """{"vaults":[]}""" - json.importResult = Backup(listOf(BackupVault("Imported", listOf(login("Email")), emptyList()))) + json.importResult = Backup(listOf(backupVault("Imported", listOf(login("Email"))))) val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) viewModel.selectJson() advanceUntilIdle() @@ -306,7 +306,7 @@ class ImportWizardViewModelTest { fileStore.contents = "name,secret\nEmail,s3cr3t\n" csv.analyzeResult = csvAnalysis() csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) @@ -384,7 +384,7 @@ class ImportWizardViewModelTest { fileStore.contents = "name,secret\nEmail,s3cr3t\n" csv.analyzeResult = csvAnalysis() csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) @@ -407,7 +407,7 @@ class ImportWizardViewModelTest { fileStore.contents = "name,secret\nEmail,s3cr3t\n" csv.analyzeResult = csvAnalysis() csv.importResult = CsvImportResult( - backup = Backup(listOf(BackupVault("CSV Import", listOf(login("Email")), emptyList()))), + backup = Backup(listOf(backupVault("CSV Import", listOf(login("Email"))))), report = ImportReport(imported = 1u, skipped = 0u), ) val viewModel = viewModel(FakeBackupDestinationResolver(result = csvDestination())) diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs index c75e738ed..82dd2d60a 100644 --- a/rust/rust-code/bindings/src/backup.rs +++ b/rust/rust-code/bindings/src/backup.rs @@ -11,6 +11,7 @@ pub struct Backup { #[derive(uniffi::Record)] pub struct BackupVault { pub name: String, + pub icon: String, pub logins: Vec<BackupLogin>, pub cards: Vec<BackupCard>, } @@ -142,6 +143,7 @@ impl From<core::Vault> 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(), } @@ -152,6 +154,7 @@ impl From<BackupVault> 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(), } diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 2f54bd107..1c8b920a7 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -87,6 +87,7 @@ mod tests { Backup { vaults: vec![Vault { name: "Personal".into(), + icon: "Work".into(), logins: vec![Login { title: "Email".into(), notes: Some("primary".into()), @@ -176,6 +177,28 @@ mod tests { 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, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let restored = import(&json, Some(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, + Some(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 diff --git a/rust/rust-code/lib/src/backup/model.rs b/rust/rust-code/lib/src/backup/model.rs index 07f95a4ff..52f6f3d13 100644 --- a/rust/rust-code/lib/src/backup/model.rs +++ b/rust/rust-code/lib/src/backup/model.rs @@ -8,6 +8,12 @@ pub struct Backup { #[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<Login>, pub cards: Vec<Card>, } From ae39ea6d8ced07480188869c2fd84ab258cda060 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 25 Jul 2026 18:18:14 +0200 Subject: [PATCH 33/59] feat(backup): let the CSV import pick an icon for the vault it creates The new-vault option in the import wizard only took a name, so every vault a CSV import created looked like every other one. It now offers the same icon grid the vault-creation dialog uses, extracted to :core:item so both flows share one picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../core/item/presentation/VaultIconPicker.kt | 64 +++++++++++++++++++ .../feature/backup/domain/BackupRestorer.kt | 7 +- .../backup/domain/model/ImportTarget.kt | 11 +++- .../import/ImportWizardContent.kt | 4 ++ .../import/ImportWizardViewModel.kt | 4 ++ .../presentation/import/SelectVaultContent.kt | 55 +++++++++++++--- .../import/model/ImportWizardUiEvent.kt | 2 + .../import/model/ImportWizardUiState.kt | 5 +- .../backup/src/main/res/values/strings.xml | 1 + .../backup/domain/BackupRestorerTest.kt | 6 +- .../import/ImportWizardViewModelTest.kt | 46 +++++++++++++ .../import/model/ImportWizardUiStateTest.kt | 27 ++++++++ .../components/VaultCreationDialog.kt | 39 +++-------- 13 files changed, 220 insertions(+), 51 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconPicker.kt 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/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 index 49333f4ab..60278827c 100644 --- 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 @@ -52,12 +52,13 @@ internal class BackupRestorer( // 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. That collapse is - // also why it keeps the default icon: the sources it absorbs may disagree on one. + // 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, Vault.Icon.Default) + is ImportTarget.New -> createVault(target.name, target.icon) .getOrNull() ?.also { acc.vaultCreated() } } 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 index 5fb80b353..62e95d74b 100644 --- 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 @@ -1,6 +1,7 @@ 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. @@ -15,8 +16,12 @@ sealed interface ImportTarget { data class Existing(val vaultId: VaultId) : ImportTarget /** - * Create a vault named [name] 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. + * 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) : ImportTarget + 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/presentation/import/ImportWizardContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/ImportWizardContent.kt index f84de9cc3..46512993e 100644 --- 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 @@ -134,8 +134,12 @@ private fun InputWizard( 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)) + }, ) } } 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 index 026703d2b..a7414be8d 100644 --- 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 @@ -102,6 +102,10 @@ internal class ImportWizardViewModel( ImportWizardUiEvent.CreateNewVault -> _state.update { it.copy(creatingNewVault = true) } + + is ImportWizardUiEvent.SelectNewVaultIcon -> _state.update { + it.copy(newVaultIcon = event.icon) + } } } 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 index e380921f1..fd63678e0 100644 --- 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 @@ -29,6 +29,7 @@ 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.semantics.Role @@ -42,6 +43,7 @@ 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.IconBadge @@ -56,8 +58,10 @@ internal fun SelectVaultContent( selectedVaultId: VaultId?, creatingNewVault: Boolean, newVaultNameState: TextFieldState, + newVaultIcon: Vault.Icon, onSelectVault: (VaultId) -> Unit, onCreateNewVault: () -> Unit, + onSelectNewVaultIcon: (Vault.Icon) -> Unit, modifier: Modifier = Modifier, ) { // One segment per vault plus the "new vault" row, so the group's rounded ends land correctly. @@ -83,8 +87,10 @@ internal fun SelectVaultContent( NewVaultSegment( selected = creatingNewVault, nameState = newVaultNameState, + icon = newVaultIcon, shapes = ListItemDefaults.segmentedShapes(vaults.size, segmentCount), onClick = onCreateNewVault, + onSelectIcon = onSelectNewVaultIcon, ) } } @@ -150,9 +156,13 @@ private fun VaultSegment( 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( onClick = onClick, @@ -160,7 +170,9 @@ private fun NewVaultSegment( colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { IconBadge( - icon = Icons.Default.Add, + // The badge previews the chosen icon once this row is the destination, the + // way the existing-vault rows show theirs; until then it invites the choice. + icon = if (selected) icon.toImageVector() else Icons.Default.Add, containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer @@ -183,15 +195,34 @@ private fun NewVaultSegment( Text(text = stringResource(R.string.select_vault_new)) } - 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), - ) + 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), + ) + + Text( + text = stringResource(R.string.select_vault_icon_label), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.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), + ) + } } } @@ -211,8 +242,10 @@ private fun SelectVaultContentExistingPreview() { selectedVaultId = previewVaults.first().vaultId, creatingNewVault = false, newVaultNameState = TextFieldState(), + newVaultIcon = Vault.Icon.Default, onSelectVault = {}, onCreateNewVault = {}, + onSelectNewVaultIcon = {}, ) } } @@ -228,8 +261,10 @@ private fun SelectVaultContentNewVaultPreview() { 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/ImportWizardUiEvent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/import/model/ImportWizardUiEvent.kt index ae8842773..8714fb455 100644 --- 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 @@ -1,6 +1,7 @@ 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 { @@ -10,4 +11,5 @@ internal sealed interface 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 index 29fe6e4d7..4f076ee6b 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -27,6 +28,7 @@ internal data class ImportWizardUiState( 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, ) { @@ -61,6 +63,7 @@ internal data class ImportWizardUiState( * the user has typed since. */ fun resolveTarget(newVaultName: String): ImportTarget? = - if (creatingNewVault) newVaultName.trim().takeIf(String::isNotBlank)?.let(ImportTarget::New) + if (creatingNewVault) newVaultName.trim().takeIf(String::isNotBlank) + ?.let { ImportTarget.New(it, newVaultIcon) } else selectedVaultId?.let(ImportTarget::Existing) } diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index ed5687cda..572d7d5fb 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -185,6 +185,7 @@ <string name="select_vault_title">Import into</string> <string name="select_vault_new">New vault</string> <string name="select_vault_name_label">Vault name</string> + <string name="select_vault_icon_label">Icon</string> <plurals name="select_vault_item_count"> <item quantity="one">%1$d item</item> <item quantity="other">%1$d items</item> 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 index 68a410a7b..423f8c624 100644 --- 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 @@ -112,7 +112,7 @@ class BackupRestorerTest { } @Test - fun `a New target keeps the default icon rather than one of the sources it absorbs`() = + fun `a New target takes the icon the user chose, not one of the sources it absorbs`() = runTest { val env = RestorerTestEnv() @@ -121,11 +121,11 @@ class BackupRestorerTest { vault("First", listOf(login("Email")), icon = "Business"), vault("Second", listOf(login("Bank")), icon = "Work"), ), - ImportTarget.New("passwords"), + ImportTarget.New("passwords", Vault.Icon.Star), ) { _, _ -> } assertEquals( - Vault.Icon.Default, + Vault.Icon.Star, env.vaultRepo.observeAllVaultMetadata().first().single().icon, ) } 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 index 32f5bc05f..4321c0809 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -426,6 +427,51 @@ class ImportWizardViewModelTest { ) } + @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" 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 index c4f6eab8c..c3a1df867 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -59,6 +61,31 @@ class ImportWizardUiStateTest { 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")) 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() + }, + ) } } From ffff3a701ec3dc7f32ae8ca12dbede045b3f9eb9 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 25 Jul 2026 19:09:25 +0200 Subject: [PATCH 34/59] feat(backup): mirror name and icon state --- .../presentation/import/SelectVaultContent.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 index fd63678e0..8b12797d5 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -192,7 +193,11 @@ private fun NewVaultSegment( this.selected = selected }, ) { - Text(text = stringResource(R.string.select_vault_new)) + // 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) { @@ -204,12 +209,20 @@ private fun NewVaultSegment( 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 = 4.dp), + modifier = Modifier.padding(top = 8.dp), ) VaultIconPicker( @@ -229,7 +242,12 @@ private fun NewVaultSegment( 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), + VaultMetadata( + vaultId = newVaultId(), + name = "Shopping", + icon = Vault.Icon.ShoppingCart, + count = 7 + ), ) @Preview(showBackground = true) From 88c853d3fdc567f1bf0ff7995bd7354f8374eac1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 25 Jul 2026 19:30:10 +0200 Subject: [PATCH 35/59] fix(backup): icons --- .../presentation/import/SelectVaultContent.kt | 39 ++++--------------- 1 file changed, 8 insertions(+), 31 deletions(-) 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 index 8b12797d5..865d1e77f 100644 --- 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 @@ -33,10 +33,6 @@ 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.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,7 +43,6 @@ 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.IconBadge /** * Where the import lands. A full step rather than a dropdown: the choice is worth the room, and a @@ -113,16 +108,14 @@ private fun VaultSegment( onClick: () -> Unit, ) { SegmentedListItem( + selected = selected, onClick = onClick, shapes = shapes, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { - IconBadge( - icon = vault.icon.toImageVector(), - containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurfaceVariant, + Icon( + imageVector = vault.icon.toImageVector(), + contentDescription = null ) }, supportingContent = { @@ -138,16 +131,9 @@ private fun VaultSegment( if (selected) Icon( imageVector = Icons.Default.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, ) }, verticalAlignment = Alignment.CenterVertically, - // The row is a one-of-many choice, not a button; role announces that, selected announces - // whether this particular row is the one currently chosen. - modifier = Modifier.semantics { - role = Role.RadioButton - this.selected = selected - }, ) { Text(text = vault.name) } @@ -166,32 +152,23 @@ private fun NewVaultSegment( Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { SegmentedListItem( + selected = selected, onClick = onClick, shapes = shapes, colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { - IconBadge( - // The badge previews the chosen icon once this row is the destination, the - // way the existing-vault rows show theirs; until then it invites the choice. - icon = if (selected) icon.toImageVector() else Icons.Default.Add, - containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurfaceVariant, + Icon( + imageVector = if (selected) icon.toImageVector() else Icons.Default.Add, + contentDescription = null ) }, trailingContent = { if (selected) Icon( imageVector = Icons.Default.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, ) }, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.semantics { - role = Role.RadioButton - this.selected = selected - }, ) { // 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 From 446496f51228a67dca1c119b60fcda5a71ca7930 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sun, 26 Jul 2026 11:30:49 +0200 Subject: [PATCH 36/59] feat(backup): UI/UX cleanup --- feature/backup/build.gradle.kts | 1 - .../backup/data/BackupSchedulerImpl.kt | 1 - .../data/ContentResolverBackupFileStore.kt | 137 ++++---- .../BackupJobRepositoryImpl.kt | 49 ++- .../BackupJobRetention.kt | 2 +- .../backup/domain/BackupArkUnlocker.kt | 4 +- .../feature/backup/domain/BackupFileStore.kt | 2 - .../backup/domain/BackupProvisioningLock.kt | 8 +- .../keygo/feature/backup/domain/SessionExt.kt | 11 + .../backup/domain/mapper/CsvMappingMappers.kt | 1 - .../backup/domain/model/BackupEntry.kt | 1 - .../domain/model/BackupFailureReason.kt | 8 +- .../backup/domain/model/BackupFileName.kt | 6 +- .../backup/domain/model/BackupResult.kt | 6 +- .../feature/backup/domain/model/CsvPreset.kt | 1 - .../backup/domain/model/EncryptionMethod.kt | 2 +- .../backup/domain/model/ExportError.kt | 5 +- .../feature/backup/domain/model/FileFormat.kt | 2 +- .../feature/backup/domain/model/LastBackup.kt | 1 - .../backup/domain/model/MappingConfidence.kt | 1 - .../domain/usecase/CancelBackupUseCase.kt | 2 - .../usecase/CleanupBackupResourcesUseCase.kt | 7 +- .../usecase/FinishExportWizardUseCase.kt | 3 +- .../domain/usecase/ImportBackupUseCase.kt | 5 +- .../ObserveDispatchedBackupsUseCase.kt | 11 +- .../usecase/ObserveLastBackupUseCase.kt | 6 +- .../component/BackupFileChooser.kt | 89 ++--- .../component/BackupListDefaults.kt | 9 + .../component/BackupWarningCard.kt | 37 +++ .../backup/presentation/component/Wizard.kt | 12 +- .../export/ExportWizardContent.kt | 4 +- .../export/ExportWizardViewModel.kt | 9 +- .../export/ProvidePassphraseContent.kt | 132 ++++---- .../export/ReviewBackupContent.kt | 314 ++++++++---------- .../presentation/export/ScheduleComponents.kt | 22 +- .../export/SelectCsvPresetContent.kt | 54 ++- .../export/SelectDestinationContent.kt | 40 +-- .../export/SelectFileFormatContent.kt | 71 ++-- .../export/SelectScheduleContent.kt | 115 ++++--- .../presentation/export/model/BackupFile.kt | 7 +- .../presentation/hub/BackupHubContent.kt | 106 +++--- .../hub/DispatchedBackupDisplay.kt | 1 - .../presentation/import/ImportPhaseContent.kt | 111 ++++++- .../import/ImportWizardContent.kt | 6 +- .../import/ImportWizardViewModel.kt | 14 +- .../presentation/import/MapColumnsContent.kt | 113 ++----- .../import/ProvidePassphraseContent.kt | 2 +- .../presentation/import/SelectVaultContent.kt | 13 +- .../import/model/ImportWizardStep.kt | 28 +- .../feature/backup/worker/BackupWorker.kt | 3 +- .../backup/src/main/res/values/strings.xml | 18 +- .../feature/backup/FakeBackupJobRepository.kt | 2 +- .../BackupJobRetentionTest.kt | 2 +- .../usecase/ObserveLastBackupUseCaseTest.kt | 11 +- .../hub/BackupHubViewModelTest.kt | 2 +- .../presentation/SettingsViewModelTest.kt | 2 +- .../components/VaultSelectionSheet.kt | 2 +- gradle/libs.versions.toml | 2 +- rust/rust-code/lib/src/b64.rs | 2 +- .../davis/keygo/rust/backup/BackupResult.kt | 21 ++ .../keygo/rust/backup/CsvBackupManager.kt | 31 +- .../keygo/rust/backup/JsonBackupManager.kt | 31 +- 62 files changed, 789 insertions(+), 932 deletions(-) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/{reository => repository}/BackupJobRepositoryImpl.kt (73%) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/{reository => repository}/BackupJobRetention.kt (95%) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/SessionExt.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupListDefaults.kt create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/BackupWarningCard.kt rename feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/{reository => repository}/BackupJobRetentionTest.kt (95%) create mode 100644 rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index 698ca0c66..0285564a8 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -30,5 +30,4 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) - testImplementation(libs.io.mockk) } 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 index 4b04eaa1a..4b443ebe8 100644 --- 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 @@ -65,7 +65,6 @@ internal class BackupSchedulerImpl( .addTag(BackupWorker.TAG_ONE_TIME) .build() - // Persist before enqueue so the worker can never start ahead of its own record. return backupJobRepository.putJob(request.id.toString(), job).onSuccess { workManager.enqueue(request) } 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 index be4114834..c9687fd67 100644 --- 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 @@ -11,99 +11,86 @@ 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 <T> io(crossinline block: () -> T): Result<T, Throwable> = + 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<String, Throwable> = - withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(uri.value.toUri()) - ?.use { it.readBytes().decodeToString() } - ?: error("Unable to open input stream for ${uri.value}") - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) }, - ) - } + override suspend fun read(uri: BackupDestinationUri): Result<String, Throwable> = 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<Unit, Throwable> = withContext(Dispatchers.IO) { - runCatching { - 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") - }.fold( - onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Failure(it) }, + ): Result<Unit, Throwable> = 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<List<BackupEntry>, Throwable> = withContext(Dispatchers.IO) { - runCatching { - 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() - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) }, + ): Result<List<BackupEntry>, 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<Unit, Throwable> = - withContext(Dispatchers.IO) { - runCatching { - val deleted = DocumentsContract.deleteDocument( - context.contentResolver, - uri.value.toUri(), - ) - if (!deleted) error("Unable to delete ${uri.value}") - }.fold( - onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Failure(it) }, - ) - } + override suspend fun delete(uri: BackupDestinationUri): Result<Unit, Throwable> = 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/reository/BackupJobRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt similarity index 73% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt index 51694e94c..e616cb46a 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRepositoryImpl.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRepositoryImpl.kt @@ -1,8 +1,9 @@ -package de.davis.keygo.feature.backup.data.reository +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 @@ -44,37 +45,35 @@ internal class BackupJobRepositoryImpl( onFailure = { Result.Failure(Unit) }, ) - override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) { - dataStore.updateData { current -> - val existing = current.jobsMap[workId] ?: return@updateData current - val updated = existing.copy { - this.finishedAt = finishedAt - writeResult(result) - } - current.upsertAndPrune(workId, updated) + override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) = + updateJob(workId) { + this.finishedAt = finishedAt + writeResult(result) } + + override suspend fun markCancelled(workId: String, cancelledAt: Long) = updateJob(workId) { + cancelled = true + finishedAt = cancelledAt } - override suspend fun markCancelled(workId: String, cancelledAt: Long) { - dataStore.updateData { current -> - val existing = current.jobsMap[workId] ?: return@updateData current - val updated = existing.copy { - cancelled = true - finishedAt = cancelledAt - } - current.upsertAndPrune(workId, updated) - } + // Clearing credentials must not prune: the record is still live and its retention position has + // not changed. + override suspend fun clearPassphrase(workId: String) = updateJob(workId, prune = false) { + clearPassphraseCt() + clearPassphraseIv() } - override suspend fun clearPassphrase(workId: String) { + /** Applies [edit] to the record under [workId]. A missing record is a no-op. */ + private suspend fun updateJob( + workId: String, + prune: Boolean = true, + edit: ProtoBackupJobKt.Dsl.() -> Unit, + ) { dataStore.updateData { current -> val existing = current.jobsMap[workId] ?: return@updateData current - current.copy { - jobs[workId] = existing.copy { - clearPassphraseCt() - clearPassphraseIv() - } - } + val updated = existing.copy(edit) + if (prune) current.upsertAndPrune(workId, updated) + else current.copy { jobs[workId] = updated } } } diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt similarity index 95% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt index db1f0d8f5..5b3154922 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetention.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetention.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.backup.data.reository +package de.davis.keygo.feature.backup.data.repository import de.davis.keygo.feature.backup.worker.BackupWorker 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 index 715d285e9..2ff8fdc21 100644 --- 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 @@ -35,7 +35,7 @@ internal class BackupArkUnlocker( * [Session.ark] is the app's own session key and is left alone. */ suspend fun <R> withArk(block: suspend (ByteArray) -> R): Result<R, ExportError> { - val live = runCatching { session.ark }.getOrNull() + val live = session.arkOrNull() if (live != null) return Result.Success(block(live)) return resultBinding { @@ -53,7 +53,7 @@ internal class BackupArkUnlocker( suspend fun <R> withScope( block: suspend (ItemWithCryptoScopeUseCase) -> R, ): Result<R, ExportError> { - val live = runCatching { session.ark }.getOrNull() + val live = session.arkOrNull() if (live != null) return Result.Success(block(scopeFor(session))) return resultBinding { 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 index cca894aa4..b1b02aedf 100644 --- 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 @@ -21,12 +21,10 @@ interface BackupFileStore { text: String, ): Result<Unit, Throwable> - /** Lists documents inside [folder] whose display name starts with [baseName]. */ suspend fun listBackups( folder: BackupDestinationUri, baseName: String, ): Result<List<BackupEntry>, Throwable> - /** Deletes the document at [uri]. */ suspend fun delete(uri: BackupDestinationUri): Result<Unit, Throwable> } 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 index 0c670f60a..349a18688 100644 --- 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 @@ -6,11 +6,9 @@ import org.koin.core.annotation.Single /** * Serializes ARK-escrow provisioning against escrow teardown. * - * Provisioning (FinishExportWizardUseCase) writes the escrow + auth-less key aliases to one - * DataStore and the job record that marks a job "live" to another, with no cross-store transaction. - * CleanupBackupResourcesUseCase decides what to tear down solely from the job records. Holding this - * single lock across the whole of each section means a cleanup can never observe a half-provisioned - * job (escrow written, record not yet) and destroy credentials the new job needs. + * 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 { 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/mapper/CsvMappingMappers.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/mapper/CsvMappingMappers.kt index 7fee3c3d0..0c99c17d6 100644 --- 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 @@ -41,7 +41,6 @@ private fun Confidence.toDomain(): MappingConfidence = when (this) { Confidence.LOW -> MappingConfidence.Low } -/** Turn a columnIndex -> type assignment into the Rust field-centric [ColumnMapping]. */ internal fun Map<Int, CsvColumnType?>.toColumnMapping(): ColumnMapping { val byType: Map<CsvColumnType, UInt> = entries .mapNotNull { (index, type) -> type?.let { it to index.toUInt() } } 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 index 4b9a7e844..8de1e28b3 100644 --- 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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.backup.domain.model -/** An existing backup document discovered inside a destination folder. */ 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 index c82746ced..346639ab9 100644 --- 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 @@ -3,10 +3,10 @@ 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]: retryable errors never reach a terminal record. It does - * not carry the Rust cause's free-form message (that would leak cryptic internals into the record - * and the UI); instead a serialization failure is split into the format-specific sub-cases that the - * export path can actually produce, so the hub row can name what went wrong. + * Deliberately narrower than [ExportError]: retryable errors never reach a terminal record, 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 { 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 index 79b94f1f3..edcbc8414 100644 --- 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 @@ -1,12 +1,10 @@ package de.davis.keygo.feature.backup.domain.model -/** Shared prefix for every backup document this app writes into a destination folder. */ const val BACKUP_BASE_NAME = "keygo-backup" /** - * The concrete document name for a single export, 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. + * 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/BackupResult.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupResult.kt index 1001b7cce..31988fe67 100644 --- 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 @@ -3,10 +3,6 @@ package de.davis.keygo.feature.backup.domain.model sealed interface BackupResult { data object Success : BackupResult - /** - * Backup failed - * - * @param reason Carries why the run failed, null when the reason predates this field or wasn't recorded - */ + /** @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/CsvPreset.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/CsvPreset.kt index dd9bcb386..f4d835049 100644 --- 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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.backup.domain.model -/** Column layout for CSV exports. */ enum class CsvPreset { /** Every field incl. TOTP; round-trips through KeyGo's import. */ KeyGo, 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 index b214bbaf7..d4402296f 100644 --- 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 @@ -1,6 +1,6 @@ package de.davis.keygo.feature.backup.domain.model -/** How a JSON backup is sealed. Irrelevant for CSV (plaintext by design). */ +/** Irrelevant for CSV, which is plaintext by design. */ enum class EncryptionMethod { /** Sealed with a user-chosen passphrase; restorable anywhere. */ Passphrase, 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 index e740a2842..f2bb0b4fb 100644 --- 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 @@ -19,10 +19,7 @@ sealed interface ExportError { internal val ExportError.retryable: Boolean get() = this == ExportError.DeviceLocked || this == ExportError.SessionLocked -/** - * The persistable, user-facing reason for a terminal failure, or null when the error is - * [retryable] and so never becomes one. - */ +/** 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 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 index 95d990604..bfed18ce4 100644 --- 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 @@ -4,7 +4,7 @@ enum class FileFormat(val mimeType: String, val extension: String) { JSON("application/json", "json"), CSV("text/csv", "csv"); - val recommented: Boolean + val recommended: Boolean get() = this == JSON val encrypted: Boolean 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 index 2ccba6d18..4710f92e8 100644 --- 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 @@ -2,5 +2,4 @@ package de.davis.keygo.feature.backup.domain.model data class LastBackup( val finishedAt: Long, - val destination: BackupDestination?, ) 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 index 7738cd3d3..e32a51e49 100644 --- 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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.backup.domain.model -/** How strongly the CSV analyzer believes a column's auto-detected type is correct. */ enum class MappingConfidence { High, Medium, 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 index 1e4568f83..7d5bb301b 100644 --- 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 @@ -16,8 +16,6 @@ internal class CancelBackupUseCase( suspend operator fun invoke(id: String, kind: DispatchedBackup.Kind) { repository.cancel(id) - // Recurring work is a singleton stored under a stable key; one-time records are keyed by - // the WorkManager id. val workId = when (kind) { DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID DispatchedBackup.Kind.OneTime -> id 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 index ab53d099e..1c5d2abbc 100644 --- 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 @@ -18,11 +18,7 @@ import org.koin.core.annotation.Single * * 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. Calling this on a live job is - * a programming error the caller must avoid; doing nothing is the safe response here. - * - * Runs after the backup file is already written, so every step is best-effort: a failure here must - * never fail a backup. + * up a still-live job would destroy credentials a future run needs. */ @Single internal class CleanupBackupResourcesUseCase( @@ -36,7 +32,6 @@ internal class CleanupBackupResourcesUseCase( suspend operator fun invoke(workId: String): Unit = provisioningLock.mutex.withLock { val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return - // Calling this on a job that is still live would strip credentials its next run needs. if (current[workId]?.isLive(workId) == true) return runCatching { jobRepository.clearPassphrase(workId) } 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 index 8edba5223..54ac69159 100644 --- 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 @@ -13,6 +13,7 @@ import de.davis.keygo.core.util.resultBinding 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 @@ -99,7 +100,7 @@ class FinishExportWizardUseCase( } private suspend fun provisionBackupArk() = resultBinding { - val ark = runCatching { session.ark }.getOrNull() + val ark = session.arkOrNull() .asResult(FinishExportWizardError.CryptoFailed).bind() val cipher = keyStoreManager.getOrCreateCipherFor( 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 index 3e4633b99..5c7930414 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -39,7 +40,7 @@ internal class ImportBackupUseCase( */ operator fun invoke(request: ImportRequest): Flow<ImportProgress> = channelFlow { val outcome = resultBinding { - if (runCatching { session.ark }.isFailure) + if (session.arkOrNull() == null) Result.Failure<Nothing, ImportError>(ImportError.SessionLocked).bind() send(ImportProgress.Reading) @@ -78,7 +79,7 @@ internal class ImportBackupUseCase( ?: return Result.Failure(ImportError.PassphraseRequired) JsonEncryption.ARK -> BackupCredential.Ark( - runCatching { session.ark }.getOrNull() + session.arkOrNull() ?: return Result.Failure(ImportError.SessionLocked), ) } 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 index 79e063155..7d41e6069 100644 --- 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 @@ -2,6 +2,7 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.domain.BackupDestinationResolver import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +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 @@ -19,14 +20,18 @@ internal class ObserveDispatchedBackupsUseCase( ) { operator fun invoke(): Flow<List<DispatchedBackup>> = - repository.observe().map { statuses -> statuses.map { it.enrich() } } + 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(): DispatchedBackup { + private suspend fun BackupWorkStatus.enrich(jobs: Map<String, BackupJob>): DispatchedBackup { val workId = when (kind) { DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID DispatchedBackup.Kind.OneTime -> id } - val job = jobRepository.getJob(workId) + val job = jobs[workId] return DispatchedBackup( id = id, kind = kind, 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 index db8bf23f4..1b4ba5d3e 100644 --- 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 @@ -1,6 +1,5 @@ package de.davis.keygo.feature.backup.domain.usecase -import de.davis.keygo.feature.backup.domain.BackupDestinationResolver 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 @@ -9,8 +8,6 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single /** - * The most recent successful backup, or `null` while none has ever completed. - * * An interface rather than a plain use case because other feature modules observe it to show backup * health: the implementation's collaborators are module-internal, so an interface is what their * tests can substitute. @@ -22,13 +19,12 @@ fun interface ObserveLastBackupUseCase { @Single internal class ObserveLastBackupUseCaseImpl( private val jobRepository: BackupJobRepository, - private val destinationResolver: BackupDestinationResolver, ) : ObserveLastBackupUseCase { override operator fun invoke(): Flow<LastBackup?> = jobRepository.observeJobs().map { jobs -> jobs.filter { it.lastResult == BackupResult.Success && it.finishedAt != null } .maxByOrNull { it.finishedAt!! } - ?.let { LastBackup(it.finishedAt!!, destinationResolver.resolve(it.uri)) } + ?.let { LastBackup(it.finishedAt!!) } } } 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 index 890a518a1..f0a3142c1 100644 --- 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 @@ -2,7 +2,6 @@ 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.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -11,9 +10,10 @@ 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.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,6 +26,7 @@ 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 @@ -34,7 +35,6 @@ import de.davis.keygo.feature.backup.domain.model.BackupDestination @Composable internal fun BackupFileChooser( destination: BackupDestination?, - fileName: String, onChoose: () -> Unit, chooserIcon: ImageVector, chooserTitle: String, @@ -56,7 +56,6 @@ internal fun BackupFileChooser( else -> SelectedCard( destination = destination, - fileName = fileName, changeLabel = changeLabel, fileNameLabel = fileNameLabel, onChange = onChoose, @@ -110,62 +109,46 @@ private fun ChooserCard( @Composable private fun SelectedCard( destination: BackupDestination, - fileName: String, changeLabel: String, fileNameLabel: String, onChange: () -> Unit, modifier: Modifier = Modifier, ) { - Card( + SegmentedListItem( + onClick = onChange, + shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large), modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), + 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, ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - IconBadge( - icon = if (destination.fileName != null) Icons.Default.Description - else Icons.Default.Folder, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - size = 44.dp, - iconSize = 24.dp, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = destination.provider.label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = destination.displayPath, - style = MaterialTheme.typography.titleSmall, - ) - } - TextButton(onClick = onChange) { - Text(text = changeLabel) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = fileNameLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(text = fileName, style = MaterialTheme.typography.bodyMedium) - } - } + Text( + text = destination.displayPath, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } 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/Wizard.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/component/Wizard.kt index d79241c99..c087b6648 100644 --- 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 @@ -53,17 +53,7 @@ import kotlin.coroutines.cancellation.CancellationException * 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 T the type identifying a step, typically an enum. - * @param steps ordered list of steps the wizard walks through. - * @param currentStep the currently displayed step; must be an element of [steps]. - * @param title the app-bar title for [currentStep], resolved by the caller. - * @param onBack invoked when the user requests the previous step (nav icon or predictive back). - * @param onContinue invoked when the continue button is tapped. - * @param navigateUp invoked when back is requested while on the first step. - * @param showContinueButton whether the bottom continue button is visible for the current step. - * @param canContinue whether the continue button is enabled. - * @param continueButtonContent the content (label, optional icon) rendered inside the button. - * @param content the body for a given step. + * @param currentStep must be an element of [steps]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable 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 index eb955ad01..558785e62 100644 --- 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 @@ -147,10 +147,10 @@ private class ExportWizardUiStateProvider : PreviewParameterProvider<ExportWizar @Preview @Composable -private fun BackupHubContentPreview(@PreviewParameter(ExportWizardUiStateProvider::class) state: ExportWizardUiState) { +private fun ExportWizardContentPreview(@PreviewParameter(ExportWizardUiStateProvider::class) state: ExportWizardUiState) { MaterialTheme { Surface( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { ExportWizardContent( state = state, 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 index 5b7395d4e..a78f57c4b 100644 --- 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 @@ -131,12 +131,9 @@ internal class ExportWizardViewModel( when (event) { ExportWizardUiEvent.Back -> previousStep() - ExportWizardUiEvent.Continue -> { - when { - _step.value == ExportWizardStep.Review -> finishExport() - else -> nextStep() - } - } + ExportWizardUiEvent.Continue -> + if (_step.value == ExportWizardStep.Review) finishExport() + else nextStep() ExportWizardUiEvent.ChooseDestination -> _event.trySend(ExportWizardEvent.PickFolder) 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 index bdafb3a5b..0fcd4a36f 100644 --- 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 @@ -5,19 +5,18 @@ 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.ExperimentalMaterial3ExpressiveApi 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.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable @@ -34,13 +33,14 @@ 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 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun ProvidePassphraseContent( state: ProvidePassphraseState, @@ -49,74 +49,84 @@ internal fun ProvidePassphraseContent( var passphraseHidden by rememberSaveable { mutableStateOf(true) } var confirmPassphraseHidden by rememberSaveable { mutableStateOf(true) } var forceCompact by rememberSaveable { mutableStateOf(false) } - Surface { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - 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 MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = contentColorFor( - if (state.method == method) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceContainerHigh - ), + 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) - } + ), + supportingContent = { + Text(text = method.description) + }, + leadingContent = { + Icon( + imageVector = method.icon, + contentDescription = null, + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = method.displayName) } + } - AnimatedVisibility(visible = state.method == EncryptionMethod.Passphrase) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.export_passphrase_instruction), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // 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), + ) + } - 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, - ) + 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.confirmPassphraseTextFieldState, - label = stringResource(R.string.confirm_passphrase), - hidden = confirmPassphraseHidden, - onToggleHidden = { confirmPassphraseHidden = !confirmPassphraseHidden }, - ) - } + 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 }, + ) } } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PassphraseField( state: TextFieldState, 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 index 5fded506b..0417d0eef 100644 --- 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 @@ -2,7 +2,6 @@ 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 @@ -17,17 +16,15 @@ 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.material.icons.filled.WarningAmber import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle +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.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -48,8 +45,10 @@ 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.displayName +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 @@ -67,133 +66,125 @@ internal fun ReviewBackupContent( ) { val encrypted = format.encrypted val recurring = scheduleState.mode == ScheduleMode.Recurring - Surface { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - ReviewHeroCard(format = format) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - ReviewRow( - icon = format.icon, - label = stringResource(R.string.review_section_format), - ) { - Text(text = format.displayName) - } - - ReviewDivider() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ReviewHeroCard(format = format) - ReviewRow( - 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), - ) - } + 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<ReviewRowSpec>() - if (recurring) { - ReviewDivider() - ReviewRow( - 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 = format.icon, + label = stringResource(R.string.review_section_format), + ) { Text(text = format.displayName) } - ReviewDivider() + 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), + ) + } - ReviewRow( - icon = Icons.Default.Folder, - label = stringResource(R.string.review_section_destination), - ) { - destinationState.destination?.let { destination -> - Text(text = destination.displayPath) - } - } + 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, + ), + ) + } - ReviewDivider() + rows += ReviewRowSpec( + icon = Icons.Default.Folder, + label = stringResource(R.string.review_section_destination), + ) { + destinationState.destination?.let { destination -> + Text(text = destination.displayPath) + } + } - ReviewRow( - 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, - ), - ) - } + 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, + ), + ) + } - ReviewDivider() + // 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, + ) + } - ReviewRow( - 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 (encrypted) { - ReviewDivider() - if (passphraseState.method == EncryptionMethod.Passphrase) - ReviewRow( - icon = Icons.Default.Password, - label = stringResource(R.string.review_section_passphrase), - ) { - StrengthIndicator(passwordScore = passphraseState.passphraseScore) - } - else - ReviewRow( - 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) } - if (format == FileFormat.CSV) { - ReviewDivider() - ReviewRow( - 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, + ) } - - if (!encrypted) ReviewPlaintextWarning() } + + // 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), + ) } } @@ -236,77 +227,32 @@ private fun ReviewHeroCard(format: FileFormat) { } } +/** 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, - modifier: Modifier = Modifier, iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, value: @Composable () -> Unit, ) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), + leadingContent = { + Icon(imageVector = icon, contentDescription = null, tint = iconTint) + }, + overlineContent = { Text(text = label) }, ) { - IconBadge( - icon = icon, - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = iconTint, - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.bodyLarge - ) { - value() - } - } - } -} - -@Composable -private fun ReviewDivider() { - HorizontalDivider( - modifier = Modifier.padding(start = 72.dp, end = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) -} - -@Composable -private fun ReviewPlaintextWarning() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Default.WarningAmber, - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringResource(R.string.review_plaintext_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } + value() } } 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 index ec7dc6b3a..990c2e745 100644 --- 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 @@ -246,8 +246,6 @@ private fun NumberStepper( value: Int, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true, - min: Int = 1, supporting: @Composable (() -> Unit)? = null, ) { Row( @@ -256,17 +254,15 @@ private fun NumberStepper( verticalAlignment = Alignment.CenterVertically, ) { StepperButton( - onStep = { onValueChange((value - 1).coerceAtLeast(min)) }, + onStep = { onValueChange((value - 1).coerceAtLeast(STEPPER_MIN)) }, imageVector = Icons.Default.Remove, - enabled = enabled && value > min, + enabled = value > STEPPER_MIN, ) Column(horizontalAlignment = Alignment.CenterHorizontally) { StepperValueField( value = value, onValueChange = onValueChange, - enabled = enabled, - min = min, ) CompositionLocalProvider( @@ -280,7 +276,7 @@ private fun NumberStepper( StepperButton( onStep = { onValueChange(value + 1) }, imageVector = Icons.Default.Add, - enabled = enabled, + enabled = true, ) } } @@ -323,8 +319,6 @@ private fun StepperButton( private fun StepperValueField( value: Int, onValueChange: (Int) -> Unit, - enabled: Boolean, - min: Int, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -336,7 +330,7 @@ private fun StepperValueField( } val commit = { - val committed = text.toIntOrNull()?.coerceAtLeast(min) + val committed = text.toIntOrNull()?.coerceAtLeast(STEPPER_MIN) if (committed != null) onValueChange(committed) text = (committed ?: value).toString() } @@ -348,9 +342,6 @@ private fun StepperValueField( if (focused && !imeVisible) focusManager.clearFocus() } - val numberColor = if (enabled) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - BasicTextField( value = text, onValueChange = { new -> if (new.all(Char::isDigit)) text = new }, @@ -360,9 +351,8 @@ private fun StepperValueField( if (focused && !focusState.isFocused) commit() focused = focusState.isFocused }, - enabled = enabled, textStyle = MaterialTheme.typography.headlineMedium.copy( - color = numberColor, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ), keyboardOptions = KeyboardOptions( @@ -375,6 +365,8 @@ private fun StepperValueField( ) } +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 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 index 40559fc28..4a5e44060 100644 --- 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 @@ -5,54 +5,50 @@ 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.ExperimentalMaterial3ExpressiveApi 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 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 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun SelectCsvPresetContent( preset: CsvPreset, onEvent: (ExportWizardUiEvent) -> Unit, ) { - Surface { - 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 MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = contentColorFor( - if (preset == candidate) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceContainerHigh - ), + 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) - } + ), + 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 index 85e1e812c..92950f625 100644 --- 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 @@ -40,27 +40,27 @@ internal fun SelectDestinationContent( format: FileFormat?, onEvent: (ExportWizardUiEvent) -> Unit, ) { - Surface { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - BackupFileChooser( - destination = state.destination, - fileName = state.destination?.fileName ?: format.backupFileName(), - 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), - ) + 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) - } + BehaviorHint(mode = scheduleState.mode, keepAll = scheduleState.keepAll) } } 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 index e1377b46a..4ccf02241 100644 --- 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 @@ -5,12 +5,9 @@ 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.ExperimentalMaterial3ExpressiveApi 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 @@ -19,48 +16,46 @@ 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 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun SelectFileFormatContent(onEvent: (ExportWizardUiEvent) -> Unit) { - Surface { - 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 = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), - ), - supportingContent = { - Text(text = type.description) - }, - leadingContent = { - Icon( - imageVector = type.icon, - contentDescription = null, - ) - }, - overlineContent = when { - type.recommented -> { - { Text(text = stringResource(R.string.recommented)) } - } + 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) - } + else -> null + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = type.displayName) } } } 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 index 85d852894..ec9bf27f4 100644 --- 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 @@ -12,7 +12,6 @@ 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -31,75 +30,73 @@ 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 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun SelectScheduleContent( state: SelectScheduleState, onEvent: (ExportWizardUiEvent) -> Unit, ) { - Surface { - 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 = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceContainerHigh), - ), - 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)) - } + 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(), + AnimatedVisibility( + visible = selected && mode == ScheduleMode.Recurring, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - 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 - ) - } + IntervalPicker( + interval = state.interval, + onEvent = onEvent, + shape = SegmentTopShape + ) + RetentionPicker( + keepCount = state.keepCount, + keepAll = state.keepAll, + onEvent = onEvent, + shape = SegmentBottomShape + ) } } } 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 index 8cd6c8463..d18ed0dbf 100644 --- 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 @@ -4,8 +4,5 @@ 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 = when (this) { - FileFormat.JSON -> "$BACKUP_BASE_NAME.json" - FileFormat.CSV -> "$BACKUP_BASE_NAME.csv" - null -> BACKUP_BASE_NAME -} +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/hub/BackupHubContent.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/BackupHubContent.kt index 90e076442..007858581 100644 --- 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 @@ -24,7 +24,6 @@ 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -40,10 +39,12 @@ 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 @@ -52,8 +53,9 @@ 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.displayName 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 @@ -61,7 +63,7 @@ import de.davis.keygo.feature.backup.presentation.hub.model.BackupSection private const val DETAIL_SEPARATOR = " \u2022 " -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEvent) -> Unit) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() @@ -69,7 +71,7 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven topBar = { MediumFlexibleTopAppBar( title = { - Text(text = "Backups") + Text(text = stringResource(R.string.dispatched_backups)) }, scrollBehavior = scrollBehavior, ) @@ -141,52 +143,54 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HubActions(onExport: () -> Unit, onImport: () -> Unit) { val buttonSize = ButtonDefaults.MediumContainerHeight Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + val actionModifier = Modifier + .heightIn(buttonSize) + .weight(1f) + val shapes = ButtonDefaults.shapesFor(buttonSize) + val contentPadding = ButtonDefaults.contentPaddingFor(buttonSize) + OutlinedButton( onClick = onExport, - modifier = Modifier - .heightIn(buttonSize) - .weight(1f), - shapes = ButtonDefaults.shapesFor(buttonSize), - contentPadding = ButtonDefaults.contentPaddingFor(buttonSize), + modifier = actionModifier, + shapes = shapes, + contentPadding = contentPadding, ) { - Icon( - imageVector = Icons.Default.Upload, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), - ) - Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) - Text( - text = stringResource(R.string.export_backup), - style = ButtonDefaults.textStyleFor(buttonSize), + HubActionLabel( + buttonSize = buttonSize, + icon = Icons.Default.Upload, + label = stringResource(R.string.export_backup), ) } Button( onClick = onImport, - modifier = Modifier - .heightIn(buttonSize) - .weight(1f), - shapes = ButtonDefaults.shapesFor(buttonSize), - contentPadding = ButtonDefaults.contentPaddingFor(buttonSize), + modifier = actionModifier, + shapes = shapes, + contentPadding = contentPadding, ) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), - ) - Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) - Text( - text = stringResource(R.string.import_backup), - style = ButtonDefaults.textStyleFor(buttonSize), + 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)) +} + @Composable private fun BackupSectionHeader(group: BackupGroup) { Surface(modifier = Modifier.fillMaxWidth()) { @@ -199,7 +203,6 @@ private fun BackupSectionHeader(group: BackupGroup) { } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun DispatchedBackupRow( item: DispatchedBackup, @@ -215,15 +218,8 @@ private fun DispatchedBackupRow( if (count == 1) ListItemDefaults.shapes(shape = MaterialTheme.shapes.large) else ListItemDefaults.shapes() SegmentedListItem( - onClick = {}, - shapes = ListItemDefaults.segmentedShapes( - index, - count, - defaultShapes = defaultShapes - ), - colors = ListItemDefaults.segmentedColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), + shapes = ListItemDefaults.segmentedShapes(index, count, defaultShapes), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { IconBadge( icon = item.icon, @@ -241,24 +237,16 @@ private fun DispatchedBackupRow( } }, trailingContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - 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), - ) - } + 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), + ) } }, - overlineContent = { - Text(text = item.kind.label) - }, - verticalAlignment = Alignment.CenterVertically + overlineContent = { Text(text = item.kind.label) }, + verticalAlignment = Alignment.CenterVertically, ) { Text( text = item.destination.displayText(), 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 index 6e6722a7b..d4ff48601 100644 --- 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 @@ -26,7 +26,6 @@ internal val DispatchedBackup.icon: ImageVector DispatchedBackup.State.Cancelled -> Icons.Default.Block } -/** Container/content pair tinting both the leading badge and the trailing status pill. */ internal data class StatusColors(val container: Color, val content: Color) @Composable 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 index 3b2cfc0cc..81fe3871b 100644 --- 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 @@ -2,30 +2,39 @@ 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.Row 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( @@ -35,7 +44,6 @@ internal fun ImportRunningContent( val label = when (progress) { ImportProgress.Reading -> stringResource(R.string.import_reading) ImportProgress.Parsing -> stringResource(R.string.import_parsing) - is ImportProgress.Running -> stringResource(R.string.import_running) else -> stringResource(R.string.import_running) } Column( @@ -104,12 +112,47 @@ internal fun ImportResultContent( ) Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - SummaryRow(stringResource(R.string.import_result_imported), summary.imported) - SummaryRow(stringResource(R.string.import_result_skipped), summary.skipped) - SummaryRow(stringResource(R.string.import_result_failed), summary.failed) - SummaryRow(stringResource(R.string.import_result_vaults), summary.vaultsCreated) + 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, @@ -120,18 +163,34 @@ internal fun ImportResultContent( } } +/** 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(label: String, value: Int) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, +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, style = MaterialTheme.typography.bodyLarge) - Text( - text = value.toString(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(text = label) } } @@ -180,3 +239,21 @@ internal fun ImportErrorContent( } } } + +@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 index 46512993e..1dfa586d5 100644 --- 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 @@ -104,14 +104,16 @@ private fun InputWizard( ImportWizardStep.SelectFile -> Column(modifier = Modifier.fillMaxSize()) { BackupFileChooser( destination = state.backupDestination, - fileName = state.backupDestination?.fileName.orEmpty(), 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), + fileNameLabel = stringResource( + R.string.import_selected_label, + state.backupDestination?.fileName.orEmpty() + ), ) } 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 index a7414be8d..ec58a1193 100644 --- 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 @@ -166,16 +166,10 @@ internal class ImportWizardViewModel( } /** - * Seeds the destination on entry rather than in [ImportWizardUiState]'s initialiser: the file - * name is only known once a file has been picked, and seeding on every state update would - * overwrite a name the user has since typed. Falling back to a new vault when the context is - * [de.davis.keygo.core.item.domain.model.VaultContext.NoSpecific] matters: that is the "all - * vaults" view, which is not somewhere an import can land. - * - * Seeding itself only ever 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. + * 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) { 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 index 32134087e..c816cb200 100644 --- 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 @@ -13,13 +13,10 @@ 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -48,6 +45,7 @@ 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 @@ -92,63 +90,22 @@ private val TYPE_OPTIONS: List<CsvColumnType?> = CsvColumnType.entries + null */ private val COLUMN_GAP = 8.dp -/** - * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, - * which resolves to `colorScheme.surface`, identical to the wizard Scaffold's background in both - * KeyGo themes, so the groups would have no visible edge at all. Every other `SegmentedListItem` - * call site in the app pins this for the same reason. - */ -private val segmentContainerColor - @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh - -/** How many segments make up one column's group; drives [ListItemDefaults.segmentedShapes]. */ private const val SEGMENTS_PER_COLUMN = 3 -/** - * Colours for a segment that exists only to display something. - * - * [SegmentedListItem] has no non-interactive overload: every overload carrying `ListItemShapes` - * takes a click, so a display-only segment has to be a disabled one, and the disabled palette is - * mapped back onto the enabled colours so it does not read as greyed out. Same workaround as - * `BackupHubContent`. - * - * The cost is that TalkBack still announces these rows as *disabled*: the clickable node emits its - * `OnClick` action regardless of `enabled` and merely marks itself unavailable, so the row leaves - * the actionable set but keeps announcing as a control. It stays readable, and removing the - * announcement entirely would mean hand-drawing the container this refactor exists to stop drawing. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun displaySegmentColors(): ListItemColors { - val base = ListItemDefaults.segmentedColors() - return ListItemDefaults.segmentedColors( - containerColor = segmentContainerColor, - disabledContainerColor = segmentContainerColor, - disabledContentColor = base.contentColor, - disabledOverlineContentColor = base.overlineContentColor, - disabledLeadingContentColor = base.leadingContentColor, - disabledSupportingContentColor = base.supportingContentColor, - disabledTrailingContentColor = base.trailingContentColor, - ) -} - /** * 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. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun MapColumnsIntroCard(columnCount: Int, fileName: String?) { val container = MaterialTheme.colorScheme.secondaryContainer val content = MaterialTheme.colorScheme.onSecondaryContainer - ListItem( - onClick = {}, - enabled = false, + SegmentedListItem( shapes = ListItemDefaults.shapes(shape = MaterialTheme.shapes.large), - colors = ListItemDefaults.colors( - disabledContainerColor = container, - disabledContentColor = content, + colors = ListItemDefaults.segmentedColors( + containerColor = container, + contentColor = content, ), leadingContent = { IconBadge( @@ -170,7 +127,6 @@ private fun MapColumnsIntroCard(columnCount: Int, fileName: String?) { columnCount, columnCount, ), - color = content.copy(alpha = 0.8f), ) }, ) { @@ -186,7 +142,6 @@ private fun MapColumnsIntroCard(columnCount: Int, fileName: String?) { * 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. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ColumnMappingGroup( column: ColumnMappingRow, @@ -194,7 +149,7 @@ private fun ColumnMappingGroup( onTypeChange: (CsvColumnType?) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { - SourceSegment(index = column.index, header = column.header) + SourceSegment(columnIndex = column.index, header = column.header) TypeSegment( selectedType = column.selectedType, @@ -208,39 +163,36 @@ private fun ColumnMappingGroup( } /** - * The file side of the group: a neutral badge and the column's raw CSV header. Neutral colouring is + * 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. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SourceSegment(index: Int, header: String) { +private fun SourceSegment(columnIndex: Int, header: String) { SegmentedListItem( - onClick = {}, - enabled = false, shapes = ListItemDefaults.segmentedShapes(0, SEGMENTS_PER_COLUMN), - colors = displaySegmentColors(), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { - IconBadge( - icon = Icons.Default.TableChart, - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + Icon( + imageVector = Icons.Default.TableChart, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, overlineContent = { - Text(text = stringResource(R.string.csv_column_source_label, index + 1)) + Text(text = stringResource(R.string.csv_column_source_label, columnIndex + 1)) }, - verticalAlignment = Alignment.CenterVertically, ) { Text(text = header, maxLines = 1, overflow = TextOverflow.Ellipsis) } } /** - * The KeyGo side of the group: a primary-coloured badge and the chosen type, tappable to open the - * type menu. The colour contrast against [SourceSegment] is what tells the two apart at a glance, - * and this is the only segment of the three that is genuinely interactive. + * 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(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TypeSegment( selectedType: CsvColumnType?, @@ -258,11 +210,7 @@ private fun TypeSegment( shapes = ListItemDefaults.segmentedShapes(1, SEGMENTS_PER_COLUMN), colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), leadingContent = { - IconBadge( - icon = selectedType.icon, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) + Icon(imageVector = selectedType.icon, contentDescription = null) }, overlineContent = { Text(text = stringResource(R.string.csv_type_row_label)) }, supportingContent = if (isDuplicate) { @@ -350,24 +298,17 @@ private fun Modifier.typePickerSemantics( * 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 a badge it does not have. + * past an icon it does not have. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SamplesSegment(samples: List<String>) { SegmentedListItem( - onClick = {}, - enabled = false, shapes = ListItemDefaults.segmentedShapes(2, SEGMENTS_PER_COLUMN), - colors = displaySegmentColors(), + colors = ListItemDefaults.segmentedColors(containerColor = segmentContainerColor), overlineContent = { Text(text = stringResource(R.string.csv_samples_label)) }, - verticalAlignment = Alignment.CenterVertically, ) { - // Toned down from the headline slot's default rather than shrunk: these are raw file - // values, and they should not outrank the CSV header or the chosen type. onSurfaceVariant - // carries that on its own, so the size stays at bodyMedium: monospace runs visually - // smaller than the sans face at the same size, and these are the values the whole step - // asks the user to read. + // 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, @@ -442,12 +383,6 @@ internal val previewColumnRows = listOf( ), ) -/** - * Covers the cases this screen exists to communicate: a settled high-confidence column, a - * low-confidence one showing the verify hint, a corrected column whose hint is therefore - * suppressed, an over-long sample value, a duplicate type in its error state, a column with no - * sample values at all, and a header long enough to exercise the single-line ellipsis. - */ @Preview(showBackground = true) @Composable private fun MapColumnsContentPreview() { 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 index a9526a6a6..f2ef2f403 100644 --- 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 @@ -28,7 +28,7 @@ import de.davis.keygo.feature.backup.R internal fun ProvidePassphraseContent( passphraseState: TextFieldState, isError: Boolean, - modifier: Modifier = Modifier.Companion, + modifier: Modifier = Modifier, ) { var hidden by rememberSaveable { mutableStateOf(true) } Column( 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 index 865d1e77f..b32337a86 100644 --- 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 @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package de.davis.keygo.feature.backup.presentation.import import android.content.res.Configuration @@ -18,7 +16,6 @@ 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemShapes @@ -43,6 +40,7 @@ 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 @@ -60,7 +58,6 @@ internal fun SelectVaultContent( onSelectNewVaultIcon: (Vault.Icon) -> Unit, modifier: Modifier = Modifier, ) { - // One segment per vault plus the "new vault" row, so the group's rounded ends land correctly. val segmentCount = vaults.size + 1 LazyColumn( @@ -92,14 +89,6 @@ internal fun SelectVaultContent( } } -/** - * Container behind every segment. Must not be left to [ListItemDefaults.segmentedColors]'s default, - * which resolves to `colorScheme.surface`, identical to the wizard Scaffold's background in both - * KeyGo themes, so the rows would have no visible edge at all. Same pin as `MapColumnsContent`. - */ -private val segmentContainerColor - @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh - @Composable private fun VaultSegment( vault: VaultMetadata, 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 index 2b174f92c..335891ba3 100644 --- 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 @@ -7,21 +7,19 @@ internal enum class ImportWizardStep { ProvidePassphrase, } -internal fun importStepsFor(step: ImportWizardStep): List<ImportWizardStep> = when (step) { - ImportWizardStep.SelectFile -> listOf(ImportWizardStep.SelectFile) - ImportWizardStep.MapColumns -> listOf( - ImportWizardStep.SelectFile, - ImportWizardStep.MapColumns, - ) +private val CsvLane = listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.MapColumns, + ImportWizardStep.SelectVault, +) - ImportWizardStep.SelectVault -> listOf( - ImportWizardStep.SelectFile, - ImportWizardStep.MapColumns, - ImportWizardStep.SelectVault, - ) +private val JsonLane = listOf( + ImportWizardStep.SelectFile, + ImportWizardStep.ProvidePassphrase, +) - ImportWizardStep.ProvidePassphrase -> 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<ImportWizardStep> { + 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/worker/BackupWorker.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/worker/BackupWorker.kt index 5e0fd7a42..5f8d10558 100644 --- 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 @@ -35,7 +35,6 @@ internal class BackupWorker( private val isRecurring = TAG_RECURRING in tags override suspend fun doWork(): Result { - // Recurring is a singleton stored under a stable key; one-time records are keyed by workId. val workId = if (isRecurring) RECURRING_WORK_ID else id.toString() val job = backupJobRepository.getJob(workId) ?: return Result.failure() @@ -58,6 +57,8 @@ internal class BackupWorker( companion object { const val UNIQUE_WORK_NAME = "backup_worker" + + /** Recurring work is a singleton; one-time job records are keyed by their WorkManager id. */ const val RECURRING_WORK_ID = "recurring_backup" const val TAG = "backup" diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 572d7d5fb..5663fc2c3 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -1,13 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="last_backup">Last Backup</string> - - <string name="recommented">Recommended</string> - - <string name="schedule_new_backup">Schedule new Backup</string> - <string name="restore_backup">Restore Backup</string> - - <string name="never_backed_up">Never Backed Up</string> + <string name="recommended">Recommended</string> <string name="csv">CSV</string> <string name="json">JSON</string> @@ -25,10 +18,6 @@ <string name="backup_section_recent">Recent</string> <string name="backup_state_enqueued">Queued</string> - <string name="backup_state_running">Running</string> - <string name="backup_state_succeeded">Completed</string> - <string name="backup_state_failed">Failed</string> - <string name="backup_state_cancelled">Cancelled</string> <string name="backup_kind_one_time">One-time</string> <string name="backup_kind_recurring">Recurring</string> @@ -65,6 +54,7 @@ <string name="encryption_method_passphrase_description">Choose a passphrase. The backup can be restored on any device with it.</string> <string name="encryption_method_ark">This device & account</string> <string name="encryption_method_ark_description">Uses this account\'s key - nothing to remember. Restores only into this account.</string> + <string name="encryption_method_ark_warning">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.</string> <string name="csv_preset_browser">Browser-compatible</string> <string name="csv_preset_browser_description">Columns: name, url, username, password, note. Importable by Chrome, Edge and others. No TOTP.</string> @@ -85,13 +75,13 @@ <string name="destination_choose_subtitle">Pick a folder on this device, or a cloud provider like Nextcloud, Drive, or Dropbox.</string> <string name="destination_choose_action">Choose location</string> <string name="file_chooser_change">Change</string> - <string name="destination_filename_label">Saved as</string> + <string name="destination_filename_label">Saved as %s</string> <string name="select_import_file_title">Import Backup</string> <string name="import_choose_title">Choose a backup file</string> <string name="import_choose_subtitle">Pick a .json or .csv backup from this device or a cloud provider like Nextcloud, Drive, or Dropbox.</string> <string name="import_choose_action">Choose file</string> - <string name="import_selected_label">Selected</string> + <string name="import_selected_label">Selected %s</string> <string name="import_passphrase_instruction">This backup is encrypted. Enter its passphrase to continue.</string> <string name="import_passphrase_error">Wrong passphrase. Please try again.</string> <string name="import_running_title">Importing</string> diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt index 86afb3144..9233d57d7 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.backup import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.backup.data.reository.retainedJobKeys +import de.davis.keygo.feature.backup.data.repository.retainedJobKeys 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 diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt similarity index 95% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt rename to feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt index 1d27aad61..495a7e588 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/reository/BackupJobRetentionTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/repository/BackupJobRetentionTest.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.backup.data.reository +package de.davis.keygo.feature.backup.data.repository import kotlin.test.Test import kotlin.test.assertEquals 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 index 6cbddad42..e6e443cb8 100644 --- 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 @@ -1,8 +1,6 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.FakeBackupJobRepository -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.BackupJob import de.davis.keygo.feature.backup.domain.model.BackupResult @@ -16,8 +14,7 @@ import kotlin.test.assertNull class ObserveLastBackupUseCaseTest { private val jobRepository = FakeBackupJobRepository() - private val destinationResolver = FakeBackupDestinationResolver() - private val useCase = ObserveLastBackupUseCaseImpl(jobRepository, destinationResolver) + private val useCase = ObserveLastBackupUseCaseImpl(jobRepository) private fun job( uri: String, @@ -36,16 +33,10 @@ class ObserveLastBackupUseCaseTest { 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()) - destinationResolver.result = BackupDestination( - provider = BackupDestination.Provider.ThirdParty("Drive"), - displayPath = "Drive/Backups", - ) val last = useCase().first() assertEquals(300L, last?.finishedAt) - assertEquals(BackupDestinationUri("content://rec.json"), destinationResolver.lastUri) - assertEquals(destinationResolver.result, last?.destination) } @Test 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 index c3ad3a5ca..1c6521855 100644 --- 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 @@ -50,7 +50,7 @@ class BackupHubViewModelTest { private fun viewModel() = BackupHubViewModel( observeDispatchedBackups = ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver), - observeLastBackup = ObserveLastBackupUseCaseImpl(jobRepository, destinationResolver), + observeLastBackup = ObserveLastBackupUseCaseImpl(jobRepository), cancelBackup = CancelBackupUseCase( repository = repository, jobRepository = jobRepository, 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 1275707bf..6c9514ecf 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 @@ -94,7 +94,7 @@ class SettingsViewModelTest { @Test fun `state carries the newest successful backup timestamp`() = runTest(dispatcher) { - lastBackup.value = LastBackup(finishedAt = 1_700_000_000_000L, destination = null) + lastBackup.value = LastBackup(finishedAt = 1_700_000_000_000L) val vm = viewModel() assertEquals(1_700_000_000_000L, vm.state.first { it.lastBackupAt != null }.lastBackupAt) 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 7c0010631..1e865096e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,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" } diff --git a/rust/rust-code/lib/src/b64.rs b/rust/rust-code/lib/src/b64.rs index 36017045d..4eb2124cd 100644 --- a/rust/rust-code/lib/src/b64.rs +++ b/rust/rust-code/lib/src/b64.rs @@ -10,7 +10,7 @@ pub(crate) fn decode(encoded: &str) -> Result<Vec<u8>, base64::DecodeError> { STANDARD.decode(encoded) } -/// serde adapter for `#[serde(with = "crate::backup::b64")]` on `Vec<u8>` fields. +/// serde adapter for `#[serde(with = "crate::b64")]` on `Vec<u8>` fields. pub(crate) fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> { serializer.serialize_str(&encode(bytes)) } 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 <T> backupResult( + crossinline block: () -> T, +): Result<T, BackupException> = 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 index c1e18d97c..3590f2a2e 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt @@ -8,42 +8,17 @@ 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -typealias CsvBackupManager = CsvBackupManagerInterface suspend fun CsvBackupManagerInterface.analyzeWithResult( data: String, -): Result<CsvAnalysis, BackupException> = withContext(Dispatchers.Default) { - runCatching { - analyze(data) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<CsvAnalysis, BackupException> = backupResult { analyze(data) } suspend fun CsvBackupManagerInterface.importWithResult( data: String, mapping: ColumnMapping, -): Result<CsvImportResult, BackupException> = withContext(Dispatchers.Default) { - runCatching { - import(data, mapping) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<CsvImportResult, BackupException> = backupResult { import(data, mapping) } suspend fun CsvBackupManagerInterface.exportWithResult( backup: Backup, preset: ExportPreset, -): Result<String, BackupException> = withContext(Dispatchers.Default) { - runCatching { - export(backup, preset) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<String, BackupException> = 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 index e09c550ac..18fadbad4 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -6,42 +6,17 @@ import de.davisalessandro.keygo.rust.BackupCredential import de.davisalessandro.keygo.rust.BackupException import de.davisalessandro.keygo.rust.JsonBackupManagerInterface import de.davisalessandro.keygo.rust.JsonEncryption -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -typealias JsonBackupManager = JsonBackupManagerInterface suspend fun JsonBackupManagerInterface.exportWithResult( backup: Backup, credential: BackupCredential?, -): Result<String, BackupException> = withContext(Dispatchers.Default) { - runCatching { - export(backup, credential) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<String, BackupException> = backupResult { export(backup, credential) } suspend fun JsonBackupManagerInterface.importWithResult( data: String, credential: BackupCredential?, -): Result<Backup, BackupException> = withContext(Dispatchers.Default) { - runCatching { - import(data, credential) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<Backup, BackupException> = backupResult { import(data, credential) } suspend fun JsonBackupManagerInterface.inspectWithResult( data: String, -): Result<JsonEncryption, BackupException> = withContext(Dispatchers.Default) { - runCatching { - inspect(data) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as BackupException) } - ) -} +): Result<JsonEncryption, BackupException> = backupResult { inspect(data) } From 779c70628139787a14c5d71cd30f22c3ee897c09 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 10:24:55 +0200 Subject: [PATCH 37/59] fix(setting): single list item border radius --- .../feature/settings/presentation/component/SettingsList.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0d88d0a7e..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 @@ -58,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, ), From 0cd5dcbb3dcf0d347bf14a31d81096c509927c7e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 11:54:22 +0200 Subject: [PATCH 38/59] refactor(setting): improve exhausting when block --- .../keygo/feature/backup/domain/usecase/ExportBackupUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fe884360a..53a2ff142 100644 --- 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 @@ -93,7 +93,7 @@ internal class ExportBackupUseCase( }.bind() // null on a persisted pre-field job means passphrase (see mapper). - else -> { + EncryptionMethod.Passphrase, null -> { val passphrase = decryptPassphrase(job).bind() try { jsonBackupManager From 488b9f6a6231292be13105b83a6d4c930b0e32ce Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 19:43:14 +0200 Subject: [PATCH 39/59] feat(backup): add retries exhausted reason and enhance backup resource cleanup --- CLAUDE.md | 27 +- .../keygo/core/security/domain/model/KeyId.kt | 30 +- .../backup/data/BackupSchedulerImpl.kt | 16 +- .../backup/domain/BackupEscrowReconciler.kt | 34 +++ .../feature/backup/domain/BackupScheduler.kt | 12 + .../domain/mapper/ImportErrorMappers.kt | 5 +- .../domain/model/BackupFailureReason.kt | 9 +- .../usecase/CleanupBackupResourcesUseCase.kt | 73 ++++- .../domain/usecase/ExportBackupUseCase.kt | 20 +- .../domain/usecase/ImportBackupUseCase.kt | 13 +- .../usecase/RecordBackupOutcomeUseCase.kt | 28 +- .../hub/DispatchedBackupDisplay.kt | 1 + .../feature/backup/worker/BackupWorker.kt | 27 +- .../backup/src/main/res/values/strings.xml | 1 + .../feature/backup/FakeBackupScheduler.kt | 22 ++ .../BackupProvisioningSerializationTest.kt | 2 + .../usecase/BackupWorkActionsUseCaseTest.kt | 3 + .../CleanupBackupResourcesUseCaseTest.kt | 102 +++++++ .../domain/usecase/ExportBackupUseCaseTest.kt | 36 +++ .../domain/usecase/ImportBackupUseCaseTest.kt | 40 +-- .../usecase/RecordBackupOutcomeUseCaseTest.kt | 48 +++- .../hub/BackupHubViewModelTest.kt | 18 +- .../import/ImportWizardViewModelTest.kt | 10 +- .../backup/worker/BackupWorkerResultTest.kt | 33 ++- rust/rust-code/bindings/src/backup.rs | 37 +-- rust/rust-code/lib/src/backup/encryption.rs | 29 +- rust/rust-code/lib/src/backup/error.rs | 6 - rust/rust-code/lib/src/backup/format/json.rs | 267 ++++++------------ .../keygo/rust/backup/JsonBackupManager.kt | 4 +- .../davis/keygo/rust/FakeJsonBackupManager.kt | 18 +- 30 files changed, 620 insertions(+), 351 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupEscrowReconciler.kt diff --git a/CLAUDE.md b/CLAUDE.md index d3021c16e..6dd10e93a 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/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 9a33a8380..b13b8b55c 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,13 +1,23 @@ package de.davis.keygo.core.security.domain.model -data class KeyId( - val id: String, - val needsAuthentication: Boolean, -) { +/** + * 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 escrowed ARK; this key can only be used once unlocked via biometrics */ + BiometricVaultKek("biometric_vault_kek", true), - companion object { - val BiometricVaultKek = KeyId("biometric_vault_kek", true) - val BackupPassphraseKey = KeyId("backup_passphrase_key", false) - val BackupArkKey = KeyId("backup_ark_key", false) - } -} \ 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/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 index 4b443ebe8..530b1de6d 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -54,7 +56,7 @@ internal class BackupSchedulerImpl( uniqueWorkName = BackupWorker.UNIQUE_WORK_NAME, existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, request = request, - ) + ).await() } } @@ -66,11 +68,21 @@ internal class BackupSchedulerImpl( .build() return backupJobRepository.putJob(request.id.toString(), job).onSuccess { - workManager.enqueue(request) + workManager.enqueue(request).await() } } override fun cancel() { workManager.cancelUniqueWork(BackupWorker.UNIQUE_WORK_NAME) } + + override suspend fun outstandingWorkIds(): Set<String> = + 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/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/BackupScheduler.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/BackupScheduler.kt index e8fa6e22e..bb80ea194 100644 --- 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 @@ -13,4 +13,16 @@ interface BackupScheduler { suspend fun scheduleOneTimeBackup(job: BackupJob): Result<Unit, Unit> 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<String> } \ No newline at end of file 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 index efbbc0bec..53e61a003 100644 --- 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 @@ -5,10 +5,7 @@ import de.davisalessandro.keygo.rust.BackupException internal fun BackupException.toImportError(): ImportError = when (this) { is BackupException.Crypto, - is BackupException.CredentialMismatch, - is BackupException.MissingCredential, - is BackupException.UnexpectedCredential, - is BackupException.EncryptionMismatch -> ImportError.WrongCredential + is BackupException.CredentialMismatch -> ImportError.WrongCredential else -> ImportError.ParseFailed(this) } 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 index 346639ab9..7d4a61adf 100644 --- 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 @@ -3,9 +3,9 @@ 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]: retryable errors never reach a terminal record, 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. + * 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. */ @@ -17,4 +17,7 @@ enum class BackupFailureReason { 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/usecase/CleanupBackupResourcesUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CleanupBackupResourcesUseCase.kt index 1c5d2abbc..39a1cd460 100644 --- 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 @@ -3,6 +3,7 @@ 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.model.BackupJob import de.davis.keygo.feature.backup.domain.repository.BackupArkKeyStore @@ -27,24 +28,66 @@ internal class CleanupBackupResourcesUseCase( private val keyStoreManager: KeyStoreManager, private val persistableUriManager: PersistableUriManager, private val provisioningLock: BackupProvisioningLock, + private val scheduler: BackupScheduler, ) { suspend operator fun invoke(workId: String): Unit = provisioningLock.mutex.withLock { + val outstanding = outstandingWorkIds() val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return - if (current[workId]?.isLive(workId) == true) return + if (current[workId]?.isLive(workId, outstanding) == true) return - runCatching { jobRepository.clearPassphrase(workId) } + sweep(current, outstanding) - // 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 - val done = jobs[workId] - val live = jobs.filter { (id, job) -> job.isLive(id) } + // 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<String, BackupJob>, outstanding: Set<String>?) { + 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<String, BackupJob>, + live: Map<String, BackupJob>, + ) { if (jobs.values.none { it.wrappedPassphrase != null }) runCatching { keyStoreManager.deleteKey(KeyId.BackupPassphraseKey) } @@ -54,8 +97,18 @@ internal class CleanupBackupResourcesUseCase( 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<String>? = + 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. - private fun BackupJob.isLive(workId: String): Boolean = - !cancelled && (workId == BackupWorker.RECURRING_WORK_ID || finishedAt == null) + // - 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: String, outstanding: Set<String>?): 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 index 53a2ff142..e8190ee86 100644 --- 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 @@ -72,16 +72,22 @@ internal class ExportBackupUseCase( val keep = job.keepCount ?: return val existing = fileStore.listBackups(job.uri, BACKUP_BASE_NAME).getOrNull() ?: return existing - .filter { it.name.endsWith(".${job.format.extension}") } - .sortedByDescending { it.timestamp(job.format) } + // 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 { fileStore.delete(it.uri) } + .forEach { (entry, _) -> fileStore.delete(entry.uri) } } - private fun BackupEntry.timestamp(format: FileFormat): Long = - name.removePrefix("$BACKUP_BASE_NAME-") - .removeSuffix(".${format.extension}") - .toLongOrNull() ?: Long.MIN_VALUE + /** 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<String, ExportError> = resultBinding { 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 index 5c7930414..9f02a7d4a 100644 --- 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 @@ -71,8 +71,6 @@ internal class ImportBackupUseCase( val credential = when ( jsonBackupManager.inspectWithResult(text).bind { it.toImportError() } ) { - JsonEncryption.NONE -> null - JsonEncryption.PASSPHRASE -> request.passphrase ?.takeIf(String::isNotBlank) ?.let { BackupCredential.Passphrase(it.encodeToByteArray()) } @@ -83,13 +81,20 @@ internal class ImportBackupUseCase( ?: return Result.Failure(ImportError.SessionLocked), ) } - jsonBackupManager.importWithResult(text, credential).bind { it.toImportError() } + // 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/RecordBackupOutcomeUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/RecordBackupOutcomeUseCase.kt index 6f0e5b7c1..e37333cd2 100644 --- 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 @@ -1,5 +1,6 @@ package de.davis.keygo.feature.backup.domain.usecase +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 @@ -14,18 +15,33 @@ internal class RecordBackupOutcomeUseCase( private val cleanupBackupResources: CleanupBackupResourcesUseCase, ) { - suspend operator fun invoke(workId: String, terminal: ExportProgress?) { + /** + * [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: String, + terminal: ExportProgress?, + canRetry: Boolean = true, + ) { val result = when (terminal) { is ExportProgress.Succeeded -> BackupResult.Success - is ExportProgress.Failed -> - if (terminal.error.retryable) return - else BackupResult.Failure(terminal.error.failureReason) + 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. - if (workId != BackupWorker.RECURRING_WORK_ID) cleanupBackupResources(workId) + // 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/hub/DispatchedBackupDisplay.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/hub/DispatchedBackupDisplay.kt index d4ff48601..9964d8813 100644 --- 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 @@ -56,6 +56,7 @@ internal val BackupFailureReason.label: String 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) } @Composable 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 index 5f8d10558..f9eb0233f 100644 --- 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 @@ -14,14 +14,15 @@ 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?): ListenableWorker.Result = when (terminal) { - is ExportProgress.Succeeded -> ListenableWorker.Result.success() - is ExportProgress.Failed -> - if (terminal.error.retryable) ListenableWorker.Result.retry() - else ListenableWorker.Result.failure() +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() -} + else -> ListenableWorker.Result.failure() + } @KoinWorker internal class BackupWorker( @@ -38,6 +39,8 @@ internal class BackupWorker( val 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) @@ -50,14 +53,20 @@ internal class BackupWorker( if (error is ExportError.SerializationFailed) Log.e(TAG, "Backup serialization failed for $workId", error.cause) - recordOutcome(workId, terminal) + recordOutcome(workId, terminal, canRetry) - return resultFor(terminal) + 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 = "recurring_backup" diff --git a/feature/backup/src/main/res/values/strings.xml b/feature/backup/src/main/res/values/strings.xml index 5663fc2c3..a58f2a147 100644 --- a/feature/backup/src/main/res/values/strings.xml +++ b/feature/backup/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ <string name="backup_failure_serialization_crypto">Couldn\'t encrypt the backup</string> <string name="backup_failure_write">Couldn\'t save the backup file</string> <string name="backup_failure_not_provisioned">Backup isn\'t set up on this device</string> + <string name="backup_failure_retries_exhausted">Couldn\'t run while the device stayed locked</string> <!-- Frames a reason on a row that still reads "Scheduled". %1$s is a backup_failure_* string. --> <string name="backup_failure_last_run">Last run failed. %1$s</string> <string name="file_type_backup">%1$s Backup</string> diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt index d9e3b6378..e146764dc 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt @@ -26,6 +26,27 @@ class FakeBackupScheduler( var cancelled = false var result: Result<Unit, Unit> = Result.Success(Unit) + private val scheduled = mutableSetOf<String>() + private val abandoned = mutableSetOf<String>() + + /** 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<String> { + outstandingFailure?.let { throw it } + return (jobRepository?.jobs?.keys ?: scheduled) - abandoned + } + + fun abandon(workId: String) { + abandoned += workId + } + override suspend fun scheduleRecurringBackup( job: BackupJob, interval: BackupInterval, @@ -50,6 +71,7 @@ class FakeBackupScheduler( gate?.await() if (result is Result.Failure) return result jobRepository?.putJob(workId, job) + scheduled += workId return result } } 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 index fd6a30f08..e0bef283f 100644 --- 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 @@ -40,6 +40,7 @@ class BackupProvisioningSerializationTest { 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<Unit>() @@ -63,6 +64,7 @@ class BackupProvisioningSerializationTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = lock, + scheduler = scheduler, ) @OptIn(ExperimentalCoroutinesApi::class) 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 index 256394690..3c8595c78 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -21,6 +22,7 @@ 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, @@ -31,6 +33,7 @@ class BackupWorkActionsUseCaseTest { keyStoreManager = FakeKeyStoreManager(), persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ), ) 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 index db8ad4580..a115fc8a4 100644 --- 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 @@ -6,6 +6,7 @@ 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.model.BackupDestinationUri @@ -29,6 +30,7 @@ class CleanupBackupResourcesUseCaseTest { 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, @@ -36,6 +38,7 @@ class CleanupBackupResourcesUseCaseTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ) private val folder = BackupDestinationUri("content://folder") @@ -152,6 +155,102 @@ class CleanupBackupResourcesUseCaseTest { 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) @@ -164,6 +263,7 @@ class CleanupBackupResourcesUseCaseTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ) useCase("w") @@ -187,6 +287,7 @@ class CleanupBackupResourcesUseCaseTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ) seedKey(KeyId.BackupArkKey) @@ -212,6 +313,7 @@ class CleanupBackupResourcesUseCaseTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ) seedKey(KeyId.BackupPassphraseKey) 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 index 4fbcd4c0d..51c5f9231 100644 --- 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 @@ -290,6 +290,42 @@ class ExportBackupUseCaseTest { 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() 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 index 8ae970ed8..5cb668b4e 100644 --- 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 @@ -3,8 +3,8 @@ 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.backupVault 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 @@ -32,7 +32,6 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull class ImportBackupUseCaseTest { @@ -109,26 +108,6 @@ class ImportBackupUseCaseTest { assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) } - @Test - fun `json parse failure maps MissingCredential to WrongCredential`() = runTest { - fileStore.contents = """{"vaults":[]}""" - json.importException = BackupException.MissingCredential() - - val emissions = useCase()(jsonRequest()).toList() - - assertEquals(ImportProgress.Failed(ImportError.WrongCredential), emissions.last()) - } - - @Test - fun `json parse failure maps UnexpectedCredential to WrongCredential`() = runTest { - fileStore.contents = """{"vaults":[]}""" - json.importException = BackupException.UnexpectedCredential() - - 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""" @@ -204,9 +183,9 @@ class ImportBackupUseCaseTest { } @Test - fun `csv analyze failure maps EncryptionMismatch to WrongCredential`() = runTest { + fun `csv analyze failure maps CredentialMismatch to WrongCredential`() = runTest { fileStore.contents = "name\nEmail\n" - csv.analyzeException = BackupException.EncryptionMismatch() + csv.analyzeException = BackupException.CredentialMismatch() val emissions = useCase()( ImportRequest(BackupDestinationUri("content://in.csv"), FileFormat.CSV, null), @@ -255,15 +234,20 @@ class ImportBackupUseCaseTest { } @Test - fun `plaintext json imports without a credential`() = runTest { + fun `passphrase bytes are zeroed once the import returns`() = runTest { fileStore.contents = "{}" - json.inspectResult = JsonEncryption.NONE + 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 = null)).toList() + val emissions = useCase()(jsonRequest(passphrase = "hunter2")).toList() assertIs<ImportProgress.Succeeded>(emissions.last()) - assertNull(json.importCalls.single().credential) + assertContentEquals(ByteArray(7), handed) } @Test 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 index 4a53db9bb..15033785e 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -29,6 +30,7 @@ class RecordBackupOutcomeUseCaseTest { 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( @@ -37,6 +39,7 @@ class RecordBackupOutcomeUseCaseTest { keyStoreManager = keyStoreManager, persistableUriManager = uriManager, provisioningLock = BackupProvisioningLock(), + scheduler = scheduler, ), ) @@ -98,6 +101,26 @@ class RecordBackupOutcomeUseCaseTest { 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() @@ -108,9 +131,11 @@ class RecordBackupOutcomeUseCaseTest { @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 = null, + wrappedPassphrase = wrappedPassphrase, format = FileFormat.JSON, createdAt = 1L, ) @@ -118,14 +143,20 @@ class RecordBackupOutcomeUseCaseTest { 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 cleans nothing`() = runTest { - val wrappedPassphrase = CryptographicData(data = byteArrayOf(1, 2, 3), iv = byteArrayOf(4, 5, 6)) + 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 = wrappedPassphrase, + wrappedPassphrase = + CryptographicData(data = byteArrayOf(1, 2, 3), iv = byteArrayOf(4, 5, 6)), format = FileFormat.JSON, createdAt = 1L, cancelled = true, @@ -133,12 +164,13 @@ class RecordBackupOutcomeUseCaseTest { 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()) - 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 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 index 1c6521855..a6b248b0e 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -47,6 +48,15 @@ class BackupHubViewModelTest { @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), @@ -54,13 +64,7 @@ class BackupHubViewModelTest { cancelBackup = CancelBackupUseCase( repository = repository, jobRepository = jobRepository, - cleanupBackupResources = CleanupBackupResourcesUseCase( - jobRepository = jobRepository, - arkKeyStore = FakeBackupArkKeyStore(), - keyStoreManager = FakeKeyStoreManager(), - persistableUriManager = FakePersistableUriManager(), - provisioningLock = BackupProvisioningLock(), - ), + cleanupBackupResources = cleanup, ), ) 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 index 4321c0809..fe043e0d1 100644 --- 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 @@ -7,15 +7,14 @@ 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.backupVault 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.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 @@ -162,7 +161,8 @@ class ImportWizardViewModelTest { @Test fun `Continue on selected JSON runs import and surfaces the summary`() = runTest { - json.inspectResult = JsonEncryption.NONE + // 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())) @@ -174,7 +174,7 @@ class ImportWizardViewModelTest { val succeeded = assertIs<ImportProgress.Succeeded>(finalState.progress) assertEquals(1, succeeded.summary.imported) - assertNull(json.importCalls.single().credential) + assertIs<BackupCredential.Ark>(json.importCalls.single().credential) } @Test @@ -215,7 +215,7 @@ class ImportWizardViewModelTest { @Test fun `terminal import error surfaces as failure`() = runTest { - json.inspectResult = JsonEncryption.NONE + json.inspectResult = JsonEncryption.ARK fileStore.contents = """{"vaults":[]}""" json.importResult = Backup(emptyList()) val viewModel = viewModel(FakeBackupDestinationResolver(result = jsonDestination())) 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 index 0fcf411db..1e63e4c97 100644 --- 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 @@ -10,26 +10,49 @@ class BackupWorkerResultTest { @Test fun `device locked retries`() { - assertIs<ListenableWorker.Result.Retry>(resultFor(ExportProgress.Failed(ExportError.DeviceLocked))) + assertIs<ListenableWorker.Result.Retry>( + resultFor(ExportProgress.Failed(ExportError.DeviceLocked), canRetry = true), + ) } @Test fun `session locked retries`() { - assertIs<ListenableWorker.Result.Retry>(resultFor(ExportProgress.Failed(ExportError.SessionLocked))) + assertIs<ListenableWorker.Result.Retry>( + 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<ListenableWorker.Result.Failure>( + resultFor(ExportProgress.Failed(ExportError.DeviceLocked), canRetry = false), + ) + } + + @Test + fun `session locked fails once attempts are spent`() { + assertIs<ListenableWorker.Result.Failure>( + resultFor(ExportProgress.Failed(ExportError.SessionLocked), canRetry = false), + ) } @Test fun `not provisioned fails`() { - assertIs<ListenableWorker.Result.Failure>(resultFor(ExportProgress.Failed(ExportError.NotProvisioned))) + assertIs<ListenableWorker.Result.Failure>( + resultFor(ExportProgress.Failed(ExportError.NotProvisioned), canRetry = true), + ) } @Test fun `succeeded returns success`() { - assertIs<ListenableWorker.Result.Success>(resultFor(ExportProgress.Succeeded(1))) + assertIs<ListenableWorker.Result.Success>( + resultFor(ExportProgress.Succeeded(1), canRetry = true), + ) } @Test fun `null terminal fails`() { - assertIs<ListenableWorker.Result.Failure>(resultFor(null)) + assertIs<ListenableWorker.Result.Failure>(resultFor(null, canRetry = true)) } } diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs index 82dd2d60a..e8897d9b1 100644 --- a/rust/rust-code/bindings/src/backup.rs +++ b/rust/rust-code/bindings/src/backup.rs @@ -238,7 +238,6 @@ pub enum ExportPreset { #[derive(uniffi::Enum)] pub enum JsonEncryption { - None, Passphrase, Ark, } @@ -348,12 +347,6 @@ pub enum BackupError { UnsupportedVersion(u32), #[error("malformed encryption header")] MalformedHeader, - #[error("encryption header and payload disagree")] - EncryptionMismatch, - #[error("a credential is required to read this encrypted backup")] - MissingCredential, - #[error("a credential was supplied for a plaintext backup")] - UnexpectedCredential, #[error("credential does not match the backup's key source")] CredentialMismatch, #[error("malformed csv: {0}")] @@ -371,9 +364,6 @@ impl From<core::BackupError> for BackupError { E::Base64 => Self::Base64, E::UnsupportedVersion(v) => Self::UnsupportedVersion(v), E::MalformedHeader => Self::MalformedHeader, - E::EncryptionMismatch => Self::EncryptionMismatch, - E::MissingCredential => Self::MissingCredential, - E::UnexpectedCredential => Self::UnexpectedCredential, E::CredentialMismatch => Self::CredentialMismatch, E::Csv(s) => Self::Csv(s), E::EmptyCsv => Self::EmptyCsv, @@ -394,16 +384,15 @@ impl JsonBackupManager { pub fn export( &self, backup: Backup, - credential: Option<BackupCredential>, + credential: BackupCredential, ) -> Result<String, BackupError> { let backup: core::Backup = backup.into(); let json = match credential { - None => core::json::export(&backup, None), - Some(BackupCredential::Passphrase { bytes }) => { - core::json::export(&backup, Some(core::BackupCredential::Passphrase(&bytes))) + BackupCredential::Passphrase { bytes } => { + core::json::export(&backup, core::BackupCredential::Passphrase(&bytes)) } - Some(BackupCredential::Ark { key }) => { - core::json::export(&backup, Some(core::BackupCredential::Ark(&key))) + BackupCredential::Ark { key } => { + core::json::export(&backup, core::BackupCredential::Ark(&key)) } }?; Ok(json) @@ -412,15 +401,14 @@ impl JsonBackupManager { pub fn import( &self, data: String, - credential: Option<BackupCredential>, + credential: BackupCredential, ) -> Result<Backup, BackupError> { let backup = match credential { - None => core::json::import(&data, None), - Some(BackupCredential::Passphrase { bytes }) => { - core::json::import(&data, Some(core::BackupCredential::Passphrase(&bytes))) + BackupCredential::Passphrase { bytes } => { + core::json::import(&data, core::BackupCredential::Passphrase(&bytes)) } - Some(BackupCredential::Ark { key }) => { - core::json::import(&data, Some(core::BackupCredential::Ark(&key))) + BackupCredential::Ark { key } => { + core::json::import(&data, core::BackupCredential::Ark(&key)) } }?; Ok(backup.into()) @@ -428,9 +416,8 @@ impl JsonBackupManager { pub fn inspect(&self, data: String) -> Result<JsonEncryption, BackupError> { Ok(match core::json::inspect(&data)? { - None => JsonEncryption::None, - Some(core::encryption::KeySource::Passphrase) => JsonEncryption::Passphrase, - Some(core::encryption::KeySource::Ark) => JsonEncryption::Ark, + core::encryption::KeySource::Passphrase => JsonEncryption::Passphrase, + core::encryption::KeySource::Ark => JsonEncryption::Ark, }) } } diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs index 2eb52e21a..23814a685 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -113,7 +113,7 @@ pub fn seal( pub fn open( header: &EncryptionHeader, ciphertext: &[u8], - cred: Option<BackupCredential<'_>>, + cred: BackupCredential<'_>, version: u32, ) -> Result<Vec<u8>, BackupError> { if header.nonce.len() != NONCE_LEN { @@ -121,12 +121,9 @@ pub fn open( } let key = match (header.source, &header.kdf, cred) { - // No credential supplied for an encrypted backup. - (_, _, None) => return Err(BackupError::MissingCredential), - // Credential's source disagrees with the header's declared source. - (KeySource::Passphrase, _, Some(BackupCredential::Ark(_))) - | (KeySource::Ark, _, Some(BackupCredential::Passphrase(_))) => { + (KeySource::Passphrase, _, BackupCredential::Ark(_)) + | (KeySource::Ark, _, BackupCredential::Passphrase(_)) => { return Err(BackupError::CredentialMismatch); } @@ -139,7 +136,7 @@ pub fn open( iters, lanes, }, - Some(BackupCredential::Passphrase(passphrase)), + BackupCredential::Passphrase(passphrase), ) => BackupKey::from_passphrase( passphrase, salt, @@ -149,13 +146,13 @@ pub fn open( lanes: *lanes, }, )?, - (KeySource::Ark, Kdf::HkdfSha256 { salt }, Some(BackupCredential::Ark(ark))) => { + (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, _, Some(BackupCredential::Passphrase(_))) - | (KeySource::Ark, _, Some(BackupCredential::Ark(_))) => { + (KeySource::Passphrase, _, BackupCredential::Passphrase(_)) + | (KeySource::Ark, _, BackupCredential::Ark(_)) => { return Err(BackupError::MalformedHeader); } }; @@ -242,7 +239,7 @@ mod tests { let pt = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap(); @@ -257,7 +254,7 @@ mod tests { let err = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap_err(); @@ -280,7 +277,7 @@ mod tests { let pt = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap(); @@ -297,7 +294,7 @@ mod tests { let err = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap_err(); @@ -312,7 +309,7 @@ mod tests { let err = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap_err(); @@ -332,7 +329,7 @@ mod tests { let err = open( &sealed.header, &sealed.ciphertext, - Some(cred), + cred, CURRENT_VERSION, ) .unwrap_err(); diff --git a/rust/rust-code/lib/src/backup/error.rs b/rust/rust-code/lib/src/backup/error.rs index 1d57f79ad..fa9d14074 100644 --- a/rust/rust-code/lib/src/backup/error.rs +++ b/rust/rust-code/lib/src/backup/error.rs @@ -12,12 +12,6 @@ pub enum BackupError { UnsupportedVersion(u32), #[error("malformed encryption header")] MalformedHeader, - #[error("encryption header and payload disagree")] - EncryptionMismatch, - #[error("a credential is required to read this encrypted backup")] - MissingCredential, - #[error("a credential was supplied for a plaintext backup")] - UnexpectedCredential, #[error("credential does not match the backup's key source")] CredentialMismatch, #[error("malformed csv: {0}")] diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 1c8b920a7..0d74ee0ea 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -2,76 +2,45 @@ 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}; -use std::borrow::Cow; +/// 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<'a> { +pub struct BackupEnvelope { pub version: u32, - pub encryption: Option<EncryptionHeader>, - pub payload: Payload<'a>, + pub encryption: EncryptionHeader, + pub payload: String, } -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum Payload<'a> { - Encrypted(String), - Plain(Cow<'a, Backup>), -} - -pub fn export(backup: &Backup, cred: Option<BackupCredential<'_>>) -> Result<String, BackupError> { - let env = match cred { - None => BackupEnvelope { - version: CURRENT_VERSION, - encryption: None, - payload: Payload::Plain(Cow::Borrowed(backup)), - }, - Some(cred) => { - let payload_bytes = serde_json::to_vec(backup)?; - let sealed = encryption::seal(&payload_bytes, cred, CURRENT_VERSION)?; - BackupEnvelope { - version: CURRENT_VERSION, - encryption: Some(sealed.header), - payload: Payload::Encrypted(b64::encode(sealed.ciphertext)), - } - } +pub fn export(backup: &Backup, cred: BackupCredential<'_>) -> Result<String, BackupError> { + 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: Option<BackupCredential<'_>>) -> Result<Backup, BackupError> { - let env: BackupEnvelope = serde_json::from_str(data)?; - let version = env.version; - if !(MIN_SUPPORTED_VERSION..=CURRENT_VERSION).contains(&version) { - return Err(BackupError::UnsupportedVersion(version)); - } - match (env.encryption, env.payload) { - (None, Payload::Plain(backup)) => { - if cred.is_some() { - return Err(BackupError::UnexpectedCredential); - } - Ok(backup.into_owned()) - } - (Some(header), Payload::Encrypted(ciphertext_b64)) => { - let ciphertext = b64::decode(&ciphertext_b64).map_err(|_| BackupError::Base64)?; - let payload_bytes = encryption::open(&header, &ciphertext, cred, version)?; - Ok(serde_json::from_slice(&payload_bytes)?) - } - _ => Err(BackupError::EncryptionMismatch), - } +pub fn import(data: &str, cred: BackupCredential<'_>) -> Result<Backup, BackupError> { + 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. -/// `Ok(None)` means the payload is plaintext. -pub fn inspect(data: &str) -> Result<Option<KeySource>, BackupError> { +pub fn inspect(data: &str) -> Result<KeySource, BackupError> { + Ok(parse_envelope(data)?.encryption.source) +} + +fn parse_envelope(data: &str) -> Result<BackupEnvelope, BackupError> { let env: BackupEnvelope = serde_json::from_str(data)?; if !(MIN_SUPPORTED_VERSION..=CURRENT_VERSION).contains(&env.version) { return Err(BackupError::UnsupportedVersion(env.version)); } - match (&env.encryption, &env.payload) { - (None, Payload::Plain(_)) => Ok(None), - (Some(header), Payload::Encrypted(_)) => Ok(Some(header.source)), - _ => Err(BackupError::EncryptionMismatch), - } + Ok(env) } #[cfg(test)] @@ -130,11 +99,7 @@ mod tests { #[test] fn golden_v1_passphrase_still_decrypts() { - let backup = import( - GOLDEN_V1, - Some(BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)), - ) - .unwrap(); + 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]; @@ -168,8 +133,8 @@ mod tests { }, ]; - let json = export(&original, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - let restored = import(&json, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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); @@ -181,8 +146,8 @@ mod tests { fn vault_icon_round_trips_through_an_encrypted_backup() { let original = sample_backup(); - let json = export(&original, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - let restored = import(&json, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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"); } @@ -191,11 +156,7 @@ mod tests { 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, - Some(BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)), - ) - .unwrap(); + let backup = import(GOLDEN_V1, BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)).unwrap(); assert!(backup.vaults[0].icon.is_empty()); } @@ -203,36 +164,24 @@ mod tests { 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, - Some(BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)), - ) - .unwrap(); + let backup = import(GOLDEN_V1, BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE)).unwrap(); assert!(backup.vaults[0].logins[0].passkeys.is_empty()); } #[test] fn version_below_minimum_fails() { - let json = export(&sample_backup(), None).unwrap(); + 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(), None).unwrap_err(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::UnsupportedVersion(0))); } - #[test] - fn plaintext_round_trip() { - let original = sample_backup(); - let json = export(&original, None).unwrap(); - let restored = import(&json, None).unwrap(); - json_eq(&restored, &original); - } - #[test] fn passphrase_round_trip() { let original = sample_backup(); - let json = export(&original, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - let restored = import(&json, Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); json_eq(&restored, &original); } @@ -245,7 +194,7 @@ mod tests { #[test] fn golden_v1_ark_still_decrypts() { let ark = AccountRootKey::try_from_bytes(&GOLDEN_V1_ARK_KEY).unwrap(); - let backup = import(GOLDEN_V1_ARK, Some(BackupCredential::Ark(&ark))).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]; @@ -260,19 +209,15 @@ mod tests { fn ark_round_trip() { let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); let original = sample_backup(); - let json = export(&original, Some(BackupCredential::Ark(&ark))).unwrap(); - let restored = import(&json, Some(BackupCredential::Ark(&ark))).unwrap(); + 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(), - Some(BackupCredential::Passphrase(b"right")), - ) - .unwrap(); - let err = import(&json, Some(BackupCredential::Passphrase(b"wrong"))).unwrap_err(); + 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) @@ -283,8 +228,8 @@ mod tests { 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(), Some(BackupCredential::Ark(&right))).unwrap(); - let err = import(&json, Some(BackupCredential::Ark(&wrong))).unwrap_err(); + 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), @@ -293,40 +238,26 @@ mod tests { #[test] fn empty_passphrase_is_rejected() { - let err = export(&sample_backup(), Some(BackupCredential::Passphrase(b""))).unwrap_err(); + 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(), Some(BackupCredential::Ark(&ark))).unwrap(); - let err = import(&json, Some(BackupCredential::Passphrase(b"x"))).unwrap_err(); + 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 missing_credential_fails() { - let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - let err = import(&json, None).unwrap_err(); - assert!(matches!(err, BackupError::MissingCredential)); - } - - #[test] - fn unexpected_credential_fails() { - let json = export(&sample_backup(), None).unwrap(); - let err = import(&json, Some(BackupCredential::Passphrase(b"x"))).unwrap_err(); - assert!(matches!(err, BackupError::UnexpectedCredential)); - } - #[test] fn tampered_ciphertext_fails() { - let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -335,10 +266,10 @@ mod tests { #[test] fn tampered_header_salt_fails() { - let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -346,57 +277,52 @@ mod tests { } #[test] - fn guard_rejects_null_encryption_with_string_payload() { - let v = serde_json::json!({ "version": 1, "encryption": null, "payload": "AAAA" }); - let err = import(&v.to_string(), None).unwrap_err(); - assert!(matches!(err, BackupError::EncryptionMismatch)); + 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 guard_rejects_encryption_with_object_payload() { + fn inspect_rejects_plaintext_envelope() { let v = serde_json::json!({ "version": 1, - "encryption": { - "source": "passphrase", - "kdf": { "type": "argon2id", "salt": b64::encode([1u8; 16]), "mem_kib": 65536, "iters": 3, "lanes": 4 }, - "nonce": b64::encode([2u8; 12]), - }, + "encryption": null, "payload": { "vaults": [] }, }); - let err = import(&v.to_string(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); - assert!(matches!(err, BackupError::EncryptionMismatch)); + assert!(matches!( + inspect(&v.to_string()).unwrap_err(), + BackupError::Json(_), + )); } #[test] fn unknown_version_fails() { - let json = export(&sample_backup(), None).unwrap(); + 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(), None).unwrap_err(); + 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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap_err(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::Base64)); } - #[test] - fn plaintext_envelope_shape() { - let json = export(&sample_backup(), None).unwrap(); - let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(v["version"], serde_json::json!(1)); - assert!(v["encryption"].is_null()); - assert!(v["payload"].is_object()); - } - #[test] fn encrypted_envelope_hides_plaintext() { - let json = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); + 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()); @@ -406,24 +332,11 @@ mod tests { assert!(!json.contains("4111111111111111")); } - #[test] - fn plaintext_envelope_serde_round_trip() { - let env = BackupEnvelope { - version: CURRENT_VERSION, - encryption: None, - payload: Payload::Plain(Cow::Owned(Backup { vaults: vec![] })), - }; - let json = serde_json::to_string(&env).unwrap(); - let back: BackupEnvelope = serde_json::from_str(&json).unwrap(); - assert!(back.encryption.is_none()); - assert!(matches!(back.payload, Payload::Plain(_))); - } - #[test] fn encrypted_envelope_serde_round_trip() { let env = BackupEnvelope { version: CURRENT_VERSION, - encryption: Some(EncryptionHeader { + encryption: EncryptionHeader { source: KeySource::Passphrase, kdf: Kdf::Argon2id { salt: vec![1u8; 16], @@ -432,39 +345,32 @@ mod tests { lanes: 4, }, nonce: vec![2u8; 12], - }), - payload: Payload::Encrypted("AAAA".into()), + }, + payload: "AAAA".into(), }; let json = serde_json::to_string(&env).unwrap(); let back: BackupEnvelope = serde_json::from_str(&json).unwrap(); - assert!(matches!(back.payload, Payload::Encrypted(_))); - let header = back.encryption.unwrap(); - assert!(matches!(header.source, KeySource::Passphrase)); - assert!(matches!(header.kdf, Kdf::Argon2id { .. })); - } - - #[test] - fn inspect_reports_plaintext() { - let json = export(&sample_backup(), None).unwrap(); - assert!(matches!(inspect(&json).unwrap(), None)); + 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(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - assert!(matches!(inspect(&json).unwrap(), Some(KeySource::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(), Some(BackupCredential::Ark(&ark))).unwrap(); - assert!(matches!(inspect(&json).unwrap(), Some(KeySource::Ark))); + 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(), None).unwrap(); + 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!( @@ -477,17 +383,4 @@ mod tests { fn inspect_rejects_malformed_json() { assert!(matches!(inspect("not json").unwrap_err(), BackupError::Json(_))); } - - #[test] - fn inspect_rejects_header_with_plain_payload() { - let sealed = export(&sample_backup(), Some(BackupCredential::Passphrase(b"pw"))).unwrap(); - let plain = export(&sample_backup(), None).unwrap(); - let mut sealed_v: serde_json::Value = serde_json::from_str(&sealed).unwrap(); - let plain_v: serde_json::Value = serde_json::from_str(&plain).unwrap(); - sealed_v["payload"] = plain_v["payload"].clone(); - assert!(matches!( - inspect(&sealed_v.to_string()).unwrap_err(), - BackupError::EncryptionMismatch, - )); - } } 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 index 18fadbad4..96fe249cf 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt @@ -9,12 +9,12 @@ import de.davisalessandro.keygo.rust.JsonEncryption suspend fun JsonBackupManagerInterface.exportWithResult( backup: Backup, - credential: BackupCredential?, + credential: BackupCredential, ): Result<String, BackupException> = backupResult { export(backup, credential) } suspend fun JsonBackupManagerInterface.importWithResult( data: String, - credential: BackupCredential?, + credential: BackupCredential, ): Result<Backup, BackupException> = backupResult { import(data, credential) } suspend fun JsonBackupManagerInterface.inspectWithResult( diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt index 6fefa780f..b60e29316 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt @@ -8,8 +8,8 @@ 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?) + data class ExportCall(val backup: Backup, val credential: BackupCredential) + data class ImportCall(val data: String, val credential: BackupCredential) val exportCalls = mutableListOf<ExportCall>() val importCalls = mutableListOf<ImportCall>() @@ -22,13 +22,20 @@ class FakeJsonBackupManager : JsonBackupManagerInterface { var importException: BackupException? = null var inspectException: BackupException? = null - override fun export(backup: Backup, credential: BackupCredential?): String { + /** + * 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 { + override fun import(data: String, credential: BackupCredential): Backup { + onImport?.invoke(data, credential) importCalls += ImportCall(data, credential.snapshot()) importException?.let { throw it } return importResult @@ -36,8 +43,7 @@ class FakeJsonBackupManager : JsonBackupManagerInterface { // 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) { - null -> null + private fun BackupCredential.snapshot(): BackupCredential = when (this) { is BackupCredential.Ark -> BackupCredential.Ark(key.copyOf()) is BackupCredential.Passphrase -> BackupCredential.Passphrase(bytes.copyOf()) } From d14598c2a677693dd8efdc8344fb901492cc5c73 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 20:53:56 +0200 Subject: [PATCH 40/59] refactor(tests): add comments to clarify test-only cryptographic values --- rust/rust-code/lib/src/backup/encryption.rs | 22 +++---- rust/rust-code/lib/src/backup/format/json.rs | 62 +++++++++---------- rust/rust-code/lib/src/backup/key.rs | 18 +++--- .../rust-code/lib/src/crypto/keys/root_kek.rs | 14 ++--- .../lib/src/crypto/primitive/argon2.rs | 10 +-- .../lib/src/crypto/primitive/hkdf.rs | 8 +-- 6 files changed, 67 insertions(+), 67 deletions(-) diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs index 23814a685..2d927ab48 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -202,8 +202,8 @@ mod tests { #[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 salt = [4u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let aad = BackupAad { version: CURRENT_VERSION, source: KeySource::Passphrase, @@ -215,8 +215,8 @@ mod tests { #[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 salt = [4u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let aad = BackupAad { version: 1, source: KeySource::Passphrase, @@ -234,7 +234,7 @@ mod tests { #[test] fn seal_then_open_round_trips() { - let cred = BackupCredential::Passphrase(b"pw"); + let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let sealed = seal(b"hello-bytes", cred, CURRENT_VERSION).unwrap(); let pt = open( &sealed.header, @@ -248,7 +248,7 @@ mod tests { #[test] fn open_rejects_wrong_nonce_length() { - let cred = BackupCredential::Passphrase(b"pw"); + let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); sealed.header.nonce.push(0); let err = open( @@ -263,7 +263,7 @@ mod tests { #[test] fn ark_seal_records_hkdf_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 { .. })); @@ -271,7 +271,7 @@ mod tests { #[test] fn ark_seal_then_open_round_trips() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let cred = BackupCredential::Ark(&ark); let sealed = seal(b"hello-ark", cred, CURRENT_VERSION).unwrap(); let pt = open( @@ -286,7 +286,7 @@ mod tests { #[test] fn open_rejects_ark_source_with_argon2id_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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. @@ -303,7 +303,7 @@ mod tests { #[test] fn open_rejects_passphrase_source_with_hkdf_kdf() { - let cred = BackupCredential::Passphrase(b"pw"); + let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); sealed.header.kdf = Kdf::hkdf_sha256(vec![1u8; 16]); let err = open( @@ -318,7 +318,7 @@ mod tests { #[test] fn open_rejects_argon2_params_exceeding_memory_limit() { - let cred = BackupCredential::Passphrase(b"pw"); + let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 0d74ee0ea..9ce866dbc 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -94,7 +94,7 @@ mod tests { // 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_PASSPHRASE: &[u8] = b"golden-pw"; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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] @@ -133,8 +133,8 @@ mod tests { }, ]; - let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); - let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let passkeys = &restored.vaults[0].logins[0].passkeys; assert_eq!(passkeys.len(), 2); @@ -146,8 +146,8 @@ mod tests { 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(); + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_eq!(restored.vaults[0].icon, "Work"); } @@ -170,25 +170,25 @@ mod tests { #[test] fn version_below_minimum_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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_KEY: [u8; 32] = [9u8; 32]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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] @@ -207,7 +207,7 @@ mod tests { #[test] fn ark_round_trip() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let original = sample_backup(); let json = export(&original, BackupCredential::Ark(&ark)).unwrap(); let restored = import(&json, BackupCredential::Ark(&ark)).unwrap(); @@ -216,8 +216,8 @@ mod tests { #[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(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"right")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let err = import(&json, BackupCredential::Passphrase(b"wrong")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -226,8 +226,8 @@ mod tests { #[test] fn wrong_ark_fails() { - let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); - let wrong = AccountRootKey::try_from_bytes(&[6u8; 32]).unwrap(); + let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let wrong = AccountRootKey::try_from_bytes(&[6u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&sample_backup(), BackupCredential::Ark(&right)).unwrap(); let err = import(&json, BackupCredential::Ark(&wrong)).unwrap_err(); assert!(matches!( @@ -238,26 +238,26 @@ mod tests { #[test] fn empty_passphrase_is_rejected() { - let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); + let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)))); } #[test] fn credential_source_mismatch_fails() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); - let err = import(&json, BackupCredential::Passphrase(b"x")).unwrap_err(); + let err = import(&json, BackupCredential::Passphrase(b"x")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(err, BackupError::CredentialMismatch)); } #[test] fn tampered_ciphertext_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -266,10 +266,10 @@ mod tests { #[test] fn tampered_header_salt_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -285,7 +285,7 @@ mod tests { "encryption": null, "payload": { "vaults": [] }, }); - let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(err, BackupError::Json(_))); } @@ -304,25 +304,25 @@ mod tests { #[test] fn unknown_version_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(err, BackupError::UnsupportedVersion(2))); } #[test] fn malformed_base64_payload_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(err, BackupError::Base64)); } #[test] fn encrypted_envelope_hides_plaintext() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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()); @@ -357,20 +357,20 @@ mod tests { #[test] fn inspect_reports_passphrase() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); + let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(matches!(inspect(&json).unwrap(), KeySource::Passphrase)); } #[test] fn inspect_reports_ark() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let mut v: serde_json::Value = serde_json::from_str(&json).unwrap(); v["version"] = serde_json::json!(0); assert!(matches!( diff --git a/rust/rust-code/lib/src/backup/key.rs b/rust/rust-code/lib/src/backup/key.rs index 1f4ae465e..6bf39241e 100644 --- a/rust/rust-code/lib/src/backup/key.rs +++ b/rust/rust-code/lib/src/backup/key.rs @@ -35,28 +35,28 @@ mod tests { use crate::crypto::key::KeyMaterial; use crate::crypto::keys::AccountRootKey; - const SALT: &[u8] = &[3u8; 16]; + const SALT: &[u8] = &[3u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret #[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(); + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = - BackupKey::from_passphrase(b"hunter2", &[9u8; 16], Argon2Params::default()).unwrap(); + BackupKey::from_passphrase(b"hunter2", &[9u8; 16], Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = BackupKey::from_passphrase( - b"hunter2", + b"hunter2", // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret SALT, Argon2Params { iters: Argon2Params::default().iters + 1, @@ -69,7 +69,7 @@ mod tests { #[test] fn ark_key_is_deterministic() { - let ark = AccountRootKey::try_from_bytes(&[7u8; 32]).unwrap(); + let ark = AccountRootKey::try_from_bytes(&[7u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = BackupKey::from_ark(&ark, SALT).unwrap(); let b = BackupKey::from_ark(&ark, SALT).unwrap(); assert_eq!(a.as_bytes(), b.as_bytes()); @@ -77,7 +77,7 @@ mod tests { #[test] fn passphrase_and_ark_paths_are_domain_separated() { - let raw = [7u8; 32]; + let raw = [7u8; 32]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); diff --git a/rust/rust-code/lib/src/crypto/keys/root_kek.rs b/rust/rust-code/lib/src/crypto/keys/root_kek.rs index e9773f048..7f6a233f3 100644 --- a/rust/rust-code/lib/src/crypto/keys/root_kek.rs +++ b/rust/rust-code/lib/src/crypto/keys/root_kek.rs @@ -28,27 +28,27 @@ impl<'a> TryDeriveFrom<&'a [u8]> for RootKEK { mod tests { use super::*; - const SALT: &[u8] = &[6; 16]; + const SALT: &[u8] = &[6; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const DOMAIN: &[u8] = b"v1:kek/pwd"; #[test] fn same_credential_same_password_same_key() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_eq!(a.as_bytes(), b.as_bytes()); } #[test] fn different_salt_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_ne!(a.as_bytes(), b.as_bytes()); } #[test] fn different_domain_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_ne!(a.as_bytes(), b.as_bytes()); } } diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index d52da3b67..fc7ad0508 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -98,8 +98,8 @@ pub(crate) fn derive_argon2id_with_params( mod tests { use super::*; - const PW: &[u8] = b"correct horse battery staple"; - const SALT: [u8; 16] = [0x11; 16]; + const PW: &[u8] = b"correct horse battery staple"; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: [u8; 16] = [0x11; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret #[test] fn deterministic_for_same_inputs() { @@ -111,18 +111,18 @@ mod tests { #[test] fn different_salt_different_key() { let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); + let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_ne!(a, b); } #[test] fn rejects_empty_password() { - assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); + assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret } #[test] fn rejects_short_salt() { - assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); + assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret } #[test] diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs index ebe6856ed..3c934575a 100644 --- a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs +++ b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs @@ -32,8 +32,8 @@ pub(crate) fn derive_hkdf_sha256( mod tests { use super::*; - const IKM: &[u8] = &[7u8; 32]; - const SALT: &[u8] = &[0x11; 16]; + const IKM: &[u8] = &[7u8; 32]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: &[u8] = &[0x11; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret #[test] fn deterministic_for_same_inputs() { @@ -45,7 +45,7 @@ mod tests { #[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(); + let b = derive_hkdf_sha256(IKM, &[0x22; 16], b"info").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_ne!(a, b); } @@ -59,7 +59,7 @@ mod tests { #[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(); + let b = derive_hkdf_sha256(&[8u8; 32], SALT, b"info").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert_ne!(a, b); } } From 1aadadc18bc83f336aebfd5b28997b3df422a1bf Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 22:47:20 +0200 Subject: [PATCH 41/59] docs(KeyId): clarify comment for biometric vault key usage --- .../kotlin/de/davis/keygo/core/security/domain/model/KeyId.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b13b8b55c..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 @@ -9,7 +9,7 @@ package de.davis.keygo.core.security.domain.model * intended, so the set of aliases is closed here by construction. */ enum class KeyId(val id: String, val needsAuthentication: Boolean) { - /** Wraps the escrowed ARK; this key can only be used once unlocked via biometrics */ + /** Wraps the account's ARK for biometric unlock; this key can only be used once unlocked via biometrics */ BiometricVaultKek("biometric_vault_kek", true), /** Wraps the escrowed backup passphrase; auth-free so a scheduled backup can run unattended. */ From 6ed263929d904ea21906db4b2e23ed166e411dd9 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 23:17:30 +0200 Subject: [PATCH 42/59] refactor(fakes): drop fake tests --- .../core/item/FakeCreditCardRepositoryTest.kt | 38 ----------- .../core/item/FakePasskeyRepositoryTest.kt | 42 ------------ .../crypto/FakeKeyStoreManagerTest.kt | 65 ------------------- .../backup/FakeBackupArkKeyStoreTest.kt | 31 --------- .../feature/backup/FakeBackupFileStoreTest.kt | 59 ----------------- .../backup/FakeBackupJobRepositoryTest.kt | 56 ---------------- 6 files changed, 291 deletions(-) delete mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt delete mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt delete mode 100644 core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt delete mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt delete mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt delete mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt deleted file mode 100644 index 963359a35..000000000 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/FakeCreditCardRepositoryTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.davis.keygo.core.item - -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.KeyInformation -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class FakeCreditCardRepositoryTest { - - private fun card(vaultId: java.util.UUID, name: String) = CreditCard( - id = newItemId(), - vaultId = vaultId, - name = name, - keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), - tags = emptySet(), - note = null, - pinned = false, - holder = null, - cardNumber = null, - cvv = null, - expirationDate = null, - ) - - @Test - fun `getCreditCardsByVault returns only cards in that vault`() = runTest { - val vaultA = newVaultId() - val vaultB = newVaultId() - val repo = FakeCreditCardRepository() - repo.seed(card(vaultA, "A1"), card(vaultA, "A2"), card(vaultB, "B1")) - - val result = repo.getCreditCardsByVault(vaultA) - - assertEquals(setOf("A1", "A2"), result.map { it.name }.toSet()) - } -} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt deleted file mode 100644 index 9521d10a4..000000000 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/FakePasskeyRepositoryTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package de.davis.keygo.core.item - -import de.davis.keygo.core.item.domain.alias.newItemId -import de.davis.keygo.core.item.domain.model.EncryptedPayload -import de.davis.keygo.core.item.domain.model.Passkey -import de.davis.keygo.core.item.domain.model.PasskeyUser -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class FakePasskeyRepositoryTest { - - private val repository = FakePasskeyRepository() - - private fun passkey(loginId: de.davis.keygo.core.item.domain.alias.ItemId, rp: String) = Passkey( - credentialId = rp.encodeToByteArray(), - rp = rp, - privateKey = Passkey.PrivateKey(EncryptedPayload(byteArrayOf(1), byteArrayOf(2))), - loginId = loginId, - user = PasskeyUser(name = "alice", displayName = "Alice"), - ) - - @Test - fun `returns every passkey of the requested login`() = runTest { - val login = newItemId() - val other = newItemId() - repository.seed( - passkey(login, "example.com"), - passkey(login, "example.org"), - passkey(other, "elsewhere.test"), - ) - - val passkeys = repository.getPasskeysByLogin(login) - - assertEquals(listOf("example.com", "example.org"), passkeys.map { it.rp }) - } - - @Test - fun `returns nothing for a login without passkeys`() = runTest { - assertEquals(emptyList(), repository.getPasskeysByLogin(newItemId())) - } -} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt deleted file mode 100644 index 9784e3fc5..000000000 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/FakeKeyStoreManagerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package de.davis.keygo.core.security.crypto - -import de.davis.keygo.core.security.domain.model.CryptographicMode -import de.davis.keygo.core.security.domain.model.KeyId -import javax.crypto.AEADBadTagException -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse - -class FakeKeyStoreManagerTest { - - private val keyId = KeyId.BackupArkKey - - @Test - fun `encrypt then decrypt round-trips under the same key id`() { - val ks = FakeKeyStoreManager() - val plaintext = ByteArray(32) { it.toByte() } - - val enc = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt) - val ct = enc.doFinal(plaintext) - val iv = enc.iv - - val dec = ks.getOrCreateCipherFor(keyId, CryptographicMode.Decrypt, iv) - assertContentEquals(plaintext, dec.doFinal(ct)) - } - - @Test - fun `nonce is randomized per encryption`() { - val ks = FakeKeyStoreManager() - val a = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt).iv - val b = ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt).iv - assertFalse(a.contentEquals(b)) - } - - @Test - fun `device locked makes cipher access throw`() { - val ks = FakeKeyStoreManager(deviceLocked = true) - assertFailsWith<IllegalStateException> { - ks.getOrCreateCipherFor(keyId, CryptographicMode.Encrypt) - } - } - - @Test - fun `deleting a key drops the alias and records it`() { - val keyStoreManager = FakeKeyStoreManager() - val cipher = keyStoreManager.getOrCreateCipherFor( - keyId = KeyId.BackupArkKey, - cryptographicMode = CryptographicMode.Encrypt, - ) - val ciphertext = cipher.doFinal("ark".encodeToByteArray()) - - keyStoreManager.deleteKey(KeyId.BackupArkKey) - - assertFalse(keyStoreManager.keys.keys.contains(KeyId.BackupArkKey)) - // The alias is gone: the next cipher is backed by a brand-new key, so the old - // ciphertext no longer opens. - val fresh = keyStoreManager.getOrCreateCipherFor( - keyId = KeyId.BackupArkKey, - cryptographicMode = CryptographicMode.Decrypt, - iv = cipher.iv, - ) - assertFailsWith<AEADBadTagException> { fresh.doFinal(ciphertext) } - } -} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt deleted file mode 100644 index 78df5f507..000000000 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStoreTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package de.davis.keygo.feature.backup - -import de.davis.keygo.core.security.domain.crypto.model.CryptographicData -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class FakeBackupArkKeyStoreTest { - - @Test - fun `load returns null before anything is saved`() = runTest { - assertNull(FakeBackupArkKeyStore().load()) - } - - @Test - fun `save then load round-trips the data`() = runTest { - val store = FakeBackupArkKeyStore() - val data = CryptographicData(byteArrayOf(1, 2, 3), byteArrayOf(9, 8)) - store.save(data) - assertEquals(data, store.load()) - } - - @Test - fun `clear removes stored data and counts`() = runTest { - val store = FakeBackupArkKeyStore(CryptographicData(byteArrayOf(1), byteArrayOf(2))) - store.clear() - assertNull(store.load()) - assertEquals(1, store.clearCount) - } -} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt deleted file mode 100644 index 760bd8e6b..000000000 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStoreTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package de.davis.keygo.feature.backup - -import de.davis.keygo.core.util.Result -import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class FakeBackupFileStoreTest { - - private val folder = BackupDestinationUri("content://tree") - - @Test - fun `writeNewDocument records the call and lists the new document`() = runTest { - val store = FakeBackupFileStore() - - val result = - store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "hello") - - assertIs<Result.Success<Unit, Throwable>>(result) - assertEquals("hello", store.writtenText) - assertEquals("keygo-backup-1.json", store.writtenFileName) - assertEquals(listOf("keygo-backup-1.json"), store.backups.map { it.name }) - } - - @Test - fun `listBackups filters by base name`() = runTest { - val store = FakeBackupFileStore() - store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "a") - store.writeNewDocument(folder, "unrelated.txt", "text/plain", "b") - - val listed = - assertIs<Result.Success<List<*>, Throwable>>(store.listBackups(folder, "keygo-backup")) - - assertEquals( - listOf("keygo-backup-1.json"), - store.backups.filter { it.name.startsWith("keygo-backup") }.map { it.name }) - assertEquals(1, listed.success.size) - } - - @Test - fun `delete removes the document`() = runTest { - val store = FakeBackupFileStore() - store.writeNewDocument(folder, "keygo-backup-1.json", "application/json", "a") - val entry = store.backups.single() - - store.delete(entry.uri) - - assertEquals(emptyList(), store.backups) - assertEquals(listOf(entry.uri), store.deleted) - } - - @Test - fun `read surfaces the configured error`() = runTest { - val store = FakeBackupFileStore().apply { readError = RuntimeException("boom") } - assertIs<Result.Failure<Nothing, Throwable>>(store.read(BackupDestinationUri("content://doc"))) - } -} diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt deleted file mode 100644 index 6f80a166b..000000000 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepositoryTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package de.davis.keygo.feature.backup - -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.FileFormat -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class FakeBackupJobRepositoryTest { - - private val repository = FakeBackupJobRepository() - - private fun seed() { - repository.jobs["w"] = BackupJob( - uri = BackupDestinationUri("content://out.json"), - wrappedPassphrase = null, - format = FileFormat.JSON, - createdAt = 1L, - ) - } - - @Test - fun `a failure records its reason`() = runTest { - seed() - - repository.markFinished("w", BackupResult.Failure(BackupFailureReason.WriteFailed), 10L) - - val saved = repository.jobs.getValue("w") - assertEquals(BackupResult.Failure(BackupFailureReason.WriteFailed), saved.lastResult) - } - - @Test - fun `a later success clears the previous reason`() = runTest { - seed() - repository.markFinished("w", BackupResult.Failure(BackupFailureReason.WriteFailed), 10L) - - repository.markFinished("w", BackupResult.Success, 20L) - - assertEquals(BackupResult.Success, repository.jobs.getValue("w").lastResult) - } - - @Test - fun `marking an absent record is a no-op`() = runTest { - repository.markFinished( - "missing", - BackupResult.Failure(BackupFailureReason.CryptoFailed), - 10L - ) - - assertNull(repository.jobs["missing"]) - } -} From 643eb2c33caebf6903092ddfae0c174748c13f7e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 23:28:52 +0200 Subject: [PATCH 43/59] refactor(worker): introduce worker id alias --- .../feature/backup/data/BackupSchedulerImpl.kt | 3 ++- .../data/repository/BackupJobRepositoryImpl.kt | 17 +++++++++-------- .../feature/backup/domain/BackupScheduler.kt | 3 ++- .../keygo/feature/backup/domain/alias/WorkId.kt | 6 ++++++ .../domain/repository/BackupJobRepository.kt | 13 +++++++------ .../domain/usecase/CancelBackupUseCase.kt | 3 ++- .../usecase/CleanupBackupResourcesUseCase.kt | 13 +++++++------ .../usecase/ObserveDispatchedBackupsUseCase.kt | 5 +++-- .../usecase/RecordBackupOutcomeUseCase.kt | 3 ++- .../keygo/feature/backup/worker/BackupWorker.kt | 5 +++-- .../feature/backup/FakeBackupJobRepository.kt | 17 +++++++++-------- .../keygo/feature/backup/FakeBackupScheduler.kt | 13 +++++++------ .../CleanupBackupResourcesUseCaseTest.kt | 3 ++- 13 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/alias/WorkId.kt 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 index 530b1de6d..b9f0a83fc 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -76,7 +77,7 @@ internal class BackupSchedulerImpl( workManager.cancelUniqueWork(BackupWorker.UNIQUE_WORK_NAME) } - override suspend fun outstandingWorkIds(): Set<String> = + override suspend fun outstandingWorkIds(): Set<WorkId> = workManager.getWorkInfosByTagFlow(BackupWorker.TAG).first() .filterNot { it.state.isFinished } .mapTo(mutableSetOf()) { info -> 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 index e616cb46a..c71d0daf4 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -25,16 +26,16 @@ internal class BackupJobRepositoryImpl( private val dataStore: DataStore<ProtoBackupJobs>, ) : BackupJobRepository { - override suspend fun getJob(workId: String): BackupJob? = + override suspend fun getJob(workId: WorkId): BackupJob? = dataStore.data.map { it.jobsMap[workId]?.toDomain() }.firstOrNull() - override suspend fun getJobs(): Map<String, BackupJob> = + override suspend fun getJobs(): Map<WorkId, BackupJob> = dataStore.data.first().jobsMap.mapValues { (_, proto) -> proto.toDomain() } override fun observeJobs(): Flow<List<BackupJob>> = dataStore.data.map { it.jobsMap.values.map { proto -> proto.toDomain() } } - override suspend fun putJob(workId: String, job: BackupJob): Result<Unit, Unit> = runCatching { + override suspend fun putJob(workId: WorkId, job: BackupJob): Result<Unit, Unit> = runCatching { dataStore.updateData { current -> current.copy { jobs[workId] = job.copy(createdAt = System.currentTimeMillis()).toProto() @@ -45,27 +46,27 @@ internal class BackupJobRepositoryImpl( onFailure = { Result.Failure(Unit) }, ) - override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) = + override suspend fun markFinished(workId: WorkId, result: BackupResult, finishedAt: Long) = updateJob(workId) { this.finishedAt = finishedAt writeResult(result) } - override suspend fun markCancelled(workId: String, cancelledAt: Long) = updateJob(workId) { + 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: String) = updateJob(workId, prune = false) { + 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: String, + workId: WorkId, prune: Boolean = true, edit: ProtoBackupJobKt.Dsl.() -> Unit, ) { @@ -80,7 +81,7 @@ internal class BackupJobRepositoryImpl( // 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: String, + workId: WorkId, job: ProtoBackupJob ): ProtoBackupJobs { val merged = jobsMap + (workId to job) 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 index bb80ea194..0f82e733c 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -24,5 +25,5 @@ interface BackupScheduler { * 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<String> + suspend fun outstandingWorkIds(): Set<WorkId> } \ No newline at end of file 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/repository/BackupJobRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/BackupJobRepository.kt index afc04e73a..ad0cbff97 100644 --- 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 @@ -1,22 +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: String): BackupJob? - suspend fun getJobs(): Map<String, BackupJob> - suspend fun putJob(workId: String, job: BackupJob): Result<Unit, Unit> + suspend fun getJob(workId: WorkId): BackupJob? + suspend fun getJobs(): Map<WorkId, BackupJob> + suspend fun putJob(workId: WorkId, job: BackupJob): Result<Unit, Unit> suspend fun markFinished( - workId: String, + workId: WorkId, result: BackupResult, finishedAt: Long = System.currentTimeMillis() ) - suspend fun markCancelled(workId: String, cancelledAt: Long = System.currentTimeMillis()) - suspend fun clearPassphrase(workId: String) + suspend fun markCancelled(workId: WorkId, cancelledAt: Long = System.currentTimeMillis()) + suspend fun clearPassphrase(workId: WorkId) fun observeJobs(): Flow<List<BackupJob>> } 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 index 7d5bb301b..efa4b2360 100644 --- 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 @@ -1,6 +1,7 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +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.worker.BackupWorker @@ -16,7 +17,7 @@ internal class CancelBackupUseCase( suspend operator fun invoke(id: String, kind: DispatchedBackup.Kind) { repository.cancel(id) - val workId = when (kind) { + val workId: WorkId = when (kind) { DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID DispatchedBackup.Kind.OneTime -> id } 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 index 39a1cd460..23674ff45 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -31,7 +32,7 @@ internal class CleanupBackupResourcesUseCase( private val scheduler: BackupScheduler, ) { - suspend operator fun invoke(workId: String): Unit = provisioningLock.mutex.withLock { + suspend operator fun invoke(workId: WorkId): Unit = provisioningLock.mutex.withLock { val outstanding = outstandingWorkIds() val current = runCatching { jobRepository.getJobs() }.getOrNull() ?: return @@ -72,7 +73,7 @@ internal class CleanupBackupResourcesUseCase( * 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<String, BackupJob>, outstanding: Set<String>?) { + private suspend fun sweep(current: Map<WorkId, BackupJob>, outstanding: Set<WorkId>?) { current.forEach { (id, job) -> if (job.wrappedPassphrase != null && !job.isLive(id, outstanding)) runCatching { jobRepository.clearPassphrase(id) } @@ -85,8 +86,8 @@ internal class CleanupBackupResourcesUseCase( } private suspend fun releaseUnusedKeys( - jobs: Map<String, BackupJob>, - live: Map<String, BackupJob>, + jobs: Map<WorkId, BackupJob>, + live: Map<WorkId, BackupJob>, ) { if (jobs.values.none { it.wrappedPassphrase != null }) runCatching { keyStoreManager.deleteKey(KeyId.BackupPassphraseKey) } @@ -99,7 +100,7 @@ internal class CleanupBackupResourcesUseCase( /** 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<String>? = + private suspend fun outstandingWorkIds(): Set<WorkId>? = runCatching { scheduler.outstandingWorkIds() }.getOrNull() // A recurring schedule stamps finishedAt after every run, so only its absence - or cancellation @@ -107,7 +108,7 @@ internal class CleanupBackupResourcesUseCase( // 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: String, outstanding: Set<String>?): Boolean = + private fun BackupJob.isLive(workId: WorkId, outstanding: Set<WorkId>?): 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/ObserveDispatchedBackupsUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/ObserveDispatchedBackupsUseCase.kt index 7d41e6069..f76ba3610 100644 --- 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 @@ -2,6 +2,7 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.domain.BackupDestinationResolver import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository +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 @@ -26,8 +27,8 @@ internal class ObserveDispatchedBackupsUseCase( statuses.map { it.enrich(jobs) } } - private suspend fun BackupWorkStatus.enrich(jobs: Map<String, BackupJob>): DispatchedBackup { - val workId = when (kind) { + private suspend fun BackupWorkStatus.enrich(jobs: Map<WorkId, BackupJob>): DispatchedBackup { + val workId: WorkId = when (kind) { DispatchedBackup.Kind.Recurring -> BackupWorker.RECURRING_WORK_ID DispatchedBackup.Kind.OneTime -> id } 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 index e37333cd2..695ec6e4c 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -20,7 +21,7 @@ internal class RecordBackupOutcomeUseCase( * record closes and the cleanup below can hand back the job's credentials. */ suspend operator fun invoke( - workId: String, + workId: WorkId, terminal: ExportProgress?, canRetry: Boolean = true, ) { 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 index f9eb0233f..3ad478ec9 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -36,7 +37,7 @@ internal class BackupWorker( private val isRecurring = TAG_RECURRING in tags override suspend fun doWork(): Result { - val workId = if (isRecurring) RECURRING_WORK_ID else id.toString() + 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 @@ -68,7 +69,7 @@ internal class BackupWorker( 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 = "recurring_backup" + const val RECURRING_WORK_ID: WorkId = "recurring_backup" const val TAG = "backup" const val TAG_RECURRING = "backup_recurring" diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt index 9233d57d7..3ff4575d9 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt @@ -2,6 +2,7 @@ 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 @@ -11,36 +12,36 @@ import kotlinx.coroutines.flow.flow class FakeBackupJobRepository( private val now: () -> Long = { 0L }, ) : BackupJobRepository { - val jobs = mutableMapOf<String, BackupJob>() + val jobs = mutableMapOf<WorkId, BackupJob>() - override suspend fun getJob(workId: String): BackupJob? = jobs[workId] + override suspend fun getJob(workId: WorkId): BackupJob? = jobs[workId] - override suspend fun getJobs(): Map<String, BackupJob> = jobs.toMap() + override suspend fun getJobs(): Map<WorkId, BackupJob> = jobs.toMap() override fun observeJobs(): Flow<List<BackupJob>> = flow { emit(jobs.values.toList()) } - override suspend fun putJob(workId: String, job: BackupJob): Result<Unit, Unit> { + override suspend fun putJob(workId: WorkId, job: BackupJob): Result<Unit, Unit> { jobs[workId] = job.copy(createdAt = now()) return Result.Success(Unit) } - override suspend fun markFinished(workId: String, result: BackupResult, finishedAt: Long) { + 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: String, cancelledAt: Long) { + 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: String, job: BackupJob) { + 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: String) { + override suspend fun clearPassphrase(workId: WorkId) { val existing = jobs[workId] ?: return jobs[workId] = existing.copy(wrappedPassphrase = null) } diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt index e146764dc..a1ad5feed 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt @@ -2,6 +2,7 @@ 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 @@ -17,7 +18,7 @@ import kotlinx.coroutines.CompletableDeferred class FakeBackupScheduler( private val jobRepository: FakeBackupJobRepository? = null, private val gate: CompletableDeferred<Unit>? = null, - private val oneTimeWorkId: String = "one-time", + private val oneTimeWorkId: WorkId = "one-time", ) : BackupScheduler { var recurringJob: BackupJob? = null @@ -26,8 +27,8 @@ class FakeBackupScheduler( var cancelled = false var result: Result<Unit, Unit> = Result.Success(Unit) - private val scheduled = mutableSetOf<String>() - private val abandoned = mutableSetOf<String>() + private val scheduled = mutableSetOf<WorkId>() + private val abandoned = mutableSetOf<WorkId>() /** When set, [outstandingWorkIds] throws - the "scheduler unreadable" case. */ var outstandingFailure: Throwable? = null @@ -38,12 +39,12 @@ class FakeBackupScheduler( * 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<String> { + override suspend fun outstandingWorkIds(): Set<WorkId> { outstandingFailure?.let { throw it } return (jobRepository?.jobs?.keys ?: scheduled) - abandoned } - fun abandon(workId: String) { + fun abandon(workId: WorkId) { abandoned += workId } @@ -67,7 +68,7 @@ class FakeBackupScheduler( // 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: String, job: BackupJob): Result<Unit, Unit> { + private suspend fun persist(workId: WorkId, job: BackupJob): Result<Unit, Unit> { gate?.await() if (result is Result.Failure) return result jobRepository?.putJob(workId, job) 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 index a115fc8a4..46831d09c 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -305,7 +306,7 @@ class CleanupBackupResourcesUseCaseTest { 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: String): Unit = throw IOException("boom") + override suspend fun clearPassphrase(workId: WorkId): Unit = throw IOException("boom") } val useCase = CleanupBackupResourcesUseCase( jobRepository = failingClear, From 81aa9ff72e5238bb8239301a90abe2c7f1ba4f68 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 23:42:26 +0200 Subject: [PATCH 44/59] refactor(proto): outsource default serializer --- .../core/identity/di/CoreIdentityModule.kt | 1 + core/util/build.gradle.kts | 3 +++ .../serializer}/DefaultProtoSerializer.kt | 6 ++--- .../feature/backup/di/FeatureBackupModule.kt | 22 +------------------ 4 files changed, 8 insertions(+), 24 deletions(-) rename core/{identity/src/main/kotlin/de/davis/keygo/core/identity/di => util/src/main/kotlin/de/davis/keygo/core/util/data/serializer}/DefaultProtoSerializer.kt (84%) 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/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<T : MessageLite>( +class DefaultProtoSerializer<T : MessageLite>( private val defaultInstance: T, private val parser: Parser<T> ) : Serializer<T> { @@ -20,4 +20,4 @@ internal class DefaultProtoSerializer<T : MessageLite>( override suspend fun writeTo(t: T, output: OutputStream) { t.writeTo(output) } -} \ 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 index c473a80e4..95ad98c46 100644 --- 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 @@ -1,11 +1,9 @@ package de.davis.keygo.feature.backup.di import android.content.Context -import androidx.datastore.core.Serializer import androidx.datastore.dataStore import androidx.work.WorkManager -import com.google.protobuf.MessageLite -import com.google.protobuf.Parser +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 @@ -14,8 +12,6 @@ import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module import org.koin.core.annotation.Single -import java.io.InputStream -import java.io.OutputStream @Module @Configuration @@ -51,20 +47,4 @@ object FeatureBackupModule { @Single internal fun provideWorkManager(context: Context): WorkManager = WorkManager.getInstance(context) -} - -internal class DefaultProtoSerializer<T : MessageLite>( - private val defaultInstance: T, - private val parser: Parser<T> -) : Serializer<T> { - - override val defaultValue: T - get() = defaultInstance - - override suspend fun readFrom(input: InputStream): T = - parser.parseFrom(input) - - override suspend fun writeTo(t: T, output: OutputStream) { - t.writeTo(output) - } } \ No newline at end of file From 6bb493b50ff8fd6ce755c2e869782470f2fe0144 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Fri, 31 Jul 2026 23:56:43 +0200 Subject: [PATCH 45/59] fix(backup): backup list of websites --- .../backup/domain/mapper/ExportMappers.kt | 2 +- .../backup/domain/mapper/ImportMappers.kt | 2 +- .../keygo/feature/backup/BackupTestData.kt | 4 +-- .../backup/domain/BackupCollectorTest.kt | 25 +++++++++++-- .../backup/domain/BackupRestorerTest.kt | 35 +++++++++++++++++-- .../domain/usecase/ImportBackupUseCaseTest.kt | 2 +- .../import/ImportWizardViewModelTest.kt | 2 +- rust/rust-code/bindings/src/backup.rs | 6 ++-- rust/rust-code/lib/src/backup/format/csv.rs | 14 ++++---- rust/rust-code/lib/src/backup/format/json.rs | 28 ++++++++++++++- rust/rust-code/lib/src/backup/model.rs | 5 ++- 11 files changed, 102 insertions(+), 23 deletions(-) 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 index 91cdc89de..ef20f6c46 100644 --- 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 @@ -24,7 +24,7 @@ internal suspend fun Login.toBackupLogin(passkeys: List<Passkey>): BackupLogin = username = username, password = passwordCredential?.secret?.decrypt(), totpSecret = totp?.secret?.decrypt(), - website = domainInfos.firstOrNull()?.value, + websites = domainInfos.map { it.value }, passkeys = passkeys.map { it.toBackupPasskey() }, ) 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 index 110902d4f..7cdccd992 100644 --- 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 @@ -23,7 +23,7 @@ internal fun BackupLogin.toUpsertLogin(vaultId: VaultId): UpsertLogin = UpsertLo password = password, totpUriOrSecret = totpSecret, username = username, - domains = website?.let { setOf(DomainInfo(value = it, eTLD1 = null)) }.orEmpty(), + domains = websites.map { DomainInfo(value = it, eTLD1 = null) }.toSet(), tags = tags.mapNotNull { Tag.of(it) }.toSet(), note = notes, ) 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 index 0c1164594..2e8fe2d68 100644 --- 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 @@ -57,7 +57,7 @@ fun testLogin( username: String? = null, password: String? = null, totpSecret: String? = null, - website: String? = null, + websites: Set<String> = emptySet(), tags: Set<String> = emptySet(), note: String? = null, passkeyRPs: Set<String> = emptySet(), @@ -66,7 +66,7 @@ fun testLogin( vaultId = vaultId, name = name, username = username, - domainInfos = website?.let { setOf(DomainInfo(loginId = id, value = it, eTLD1 = null)) }.orEmpty(), + domainInfos = websites.map { DomainInfo(loginId = id, value = it, eTLD1 = null) }.toSet(), passwordCredential = password?.let { PasswordCredential(secret = PasswordSecret(secretPayload(it)), score = PasswordScore.Strong) }, 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 index 75ca95ad0..e17f7a708 100644 --- 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 @@ -73,7 +73,7 @@ class BackupCollectorTest { username = "alice", password = "s3cr3t", totpSecret = "JBSWY3DPEHPK3PXP", - website = "https://mail.example", + websites = setOf("https://mail.example"), tags = setOf("work"), note = "remember", ) @@ -86,12 +86,33 @@ class BackupCollectorTest { assertEquals("alice", login.username) assertEquals("s3cr3t", login.password) assertEquals("JBSWY3DPEHPK3PXP", login.totpSecret) - assertEquals("https://mail.example", login.website) + 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") 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 index 423f8c624..0728d5ad6 100644 --- 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 @@ -9,7 +9,6 @@ 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.BackupCard import de.davisalessandro.keygo.rust.BackupLogin import de.davisalessandro.keygo.rust.BackupVault import kotlinx.coroutines.flow.first @@ -20,7 +19,11 @@ import kotlin.test.assertIs class BackupRestorerTest { - private fun login(title: String, username: String? = null) = BackupLogin( + private fun login( + title: String, + username: String? = null, + websites: List<String> = emptyList(), + ) = BackupLogin( title = title, notes = null, tags = emptyList(), @@ -28,7 +31,7 @@ class BackupRestorerTest { username = username, password = "pw", totpSecret = null, - website = null, + websites = websites, passkeys = emptyList(), ) @@ -56,6 +59,32 @@ class BackupRestorerTest { 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() 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 index 5cb668b4e..c31d6b238 100644 --- 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 @@ -57,7 +57,7 @@ class ImportBackupUseCaseTest { username = null, password = "pw", totpSecret = null, - website = null, + websites = emptyList(), passkeys = emptyList(), ) 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 index fe043e0d1..64826f8b3 100644 --- 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 @@ -83,7 +83,7 @@ class ImportWizardViewModelTest { username = null, password = "pw", totpSecret = null, - website = null, + websites = emptyList(), passkeys = emptyList(), ) diff --git a/rust/rust-code/bindings/src/backup.rs b/rust/rust-code/bindings/src/backup.rs index e8897d9b1..3eab2be1a 100644 --- a/rust/rust-code/bindings/src/backup.rs +++ b/rust/rust-code/bindings/src/backup.rs @@ -25,7 +25,7 @@ pub struct BackupLogin { pub username: Option<String>, pub password: Option<String>, pub totp_secret: Option<String>, - pub website: Option<String>, + pub websites: Vec<String>, pub passkeys: Vec<BackupPasskey>, } @@ -85,7 +85,7 @@ impl From<core::Login> for BackupLogin { username: l.username, password: l.password, totp_secret: l.totp_secret, - website: l.website, + websites: l.websites, passkeys: l.passkeys.into_iter().map(Into::into).collect(), } } @@ -101,7 +101,7 @@ impl From<BackupLogin> for core::Login { username: l.username, password: l.password, totp_secret: l.totp_secret, - website: l.website, + websites: l.websites, passkeys: l.passkeys.into_iter().map(Into::into).collect(), } } diff --git a/rust/rust-code/lib/src/backup/format/csv.rs b/rust/rust-code/lib/src/backup/format/csv.rs index adc25a4df..6c8ce3fcb 100644 --- a/rust/rust-code/lib/src/backup/format/csv.rs +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -87,7 +87,7 @@ impl Field { fn read(self, login: &Login) -> Option<&str> { match self { Field::Title => Some(login.title.as_str()), - Field::Url => login.website.as_deref(), + 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(), @@ -559,7 +559,7 @@ pub fn import(data: &str, mapping: &ColumnMapping) -> Result<(Backup, ImportRepo username, password, totp_secret: totp, - website: url, + websites: url.into_iter().collect(), notes, ..Default::default() }); @@ -847,7 +847,7 @@ Bank,https://bank.example,bob,hunter2,\n"; 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].website.as_deref(), Some("https://mail.example")); + 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 } @@ -944,7 +944,7 @@ Bank,https://bank.example,bob,hunter2,\n"; username: Some("alice".into()), password: Some("s3cr3t".into()), totp_secret: Some("JBSWY3DPEHPK3PXP".into()), - website: Some("https://mail.example".into()), + websites: vec!["https://mail.example".into()], notes: Some("primary".into()), ..Default::default() }, @@ -968,7 +968,7 @@ Bank,https://bank.example,bob,hunter2,\n"; username: Some("alice".into()), password: Some("s3cr3t".into()), totp_secret: Some("JBSWY3DPEHPK3PXP".into()), - website: Some("https://mail.example".into()), + websites: vec!["https://mail.example".into()], notes: Some("primary".into()), ..Default::default() }]); @@ -1020,7 +1020,7 @@ Bank,https://bank.example,bob,hunter2,\n"; username: Some("alice".into()), password: Some("s3cr3t".into()), totp_secret: Some("JBSWY3DPEHPK3PXP".into()), - website: Some("https://mail.example".into()), + websites: vec!["https://mail.example".into()], notes: Some("primary".into()), ..Default::default() }]); @@ -1032,7 +1032,7 @@ Bank,https://bank.example,bob,hunter2,\n"; assert_eq!(l.title, "Email"); assert_eq!(l.username.as_deref(), Some("alice")); assert_eq!(l.password.as_deref(), Some("s3cr3t")); - assert_eq!(l.website.as_deref(), Some("https://mail.example")); + 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")); } diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 9ce866dbc..4bd075e9a 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -65,7 +65,7 @@ mod tests { username: Some("alice".into()), password: Some("s3cr3t-password".into()), totp_secret: None, - website: Some("https://mail.example".into()), + websites: vec!["https://mail.example".into()], passkeys: vec![], }], cards: vec![Card { @@ -168,6 +168,32 @@ mod tests { 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret diff --git a/rust/rust-code/lib/src/backup/model.rs b/rust/rust-code/lib/src/backup/model.rs index 52f6f3d13..00a65e8df 100644 --- a/rust/rust-code/lib/src/backup/model.rs +++ b/rust/rust-code/lib/src/backup/model.rs @@ -49,7 +49,10 @@ backup_item! { pub username: Option<String>, pub password: Option<String>, pub totp_secret: Option<String>, - pub website: Option<String>, + /// A login can be associated with several sites. `default` keeps pre-field backups - + /// including the frozen v1 goldens - readable. + #[serde(default)] + pub websites: Vec<String>, /// A login can hold several passkeys (one per RP). `default` keeps pre-field backups - /// including the frozen v1 goldens - readable. #[serde(default)] From 43172384a720aa7881cb5afd0fa3e714c70e0398 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 00:02:36 +0200 Subject: [PATCH 46/59] refactor(backup): move repository to repo package --- .../data/{ => repository}/DispatchedBackupRepositoryImpl.kt | 6 +++--- .../domain/{ => repository}/DispatchedBackupRepository.kt | 4 ++-- .../feature/backup/domain/usecase/CancelBackupUseCase.kt | 2 +- .../domain/usecase/ObserveDispatchedBackupsUseCase.kt | 2 +- .../keygo/feature/backup/FakeDispatchedBackupRepository.kt | 6 ++++-- 5 files changed, 11 insertions(+), 9 deletions(-) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/{ => repository}/DispatchedBackupRepositoryImpl.kt (84%) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/{ => repository}/DispatchedBackupRepository.kt (79%) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt similarity index 84% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt index 0578ca07e..cb04940b0 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/DispatchedBackupRepositoryImpl.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/data/repository/DispatchedBackupRepositoryImpl.kt @@ -1,9 +1,9 @@ -package de.davis.keygo.feature.backup.data +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.DispatchedBackupRepository 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 @@ -22,4 +22,4 @@ internal class DispatchedBackupRepositoryImpl( 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/domain/DispatchedBackupRepository.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt similarity index 79% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt index 5fe02b06c..8f5b353c8 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/DispatchedBackupRepository.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/repository/DispatchedBackupRepository.kt @@ -1,4 +1,4 @@ -package de.davis.keygo.feature.backup.domain +package de.davis.keygo.feature.backup.domain.repository import de.davis.keygo.feature.backup.domain.model.BackupWorkStatus import kotlinx.coroutines.flow.Flow @@ -6,4 +6,4 @@ import kotlinx.coroutines.flow.Flow interface DispatchedBackupRepository { fun observe(): Flow<List<BackupWorkStatus>> 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/CancelBackupUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/CancelBackupUseCase.kt index efa4b2360..37eb09049 100644 --- 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 @@ -1,9 +1,9 @@ package de.davis.keygo.feature.backup.domain.usecase -import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository 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 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 index f76ba3610..b0d9157c3 100644 --- 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 @@ -1,13 +1,13 @@ package de.davis.keygo.feature.backup.domain.usecase import de.davis.keygo.feature.backup.domain.BackupDestinationResolver -import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository 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 diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt index 389c7ce38..1d1a8de30 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt @@ -1,7 +1,7 @@ package de.davis.keygo.feature.backup -import de.davis.keygo.feature.backup.domain.DispatchedBackupRepository 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 @@ -11,5 +11,7 @@ class FakeDispatchedBackupRepository : DispatchedBackupRepository { val cancelledIds = mutableListOf<String>() override fun observe(): Flow<List<BackupWorkStatus>> = statuses.asStateFlow() - override suspend fun cancel(id: String) { cancelledIds += id } + override suspend fun cancel(id: String) { + cancelledIds += id + } } From 961a771bb0b04d13eef391145b915907ac9482d5 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 00:04:19 +0200 Subject: [PATCH 47/59] refactor(backup): drop interface --- .../domain/usecase/ObserveLastBackupUseCase.kt | 15 +++------------ .../usecase/ObserveLastBackupUseCaseTest.kt | 2 +- .../presentation/hub/BackupHubViewModelTest.kt | 4 ++-- 3 files changed, 6 insertions(+), 15 deletions(-) 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 index 1b4ba5d3e..9c042a898 100644 --- 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 @@ -7,21 +7,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single -/** - * An interface rather than a plain use case because other feature modules observe it to show backup - * health: the implementation's collaborators are module-internal, so an interface is what their - * tests can substitute. - */ -fun interface ObserveLastBackupUseCase { - operator fun invoke(): Flow<LastBackup?> -} - @Single -internal class ObserveLastBackupUseCaseImpl( +class ObserveLastBackupUseCase( private val jobRepository: BackupJobRepository, -) : ObserveLastBackupUseCase { +) { - override operator fun invoke(): Flow<LastBackup?> = + operator fun invoke(): Flow<LastBackup?> = jobRepository.observeJobs().map { jobs -> jobs.filter { it.lastResult == BackupResult.Success && it.finishedAt != null } .maxByOrNull { it.finishedAt!! } 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 index e6e443cb8..73231d90b 100644 --- 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 @@ -14,7 +14,7 @@ import kotlin.test.assertNull class ObserveLastBackupUseCaseTest { private val jobRepository = FakeBackupJobRepository() - private val useCase = ObserveLastBackupUseCaseImpl(jobRepository) + private val useCase = ObserveLastBackupUseCase(jobRepository) private fun job( uri: String, 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 index a6b248b0e..570367da9 100644 --- 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 @@ -17,7 +17,7 @@ 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.ObserveLastBackupUseCaseImpl +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 @@ -60,7 +60,7 @@ class BackupHubViewModelTest { private fun viewModel() = BackupHubViewModel( observeDispatchedBackups = ObserveDispatchedBackupsUseCase(repository, jobRepository, destinationResolver), - observeLastBackup = ObserveLastBackupUseCaseImpl(jobRepository), + observeLastBackup = ObserveLastBackupUseCase(jobRepository), cancelBackup = CancelBackupUseCase( repository = repository, jobRepository = jobRepository, From 2bf64038546b2b969e3f751a94f48b45d1e7c1ce Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 00:22:50 +0200 Subject: [PATCH 48/59] fix(codeql): move suppressing comments into standalone line --- rust/rust-code/lib/src/backup/encryption.rs | 33 ++++--- rust/rust-code/lib/src/backup/format/json.rs | 99 ++++++++++++------- rust/rust-code/lib/src/backup/key.rs | 27 +++-- .../rust-code/lib/src/crypto/keys/root_kek.rs | 21 ++-- .../lib/src/crypto/primitive/argon2.rs | 15 ++- .../lib/src/crypto/primitive/hkdf.rs | 12 ++- 6 files changed, 138 insertions(+), 69 deletions(-) diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs index 2d927ab48..2b1c3e12d 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -202,8 +202,10 @@ mod tests { #[test] fn backup_key_encrypts_and_decrypts_with_aad() { - let salt = [4u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let salt = [4u8; 16]; + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); let aad = BackupAad { version: CURRENT_VERSION, source: KeySource::Passphrase, @@ -215,8 +217,10 @@ mod tests { #[test] fn backup_key_decrypt_fails_with_different_aad() { - let salt = [4u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let salt = [4u8; 16]; + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); let aad = BackupAad { version: 1, source: KeySource::Passphrase, @@ -234,7 +238,8 @@ mod tests { #[test] fn seal_then_open_round_trips() { - let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let cred = BackupCredential::Passphrase(b"pw"); let sealed = seal(b"hello-bytes", cred, CURRENT_VERSION).unwrap(); let pt = open( &sealed.header, @@ -248,7 +253,8 @@ mod tests { #[test] fn open_rejects_wrong_nonce_length() { - let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let cred = BackupCredential::Passphrase(b"pw"); let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); sealed.header.nonce.push(0); let err = open( @@ -263,7 +269,8 @@ mod tests { #[test] fn ark_seal_records_hkdf_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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 { .. })); @@ -271,7 +278,8 @@ mod tests { #[test] fn ark_seal_then_open_round_trips() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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( @@ -286,7 +294,8 @@ mod tests { #[test] fn open_rejects_ark_source_with_argon2id_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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. @@ -303,7 +312,8 @@ mod tests { #[test] fn open_rejects_passphrase_source_with_hkdf_kdf() { - let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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( @@ -318,7 +328,8 @@ mod tests { #[test] fn open_rejects_argon2_params_exceeding_memory_limit() { - let cred = BackupCredential::Passphrase(b"pw"); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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 diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 4bd075e9a..3ab399c42 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -94,7 +94,8 @@ mod tests { // 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"; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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] @@ -133,8 +134,10 @@ mod tests { }, ]; - let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); let passkeys = &restored.vaults[0].logins[0].passkeys; assert_eq!(passkeys.len(), 2); @@ -146,8 +149,10 @@ mod tests { fn vault_icon_round_trips_through_an_encrypted_backup() { let original = sample_backup(); - let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); assert_eq!(restored.vaults[0].icon, "Work"); } @@ -185,8 +190,10 @@ mod tests { "https://mail.example.org".into(), ]; - let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); assert_eq!( restored.vaults[0].logins[0].websites, @@ -196,25 +203,30 @@ mod tests { #[test] fn version_below_minimum_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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] @@ -233,7 +245,8 @@ mod tests { #[test] fn ark_round_trip() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); @@ -242,8 +255,10 @@ mod tests { #[test] fn wrong_passphrase_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"right")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let err = import(&json, BackupCredential::Passphrase(b"wrong")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let json = export(&sample_backup(), BackupCredential::Passphrase(b"right")).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let err = import(&json, BackupCredential::Passphrase(b"wrong")).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -252,8 +267,10 @@ mod tests { #[test] fn wrong_ark_fails() { - let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let wrong = AccountRootKey::try_from_bytes(&[6u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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!( @@ -264,26 +281,31 @@ mod tests { #[test] fn empty_passphrase_is_rejected() { - let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -292,10 +314,12 @@ mod tests { #[test] fn tampered_header_salt_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::DecryptionFailed) @@ -311,7 +335,8 @@ mod tests { "encryption": null, "payload": { "vaults": [] }, }); - let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::Json(_))); } @@ -330,25 +355,30 @@ mod tests { #[test] fn unknown_version_fails() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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()); @@ -383,20 +413,23 @@ mod tests { #[test] fn inspect_reports_passphrase() { - let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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!( diff --git a/rust/rust-code/lib/src/backup/key.rs b/rust/rust-code/lib/src/backup/key.rs index 6bf39241e..4445645e1 100644 --- a/rust/rust-code/lib/src/backup/key.rs +++ b/rust/rust-code/lib/src/backup/key.rs @@ -35,28 +35,35 @@ mod tests { use crate::crypto::key::KeyMaterial; use crate::crypto::keys::AccountRootKey; - const SALT: &[u8] = &[3u8; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: &[u8] = &[3u8; 16]; #[test] fn passphrase_key_is_deterministic() { - let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let b = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); let b = - BackupKey::from_passphrase(b"hunter2", &[9u8; 16], Argon2Params::default()).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); let b = BackupKey::from_passphrase( - b"hunter2", // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + b"hunter2", SALT, Argon2Params { iters: Argon2Params::default().iters + 1, @@ -69,7 +76,8 @@ mod tests { #[test] fn ark_key_is_deterministic() { - let ark = AccountRootKey::try_from_bytes(&[7u8; 32]).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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()); @@ -77,7 +85,8 @@ mod tests { #[test] fn passphrase_and_ark_paths_are_domain_separated() { - let raw = [7u8; 32]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + 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(); diff --git a/rust/rust-code/lib/src/crypto/keys/root_kek.rs b/rust/rust-code/lib/src/crypto/keys/root_kek.rs index 7f6a233f3..94ef52c5a 100644 --- a/rust/rust-code/lib/src/crypto/keys/root_kek.rs +++ b/rust/rust-code/lib/src/crypto/keys/root_kek.rs @@ -28,27 +28,34 @@ impl<'a> TryDeriveFrom<&'a [u8]> for RootKEK { mod tests { use super::*; - const SALT: &[u8] = &[6; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: &[u8] = &[6; 16]; const DOMAIN: &[u8] = b"v1:kek/pwd"; #[test] fn same_credential_same_password_same_key() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); assert_eq!(a.as_bytes(), b.as_bytes()); } #[test] fn different_salt_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); assert_ne!(a.as_bytes(), b.as_bytes()); } #[test] fn different_domain_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); assert_ne!(a.as_bytes(), b.as_bytes()); } } diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index fc7ad0508..056c6cb6b 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -98,8 +98,10 @@ pub(crate) fn derive_argon2id_with_params( mod tests { use super::*; - const PW: &[u8] = b"correct horse battery staple"; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - const SALT: [u8; 16] = [0x11; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const PW: &[u8] = b"correct horse battery staple"; + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: [u8; 16] = [0x11; 16]; #[test] fn deterministic_for_same_inputs() { @@ -111,18 +113,21 @@ mod tests { #[test] fn different_salt_different_key() { let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); assert_ne!(a, b); } #[test] fn rejects_empty_password() { - assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); } #[test] fn rejects_short_salt() { - assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); } #[test] diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs index 3c934575a..8e660a076 100644 --- a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs +++ b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs @@ -32,8 +32,10 @@ pub(crate) fn derive_hkdf_sha256( mod tests { use super::*; - const IKM: &[u8] = &[7u8; 32]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret - const SALT: &[u8] = &[0x11; 16]; // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const IKM: &[u8] = &[7u8; 32]; + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + const SALT: &[u8] = &[0x11; 16]; #[test] fn deterministic_for_same_inputs() { @@ -45,7 +47,8 @@ mod tests { #[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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = derive_hkdf_sha256(IKM, &[0x22; 16], b"info").unwrap(); assert_ne!(a, b); } @@ -59,7 +62,8 @@ mod tests { #[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(); // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret + let b = derive_hkdf_sha256(&[8u8; 32], SALT, b"info").unwrap(); assert_ne!(a, b); } } From f61f05ce81752720a60420f2ff0801a54382c4ba Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 10:02:58 +0200 Subject: [PATCH 49/59] fix: place buttons below each other on large dpi --- .../backup/presentation/hub/BackupHubContent.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 index 007858581..920633eb7 100644 --- 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 @@ -6,8 +6,8 @@ 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -146,7 +146,11 @@ internal fun BackupHubContent(state: BackupHubUiState, onEvent: (BackupHubUiEven @Composable private fun HubActions(onExport: () -> Unit, onImport: () -> Unit) { val buttonSize = ButtonDefaults.MediumContainerHeight - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { val actionModifier = Modifier .heightIn(buttonSize) .weight(1f) @@ -188,7 +192,12 @@ private fun HubActionLabel(buttonSize: Dp, icon: ImageVector, label: String) { modifier = Modifier.size(ButtonDefaults.iconSizeFor(buttonSize)), ) Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(buttonSize))) - Text(text = label, style = ButtonDefaults.textStyleFor(buttonSize)) + Text( + text = label, + style = ButtonDefaults.textStyleFor(buttonSize), + softWrap = false, + maxLines = 1 + ) } @Composable From 0e549562c8eb6823298f88b8e994e04635223755 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 10:10:37 +0200 Subject: [PATCH 50/59] refactor: move to presentation package --- .../keygo/feature/backup/{ => presentation}/BackupInterval.kt | 3 ++- .../feature/backup/presentation/export/ReviewBackupContent.kt | 2 +- .../feature/backup/presentation/export/ScheduleComponents.kt | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) rename feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/{ => presentation}/BackupInterval.kt (90%) diff --git a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt similarity index 90% rename from feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt rename to feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt index 18c9049e8..2c8cdc3bc 100644 --- a/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/BackupInterval.kt +++ b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/presentation/BackupInterval.kt @@ -1,8 +1,9 @@ -package de.davis.keygo.feature.backup +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 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 index 0417d0eef..aa5f3c95d 100644 --- 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 @@ -54,7 +54,7 @@ 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.displayName as intervalDisplayName +import de.davis.keygo.feature.backup.presentation.displayName as intervalDisplayName @Composable internal fun ReviewBackupContent( 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 index 990c2e745..557a5a3c2 100644 --- 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 @@ -69,12 +69,12 @@ 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.displayName import de.davis.keygo.feature.backup.domain.model.BackupInterval import de.davis.keygo.feature.backup.domain.model.IntervalUnit -import de.davis.keygo.feature.backup.label +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 From 1c6bde51282087c1a8626bd7319cf49ca9aa4103 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 10:14:50 +0200 Subject: [PATCH 51/59] refactor: improve performance --- .../crypto/FakeCryptographicScopeProvider.kt | 5 ++- .../feature/backup/domain/BackupCollector.kt | 42 ++++++++++++------- .../domain/usecase/ExportBackupUseCase.kt | 12 +++--- .../backup/domain/BackupCollectorTest.kt | 33 ++++++++++++++- 4 files changed, 70 insertions(+), 22 deletions(-) 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<CallHistory>() + // Synchronized: BackupCollector calls encrypt/decrypt concurrently across items, and this + // list is written from whichever real thread each call lands on. + val callHistory: MutableList<CallHistory> = Collections.synchronizedList(mutableListOf()) val encryptCalls get() = callHistory.filterIsInstance<CallHistory.EncryptCall>() val rewrapCalls 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 index 5d958a579..f9734349e 100644 --- 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 @@ -21,8 +21,11 @@ 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 @@ -54,34 +57,45 @@ internal class BackupCollector( ): Result<CollectedBackup, ExportError> = resultBinding { val perVault = coroutineScope { vaultRepository.observeAllVaultMetadata().first().map { meta -> - val logins = async { loginRepository.getLoginsByVault(meta.vaultId) } - val cards = async { creditCardRepository.getCreditCardsByVault(meta.vaultId) } - VaultItems( - meta = meta, - logins = logins.await(), - cards = cards.await(), - ) - } + 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 : Item, R> I.export(map: suspend CryptographicScope.(I) -> R): R = scope.withItem(this, map) .bind { ExportError.CryptoFailed } - .also { onProgress(++processed, total) } + .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 = logins.map { login -> - val passkeys = passkeyRepository.getPasskeysByLogin(login.id) - login.export { it.toBackupLogin(passkeys) } - }, - cards = cards.map { it.export { card -> card.toBackupCard() } }, + logins = exportedLogins, + cards = exportedCards, ) } 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 index e8190ee86..35c10f43d 100644 --- 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 @@ -31,7 +31,7 @@ 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.flow +import kotlinx.coroutines.flow.channelFlow import org.koin.core.annotation.Single @Single @@ -44,11 +44,11 @@ internal class ExportBackupUseCase( private val arkUnlocker: BackupArkUnlocker, ) { - operator fun invoke(job: BackupJob): Flow<ExportProgress> = flow { + operator fun invoke(job: BackupJob): Flow<ExportProgress> = channelFlow { resultBinding { - val collected = collector.collect { p, t -> emit(ExportProgress.Running(p, t)) }.bind() + val collected = collector.collect { p, t -> send(ExportProgress.Running(p, t)) }.bind() - emit(ExportProgress.Writing) + send(ExportProgress.Writing) val serialized = serialize(job, collected.backup).bind() @@ -60,9 +60,9 @@ internal class ExportBackupUseCase( collected.itemCount }.onSuccess { count -> prune(job) - emit(ExportProgress.Succeeded(count)) + send(ExportProgress.Succeeded(count)) }.onFailure { failure -> - emit(ExportProgress.Failed(failure)) + send(ExportProgress.Failed(failure)) } } 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 index e17f7a708..7d1d4c920 100644 --- 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 @@ -20,8 +20,11 @@ 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 @@ -179,7 +182,10 @@ class BackupCollectorTest { 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")) + loginRepo.seed( + testLogin(vaultId = vault.id, name = "L1"), + testLogin(vaultId = vault.id, name = "L2") + ) val seen = mutableListOf<Pair<Int, Int>>() val result = collector().collect { processed, total -> seen += processed to total } @@ -188,6 +194,31 @@ class BackupCollectorTest { 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<Int>() + + val result = collector().collect { processed, _ -> seen += processed } + + assertIs<Result.Success<*, *>>(result) + assertEquals((1..total).toList(), seen.toList()) + } + } + @Test fun `crypto scope failure surfaces CryptoFailed`() = runTest { val vault = testVault(name = "V") From cd23efedc2a81a8f964eec0e33fc1cfb869c419e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 10:37:21 +0200 Subject: [PATCH 52/59] refactor: move fakes --- feature/backup/build.gradle.kts | 12 ++++++++++++ .../feature/backup/FakeBackupArkKeyStore.kt | 0 .../feature/backup/FakeBackupFileStore.kt | 0 .../feature/backup/FakeBackupJobRepository.kt | 0 .../feature/backup/FakeBackupScheduler.kt | 0 .../backup/FakeDispatchedBackupRepository.kt | 0 .../backup/FakePersistableUriManager.kt | 0 .../data/FakeBackupDestinationResolver.kt | 0 .../legacy_data/data/LegacyV1Encryption.kt | 18 ++++++++++++++++++ 9 files changed, 30 insertions(+) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt (100%) rename feature/backup/src/{test => testFixtures}/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt (100%) create mode 100644 migration/legacy-data/src/testFixtures/kotlin/de/davis/keygo/migration/legacy_data/data/LegacyV1Encryption.kt diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index 0285564a8..7e48e0c01 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -10,6 +10,10 @@ android { defaultConfig { missingDimensionStrategy("store", "playStore") } + + testFixtures { + enable = true + } } dependencies { @@ -30,4 +34,12 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.rust)) + + 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/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupArkKeyStore.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupFileStore.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupJobRepository.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeBackupScheduler.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakeDispatchedBackupRepository.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/FakePersistableUriManager.kt diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt b/feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt similarity index 100% rename from feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt rename to feature/backup/src/testFixtures/kotlin/de/davis/keygo/feature/backup/data/FakeBackupDestinationResolver.kt 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) +} From f119202c42c29f10447ba0334078b2b4dbf3e747 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 10:56:58 +0200 Subject: [PATCH 53/59] refactor: revert suppression comments --- rust/rust-code/lib/src/backup/encryption.rs | 59 ++-------------- rust/rust-code/lib/src/backup/format/csv.rs | 5 +- rust/rust-code/lib/src/backup/format/json.rs | 67 ++++++++----------- rust/rust-code/lib/src/backup/key.rs | 9 --- .../rust-code/lib/src/crypto/keys/root_kek.rs | 7 -- .../lib/src/crypto/primitive/argon2.rs | 5 -- .../lib/src/crypto/primitive/hkdf.rs | 4 -- 7 files changed, 38 insertions(+), 118 deletions(-) diff --git a/rust/rust-code/lib/src/backup/encryption.rs b/rust/rust-code/lib/src/backup/encryption.rs index 2b1c3e12d..26c54e09f 100644 --- a/rust/rust-code/lib/src/backup/encryption.rs +++ b/rust/rust-code/lib/src/backup/encryption.rs @@ -202,9 +202,7 @@ mod tests { #[test] fn backup_key_encrypts_and_decrypts_with_aad() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let salt = [4u8; 16]; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); let aad = BackupAad { version: CURRENT_VERSION, @@ -217,9 +215,7 @@ mod tests { #[test] fn backup_key_decrypt_fails_with_different_aad() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let salt = [4u8; 16]; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let key = BackupKey::from_passphrase(b"pw", &salt, Argon2Params::default()).unwrap(); let aad = BackupAad { version: 1, @@ -238,38 +234,23 @@ mod tests { #[test] fn seal_then_open_round_trips() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let pt = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); assert_eq!(pt, b"hello-bytes"); } #[test] fn open_rejects_wrong_nonce_length() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); assert!(matches!(err, BackupError::MalformedHeader)); } #[test] fn ark_seal_records_hkdf_kdf() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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)); @@ -278,57 +259,35 @@ mod tests { #[test] fn ark_seal_then_open_round_trips() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + 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() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + 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() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + 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() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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 @@ -337,13 +296,7 @@ mod tests { 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(); + 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/format/csv.rs b/rust/rust-code/lib/src/backup/format/csv.rs index 6c8ce3fcb..20cea90e9 100644 --- a/rust/rust-code/lib/src/backup/format/csv.rs +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -847,7 +847,10 @@ Bank,https://bank.example,bob,hunter2,\n"; 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].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 } diff --git a/rust/rust-code/lib/src/backup/format/json.rs b/rust/rust-code/lib/src/backup/format/json.rs index 3ab399c42..2c427b4fa 100644 --- a/rust/rust-code/lib/src/backup/format/json.rs +++ b/rust/rust-code/lib/src/backup/format/json.rs @@ -94,13 +94,16 @@ mod tests { // 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. - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); + 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]; @@ -134,9 +137,7 @@ mod tests { }, ]; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); let passkeys = &restored.vaults[0].logins[0].passkeys; @@ -149,9 +150,7 @@ mod tests { fn vault_icon_round_trips_through_an_encrypted_backup() { let original = sample_backup(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); assert_eq!(restored.vaults[0].icon, "Work"); @@ -161,7 +160,11 @@ mod tests { 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(); + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); assert!(backup.vaults[0].icon.is_empty()); } @@ -169,7 +172,11 @@ mod tests { 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(); + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); assert!(backup.vaults[0].logins[0].passkeys.is_empty()); } @@ -178,7 +185,11 @@ mod tests { // 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(); + let backup = import( + GOLDEN_V1, + BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), + ) + .unwrap(); assert!(backup.vaults[0].logins[0].websites.is_empty()); } @@ -190,24 +201,23 @@ mod tests { "https://mail.example.org".into(), ]; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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()], + vec![ + "https://mail.example".to_string(), + "https://mail.example.org".to_string() + ], ); } #[test] fn version_below_minimum_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::UnsupportedVersion(0))); } @@ -215,9 +225,7 @@ mod tests { #[test] fn passphrase_round_trip() { let original = sample_backup(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&original, BackupCredential::Passphrase(b"pw")).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let restored = import(&json, BackupCredential::Passphrase(b"pw")).unwrap(); json_eq(&restored, &original); } @@ -225,7 +233,6 @@ mod tests { // 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. - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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"}"#; @@ -245,7 +252,6 @@ mod tests { #[test] fn ark_round_trip() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); let original = sample_backup(); let json = export(&original, BackupCredential::Ark(&ark)).unwrap(); @@ -255,9 +261,7 @@ mod tests { #[test] fn wrong_passphrase_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&sample_backup(), BackupCredential::Passphrase(b"right")).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&json, BackupCredential::Passphrase(b"wrong")).unwrap_err(); assert!(matches!( err, @@ -267,9 +271,7 @@ mod tests { #[test] fn wrong_ark_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); @@ -281,30 +283,25 @@ mod tests { #[test] fn empty_passphrase_is_rejected() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)))); } #[test] fn credential_source_mismatch_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&json, BackupCredential::Passphrase(b"x")).unwrap_err(); assert!(matches!(err, BackupError::CredentialMismatch)); } #[test] fn tampered_ciphertext_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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)); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, @@ -314,11 +311,9 @@ mod tests { #[test] fn tampered_header_salt_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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])); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!( err, @@ -335,7 +330,6 @@ mod tests { "encryption": null, "payload": { "vaults": [] }, }); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::Json(_))); } @@ -355,29 +349,24 @@ mod tests { #[test] fn unknown_version_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::UnsupportedVersion(2))); } #[test] fn malformed_base64_payload_fails() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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!!!"); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let err = import(&v.to_string(), BackupCredential::Passphrase(b"pw")).unwrap_err(); assert!(matches!(err, BackupError::Base64)); } #[test] fn encrypted_envelope_hides_plaintext() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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")); @@ -413,14 +402,12 @@ mod tests { #[test] fn inspect_reports_passphrase() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let json = export(&sample_backup(), BackupCredential::Passphrase(b"pw")).unwrap(); assert!(matches!(inspect(&json).unwrap(), KeySource::Passphrase)); } #[test] fn inspect_reports_ark() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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)); @@ -428,7 +415,6 @@ mod tests { #[test] fn inspect_rejects_unsupported_version() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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); @@ -440,6 +426,9 @@ mod tests { #[test] fn inspect_rejects_malformed_json() { - assert!(matches!(inspect("not json").unwrap_err(), BackupError::Json(_))); + assert!(matches!( + inspect("not json").unwrap_err(), + BackupError::Json(_) + )); } } diff --git a/rust/rust-code/lib/src/backup/key.rs b/rust/rust-code/lib/src/backup/key.rs index 4445645e1..1f4ae465e 100644 --- a/rust/rust-code/lib/src/backup/key.rs +++ b/rust/rust-code/lib/src/backup/key.rs @@ -35,34 +35,27 @@ mod tests { use crate::crypto::key::KeyMaterial; use crate::crypto::keys::AccountRootKey; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const SALT: &[u8] = &[3u8; 16]; #[test] fn passphrase_key_is_deterministic() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); let b = - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = BackupKey::from_passphrase(b"hunter2", SALT, Argon2Params::default()).unwrap(); let b = BackupKey::from_passphrase( - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret b"hunter2", SALT, Argon2Params { @@ -76,7 +69,6 @@ mod tests { #[test] fn ark_key_is_deterministic() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret 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(); @@ -85,7 +77,6 @@ mod tests { #[test] fn passphrase_and_ark_paths_are_domain_separated() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let raw = [7u8; 32]; let ark = AccountRootKey::try_from_bytes(&raw).unwrap(); let pass = BackupKey::from_passphrase(&raw, SALT, Argon2Params::default()).unwrap(); diff --git a/rust/rust-code/lib/src/crypto/keys/root_kek.rs b/rust/rust-code/lib/src/crypto/keys/root_kek.rs index 94ef52c5a..e9773f048 100644 --- a/rust/rust-code/lib/src/crypto/keys/root_kek.rs +++ b/rust/rust-code/lib/src/crypto/keys/root_kek.rs @@ -28,33 +28,26 @@ impl<'a> TryDeriveFrom<&'a [u8]> for RootKEK { mod tests { use super::*; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const SALT: &[u8] = &[6; 16]; const DOMAIN: &[u8] = b"v1:kek/pwd"; #[test] fn same_credential_same_password_same_key() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); assert_eq!(a.as_bytes(), b.as_bytes()); } #[test] fn different_salt_different_keys() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); assert_ne!(a.as_bytes(), b.as_bytes()); } #[test] fn different_domain_different_keys() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); assert_ne!(a.as_bytes(), b.as_bytes()); } diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs index 056c6cb6b..d52da3b67 100644 --- a/rust/rust-code/lib/src/crypto/primitive/argon2.rs +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -98,9 +98,7 @@ pub(crate) fn derive_argon2id_with_params( mod tests { use super::*; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const PW: &[u8] = b"correct horse battery staple"; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const SALT: [u8; 16] = [0x11; 16]; #[test] @@ -113,20 +111,17 @@ mod tests { #[test] fn different_salt_different_key() { let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); assert_ne!(a, b); } #[test] fn rejects_empty_password() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); } #[test] fn rejects_short_salt() { - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); } diff --git a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs index 8e660a076..ebe6856ed 100644 --- a/rust/rust-code/lib/src/crypto/primitive/hkdf.rs +++ b/rust/rust-code/lib/src/crypto/primitive/hkdf.rs @@ -32,9 +32,7 @@ pub(crate) fn derive_hkdf_sha256( mod tests { use super::*; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const IKM: &[u8] = &[7u8; 32]; - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret const SALT: &[u8] = &[0x11; 16]; #[test] @@ -47,7 +45,6 @@ mod tests { #[test] fn different_salt_different_key() { let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = derive_hkdf_sha256(IKM, &[0x22; 16], b"info").unwrap(); assert_ne!(a, b); } @@ -62,7 +59,6 @@ mod tests { #[test] fn different_ikm_different_key() { let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - // codeql[rust/hard-coded-cryptographic-value] test-only value, not a real secret let b = derive_hkdf_sha256(&[8u8; 32], SALT, b"info").unwrap(); assert_ne!(a, b); } From 49dc8e726a515e66e586b476c5eca55aaeffda48 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 12:01:46 +0200 Subject: [PATCH 54/59] fix: resolve backup destinations without a live folder grant --- feature/backup/build.gradle.kts | 1 + .../data/BackupDestinationResolverImpl.kt | 43 +++++--- .../backup/data/mapper/BackupJobMapper.kt | 2 + .../domain/BackupDestinationResolver.kt | 9 +- .../feature/backup/domain/model/BackupJob.kt | 7 ++ .../usecase/FinishExportWizardUseCase.kt | 23 +++-- .../ObserveDispatchedBackupsUseCase.kt | 2 +- .../presentation/hub/BackupHubContent.kt | 12 ++- .../hub/DispatchedBackupDisplay.kt | 11 +++ .../backup/src/main/proto/backup_jobs.proto | 5 + .../data/BackupDestinationResolverImplTest.kt | 97 +++++++++++++++++++ .../backup/data/mapper/BackupJobMapperTest.kt | 22 +++++ .../BackupProvisioningSerializationTest.kt | 2 + .../usecase/FinishExportWizardUseCaseTest.kt | 27 ++++++ .../ObserveDispatchedBackupsUseCaseTest.kt | 15 +++ .../data/FakeBackupDestinationResolver.kt | 7 +- 16 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/data/BackupDestinationResolverImplTest.kt diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index 7e48e0c01..c235638aa 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { 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) 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 index 3b9707033..d7481a5d0 100644 --- 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 @@ -18,23 +18,27 @@ internal class BackupDestinationResolverImpl( override suspend fun resolve( uri: BackupDestinationUri, + cachedName: String?, ): BackupDestination = withContext(Dispatchers.IO) { val parsed = uri.value.toUri() - if (DocumentsContract.isTreeUri(parsed)) parsed.asFolderDestination() - else parsed.asFileDestination() + if (DocumentsContract.isTreeUri(parsed)) parsed.asFolderDestination(cachedName) + else parsed.asFileDestination(cachedName) } - private fun Uri.asFolderDestination() = BackupDestination( + // 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 = treeDisplayPath(), + displayPath = cachedName ?: treeDisplayPath(), fileName = null, ) - private fun Uri.asFileDestination() = BackupDestination( + private fun Uri.asFileDestination(cachedName: String?) = BackupDestination( provider = providerLabel(), displayPath = documentDisplayPath(), - fileName = queryDisplayName(), + fileName = cachedName ?: queryDisplayName(), ) private fun Uri.providerLabel(): BackupDestination.Provider { @@ -86,15 +90,26 @@ internal class BackupDestinationResolverImpl( 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? = - context.contentResolver.query( - this, - arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), - null, - null, - null, - )?.use { cursor -> - if (cursor.moveToFirst()) cursor.getString(0) else null + 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 { 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 index 9ba87dad4..9bd2454b7 100644 --- 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 @@ -40,6 +40,7 @@ internal fun ProtoBackupJob.toDomain(): BackupJob { errorName = if (hasLastError()) lastError else null, ), cancelled = this.cancelled, + destinationName = if (hasDestinationName()) destinationName else null, ) } @@ -57,6 +58,7 @@ internal fun BackupJob.toProto() = protoBackupJob { 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 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 index 269c317d4..8fb579656 100644 --- 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 @@ -5,5 +5,12 @@ import de.davis.keygo.feature.backup.domain.model.BackupDestinationUri interface BackupDestinationResolver { - suspend fun resolve(uri: BackupDestinationUri): BackupDestination + /** + * @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/model/BackupJob.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/model/BackupJob.kt index 42bc05374..7a692300c 100644 --- 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 @@ -17,4 +17,11 @@ data class BackupJob( /** 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/usecase/FinishExportWizardUseCase.kt b/feature/backup/src/main/kotlin/de/davis/keygo/feature/backup/domain/usecase/FinishExportWizardUseCase.kt index 54ac69159..ed5e72cbb 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -25,6 +26,7 @@ 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, @@ -48,6 +50,19 @@ class FinishExportWizardUseCase( 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, @@ -55,15 +70,9 @@ class FinishExportWizardUseCase( encryption = details.encryption, csvPreset = details.csvPreset, keepCount = details.keepCount, + destinationName = destination.displayPath, ) - // 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() - provisionBackupArk().bind() when (val interval = details.interval) { 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 index b0d9157c3..a823717d0 100644 --- 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 @@ -38,7 +38,7 @@ internal class ObserveDispatchedBackupsUseCase( kind = kind, state = state, format = job?.format, - destination = job?.let { destinationResolver.resolve(it.uri) }, + 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 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 index 920633eb7..dca1b0ae4 100644 --- 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 @@ -254,7 +254,17 @@ private fun DispatchedBackupRow( ) } }, - overlineContent = { Text(text = item.kind.label) }, + // 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( 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 index 9964d8813..0f325aa70 100644 --- 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 @@ -59,6 +59,17 @@ internal val BackupFailureReason.label: String 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) diff --git a/feature/backup/src/main/proto/backup_jobs.proto b/feature/backup/src/main/proto/backup_jobs.proto index 648392bf0..ff492792c 100644 --- a/feature/backup/src/main/proto/backup_jobs.proto +++ b/feature/backup/src/main/proto/backup_jobs.proto @@ -27,6 +27,11 @@ message ProtoBackupJob { // 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 { 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<out String>?, + selection: String?, + selectionArgs: Array<out String>?, + sortOrder: String?, + ): Cursor = throw SecurityException("Permission Denial: reading $uri") + } + + private class NamingProvider : StubProvider() { + override fun query( + uri: Uri, + projection: Array<out String>?, + selection: String?, + selectionArgs: Array<out String>?, + 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<out String>?) = 0 + override fun update(uri: Uri, v: ContentValues?, s: String?, a: Array<out String>?) = 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 index 3d46ffeeb..b90a613ab 100644 --- 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 @@ -47,6 +47,28 @@ class BackupJobMapperTest { 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 { 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 index e0bef283f..b8d7f6f0a 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -51,6 +52,7 @@ class BackupProvisioningSerializationTest { gate = gate, oneTimeWorkId = "B", ), + destinationResolver = FakeBackupDestinationResolver(), keyStoreManager = keyStoreManager, persistableUriManager = uriManager, session = session, 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 index 51245ccef..bfeebab9d 100644 --- 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 @@ -8,7 +8,9 @@ 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 @@ -33,9 +35,11 @@ class FinishExportWizardUseCaseTest { 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, @@ -86,6 +90,29 @@ class FinishExportWizardUseCaseTest { 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") 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 index cd05340ec..856611bb6 100644 --- 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 @@ -53,6 +53,21 @@ class ObserveDispatchedBackupsUseCaseTest { 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( 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 index 783dc9c78..7cae396c4 100644 --- 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 @@ -12,9 +12,14 @@ class FakeBackupDestinationResolver( ) : BackupDestinationResolver { var lastUri: BackupDestinationUri? = null + var lastCachedName: String? = null - override suspend fun resolve(uri: BackupDestinationUri): BackupDestination { + override suspend fun resolve( + uri: BackupDestinationUri, + cachedName: String?, + ): BackupDestination { lastUri = uri + lastCachedName = cachedName return result } } From 97e171129f50d4355c5d8fa1565d38335c9534de Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 12:27:20 +0200 Subject: [PATCH 55/59] fix(rust): clippy --- rust/rust-code/lib/src/backup/format/csv.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/rust-code/lib/src/backup/format/csv.rs b/rust/rust-code/lib/src/backup/format/csv.rs index 20cea90e9..d1aaaf279 100644 --- a/rust/rust-code/lib/src/backup/format/csv.rs +++ b/rust/rust-code/lib/src/backup/format/csv.rs @@ -155,7 +155,7 @@ impl ExportPreset { } } -const DELIMITERS: [u8; 4] = [b',', b';', b'\t', b'|']; +const DELIMITERS: [u8; 4] = *b",;\t|"; /// Strip a leading UTF-8 BOM, if present. fn strip_bom(data: &str) -> &str { From 884f04f47d11e28c5021dd8df458be887a647344 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 12:27:37 +0200 Subject: [PATCH 56/59] chore(rust): update dependencies --- rust/rust-code/Cargo.lock | 380 +++++++++----------------------------- 1 file changed, 91 insertions(+), 289 deletions(-) diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock index c6f72a718..6e9927d59 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" @@ -76,20 +76,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ - "askama_derive 0.14.0", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" -dependencies = [ - "askama_macros", + "askama_derive", "itoa", "percent-encoding", "serde", @@ -102,26 +89,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ - "askama_parser 0.14.0", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.119", -] - -[[package]] -name = "askama_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" -dependencies = [ - "askama_parser 0.16.0", + "askama_parser", "basic-toml", - "glob", "memchr", "proc-macro2", "quote", @@ -131,15 +100,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "askama_macros" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" -dependencies = [ - "askama_derive 0.16.0", -] - [[package]] name = "askama_parser" version = "0.14.0" @@ -152,19 +112,6 @@ dependencies = [ "winnow 0.7.15", ] -[[package]] -name = "askama_parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" -dependencies = [ - "rustc-hash", - "serde", - "serde_derive", - "unicode-ident", - "winnow 1.0.4", -] - [[package]] name = "async-compat" version = "0.2.5" @@ -180,13 +127,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]] @@ -284,9 +231,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", ] @@ -300,16 +247,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cargo-platform" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "cargo_metadata" version = "0.19.2" @@ -317,21 +254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform 0.1.9", - "semver", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform 0.3.3", + "cargo-platform", "semver", "serde", "serde_json", @@ -395,9 +318,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", @@ -405,9 +328,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", @@ -416,14 +339,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]] @@ -637,13 +560,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]] @@ -692,9 +615,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" @@ -746,9 +669,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" @@ -802,15 +725,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "fs-err" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" -dependencies = [ - "autocfg", -] - [[package]] name = "futures-core" version = "0.3.33" @@ -879,9 +793,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" @@ -948,9 +862,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", ] @@ -1122,8 +1036,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "uniffi 0.31.2", - "uniffi 0.32.0", + "uniffi", "uuid", ] @@ -1166,9 +1079,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" @@ -1463,9 +1376,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", ] @@ -1478,9 +1391,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", ] @@ -1664,9 +1577,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", @@ -1674,29 +1587,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", @@ -1878,6 +1791,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" @@ -1919,22 +1843,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]] @@ -1949,9 +1873,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", ] @@ -1982,9 +1906,9 @@ dependencies = [ [[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", ] @@ -2032,27 +1956,13 @@ checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" dependencies = [ "anyhow", "camino", - "cargo_metadata 0.19.2", + "cargo_metadata", "clap", - "uniffi_bindgen 0.31.2", - "uniffi_core 0.31.2", - "uniffi_macros 0.31.2", - "uniffi_pipeline 0.31.2", -] - -[[package]] -name = "uniffi" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" -dependencies = [ - "anyhow", - "cargo_metadata 0.23.1", - "uniffi_bindgen 0.32.0", + "uniffi_bindgen", "uniffi_build", - "uniffi_core 0.32.0", - "uniffi_macros 0.32.0", - "uniffi_pipeline 0.32.0", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", ] [[package]] @@ -2062,36 +1972,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" dependencies = [ "anyhow", - "askama 0.14.0", - "camino", - "cargo_metadata 0.19.2", - "fs-err 2.11.0", - "glob", - "goblin", - "heck", - "indexmap", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml", - "uniffi_internal_macros 0.31.2", - "uniffi_meta 0.31.2", - "uniffi_pipeline 0.31.2", - "uniffi_udl 0.31.2", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" -dependencies = [ - "anyhow", - "askama 0.16.0", + "askama", "camino", - "cargo_metadata 0.23.1", - "fs-err 3.3.1", + "cargo_metadata", + "fs-err", "glob", "goblin", "heck", @@ -2101,21 +1985,21 @@ dependencies = [ "tempfile", "textwrap", "toml", - "uniffi_internal_macros 0.32.0", - "uniffi_meta 0.32.0", - "uniffi_pipeline 0.32.0", - "uniffi_udl 0.32.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", ] [[package]] name = "uniffi_build" -version = "0.32.0" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a19ad720fce8c9a98576e04c0ccc5d2d155aa6b953c864587073292c7ccfc" +checksum = "744fe15bcd3e2b1712a4573a45ce749af19cf28d69027ca5789619014955668c" dependencies = [ "anyhow", "camino", - "uniffi_bindgen 0.32.0", + "uniffi_bindgen", ] [[package]] @@ -2131,18 +2015,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "uniffi_core" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" -dependencies = [ - "anyhow", - "bytes", - "once_cell", - "static_assertions", -] - [[package]] name = "uniffi_internal_macros" version = "0.31.2" @@ -2156,19 +2028,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "uniffi_internal_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" -dependencies = [ - "anyhow", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "uniffi_macros" version = "0.31.2" @@ -2176,31 +2035,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" dependencies = [ "camino", - "fs-err 2.11.0", + "fs-err", "once_cell", "proc-macro2", "quote", "serde", "syn 2.0.119", "toml", - "uniffi_meta 0.31.2", -] - -[[package]] -name = "uniffi_macros" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" -dependencies = [ - "camino", - "fs-err 3.3.1", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", - "toml", - "uniffi_meta 0.32.0", + "uniffi_meta", ] [[package]] @@ -2211,20 +2053,8 @@ checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" dependencies = [ "anyhow", "siphasher", - "uniffi_internal_macros 0.31.2", - "uniffi_pipeline 0.31.2", -] - -[[package]] -name = "uniffi_meta" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" -dependencies = [ - "anyhow", - "siphasher", - "uniffi_internal_macros 0.32.0", - "uniffi_pipeline 0.32.0", + "uniffi_internal_macros", + "uniffi_pipeline", ] [[package]] @@ -2237,20 +2067,7 @@ dependencies = [ "heck", "indexmap", "tempfile", - "uniffi_internal_macros 0.31.2", -] - -[[package]] -name = "uniffi_pipeline" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "tempfile", - "uniffi_internal_macros 0.32.0", + "uniffi_internal_macros", ] [[package]] @@ -2261,19 +2078,7 @@ checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" dependencies = [ "anyhow", "textwrap", - "uniffi_meta 0.31.2", - "weedle2", -] - -[[package]] -name = "uniffi_udl" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta 0.32.0", + "uniffi_meta", "weedle2", ] @@ -2419,9 +2224,6 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] [[package]] name = "writeable" @@ -2454,18 +2256,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", From 272cd76ec38c659bbf542c8d01c3b3bfdc6c5ed9 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 13:07:55 +0200 Subject: [PATCH 57/59] fix: merge --- .../keygo/feature/settings/presentation/SettingsViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a666f6cc3..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 @@ -42,7 +43,8 @@ internal class SettingsViewModel( accountRepository.observe(), autofillEnabled, biometricsAvailable, - ) { account, autofill, biometrics -> + observeLastBackup(), + ) { account, autofill, biometrics, lastBackup -> SettingsUiState( autofillEnabled = autofill, biometricsAvailable = biometrics, From 83affeb6cd82a9f61bee062d89b8826a00202e53 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 13:13:07 +0200 Subject: [PATCH 58/59] chore(rust): update dependencies --- rust/rust-code/Cargo.lock | 289 ++++++++++++++++++++++++++++++++++---- 1 file changed, 261 insertions(+), 28 deletions(-) diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock index 6e9927d59..90040b104 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/rust-code/Cargo.lock @@ -76,7 +76,20 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ - "askama_derive", + "askama_derive 0.14.0", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", "itoa", "percent-encoding", "serde", @@ -89,8 +102,26 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ - "askama_parser", + "askama_parser 0.14.0", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser 0.16.0", "basic-toml", + "glob", "memchr", "proc-macro2", "quote", @@ -100,6 +131,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive 0.16.0", +] + [[package]] name = "askama_parser" version = "0.14.0" @@ -112,6 +152,19 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.4", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -247,6 +300,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.19.2" @@ -254,7 +317,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", @@ -725,6 +802,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -1036,7 +1122,8 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "uniffi", + "uniffi 0.31.2", + "uniffi 0.32.0", "uuid", ] @@ -1889,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" @@ -1904,6 +2006,15 @@ 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.3+spec-1.1.0" @@ -1956,13 +2067,27 @@ checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" dependencies = [ "anyhow", "camino", - "cargo_metadata", + "cargo_metadata 0.19.2", "clap", - "uniffi_bindgen", + "uniffi_bindgen 0.31.2", + "uniffi_core 0.31.2", + "uniffi_macros 0.31.2", + "uniffi_pipeline 0.31.2", +] + +[[package]] +name = "uniffi" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" +dependencies = [ + "anyhow", + "cargo_metadata 0.23.1", + "uniffi_bindgen 0.32.0", "uniffi_build", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", + "uniffi_core 0.32.0", + "uniffi_macros 0.32.0", + "uniffi_pipeline 0.32.0", ] [[package]] @@ -1972,10 +2097,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" dependencies = [ "anyhow", - "askama", + "askama 0.14.0", + "camino", + "cargo_metadata 0.19.2", + "fs-err 2.11.0", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.9.12+spec-1.1.0", + "uniffi_internal_macros 0.31.2", + "uniffi_meta 0.31.2", + "uniffi_pipeline 0.31.2", + "uniffi_udl 0.31.2", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" +dependencies = [ + "anyhow", + "askama 0.16.0", "camino", - "cargo_metadata", - "fs-err", + "cargo_metadata 0.23.1", + "fs-err 3.3.1", "glob", "goblin", "heck", @@ -1984,22 +2135,22 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", + "toml 1.1.4+spec-1.1.0", + "uniffi_internal_macros 0.32.0", + "uniffi_meta 0.32.0", + "uniffi_pipeline 0.32.0", + "uniffi_udl 0.32.0", ] [[package]] name = "uniffi_build" -version = "0.31.2" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744fe15bcd3e2b1712a4573a45ce749af19cf28d69027ca5789619014955668c" +checksum = "763a19ad720fce8c9a98576e04c0ccc5d2d155aa6b953c864587073292c7ccfc" dependencies = [ "anyhow", "camino", - "uniffi_bindgen", + "uniffi_bindgen 0.32.0", ] [[package]] @@ -2015,6 +2166,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "uniffi_core" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + [[package]] name = "uniffi_internal_macros" version = "0.31.2" @@ -2028,6 +2191,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "uniffi_internal_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "uniffi_macros" version = "0.31.2" @@ -2035,14 +2211,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" dependencies = [ "camino", - "fs-err", + "fs-err 2.11.0", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml 0.9.12+spec-1.1.0", + "uniffi_meta 0.31.2", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" +dependencies = [ + "camino", + "fs-err 3.3.1", "once_cell", "proc-macro2", "quote", "serde", "syn 2.0.119", - "toml", - "uniffi_meta", + "toml 1.1.4+spec-1.1.0", + "uniffi_meta 0.32.0", ] [[package]] @@ -2053,8 +2246,20 @@ checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" dependencies = [ "anyhow", "siphasher", - "uniffi_internal_macros", - "uniffi_pipeline", + "uniffi_internal_macros 0.31.2", + "uniffi_pipeline 0.31.2", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros 0.32.0", + "uniffi_pipeline 0.32.0", ] [[package]] @@ -2067,7 +2272,20 @@ dependencies = [ "heck", "indexmap", "tempfile", - "uniffi_internal_macros", + "uniffi_internal_macros 0.31.2", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros 0.32.0", ] [[package]] @@ -2078,7 +2296,19 @@ checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" dependencies = [ "anyhow", "textwrap", - "uniffi_meta", + "uniffi_meta 0.31.2", + "weedle2", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta 0.32.0", "weedle2", ] @@ -2224,6 +2454,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "writeable" From 5cc3e45f29649f84daa3ec8a44d21022a2b99961 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann <offrange.developer@gmail.com> Date: Sat, 1 Aug 2026 13:48:58 +0200 Subject: [PATCH 59/59] fix(settings): fix SettingsViewModelTest compile error after ObserveLastBackupUseCase change The test constructed ObserveLastBackupUseCase with a trailing lambda as if it were a functional interface, but it's a plain class taking a BackupJobRepository. Use the existing FakeBackupJobRepository testFixture instead, mirroring the pattern already used by feature/backup's own ObserveLastBackupUseCaseTest. Also adds the missing testImplementation(testFixtures(projects.feature.backup)) dependency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- feature/settings/build.gradle.kts | 1 + .../presentation/SettingsViewModelTest.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 597747a71..81ee6bc56 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -28,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/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 f4dde32ab..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,11 +4,14 @@ 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.domain.model.LastBackup +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.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -31,7 +34,7 @@ class SettingsViewModelTest { private val biometricAvailability = FakeBiometricAvailabilityRepository() private val autofillServiceRepository = FakeAutofillServiceRepository() private val appVersionRepository = FakeAppVersionRepository() - private val lastBackup = MutableStateFlow<LastBackup?>(null) + private val backupJobRepository = FakeBackupJobRepository() @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) @@ -44,7 +47,7 @@ class SettingsViewModelTest { autofillServiceRepository = autofillServiceRepository, accountRepository = accountRepository, appVersionRepository = appVersionRepository, - observeLastBackup = ObserveLastBackupUseCase { lastBackup }, + observeLastBackup = ObserveLastBackupUseCase(backupJobRepository), ) @Test @@ -110,7 +113,13 @@ class SettingsViewModelTest { @Test fun `state carries the newest successful backup timestamp`() = runTest(dispatcher) { - lastBackup.value = LastBackup(finishedAt = 1_700_000_000_000L) + 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)