501a671f57b5

add Amazon passkey-only entry to seedDemo() for import/export round-trip coverage; fix pre-vault PeachScript/CSV restore silently reporting 0 entries imported by having confirmImport create the vault (with a new master password collected in the preview step) before adding entries when unlocked

time
2026-07-24 16:46 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
verify passkey import + fix pre-vault restore
parent
cc9b66d963a4
3 files app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+10 −1app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+92 −10app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+19 −0
app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+10 −1
@@ -218,7 +218,16 @@218218             VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })219219         }220220         composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }221-        composable(PeachNav.Import.route) { ImportScreen(onBack = back) }221+        composable(PeachNav.Import.route) {222+            ImportScreen(223+                onBack = back,224+                onVaultCreated = {225+                    navController.navigate(PeachNav.RecoverySetup.route) {226+                        popUpTo(PeachNav.Lock.route) { inclusive = true }227+                    }228+                },229+            )230+        }222231         composable(PeachNav.Export.route) { ExportScreen(onBack = back) }223232         composable(PeachNav.Panic.route) {224233             PanicScreen(

view file

app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+92 −10
@@ -50,11 +50,16 @@5050 import com.peachpasswords.ui.components.PeachEmptyOrEndState5151 import com.peachpasswords.ui.components.PeachField as DesignPeachField5252 import com.peachpasswords.ui.components.PeachTopBar53+import com.peachpasswords.ui.lock.canSubmitCreateVault54+import com.peachpasswords.ui.security.scorePassword55+import com.peachpasswords.ui.security.strengthColor56+import com.peachpasswords.ui.security.strengthLabel5357 import com.peachpasswords.ui.theme.LocalPeachAccent5458 import com.peachpasswords.ui.theme.PeachSpacing5559 import com.peachpasswords.ui.theme.peachColors5660 import com.peachpasswords.ui.theme.PeachType5761 import com.peachpasswords.ui.theme.ShellBackground62+import com.peachpasswords.vault.CreateVaultResult5863 import com.peachpasswords.vault.VaultEntry5964 import com.peachpasswords.vault.VaultRepository6065 import dagger.hilt.android.lifecycle.HiltViewModel@@ -94,7 +99,7 @@9499     data object Working : ImportUiState95100     data class PeachScriptFile(val text: String) : ImportUiState96101     data class Preview(val preview: ImportPreview) : ImportUiState97-    data class Done(val count: Int) : ImportUiState102+    data class Done(val count: Int, val createdVault: Boolean = false) : ImportUiState98103     data class Failed(val message: String) : ImportUiState99104 }100105 @@ -105,6 +110,7 @@105110     const val NO_ENTRIES = "No importable password entries were found in that file."106111     const val UNREADABLE_FILE = "Couldn't read that file."107112     const val UNRECOGNISED_FILE = "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup."113+    const val NO_MASTER_PASSWORD = "Choose a master password for the new vault first."108114 }109115 110116 internal fun shouldRouteFileToPeachScript(path: String?, content: String): Boolean {@@ -126,6 +132,14 @@126132     private val _detectedProvider = MutableStateFlow<String?>(null)127133     val detectedProvider: StateFlow<String?> = _detectedProvider.asStateFlow()128134 135+    /**136+     * False when this screen was reached with no vault to import into yet — fresh137+     * install ("restore instead") or "forgot your password" recovery. The preview138+     * step then collects a new master password and [confirmImport] creates the139+     * vault before adding entries, instead of every addEntry silently failing.140+     */141+    val vaultUnlocked: StateFlow<Boolean> = vault.isUnlocked142+129143     fun reset() {130144         _state.value = ImportUiState.Idle131145         _detectedProvider.value = null@@ -244,11 +258,33 @@244258      * Persist the previewed entries. No batch API exists on VaultRepository, so245259      * import loops [VaultRepository.addEntry]. REPLACE soft-deletes the current246260      * active set first; MERGE simply appends.261+     *262+     * [newVaultPassword] is required when [vaultUnlocked] is false: there's no263+     * vault yet (fresh install "restore instead", or "forgot password" recovery)264+     * so one is created from it before any entry can be added. Without this,265+     * every addEntry threw requireUnlocked() and got swallowed by runCatching,266+     * silently reporting "0 entries imported" as if it had succeeded.247267      */248-    fun confirmImport(preview: ImportPreview, mode: ImportMode) {268+    fun confirmImport(preview: ImportPreview, mode: ImportMode, newVaultPassword: String? = null) {249269         _state.value = ImportUiState.Working250270         viewModelScope.launch {251271             try {272+                val createdVault: Boolean273+                if (!vault.isUnlocked.value) {274+                    if (newVaultPassword.isNullOrBlank()) {275+                        _state.value = ImportUiState.Failed(ImportErrors.NO_MASTER_PASSWORD)276+                        return@launch277+                    }278+                    when (val r = vault.createVault(newVaultPassword)) {279+                        is CreateVaultResult.Success -> createdVault = true280+                        is CreateVaultResult.Error -> {281+                            _state.value = ImportUiState.Failed(r.reason)282+                            return@launch283+                        }284+                    }285+                } else {286+                    createdVault = false287+                }252288                 if (mode == ImportMode.REPLACE) {253289                     vault.getEntries().forEach { existing ->254290                         runCatching { vault.deleteEntry(existing.id) }@@ -284,7 +320,7 @@284320                 preview.licenseKey285321                     ?.takeIf { license.validateLicense(it) }286322                     ?.let { license.storeLicense(it) }287-                _state.value = ImportUiState.Done(imported)323+                _state.value = ImportUiState.Done(imported, createdVault = createdVault)288324             } catch (e: Exception) {289325                 _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)290326             }@@ -301,9 +337,15 @@301337 @Composable302338 fun ImportScreen(303339     onBack: () -> Unit,340+    // Only reached when confirmImport had to create a new vault first (fresh341+    // install or "forgot password" recovery) — routes to recovery-word setup,342+    // same as LockScreen.create, instead of popping back to a stale Lock/Import343+    // screen that still thinks there's nothing to unlock.344+    onVaultCreated: () -> Unit = onBack,304345     vm: ImportViewModel = hiltViewModel(),305346 ) {306347     val state by vm.state.collectAsState()348+    val vaultUnlocked by vm.vaultUnlocked.collectAsState()307349     var route by remember { mutableStateOf(ImportRoute.CHOOSE) }308350 309351     ShellBackground {@@ -351,10 +393,14 @@351393                     when (val s = state) {352394                         is ImportUiState.Preview -> PreviewView(353395                             preview = s.preview,354-                            onConfirm = { mode -> vm.confirmImport(s.preview, mode) },396+                            vaultUnlocked = vaultUnlocked,397+                            onConfirm = { mode, newPassword -> vm.confirmImport(s.preview, mode, newPassword) },355398                             onCancel = { vm.reset() },356399                         )357-                        is ImportUiState.Done -> DoneView(count = s.count, onDone = onBack)400+                        is ImportUiState.Done -> DoneView(401+                            count = s.count,402+                            onDone = if (s.createdVault) onVaultCreated else onBack,403+                        )358404                         is ImportUiState.Failed -> {359405                             ErrorBanner(s.message)360406                             Spacer(Modifier.size(16.dp))@@ -525,10 +571,14 @@525571 @Composable526572 private fun PreviewView(527573     preview: ImportPreview,528-    onConfirm: (ImportMode) -> Unit,574+    vaultUnlocked: Boolean,575+    onConfirm: (ImportMode, String?) -> Unit,529576     onCancel: () -> Unit,530577 ) {531578     var mode by remember { mutableStateOf(ImportMode.MERGE) }579+    var newPassword by remember { mutableStateOf("") }580+    var newConfirm by remember { mutableStateOf("") }581+    val createScore = remember(newPassword) { scorePassword(newPassword) }532582 533583     GlassCard {534584         Text(@@ -568,12 +618,44 @@568618             }569619         }570620         Divider()571-        RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE }572-        Spacer(Modifier.size(PeachSpacing.sm))573-        RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE }621+        if (vaultUnlocked) {622+            RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE }623+            Spacer(Modifier.size(PeachSpacing.sm))624+            RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE }625+        } else {626+            FieldLabel("No vault yet — create one to restore into")627+            Spacer(Modifier.size(PeachSpacing.sm))628+            PeachUnderlineField(629+                label = "Master password",630+                value = newPassword,631+                onValueChange = { newPassword = it },632+                isPassword = true,633+            )634+            Spacer(Modifier.size(PeachSpacing.md))635+            PeachUnderlineField(636+                label = "Confirm password",637+                value = newConfirm,638+                onValueChange = { newConfirm = it },639+                isPassword = true,640+            )641+            if (newPassword.isNotEmpty()) {642+                Spacer(Modifier.size(PeachSpacing.sm))643+                Text(644+                    "strength: ${strengthLabel(createScore)}",645+                    color = strengthColor(createScore),646+                    style = PeachType.labelMono,647+                )648+            }649+        }574650     }575651     Spacer(Modifier.size(PeachSpacing.lg))576-    AccentButton(label = "Confirm Import", onClick = { onConfirm(mode) })652+    val confirmEnabled = vaultUnlocked ||653+        canSubmitCreateVault(newPassword, newConfirm, createScore)654+    AccentButton(655+        label = "Confirm Import",656+        enabled = confirmEnabled,657+        onClick = { onConfirm(mode, if (vaultUnlocked) null else newPassword) },658+    )577659     Spacer(Modifier.size(PeachSpacing.sm))578660     GhostButton("Cancel", peachColors.T1, onCancel)579661 }

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+19 −0
@@ -9,6 +9,7 @@99 import com.peachpasswords.sync.SyncAlert1010 import com.peachpasswords.sync.SyncAlertStore1111 import com.peachpasswords.vault.TOTPGenerator12+import com.peachpasswords.vault.PasskeyData1213 import com.peachpasswords.vault.VaultEntry1314 import com.peachpasswords.vault.VaultRepository1415 import com.peachpasswords.vault.isLogin@@ -189,6 +190,24 @@189190             vault.addEntry(login("Old Bank", "june", "weakpw", "https://bank.example.com", breach = 3))190191             vault.addEntry(login("Fastmail", "[email protected]", "another-long-passphrase", "https://fastmail.com"))191192             vault.addEntry(VaultEntry(id = "", title = "Recovery Notes", type = "note", notes = "x", createdAt = now, modifiedAt = now))193+            // Passkey-only entry (no password/TOTP/email) — exercises the194+            // passkey code path through import/export round trips, which195+            // previously had no seeded coverage at all.196+            vault.addEntry(197+                VaultEntry(198+                    id = "", title = "Amazon", type = "login", url = "https://amazon.com",199+                    passkey = PasskeyData(200+                        credentialId = "demo-credential-id",201+                        publicKeyCose = "demo-cose-public-key",202+                        rpId = "amazon.com",203+                        rpName = "Amazon",204+                        userHandle = "demo-user-handle",205+                        userName = "[email protected]",206+                        userDisplayName = "June",207+                    ),208+                    createdAt = now, modifiedAt = now,209+                ),210+            )192211         }193212     }194213 }

view file