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.
- time
- 2026-07-24 21:36 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- recovery words one screen
- parent
- c7836f5947ee
↓ peach-android-896672fa.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt+70 −40app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+9 −0app/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)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)