package com.peachpasswords.ui.recovery import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation 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.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachField import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.lock.MIN_PASSWORD_LENGTH import com.peachpasswords.ui.security.StrengthBar import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthColor import com.peachpasswords.ui.security.strengthLabel 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.VaultRepository import com.peachpasswords.vault.WORD_LIST import com.peachpasswords.vault.findInvalidWords import com.peachpasswords.vault.getWordSuggestions import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject private const val WORD_COUNT = 12 enum class RecoveryStep { WORDS, PASSWORD, NO_ESCROW } data class RecoveryEntryUiState( val step: RecoveryStep = RecoveryStep.WORDS, val checksumValid: Boolean = false, val wordsError: String? = null, val passwordError: String? = null, val working: Boolean = false, ) @HiltViewModel class RecoveryEntryViewModel @Inject constructor( private val vault: VaultRepository, ) : ViewModel() { private val _state = MutableStateFlow( RecoveryEntryUiState( // If this vault was never set up for recovery, drop straight to the // no-escrow state instead of letting the user type 12 words for nothing. step = if (vault.hasRecoveryEscrow()) RecoveryStep.WORDS else RecoveryStep.NO_ESCROW, ), ) val state: StateFlow = _state.asStateFlow() private var validatedWords: List = emptyList() /** * Gate the [Continue] button on all 12 slots holding valid BIP39 words. We do * NOT use the entropy checksum here: WORD_LIST carries a duplicate "abandon", * so an app-generated phrase using that index fails a strict checksum even * though it is the user's real phrase. The escrow's GCM tag is the true * correctness check at recovery time (surfaced as "Incorrect recovery words"). */ fun validateWords(words: List) { viewModelScope.launch { val ok = withContext(Dispatchers.Default) { words.size == WORD_COUNT && words.all { it.isNotBlank() } && findInvalidWords(words).isEmpty() } validatedWords = if (ok) words.map { it.trim().lowercase() } else emptyList() _state.update { it.copy( checksumValid = ok, wordsError = if (!ok && words.all { w -> w.isNotBlank() }) { "Some words aren't valid recovery words — check for typos" } else null, ) } } } fun continueToPassword() { if (_state.value.checksumValid) _state.update { it.copy(step = RecoveryStep.PASSWORD) } } /** * Recover the vault with the validated words and set [newPassword]. On success * the vault is left unlocked; [onSuccess] should navigate to the vault list. */ fun recover(newPassword: String, confirm: String, onSuccess: () -> Unit) { val s = _state.value if (s.working) return when { newPassword.length < MIN_PASSWORD_LENGTH -> return _state.update { it.copy(passwordError = "Password must be at least $MIN_PASSWORD_LENGTH characters") } newPassword != confirm -> return _state.update { it.copy(passwordError = "Passwords do not match") } scorePassword(newPassword) < 2 -> return _state.update { it.copy(passwordError = "Password too weak — try a longer or more varied password") } } _state.update { it.copy(working = true, passwordError = null) } viewModelScope.launch { val unlocked = vault.unlockWithRecoveryWords(validatedWords) if (!unlocked) { _state.update { it.copy(working = false, passwordError = "Incorrect recovery words — vault cannot be unlocked") } return@launch } val reset = vault.resetPasswordWithRecoveryWords(validatedWords, newPassword) if (!reset) { _state.update { it.copy(working = false, passwordError = "Something went wrong — please try again") } return@launch } _state.update { it.copy(working = false) } onSuccess() } } } @Composable fun RecoveryWordEntryScreen( onSuccess: () -> Unit, onBack: () -> Unit, onRestore: () -> Unit = onBack, vm: RecoveryEntryViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() SecureFlagEffect() // MN-3: never screenshot entered recovery words / new password ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md) .verticalScroll(rememberScrollState()), ) { PeachSectionHeader(section = "recovery", onHome = onBack) Spacer(Modifier.height(PeachSpacing.lg)) when (state.step) { RecoveryStep.WORDS -> WordStep(state, vm, onBack) RecoveryStep.PASSWORD -> PasswordStep(state, vm, onSuccess) RecoveryStep.NO_ESCROW -> NoEscrowStep(onRestore = onRestore, onBack = onBack) } } } } @Composable private fun WordStep(state: RecoveryEntryUiState, vm: RecoveryEntryViewModel, onBack: () -> Unit) { val words = remember { mutableStateListOf(*Array(WORD_COUNT) { "" }) } val focusers = remember { List(WORD_COUNT) { FocusRequester() } } fun onWordChange(i: Int, raw: String) { words[i] = raw.trim().lowercase().filter { it.isLetter() } // Re-run checksum validation once all 12 are filled, or to clear a stale // valid/error state the moment a previously-complete grid is edited. if (words.all { it.isNotBlank() } || state.checksumValid || state.wordsError != null) { vm.validateWords(words.toList()) } } Eyebrow("VAULT RECOVERY") Spacer(Modifier.height(PeachSpacing.sm)) Headline("enter your\nrecovery words.") Spacer(Modifier.height(PeachSpacing.lg)) GlassPanel { // 3 columns × 4 rows. for (row in 0 until 4) { Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) { for (col in 0 until 3) { val i = row * 3 + col WordSlot( index = i, value = words[i], focusRequester = focusers[i], onValueChange = { onWordChange(i, it) }, onPicked = { picked -> words[i] = picked if (i + 1 < WORD_COUNT) focusers[i + 1].requestFocus() if (words.all { it.isNotBlank() }) vm.validateWords(words.toList()) }, imeAction = if (i == WORD_COUNT - 1) ImeAction.Done else ImeAction.Next, onNext = { if (i + 1 < WORD_COUNT) focusers[i + 1].requestFocus() }, modifier = Modifier.weight(1f), ) } } } if (state.wordsError != null) { Spacer(Modifier.height(PeachSpacing.sm)) Text( state.wordsError, color = peachColors.Breach, style = PeachType.labelMono.copy(fontSize = 10.sp), ) } } Spacer(Modifier.height(PeachSpacing.lg)) AccentButton("Continue", enabled = state.checksumValid, onClick = vm::continueToPassword) Spacer(Modifier.height(PeachSpacing.sm)) GhostButton("Back to login", peachColors.T1, onBack) } @Composable private fun WordSlot( index: Int, value: String, focusRequester: FocusRequester, onValueChange: (String) -> Unit, onPicked: (String) -> Unit, imeAction: ImeAction, onNext: () -> Unit, modifier: Modifier = Modifier, ) { val accent = LocalPeachAccent.current val interaction = remember { MutableInteractionSource() } val focused by interaction.collectIsFocusedAsState() val hairline = peachColors.T3 val isValidWord = remember(value) { value.isNotBlank() && WORD_LIST.contains(value) } val suggestions = remember(value, focused) { if (focused && value.isNotBlank() && !isValidWord) getWordSuggestions(value, 4) else emptyList() } Column(modifier = modifier) { Row(verticalAlignment = Alignment.CenterVertically) { Text( "${index + 1}", color = accent, style = PeachType.recoveryNumeral, textAlign = TextAlign.End, modifier = Modifier.width(20.dp), ) Spacer(Modifier.width(PeachSpacing.xs)) BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, textStyle = PeachType.recoveryWord.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, // hint: no autocorrect/suggestions imeAction = imeAction, ), keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }), interactionSource = interaction, modifier = Modifier .weight(1f) .focusRequester(focusRequester) .background(if (isValidWord) accent.copy(alpha = 0.08f) else Color.Transparent) .drawBehind { drawRect( color = if (focused) accent else hairline, topLeft = Offset(0f, size.height - 1.dp.toPx()), size = Size(size.width, 1.dp.toPx()), ) } .padding(vertical = 6.dp, horizontal = 2.dp), ) } // Inline autocomplete dropdown. if (suggestions.isNotEmpty()) { Column( Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .padding(vertical = 2.dp), ) { suggestions.forEach { word -> Text( word, color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 11.sp), modifier = Modifier .fillMaxWidth() .clickable { onPicked(word) } .padding(horizontal = 8.dp, vertical = 5.dp), ) } } } } } @Composable private fun PasswordStep(state: RecoveryEntryUiState, vm: RecoveryEntryViewModel, onSuccess: () -> Unit) { var password by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } var reveal by remember { mutableStateOf(false) } val score = remember(password) { scorePassword(password) } val canSubmit = score >= 2 && password.isNotEmpty() && password == confirm && !state.working Eyebrow("VAULT RECOVERY") Spacer(Modifier.height(PeachSpacing.sm)) Headline("set a new\npassword.") Spacer(Modifier.height(PeachSpacing.lg)) GlassPanel { PeachField( label = "new password", value = password, onValueChange = { password = it }, keyboardType = KeyboardType.Password, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), trailing = { Text( if (reveal) "hide" else "show", color = LocalPeachAccent.current, style = PeachType.labelMono, modifier = Modifier.clickable(onClick = { reveal = !reveal }).padding(start = PeachSpacing.sm, top = 6.dp, bottom = 6.dp), ) }, ) if (password.isNotEmpty()) { Spacer(Modifier.height(PeachSpacing.md)) StrengthBar(score = score) Spacer(Modifier.height(PeachSpacing.xs)) Text( "strength: ${strengthLabel(score)}", color = strengthColor(score), style = PeachType.labelMono, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) } Spacer(Modifier.height(PeachSpacing.md)) PeachField( label = "confirm password", value = confirm, onValueChange = { confirm = it }, keyboardType = KeyboardType.Password, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), ) Text( state.passwordError ?: " ", color = peachColors.Breach, style = PeachType.labelMono.copy(fontSize = 10.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(16.dp), ) } Spacer(Modifier.height(PeachSpacing.lg)) AccentButton( label = if (state.working) "recovering…" else "Recover vault", enabled = canSubmit, onClick = { vm.recover(password, confirm, onSuccess) }, ) } @Composable private fun NoEscrowStep(onRestore: () -> Unit, onBack: () -> Unit) { PeachEmptyOrEndState( message = "recovery not available", detail = "Recovery words were not set up for this vault. You can restore from a backup instead.", action = { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) { GhostButton("Restore from backup", LocalPeachAccent.current, onRestore) GhostButton("Back to login", peachColors.T1, onBack) } }, ) } // ── Shared local primitives (match LockScreen / RecoveryWordsScreen idiom) ── @Composable private fun Eyebrow(text: String) = Text(text, color = peachColors.T2, style = PeachType.lockEyebrow) @Composable private fun Headline(text: String) = Text( text, color = peachColors.T0, style = PeachType.lockHeadline, ) @Composable private fun GlassPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(Color(0xE0131109)) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(PeachSpacing.lg), content = content, ) } @Composable private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) { 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 = PeachSpacing.md), contentAlignment = Alignment.Center, ) { Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono) } } @Composable private fun GhostButton(label: String, color: Color, onClick: () -> Unit) { Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) .clickable(onClick = onClick) .padding(vertical = PeachSpacing.md), contentAlignment = Alignment.Center, ) { Text(label, color = color, style = PeachType.bodyMono) } }