package com.peachpasswords.ui.vault import com.peachpasswords.shared.PeachClipboard import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.ui.components.SecureFlagEffect import com.peachpasswords.ui.components.PeachField as DesignPeachField import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.rootKeyToWords import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RecoveryWordsViewModel @Inject constructor( private val vault: VaultRepository, ) : ViewModel() { private val _words = MutableStateFlow>(emptyList()) /** * The 12 recovery words derived from the vault's stable sync root key. * * Observed, not read once: this screen is reached the instant a vault is * created, and the first composition can land a beat before the unlock * does. A plain read cached an empty list at that moment and — being no * kind of Compose state — never triggered a recomposition once the key * arrived, so the user was shown a recovery screen with no words on it. */ val words: StateFlow> = _words.asStateFlow() init { viewModelScope.launch { vault.isUnlocked.collect { unlocked -> if (unlocked && _words.value.isEmpty()) { _words.value = vault.sessionRootKey()?.let { key -> try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) } } ?: emptyList() } } } } /** * Re-derive if we still have nothing. The collector above covers the vault * unlocking after this screen exists; this covers the mirror case, where * the screen composes after the unlock but the ViewModel was built around * the same instant and its collector saw only the pre-unlock value. */ fun refresh() { if (_words.value.isEmpty()) { _words.value = vault.sessionRootKey()?.let { key -> try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) } } ?: emptyList() } } val alreadySet: Boolean get() = vault.isRecoveryWordsSet() /** See VaultRepository.expectTransientBackground — call before launching the SAF save dialog. */ fun expectTransientBackground() = vault.expectTransientBackground() /** Mark recovery words confirmed (stores their BLAKE3 hash), then continue. */ fun confirmAndComplete(onDone: () -> Unit) = viewModelScope.launch { vault.setRecoveryWords(_words.value) onDone() } } /** * Recovery-word display + confirmation. [requireConfirm] true is the post-vault-creation * flow (countdown → confirm 3 positions → [onDone] into the vault). False is the read-only * "view recovery words" path from Settings ([onDone] returns to settings). */ @Composable fun RecoveryWordsScreen( requireConfirm: Boolean, onDone: () -> Unit, vm: RecoveryWordsViewModel = hiltViewModel(), ) { val words by vm.words.collectAsState() LaunchedEffect(Unit) { vm.refresh() } SecureFlagEffect() // MN-3: never screenshot the recovery words ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md) .verticalScroll(rememberScrollState()), ) { // Words and their confirmation live on one screen so they can be // read straight off the list while typing, rather than being hidden // behind a second step that turned a check into a memory test. ShowPhase( words = words, requireConfirm = requireConfirm, onDone = onDone, onConfirm = { vm.confirmAndComplete(onDone) }, onExpectTransientBackground = vm::expectTransientBackground, ) } } } @Composable private fun ShowPhase( words: List, requireConfirm: Boolean, onDone: () -> Unit, onConfirm: () -> Unit, onExpectTransientBackground: () -> Unit, ) { val accent = LocalPeachAccent.current val context = LocalContext.current val joined = remember(words) { words.joinToString(" ") } var acknowledged by remember { mutableStateOf(false) } val saveLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("text/plain"), ) { uri -> if (uri != null) runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(joined.toByteArray()) } } } GlassCard { Text( "keep these\nsafe.", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp, lineHeight = 26.sp), ) Divider() Text( "These 12 words can restore your vault if you forget your master password. Write them down somewhere safe.", color = peachColors.T1, style = PeachType.body, ) Spacer(Modifier.size(PeachSpacing.lg)) // 12-word grid, two columns. Column { for (rowStart in 0 until words.size step 2) { Row(Modifier.fillMaxWidth().padding(vertical = 5.dp)) { WordCell(rowStart + 1, words.getOrElse(rowStart) { "" }, Modifier.weight(1f)) if (rowStart + 1 < words.size) { WordCell(rowStart + 2, words[rowStart + 1], Modifier.weight(1f)) } else { Spacer(Modifier.weight(1f)) } } } } Divider() GhostButton("copy all words", accent) { PeachClipboard.copy(context, "peach recovery", joined) } } Spacer(Modifier.size(PeachSpacing.sm)) WarningCard(accent) { Text( "⚠ This file is unencrypted. The destination may be a synchronised cloud provider; store it securely and delete it after saving.", color = peachColors.T0, style = PeachType.body, ) Spacer(Modifier.size(PeachSpacing.lg)) CheckboxRow( label = "I understand this file is unencrypted", checked = acknowledged, accent = accent, onToggle = { acknowledged = !acknowledged }, ) } Spacer(Modifier.size(PeachSpacing.sm)) GhostButton("save as text file", peachColors.T1, enabled = acknowledged) { onExpectTransientBackground() saveLauncher.launch("peach-recovery.txt") } Spacer(Modifier.size(PeachSpacing.lg)) if (requireConfirm) { ConfirmBlock(words = words, onConfirm = onConfirm) } else { AccentButton("Done", onDone) } } @Composable private fun ConfirmBlock(words: List, onConfirm: () -> Unit) { val accent = LocalPeachAccent.current val positions = listOf(3, 7, 11) // 1-indexed val inputs = remember { mutableStateListOf("", "", "") } // Both sides must be non-blank. Comparing raw values meant that if `words` // was ever empty, every blank input compared equal to "" and the whole // confirmation passed with nothing typed. fun correct(i: Int): Boolean { val expected = words.getOrElse(positions[i] - 1) { "" } val typed = inputs[i].trim().lowercase() return expected.isNotBlank() && typed == expected } val allCorrect = words.size >= positions.max() && positions.indices.all { correct(it) } GlassCard { Text( "let's check.", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp), ) Divider() Text("Enter the words at positions 3, 7, and 11 from the list above.", color = peachColors.T1, style = PeachType.body) Spacer(Modifier.size(PeachSpacing.md)) positions.forEachIndexed { i, pos -> val ok = correct(i) DesignPeachField( label = "Word #$pos", value = inputs[i], onValueChange = { inputs[i] = it }, trailing = { if (ok) Text("✓", color = accent, style = PeachType.bodyMono) }, ) } } Spacer(Modifier.size(PeachSpacing.lg)) AccentButton("Confirm", onConfirm, enabled = allCorrect) } @Composable private fun WordCell(number: Int, word: String, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Text( "$number", color = LocalPeachAccent.current, style = PeachType.recoveryNumeral, modifier = Modifier.width(28.dp), ) Text(word, color = peachColors.T0, style = PeachType.recoveryWord) } } @Composable private fun GlassCard(content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(20.dp), content = { content() }, ) } @Composable private fun Divider() { Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3)) } @Composable private fun WarningCard(accent: Color, content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(accent.copy(alpha = 0.08f)) .border(1.dp, accent.copy(alpha = 0.35f), RoundedCornerShape(18.dp)) .padding(20.dp), content = { content() }, ) } @Composable private fun CheckboxRow(label: String, checked: Boolean, accent: Color, onToggle: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), ) { Box( modifier = Modifier .size(18.dp) .clip(RoundedCornerShape(5.dp)) .background(if (checked) accent else Color.Transparent) .border(1.5.dp, if (checked) accent else peachColors.T2, RoundedCornerShape(5.dp)), contentAlignment = Alignment.Center, ) { if (checked) Text("✓", color = Color(0xFF0C0A06), style = PeachType.tinyMono.copy(fontSize = 12.sp)) } Spacer(Modifier.width(12.dp)) Text(label, color = peachColors.T0, style = PeachType.body) } } @Composable private fun GhostButton(label: String, color: Color, enabled: Boolean = true, onClick: () -> Unit) { Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .border(1.dp, color.copy(alpha = if (enabled) 0.4f else 0.15f), RoundedCornerShape(12.dp)) .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text(label, color = color.copy(alpha = if (enabled) 1f else 0.4f), style = PeachType.bodyMono) } } @Composable private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(if (enabled) accent else accent.copy(alpha = 0.3f)) .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 14.dp), contentAlignment = Alignment.Center, ) { Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono) } }