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
↓ peach-android-501a671f.tar.gzatlas at this save
files changed3 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 −0app/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(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 }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 }