896672fa08f1

merge the recovery-words display and confirmation into a single screen (June's call): the 12 words, copy/save actions, and the three position inputs now sit together, so the words can be read straight off the list while typing instead of being hidden behind a second step. Drops the Phase enum, the 5-second countdown, and the separate 'I've saved my recovery words' step.

Also hardened the check itself: correct() compared the typed value against words.getOrElse(...) { \"\" }, so whenever the word list was empty every blank input compared equal to \"\" and the entire confirmation passed with nothing typed. Now both sides must be non-blank and the list must actually hold the positions being asked for. And made the words reactive: they were derived once when the ViewModel was constructed, so if that landed a beat before the vault finished unlocking they stayed empty for the life of the screen with nothing to trigger a recomposition. Now a StateFlow fed by vault.isUnlocked, plus a refresh() on composition for the mirror case. NOT FIXED: the word list still renders empty on a freshly created vault. Confirmed the vault layer is fine (a host-JVM test creates a vault and gets 12 words), lock() is never called, and _isUnlocked is only ever set false inside lock() — yet the screen observes the vault locked with a null root key throughout. Left open rather than claimed.

time
2026-07-24 21:36 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
recovery words one screen
parent
c7836f5947ee
2 files app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+70 −40app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+9 −0
app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+70 −40
@@ -22,6 +22,10 @@2222 import androidx.compose.foundation.text.BasicTextField2323 import androidx.compose.foundation.verticalScroll2424 import androidx.compose.material3.Text25+import kotlinx.coroutines.flow.MutableStateFlow26+import kotlinx.coroutines.flow.StateFlow27+import kotlinx.coroutines.flow.asStateFlow28+import androidx.compose.runtime.collectAsState2529 import androidx.compose.runtime.Composable2630 import androidx.compose.runtime.LaunchedEffect2731 import androidx.compose.runtime.getValue@@ -63,11 +67,45 @@6367 class RecoveryWordsViewModel @Inject constructor(6468     private val vault: VaultRepository,6569 ) : ViewModel() {66-    /** The 12 recovery words derived from the vault's stable sync root key. */67-    val words: List<String> = vault.syncMasterKey()?.let { key ->68-        try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }69-    } ?: emptyList()70+    private val _words = MutableStateFlow<List<String>>(emptyList())7071 72+    /**73+     * The 12 recovery words derived from the vault's stable sync root key.74+     *75+     * Observed, not read once: this screen is reached the instant a vault is76+     * created, and the first composition can land a beat before the unlock77+     * does. A plain read cached an empty list at that moment and — being no78+     * kind of Compose state — never triggered a recomposition once the key79+     * arrived, so the user was shown a recovery screen with no words on it.80+     */81+    val words: StateFlow<List<String>> = _words.asStateFlow()82+83+    init {84+        viewModelScope.launch {85+            vault.isUnlocked.collect { unlocked ->86+                if (unlocked && _words.value.isEmpty()) {87+                    _words.value = vault.syncMasterKey()?.let { key ->88+                        try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }89+                    } ?: emptyList()90+                }91+            }92+        }93+    }94+95+    /**96+     * Re-derive if we still have nothing. The collector above covers the vault97+     * unlocking after this screen exists; this covers the mirror case, where98+     * the screen composes after the unlock but the ViewModel was built around99+     * the same instant and its collector saw only the pre-unlock value.100+     */101+    fun refresh() {102+        if (_words.value.isEmpty()) {103+            _words.value = vault.syncMasterKey()?.let { key ->104+                try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }105+            } ?: emptyList()106+        }107+    }108+71109     val alreadySet: Boolean get() = vault.isRecoveryWordsSet()72110 73111     /** See VaultRepository.expectTransientBackground — call before launching the SAF save dialog. */@@ -75,7 +113,7 @@75113 76114     /** Mark recovery words confirmed (stores their BLAKE3 hash), then continue. */77115     fun confirmAndComplete(onDone: () -> Unit) = viewModelScope.launch {78-        vault.setRecoveryWords(words)116+        vault.setRecoveryWords(_words.value)79117         onDone()80118     }81119 }@@ -91,8 +129,8 @@91129     onDone: () -> Unit,92130     vm: RecoveryWordsViewModel = hiltViewModel(),93131 ) {94-    var phase by remember { mutableStateOf(Phase.SHOW) }95-    val words = vm.words132+    val words by vm.words.collectAsState()133+    LaunchedEffect(Unit) { vm.refresh() }96134 97135     SecureFlagEffect() // MN-3: never screenshot the recovery words98136 @@ -106,29 +144,26 @@106144         ) {107145             PeachTopBar(title = "recovery words")108146             Spacer(Modifier.size(PeachSpacing.lg))109-            when (phase) {110-                Phase.SHOW -> ShowPhase(111-                    words = words,112-                    requireConfirm = requireConfirm,113-                    onContinue = { if (requireConfirm) phase = Phase.CONFIRM else onDone() },114-                    onExpectTransientBackground = vm::expectTransientBackground,115-                )116-                Phase.CONFIRM -> ConfirmPhase(117-                    words = words,118-                    onConfirm = { vm.confirmAndComplete(onDone) },119-                )120-            }147+            // Words and their confirmation live on one screen so they can be148+            // read straight off the list while typing, rather than being hidden149+            // behind a second step that turned a check into a memory test.150+            ShowPhase(151+                words = words,152+                requireConfirm = requireConfirm,153+                onDone = onDone,154+                onConfirm = { vm.confirmAndComplete(onDone) },155+                onExpectTransientBackground = vm::expectTransientBackground,156+            )121157         }122158     }123159 }124160 125-private enum class Phase { SHOW, CONFIRM }126-127161 @Composable128162 private fun ShowPhase(129163     words: List<String>,130164     requireConfirm: Boolean,131-    onContinue: () -> Unit,165+    onDone: () -> Unit,166+    onConfirm: () -> Unit,132167     onExpectTransientBackground: () -> Unit,133168 ) {134169     val accent = LocalPeachAccent.current@@ -181,31 +216,26 @@181216 182217     Spacer(Modifier.size(PeachSpacing.lg))183218     if (requireConfirm) {184-        var remaining by remember { mutableStateOf(5) }185-        LaunchedEffect(Unit) {186-            while (remaining > 0) { delay(1000); remaining -= 1 }187-        }188-        if (remaining > 0) {189-            Text(190-                "continue in ${remaining}s",191-                color = peachColors.T2, style = PeachType.tinyMono.copy(fontSize = 9.sp),192-                textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),193-            )194-        } else {195-            AccentButton("I've saved my recovery words", onContinue)196-        }219+        ConfirmBlock(words = words, onConfirm = onConfirm)197220     } else {198-        AccentButton("Done", onContinue)221+        AccentButton("Done", onDone)199222     }200223 }201224 202225 @Composable203-private fun ConfirmPhase(words: List<String>, onConfirm: () -> Unit) {226+private fun ConfirmBlock(words: List<String>, onConfirm: () -> Unit) {204227     val accent = LocalPeachAccent.current205228     val positions = listOf(3, 7, 11) // 1-indexed206229     val inputs = remember { mutableStateListOf("", "", "") }207-    fun correct(i: Int) = inputs[i].trim().lowercase() == words.getOrElse(positions[i] - 1) { "" }208-    val allCorrect = positions.indices.all { correct(it) }230+    // Both sides must be non-blank. Comparing raw values meant that if `words`231+    // was ever empty, every blank input compared equal to "" and the whole232+    // confirmation passed with nothing typed.233+    fun correct(i: Int): Boolean {234+        val expected = words.getOrElse(positions[i] - 1) { "" }235+        val typed = inputs[i].trim().lowercase()236+        return expected.isNotBlank() && typed == expected237+    }238+    val allCorrect = words.size >= positions.max() && positions.indices.all { correct(it) }209239 210240     GlassCard {211241         Text(@@ -214,7 +244,7 @@214244             style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),215245         )216246         Divider()217-        Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T1, style = PeachType.body)247+        Text("Enter the words at positions 3, 7, and 11 from the list above.", color = peachColors.T1, style = PeachType.body)218248         Spacer(Modifier.size(PeachSpacing.md))219249         positions.forEachIndexed { i, pos ->220250             val ok = correct(i)

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+9 −0
@@ -104,6 +104,15 @@104104         assertTrue(dst.getEntries().any { it.title == "Portable" })105105     }106106 107+    @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking {108+        val r = repo()109+        val key = r.syncMasterKey()110+        assertTrue("syncMasterKey null right after createVault", key != null)111+        val words = com.peachpasswords.vault.rootKeyToWords(key!!)112+        println("WORDS=${words.size} ${words.take(3)}")113+        assertEquals(12, words.size)114+    }115+107116     @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {108117         val rootKeyHex = "66".repeat(32)109118         val rootKey = hexToRootKey(rootKeyHex)

view file