099f17d7211c

stop a real vault's import from hanging the app. Three things were wrong at once on the confirm step:

(1) Quadratic writes. confirmImport looped VaultRepository.addEntry, and every call took the lock and re-encrypted and rewrote the ENTIRE vault, so an 806-entry restore did 806 full-vault writes. New addEntries() inserts the batch under one lock with a single write, rolling back the whole batch if that write fails, matching addEntry's all-or-nothing behaviour. Measured on June's real entry count: 12ms batched vs 4368ms looped, and that 4.4s is a desktop JVM — on-device with real crypto it is far worse. (2) Main thread. viewModelScope defaults to Dispatchers.Main, so the vault creation (deliberately expensive key derivation), the REPLACE-mode clear, and all those writes ran on the UI thread. Android offered to kill the app. Each of those now runs under withContext(Dispatchers.Default). (3) No feedback. The screen simply froze. Working state now carries step copy ('creating your vault…', 'saving N entries…') shown with an indeterminate progress bar and a line telling the user to keep Peach open, alongside the existing rune animation on the PeachScript path. Also: stopped installing the debug build to June's phone — it lands as a separate app beside the real one and was confusing. ~/peach-push.sh is release-only now, and the debug app has been removed from the device.

time
2026-07-24 22:07 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
fix import ANR on a real-sized vault
parent
3573fda2b97b
3 files app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+59 −36app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+25 −0
app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+59 −36
@@ -26,6 +26,7 @@2626 import androidx.compose.material.icons.Icons2727 import androidx.compose.material.icons.automirrored.rounded.ArrowBack2828 import androidx.compose.material3.Icon29+import androidx.compose.material3.LinearProgressIndicator2930 import androidx.compose.material3.Text3031 import androidx.compose.runtime.Composable3132 import androidx.compose.runtime.LaunchedEffect@@ -96,7 +97,8 @@9697 9798 sealed interface ImportUiState {9899     data object Idle : ImportUiState99-    data object Working : ImportUiState100+    /** [step] is user-facing progress copy; null while there's nothing to say yet. */101+    data class Working(val step: String? = null) : ImportUiState100102     data class PeachScriptFile(val text: String) : ImportUiState101103     data class Preview(val preview: ImportPreview) : ImportUiState102104     data class Done(val count: Int, val createdVault: Boolean = false) : ImportUiState@@ -168,7 +170,7 @@168170             _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED)169171             return170172         }171-        _state.value = ImportUiState.Working173+        _state.value = ImportUiState.Working()172174         viewModelScope.launch {173175             val result = withContext(Dispatchers.Default) {174176                 PeachScriptImport.decryptAndParse(trimmed, backupPassword)@@ -215,7 +217,7 @@215217     // ---- File import ------------------------------------------------------216218 217219     fun importFile(uri: Uri) {218-        _state.value = ImportUiState.Working220+        _state.value = ImportUiState.Working()219221         _detectedProvider.value = null220222         viewModelScope.launch {221223             val content = withContext(Dispatchers.IO) {@@ -266,7 +268,7 @@266268      * silently reporting "0 entries imported" as if it had succeeded.267269      */268270     fun confirmImport(preview: ImportPreview, mode: ImportMode, newVaultPassword: String? = null) {269-        _state.value = ImportUiState.Working271+        _state.value = ImportUiState.Working()270272         viewModelScope.launch {271273             try {272274                 val createdVault: Boolean@@ -275,10 +277,14 @@275277                         _state.value = ImportUiState.Failed(ImportErrors.NO_MASTER_PASSWORD)276278                         return@launch277279                     }278-                    when (val r = vault.createVault(newVaultPassword)) {280+                    _state.value = ImportUiState.Working("creating your vault…")281+                    // Key derivation is deliberately expensive; off the main282+                    // thread or it blocks the frame loop on its own.283+                    val created = withContext(Dispatchers.Default) { vault.createVault(newVaultPassword) }284+                    when (created) {279285                         is CreateVaultResult.Success -> createdVault = true280286                         is CreateVaultResult.Error -> {281-                            _state.value = ImportUiState.Failed(r.reason)287+                            _state.value = ImportUiState.Failed(created.reason)282288                             return@launch283289                         }284290                     }@@ -286,40 +292,38 @@286292                     createdVault = false287293                 }288294                 if (mode == ImportMode.REPLACE) {289-                    vault.getEntries().forEach { existing ->290-                        runCatching { vault.deleteEntry(existing.id) }295+                    _state.value = ImportUiState.Working("clearing the old vault…")296+                    withContext(Dispatchers.Default) {297+                        vault.getEntries().forEach { existing -> runCatching { vault.deleteEntry(existing.id) } }291298                     }292299                 }293300                 val now = System.currentTimeMillis()294-                var imported = 0295301                 val fulls = preview.fullEntries296-                if (fulls != null) {297-                    // PeachScript: import the complete entries (all fields, all types).302+                val drafts = if (fulls != null) {303+                    // PeachScript: the complete entries (all fields, all types).298304                     // Re-id so an import can never overwrite an existing entry.299-                    for (e in fulls) {300-                        runCatching { vault.addEntry(e.copy(id = "")) }.onSuccess { imported++ }301-                    }305+                    fulls.map { it.copy(id = "") }302306                 } else {303-                    for (p in preview.entries) {304-                        val draft = VaultEntry(305-                            id = "",306-                            type = "login",307-                            title = p.title,308-                            username = p.username,309-                            password = p.password,310-                            url = p.url,311-                            notes = p.notes,312-                            totp = p.totp,313-                            createdAt = now,314-                            modifiedAt = now,307+                    preview.entries.map { p ->308+                        VaultEntry(309+                            id = "", type = "login", title = p.title, username = p.username,310+                            password = p.password, url = p.url, notes = p.notes, totp = p.totp,311+                            createdAt = now, modifiedAt = now,315312                         )316-                        runCatching { vault.addEntry(draft) }.onSuccess { imported++ }317313                     }318314                 }319-                preview.settings?.let { vault.updateSettings(it) }320-                preview.licenseKey321-                    ?.takeIf { license.validateLicense(it) }322-                    ?.let { license.storeLicense(it) }315+                _state.value = ImportUiState.Working("saving ${drafts.size} entries…")316+                // One batched write on a background thread. Looping addEntry317+                // re-encrypted and rewrote the whole vault per entry, which on318+                // a real 806-entry vault hung the app until Android offered to319+                // kill it.320+                val imported = withContext(Dispatchers.Default) { vault.addEntries(drafts) }321+                withContext(Dispatchers.Default) {322+                    preview.settings?.let { vault.updateSettings(it) }323+                    preview.licenseKey324+                        ?.takeIf { license.validateLicense(it) }325+                        ?.let { license.storeLicense(it) }326+                }323327                 _state.value = ImportUiState.Done(imported, createdVault = createdVault)324328             } catch (e: Exception) {325329                 _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)@@ -434,8 +438,10 @@434438                                 )435439                             }436440                         }437-                        ImportUiState.Working ->438-                            if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS) else WorkingView()441+                        is ImportUiState.Working -> {442+                            if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS)443+                            WorkingView(s.step)444+                        }439445                         ImportUiState.Idle -> when (route) {440446                             ImportRoute.CHOOSE -> Unit // handled above, outside the scroll container441447                             ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)@@ -751,12 +757,29 @@751757 }752758 753759 @Composable754-private fun WorkingView() {755-    Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {760+private fun WorkingView(step: String? = null) {761+    Column(762+        Modifier.fillMaxWidth().padding(vertical = 24.dp),763+        horizontalAlignment = Alignment.CenterHorizontally,764+    ) {756765         Text(757-            "working…",766+            step ?: "working…",758767             color = peachColors.T1,759768             style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp),769+        )770+        Spacer(Modifier.size(PeachSpacing.md))771+        // Indeterminate: a real vault's write is one batched operation, so772+        // there is no honest per-entry percentage to report.773+        LinearProgressIndicator(774+            modifier = Modifier.fillMaxWidth(),775+            color = LocalPeachAccent.current,776+            trackColor = peachColors.T3,777+        )778+        Spacer(Modifier.size(PeachSpacing.sm))779+        Text(780+            "keep Peach open — this can take a moment for a large vault.",781+            color = peachColors.T2,782+            style = PeachType.hint,760783         )761784     }762785 }

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −0
@@ -643,6 +643,37 @@643643         entry644644     }645645 646+    /**647+     * Insert many entries with a single encrypt-and-write at the end.648+     *649+     * Importing a real vault through [addEntry] was quadratic: every entry took650+     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry651+     * restore did 806 full-vault writes and hung the app long enough to ANR.652+     * Rolls back to the pre-import list if the one write fails, matching653+     * [addEntry]'s all-or-nothing behaviour.654+     */655+    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {656+        requireUnlocked()657+        if (drafts.isEmpty()) return 0658+        val rollback = entryList.toMutableList()659+        val now = System.currentTimeMillis()660+        for (draft in drafts) {661+            val stamp = VaultCrypto.monotonicEntryTimestamp()662+            entryList.add(663+                withDerivedLast4(664+                    draft.copy(665+                        id = draft.id.ifBlank { UUID.randomUUID().toString() },666+                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,667+                        modifiedAt = stamp,668+                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,669+                    ),670+                ),671+            )672+        }673+        persistOrRollback(rollback)674+        return drafts.size675+    }676+646677     suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {647678         requireUnlocked()648679         val idx = entryList.indexOfFirst { it.id == patch.id }

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+25 −0
@@ -113,6 +113,31 @@113113         assertEquals(12, words.size)114114     }115115 116+    /**117+     * A real vault is ~800 entries. Looping addEntry re-encrypts and rewrites118+     * the whole vault per entry, which is quadratic and hung the app hard119+     * enough to ANR on import. Batched insert must do one write.120+     */121+    @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking {122+        val n = 806123+        val drafts = (1..n).map { login("Entry $it") }124+125+        val batched = repo()126+        val tBatch = System.currentTimeMillis().let { start ->127+            assertEquals(n, batched.addEntries(drafts))128+            System.currentTimeMillis() - start129+        }130+        assertEquals(n, batched.getEntries().size)131+132+        val looped = repo()133+        val tLoop = System.currentTimeMillis().let { start ->134+            drafts.forEach { looped.addEntry(it.copy(id = "")) }135+            System.currentTimeMillis() - start136+        }137+        println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms")138+        assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop)139+    }140+116141     @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {117142         val rootKeyHex = "66".repeat(32)118143         val rootKey = hexToRootKey(rootKeyHex)

view file