099f17d7211c
stop a real vault's import from hanging the app. Three things were wrong at once on the confirm step:
- 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
↓ peach-android-099f17d7.tar.gzatlas at this save
files changed3 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 −0app/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 }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 }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)