1package com.peachpasswords.ui.recovery23import androidx.compose.foundation.background4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.interaction.MutableInteractionSource7import androidx.compose.foundation.interaction.collectIsFocusedAsState8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.layout.systemBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.rememberScrollState21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.text.BasicTextField23import androidx.compose.foundation.text.KeyboardActions24import androidx.compose.foundation.text.KeyboardOptions25import androidx.compose.foundation.verticalScroll26import androidx.compose.material.icons.Icons27import androidx.compose.material.icons.automirrored.rounded.ArrowBack28import androidx.compose.material3.Icon29import androidx.compose.material3.Text30import androidx.compose.runtime.Composable31import androidx.compose.runtime.collectAsState32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateListOf34import androidx.compose.runtime.mutableStateOf35import androidx.compose.runtime.remember36import androidx.compose.runtime.setValue37import androidx.compose.ui.Alignment38import androidx.compose.ui.Modifier39import androidx.compose.ui.draw.clip40import androidx.compose.ui.draw.drawBehind41import androidx.compose.ui.focus.FocusRequester42import androidx.compose.ui.focus.focusRequester43import androidx.compose.ui.geometry.Offset44import androidx.compose.ui.geometry.Size45import androidx.compose.ui.graphics.Color46import androidx.compose.ui.graphics.SolidColor47import androidx.compose.ui.text.font.FontStyle48import androidx.compose.ui.text.input.ImeAction49import androidx.compose.ui.text.input.KeyboardType50import androidx.compose.ui.text.input.PasswordVisualTransformation51import androidx.compose.ui.text.input.VisualTransformation52import androidx.compose.ui.text.style.TextAlign53import androidx.compose.ui.unit.dp54import androidx.compose.ui.unit.sp55import androidx.hilt.navigation.compose.hiltViewModel56import androidx.lifecycle.ViewModel57import androidx.lifecycle.viewModelScope58import com.peachpasswords.ui.components.SecureFlagEffect59import com.peachpasswords.ui.components.PeachEmptyOrEndState60import com.peachpasswords.ui.components.PeachField61import com.peachpasswords.ui.components.PeachSectionHeader62import com.peachpasswords.ui.lock.MIN_PASSWORD_LENGTH63import com.peachpasswords.ui.security.StrengthBar64import com.peachpasswords.ui.security.scorePassword65import com.peachpasswords.ui.security.strengthColor66import com.peachpasswords.ui.security.strengthLabel67import com.peachpasswords.ui.theme.LocalPeachAccent68import com.peachpasswords.ui.theme.PeachSpacing69import com.peachpasswords.ui.theme.peachColors70import com.peachpasswords.ui.theme.PeachType71import com.peachpasswords.ui.theme.ShellBackground72import com.peachpasswords.vault.VaultRepository73import com.peachpasswords.vault.WORD_LIST74import com.peachpasswords.vault.findInvalidWords75import com.peachpasswords.vault.getWordSuggestions76import dagger.hilt.android.lifecycle.HiltViewModel77import kotlinx.coroutines.Dispatchers78import kotlinx.coroutines.flow.MutableStateFlow79import kotlinx.coroutines.flow.StateFlow80import kotlinx.coroutines.flow.asStateFlow81import kotlinx.coroutines.flow.update82import kotlinx.coroutines.launch83import kotlinx.coroutines.withContext84import javax.inject.Inject8586private const val WORD_COUNT = 128788enum class RecoveryStep { WORDS, PASSWORD, NO_ESCROW }8990data class RecoveryEntryUiState(91 val step: RecoveryStep = RecoveryStep.WORDS,92 val checksumValid: Boolean = false,93 val wordsError: String? = null,94 val passwordError: String? = null,95 val working: Boolean = false,96)9798@HiltViewModel99class RecoveryEntryViewModel @Inject constructor(100 private val vault: VaultRepository,101) : ViewModel() {102103 private val _state = MutableStateFlow(104 RecoveryEntryUiState(105 106 107 step = if (vault.hasRecoveryEscrow()) RecoveryStep.WORDS else RecoveryStep.NO_ESCROW,108 ),109 )110 val state: StateFlow<RecoveryEntryUiState> = _state.asStateFlow()111112 private var validatedWords: List<String> = emptyList()113114 115116117118119120121 fun validateWords(words: List<String>) {122 viewModelScope.launch {123 val ok = withContext(Dispatchers.Default) {124 words.size == WORD_COUNT && words.all { it.isNotBlank() } && findInvalidWords(words).isEmpty()125 }126 validatedWords = if (ok) words.map { it.trim().lowercase() } else emptyList()127 _state.update {128 it.copy(129 checksumValid = ok,130 wordsError = if (!ok && words.all { w -> w.isNotBlank() }) {131 "Some words aren't valid recovery words — check for typos"132 } else null,133 )134 }135 }136 }137138 fun continueToPassword() {139 if (_state.value.checksumValid) _state.update { it.copy(step = RecoveryStep.PASSWORD) }140 }141142 143144145146 fun recover(newPassword: String, confirm: String, onSuccess: () -> Unit) {147 val s = _state.value148 if (s.working) return149 when {150 newPassword.length < MIN_PASSWORD_LENGTH ->151 return _state.update { it.copy(passwordError = "Password must be at least $MIN_PASSWORD_LENGTH characters") }152 newPassword != confirm ->153 return _state.update { it.copy(passwordError = "Passwords do not match") }154 scorePassword(newPassword) < 2 ->155 return _state.update { it.copy(passwordError = "Password too weak — try a longer or more varied password") }156 }157 _state.update { it.copy(working = true, passwordError = null) }158 viewModelScope.launch {159 val unlocked = vault.unlockWithRecoveryWords(validatedWords)160 if (!unlocked) {161 _state.update { it.copy(working = false, passwordError = "Incorrect recovery words — vault cannot be unlocked") }162 return@launch163 }164 val reset = vault.resetPasswordWithRecoveryWords(validatedWords, newPassword)165 if (!reset) {166 _state.update { it.copy(working = false, passwordError = "Something went wrong — please try again") }167 return@launch168 }169 _state.update { it.copy(working = false) }170 onSuccess()171 }172 }173}174175@Composable176fun RecoveryWordEntryScreen(177 onSuccess: () -> Unit,178 onBack: () -> Unit,179 onRestore: () -> Unit = onBack,180 vm: RecoveryEntryViewModel = hiltViewModel(),181) {182 val state by vm.state.collectAsState()183 SecureFlagEffect() 184 ShellBackground {185 Column(186 modifier = Modifier187 .fillMaxSize()188 .systemBarsPadding()189 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)190 .verticalScroll(rememberScrollState()),191 ) {192 PeachSectionHeader(section = "recovery", onHome = onBack)193 Spacer(Modifier.height(PeachSpacing.lg))194 when (state.step) {195 RecoveryStep.WORDS -> WordStep(state, vm, onBack)196 RecoveryStep.PASSWORD -> PasswordStep(state, vm, onSuccess)197 RecoveryStep.NO_ESCROW -> NoEscrowStep(onRestore = onRestore, onBack = onBack)198 }199 }200 }201}202203@Composable204private fun WordStep(state: RecoveryEntryUiState, vm: RecoveryEntryViewModel, onBack: () -> Unit) {205 val words = remember { mutableStateListOf(*Array(WORD_COUNT) { "" }) }206 val focusers = remember { List(WORD_COUNT) { FocusRequester() } }207208 fun onWordChange(i: Int, raw: String) {209 words[i] = raw.trim().lowercase().filter { it.isLetter() }210 211 212 if (words.all { it.isNotBlank() } || state.checksumValid || state.wordsError != null) {213 vm.validateWords(words.toList())214 }215 }216217 Eyebrow("VAULT RECOVERY")218 Spacer(Modifier.height(PeachSpacing.sm))219 Headline("enter your\nrecovery words.")220 Spacer(Modifier.height(PeachSpacing.lg))221222 GlassPanel {223 224 for (row in 0 until 4) {225 Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {226 for (col in 0 until 3) {227 val i = row * 3 + col228 WordSlot(229 index = i,230 value = words[i],231 focusRequester = focusers[i],232 onValueChange = { onWordChange(i, it) },233 onPicked = { picked ->234 words[i] = picked235 if (i + 1 < WORD_COUNT) focusers[i + 1].requestFocus()236 if (words.all { it.isNotBlank() }) vm.validateWords(words.toList())237 },238 imeAction = if (i == WORD_COUNT - 1) ImeAction.Done else ImeAction.Next,239 onNext = { if (i + 1 < WORD_COUNT) focusers[i + 1].requestFocus() },240 modifier = Modifier.weight(1f),241 )242 }243 }244 }245246 if (state.wordsError != null) {247 Spacer(Modifier.height(PeachSpacing.sm))248 Text(249 state.wordsError,250 color = peachColors.Breach,251 style = PeachType.labelMono.copy(fontSize = 10.sp),252 )253 }254 }255256 Spacer(Modifier.height(PeachSpacing.lg))257 AccentButton("Continue", enabled = state.checksumValid, onClick = vm::continueToPassword)258 Spacer(Modifier.height(PeachSpacing.sm))259 GhostButton("Back to login", peachColors.T1, onBack)260}261262@Composable263private fun WordSlot(264 index: Int,265 value: String,266 focusRequester: FocusRequester,267 onValueChange: (String) -> Unit,268 onPicked: (String) -> Unit,269 imeAction: ImeAction,270 onNext: () -> Unit,271 modifier: Modifier = Modifier,272) {273 val accent = LocalPeachAccent.current274 val interaction = remember { MutableInteractionSource() }275 val focused by interaction.collectIsFocusedAsState()276 val hairline = peachColors.T3277 val isValidWord = remember(value) { value.isNotBlank() && WORD_LIST.contains(value) }278 val suggestions = remember(value, focused) {279 if (focused && value.isNotBlank() && !isValidWord) getWordSuggestions(value, 4) else emptyList()280 }281282 Column(modifier = modifier) {283 Row(verticalAlignment = Alignment.CenterVertically) {284 Text(285 "${index + 1}",286 color = accent,287 style = PeachType.recoveryNumeral,288 textAlign = TextAlign.End,289 modifier = Modifier.width(20.dp),290 )291 Spacer(Modifier.width(PeachSpacing.xs))292 BasicTextField(293 value = value,294 onValueChange = onValueChange,295 singleLine = true,296 textStyle = PeachType.recoveryWord.copy(color = peachColors.T0),297 cursorBrush = SolidColor(accent),298 keyboardOptions = KeyboardOptions(299 keyboardType = KeyboardType.Password, 300 imeAction = imeAction,301 ),302 keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),303 interactionSource = interaction,304 modifier = Modifier305 .weight(1f)306 .focusRequester(focusRequester)307 .background(if (isValidWord) accent.copy(alpha = 0.08f) else Color.Transparent)308 .drawBehind {309 drawRect(310 color = if (focused) accent else hairline,311 topLeft = Offset(0f, size.height - 1.dp.toPx()),312 size = Size(size.width, 1.dp.toPx()),313 )314 }315 .padding(vertical = 6.dp, horizontal = 2.dp),316 )317 }318 319 if (suggestions.isNotEmpty()) {320 Column(321 Modifier322 .fillMaxWidth()323 .clip(RoundedCornerShape(6.dp))324 .background(peachColors.S2)325 .padding(vertical = 2.dp),326 ) {327 suggestions.forEach { word ->328 Text(329 word,330 color = peachColors.T1,331 style = PeachType.labelMono.copy(fontSize = 11.sp),332 modifier = Modifier333 .fillMaxWidth()334 .clickable { onPicked(word) }335 .padding(horizontal = 8.dp, vertical = 5.dp),336 )337 }338 }339 }340 }341}342343@Composable344private fun PasswordStep(state: RecoveryEntryUiState, vm: RecoveryEntryViewModel, onSuccess: () -> Unit) {345 var password by remember { mutableStateOf("") }346 var confirm by remember { mutableStateOf("") }347 var reveal by remember { mutableStateOf(false) }348 val score = remember(password) { scorePassword(password) }349 val canSubmit = score >= 2 && password.isNotEmpty() && password == confirm && !state.working350351 Eyebrow("VAULT RECOVERY")352 Spacer(Modifier.height(PeachSpacing.sm))353 Headline("set a new\npassword.")354 Spacer(Modifier.height(PeachSpacing.lg))355356 GlassPanel {357 PeachField(358 label = "new password",359 value = password,360 onValueChange = { password = it },361 keyboardType = KeyboardType.Password,362 visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(),363 trailing = {364 Text(365 if (reveal) "hide" else "show",366 color = LocalPeachAccent.current,367 style = PeachType.labelMono,368 modifier = Modifier.clickable(onClick = { reveal = !reveal }).padding(start = PeachSpacing.sm, top = 6.dp, bottom = 6.dp),369 )370 },371 )372373 if (password.isNotEmpty()) {374 Spacer(Modifier.height(PeachSpacing.md))375 StrengthBar(score = score)376 Spacer(Modifier.height(PeachSpacing.xs))377 Text(378 "strength: ${strengthLabel(score)}",379 color = strengthColor(score),380 style = PeachType.labelMono,381 modifier = Modifier.fillMaxWidth(),382 textAlign = TextAlign.Center,383 )384 }385386 Spacer(Modifier.height(PeachSpacing.md))387 PeachField(388 label = "confirm password",389 value = confirm,390 onValueChange = { confirm = it },391 keyboardType = KeyboardType.Password,392 visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(),393 )394395 Text(396 state.passwordError ?: " ",397 color = peachColors.Breach,398 style = PeachType.labelMono.copy(fontSize = 10.sp),399 textAlign = TextAlign.Center,400 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(16.dp),401 )402 }403404 Spacer(Modifier.height(PeachSpacing.lg))405 AccentButton(406 label = if (state.working) "recovering…" else "Recover vault",407 enabled = canSubmit,408 onClick = { vm.recover(password, confirm, onSuccess) },409 )410}411412@Composable413private fun NoEscrowStep(onRestore: () -> Unit, onBack: () -> Unit) {414 PeachEmptyOrEndState(415 message = "recovery not available",416 detail = "Recovery words were not set up for this vault. You can restore from a backup instead.",417 action = {418 Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {419 GhostButton("Restore from backup", LocalPeachAccent.current, onRestore)420 GhostButton("Back to login", peachColors.T1, onBack)421 }422 },423 )424}425426427428@Composable429private fun Eyebrow(text: String) =430 Text(text, color = peachColors.T2, style = PeachType.lockEyebrow)431432@Composable433private fun Headline(text: String) =434 Text(435 text,436 color = peachColors.T0,437 style = PeachType.lockHeadline,438 )439440@Composable441private fun GlassPanel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {442 Column(443 modifier = Modifier444 .fillMaxWidth()445 .clip(RoundedCornerShape(18.dp))446 .background(Color(0xE0131109))447 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))448 .padding(PeachSpacing.lg),449 content = content,450 )451}452453@Composable454private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {455 val accent = LocalPeachAccent.current456 Box(457 modifier = Modifier458 .fillMaxWidth()459 .clip(RoundedCornerShape(12.dp))460 .background(if (enabled) accent else accent.copy(alpha = 0.3f))461 .clickable(enabled = enabled, onClick = onClick)462 .padding(vertical = PeachSpacing.md),463 contentAlignment = Alignment.Center,464 ) {465 Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)466 }467}468469@Composable470private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {471 Box(472 modifier = Modifier473 .fillMaxWidth()474 .clip(RoundedCornerShape(12.dp))475 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp))476 .clickable(onClick = onClick)477 .padding(vertical = PeachSpacing.md),478 contentAlignment = Alignment.Center,479 ) {480 Text(label, color = color, style = PeachType.bodyMono)481 }482}