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
8 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 −0
app/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             }

view file

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)

view file

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                 )

view file

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) }

view file

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))

view file

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) {

view file

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))

view file

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     /**

view file