RecoveryWordEntryScreen.kt

19.1 KB · raw

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            // If this vault was never set up for recovery, drop straight to the106            // no-escrow state instead of letting the user type 12 words for nothing.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    /**115     * Gate the [Continue] button on all 12 slots holding valid BIP39 words. We do116     * NOT use the entropy checksum here: WORD_LIST carries a duplicate "abandon",117     * so an app-generated phrase using that index fails a strict checksum even118     * though it is the user's real phrase. The escrow's GCM tag is the true119     * correctness check at recovery time (surfaced as "Incorrect recovery words").120     */121    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    /**143     * Recover the vault with the validated words and set [newPassword]. On success144     * the vault is left unlocked; [onSuccess] should navigate to the vault list.145     */146    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() // MN-3: never screenshot entered recovery words / new password184    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        // Re-run checksum validation once all 12 are filled, or to clear a stale211        // valid/error state the moment a previously-complete grid is edited.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        // 3 columns × 4 rows.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, // hint: no autocorrect/suggestions300                    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        // Inline autocomplete dropdown.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}425426// ── Shared local primitives (match LockScreen / RecoveryWordsScreen idiom) ──427428@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}