73d0f6503c8a
fix critical data-loss bug: SAF file pickers silently wiped the vault mid-export/import - ProcessLifecycleOwner.onStop() called vault.lock() (zeroing keys, clearing entryList) whenever the WHOLE PROCESS backgrounds, which fires not just on genuine user backgrounding but also whenever a system Activity (SAF save/open dialog, share sheet) takes foreground as an expected, momentary part of an in-app flow. Result: CSV export always produced a 35-byte header-only file with zero entry rows, silently discarding all vault data, and the app dropped to the lock screen right after - confirmed via temporary logcat instrumentation showing entries.size=0 at buildCsv() time despite 5 real entries existing seconds earlier. Added VaultRepository.expectTransientBackground()/consumeTransientBackgroundSuppression() (a single-use flag) and wired it into PeachApplication's onStop to skip the lock when set. Applied at all 6 external-activity launch sites found across the app: CSV export, PeachScript export, CSV/JSON import picker, recovery-words save-as-text, Codex PDF save, Codex PDF share, and all 3 Codex PDF-restore picker launches. Live-verified the fix end to end: re-ran the exact same CSV export flow, confirmed via logcat entries.size=4 (real count) and isUnlocked=true at export time (previously 0/would-have-locked), pulled the resulting file and confirmed real entry rows (GitHub/Google/Old Bank/Fastmail with real usernames, passwords, urls) instead of just the header, and confirmed the app no longer drops to the lock screen after export.
- time
- 2026-07-24 14:04 UTC · 3 months ago
- machine
- koh2
- agent
- claude-sonnet-5
- session
- critical-corrections-c1-c7
- parent
- b2f98db39dab
↓ peach-android-73d0f650.tar.gzatlas at this save
files changed8 files
app/src/main/java/com/peachpasswords/PeachApplication.kt+6 −0app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt+5 −1app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+7 −3app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+3 −0app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt+13 −1app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+4 −0app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+14 −2app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+22 −0app/src/main/java/com/peachpasswords/PeachApplication.kt+6 −0
@@ -27,6 +27,12 @@2727 }2828 2929 override fun onStop(owner: LifecycleOwner) {30+ // A SAF file picker/save dialog or share sheet also stops the31+ // process's activities while it's in the foreground — that's32+ // an expected, momentary handoff as part of an in-app export/33+ // import flow, not a real backgrounding, so it's exempted via34+ // VaultRepository.expectTransientBackground().35+ if (vault.consumeTransientBackgroundSuppression()) return3036 vault.lock()3137 lockTimer.cancel()3238 }app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt+5 −1
@@ -138,10 +138,14 @@138138 SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)139139 SummaryRow("pages", s.manifest.pageCount.toString(), accent)140140 Spacer(Modifier.height(PeachSpacing.xl))141- CodexButton("Save PDF…", accent, filled = true) { saveLauncher.launch(codexPdfFileName()) }141+ CodexButton("Save PDF…", accent, filled = true) {142+ vm.expectTransientBackground()143+ saveLauncher.launch(codexPdfFileName())144+ }142145 Spacer(Modifier.height(PeachSpacing.sm))143146 CodexButton("Share PDF…", peachColors.T2, filled = false) {144147 val uri = vm.sharePdfUri() ?: return@CodexButton148+ vm.expectTransientBackground()145149 val intent = Intent(Intent.ACTION_SEND).apply {146150 type = "application/pdf"147151 putExtra(Intent.EXTRA_STREAM, uri)app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+7 −3
@@ -42,6 +42,10 @@4242 val pickLauncher = rememberLauncherForActivityResult(4343 ActivityResultContracts.GetContent(),4444 ) { uri -> uri?.let { vm.scan(it) } }45+ fun launchPicker() {46+ vm.expectTransientBackground()47+ pickLauncher.launch("application/pdf")48+ }4549 4650 SecureFlagEffect()4751 @@ -61,7 +65,7 @@6165 is CameraScanState.Choosing -> CodexRestoreChoose(6266 accent = accent,6367 onScanCamera = { vm.startCameraScan() },64- onImportPdf = { pickLauncher.launch("application/pdf") },68+ onImportPdf = { launchPicker() },6569 modifier = Modifier.weight(1f),6670 )6771 is CameraScanState.Scanning -> CodexCameraScan(@@ -90,7 +94,7 @@9094 is RestoreState.Shortfall -> ShortfallBody(9195 s = s,9296 accent = accent,93- onRetryPdf = { vm.resetRestore(); pickLauncher.launch("application/pdf") },97+ onRetryPdf = { vm.resetRestore(); launchPicker() },9498 onUseCamera = { vm.startCameraScan() },9599 )96100 @@ -110,7 +114,7 @@110114 action = {111115 CodexButton("Choose another file", accent, filled = true) {112116 vm.resetRestore()113- pickLauncher.launch("application/pdf")117+ launchPicker()114118 }115119 },116120 )app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+3 −0
@@ -74,6 +74,9 @@7474 /** Browser parity: Codex generation is Pro-only, while restore remains free. */7575 val isPro: Boolean get() = license.isProActive()7676 77+ /** See VaultRepository.expectTransientBackground — call before launching a SAF picker/save dialog. */78+ fun expectTransientBackground() = vault.expectTransientBackground()79+7780 private val recoveryWords: List<String> by lazy {7881 vault.syncMasterKey()?.let { k ->7982 try { com.peachpasswords.vault.rootKeyToWords(k) }app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt+13 −1
@@ -100,6 +100,14 @@100100 101101 fun entryCount(): Int = vault.getEntries().size102102 103+ /**104+ * Call right before launching the SAF save dialog — it briefly takes the105+ * whole process out of the foreground, which would otherwise trip the106+ * app-wide background lock and wipe the vault before the save callback107+ * (which reads live entries) ever runs. See VaultRepository docs.108+ */109+ fun expectTransientBackground() = vault.expectTransientBackground()110+103111 /** Encrypted PeachScript export (backup-password protected). */104112 fun buildPeachScript(backupPassword: String): String =105113 PeachScriptExport.buildEncrypted(@@ -349,6 +357,7 @@349357 }350358 Spacer(Modifier.size(PeachSpacing.sm))351359 GhostButton("Save to file", peachColors.T1) {360+ vm.expectTransientBackground()352361 saveLauncher.launch(exportFileName("peach-backup", "peach"))353362 }354363 status?.let {@@ -404,7 +413,10 @@404413 AccentButton(405414 label = "Export CSV",406415 enabled = acknowledged && !isEmpty,407- onClick = { saveLauncher.launch(exportFileName("peach-export", "csv")) },416+ onClick = {417+ vm.expectTransientBackground()418+ saveLauncher.launch(exportFileName("peach-export", "csv"))419+ },408420 )409421 status?.let {410422 Spacer(Modifier.size(PeachSpacing.md))app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+4 −0
@@ -131,6 +131,9 @@131131 _detectedProvider.value = null132132 }133133 134+ /** See VaultRepository.expectTransientBackground — call before launching the SAF picker. */135+ fun expectTransientBackground() = vault.expectTransientBackground()136+134137 fun back(onBack: () -> Unit) {135138 // Preview/error back returns to the input view; otherwise leave the screen.136139 when (_state.value) {@@ -503,6 +506,7 @@503506 // GetContent takes a single MIME type; the chooser still lists CSV504507 // and JSON. "*/*" is intentional so providers exporting odd MIME505508 // types for .csv are not filtered out by the system picker.509+ vm.expectTransientBackground()506510 picker.launch("*/*")507511 }508512 if (detected != null) {app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+14 −2
@@ -70,6 +70,9 @@7070 7171 val alreadySet: Boolean get() = vault.isRecoveryWordsSet()7272 73+ /** See VaultRepository.expectTransientBackground — call before launching the SAF save dialog. */74+ fun expectTransientBackground() = vault.expectTransientBackground()75+7376 /** Mark recovery words confirmed (stores their BLAKE3 hash), then continue. */7477 fun confirmAndComplete(onDone: () -> Unit) = viewModelScope.launch {7578 vault.setRecoveryWords(words)@@ -108,6 +111,7 @@108111 words = words,109112 requireConfirm = requireConfirm,110113 onContinue = { if (requireConfirm) phase = Phase.CONFIRM else onDone() },114+ onExpectTransientBackground = vm::expectTransientBackground,111115 )112116 Phase.CONFIRM -> ConfirmPhase(113117 words = words,@@ -121,7 +125,12 @@121125 private enum class Phase { SHOW, CONFIRM }122126 123127 @Composable124-private fun ShowPhase(words: List<String>, requireConfirm: Boolean, onContinue: () -> Unit) {128+private fun ShowPhase(129+ words: List<String>,130+ requireConfirm: Boolean,131+ onContinue: () -> Unit,132+ onExpectTransientBackground: () -> Unit,133+) {125134 val accent = LocalPeachAccent.current126135 val context = LocalContext.current127136 val joined = remember(words) { words.joinToString(" ") }@@ -164,7 +173,10 @@164173 PeachClipboard.copy(context, "peach recovery", joined)165174 }166175 Spacer(Modifier.size(PeachSpacing.sm))167- GhostButton("save as text file", peachColors.T1) { saveLauncher.launch("peach-recovery.txt") }176+ GhostButton("save as text file", peachColors.T1) {177+ onExpectTransientBackground()178+ saveLauncher.launch("peach-recovery.txt")179+ }168180 }169181 170182 Spacer(Modifier.size(PeachSpacing.lg))app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+22 −0
@@ -449,6 +449,28 @@449449 _isUnlocked.value = false450450 }451451 452+ @Volatile private var suppressNextBackgroundLock = false453+454+ /**455+ * Call immediately before launching a system Activity (a SAF file456+ * picker/save dialog, a share sheet) that briefly takes the whole process457+ * out of the foreground as an expected part of an in-app flow — not a458+ * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the459+ * two apart on its own (it fires for both), and without this, exporting or460+ * importing a file silently locked and wiped the vault mid-flow the moment461+ * the picker took over, before its result ever reached the app.462+ */463+ fun expectTransientBackground() {464+ suppressNextBackgroundLock = true465+ }466+467+ /** Consumed once by the next background-lock check; see [expectTransientBackground]. */468+ fun consumeTransientBackgroundSuppression(): Boolean {469+ val suppressed = suppressNextBackgroundLock470+ suppressNextBackgroundLock = false471+ return suppressed472+ }473+452474 // ---- Sync identity ----------------------------------------------------453475 454476 /**