RecoveryWordsScreen.kt

14.1 KB · raw

1package com.peachpasswords.ui.vault23import com.peachpasswords.shared.PeachClipboard4import androidx.activity.compose.rememberLauncherForActivityResult5import androidx.activity.result.contract.ActivityResultContracts6import androidx.compose.foundation.background7import androidx.compose.foundation.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Row13import androidx.compose.foundation.layout.Spacer14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.layout.width19import androidx.compose.foundation.layout.systemBarsPadding20import androidx.compose.foundation.rememberScrollState21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.text.BasicTextField23import androidx.compose.foundation.verticalScroll24import androidx.compose.material3.Text25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.StateFlow27import kotlinx.coroutines.flow.asStateFlow28import androidx.compose.runtime.collectAsState29import androidx.compose.runtime.Composable30import androidx.compose.runtime.LaunchedEffect31import androidx.compose.runtime.getValue32import androidx.compose.runtime.mutableStateListOf33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.setValue36import androidx.compose.ui.Alignment37import androidx.compose.ui.Modifier38import androidx.compose.ui.draw.clip39import androidx.compose.ui.graphics.Color40import androidx.compose.ui.graphics.SolidColor41import androidx.compose.ui.platform.LocalContext42import androidx.compose.ui.text.TextStyle43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.text.style.TextAlign45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.sp47import androidx.hilt.navigation.compose.hiltViewModel48import androidx.lifecycle.ViewModel49import androidx.lifecycle.viewModelScope50import com.peachpasswords.ui.components.SecureFlagEffect51import com.peachpasswords.ui.components.PeachField as DesignPeachField52import com.peachpasswords.ui.theme.LocalPeachAccent53import com.peachpasswords.ui.theme.PeachSpacing54import com.peachpasswords.ui.theme.peachColors55import com.peachpasswords.ui.theme.PeachType56import com.peachpasswords.ui.theme.ShellBackground57import com.peachpasswords.vault.VaultCrypto58import com.peachpasswords.vault.VaultRepository59import com.peachpasswords.vault.rootKeyToWords60import dagger.hilt.android.lifecycle.HiltViewModel61import kotlinx.coroutines.delay62import kotlinx.coroutines.launch63import javax.inject.Inject6465@HiltViewModel66class RecoveryWordsViewModel @Inject constructor(67    private val vault: VaultRepository,68) : ViewModel() {69    private val _words = MutableStateFlow<List<String>>(emptyList())7071    /**72     * The 12 recovery words derived from the vault's stable sync root key.73     *74     * Observed, not read once: this screen is reached the instant a vault is75     * created, and the first composition can land a beat before the unlock76     * does. A plain read cached an empty list at that moment and — being no77     * kind of Compose state — never triggered a recomposition once the key78     * arrived, so the user was shown a recovery screen with no words on it.79     */80    val words: StateFlow<List<String>> = _words.asStateFlow()8182    init {83        viewModelScope.launch {84            vault.isUnlocked.collect { unlocked ->85                if (unlocked && _words.value.isEmpty()) {86                    _words.value = vault.sessionRootKey()?.let { key ->87                        try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }88                    } ?: emptyList()89                }90            }91        }92    }9394    /**95     * Re-derive if we still have nothing. The collector above covers the vault96     * unlocking after this screen exists; this covers the mirror case, where97     * the screen composes after the unlock but the ViewModel was built around98     * the same instant and its collector saw only the pre-unlock value.99     */100    fun refresh() {101        if (_words.value.isEmpty()) {102            _words.value = vault.sessionRootKey()?.let { key ->103                try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }104            } ?: emptyList()105        }106    }107108    val alreadySet: Boolean get() = vault.isRecoveryWordsSet()109110    /** See VaultRepository.expectTransientBackground — call before launching the SAF save dialog. */111    fun expectTransientBackground() = vault.expectTransientBackground()112113    /** Mark recovery words confirmed (stores their BLAKE3 hash), then continue. */114    fun confirmAndComplete(onDone: () -> Unit) = viewModelScope.launch {115        vault.setRecoveryWords(_words.value)116        onDone()117    }118}119120/**121 * Recovery-word display + confirmation. [requireConfirm] true is the post-vault-creation122 * flow (countdown → confirm 3 positions → [onDone] into the vault). False is the read-only123 * "view recovery words" path from Settings ([onDone] returns to settings).124 */125@Composable126fun RecoveryWordsScreen(127    requireConfirm: Boolean,128    onDone: () -> Unit,129    vm: RecoveryWordsViewModel = hiltViewModel(),130) {131    val words by vm.words.collectAsState()132    LaunchedEffect(Unit) { vm.refresh() }133134    SecureFlagEffect() // MN-3: never screenshot the recovery words135136    ShellBackground {137        Column(138            modifier = Modifier139                .fillMaxSize()140                .systemBarsPadding()141                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)142                .verticalScroll(rememberScrollState()),143        ) {144            // Words and their confirmation live on one screen so they can be145            // read straight off the list while typing, rather than being hidden146            // behind a second step that turned a check into a memory test.147            ShowPhase(148                words = words,149                requireConfirm = requireConfirm,150                onDone = onDone,151                onConfirm = { vm.confirmAndComplete(onDone) },152                onExpectTransientBackground = vm::expectTransientBackground,153            )154        }155    }156}157158@Composable159private fun ShowPhase(160    words: List<String>,161    requireConfirm: Boolean,162    onDone: () -> Unit,163    onConfirm: () -> Unit,164    onExpectTransientBackground: () -> Unit,165) {166    val accent = LocalPeachAccent.current167    val context = LocalContext.current168    val joined = remember(words) { words.joinToString(" ") }169    var acknowledged by remember { mutableStateOf(false) }170171    val saveLauncher = rememberLauncherForActivityResult(172        ActivityResultContracts.CreateDocument("text/plain"),173    ) { uri ->174        if (uri != null) runCatching {175            context.contentResolver.openOutputStream(uri)?.use { it.write(joined.toByteArray()) }176        }177    }178179    GlassCard {180        Text(181            "keep these\nsafe.",182            color = peachColors.T0,183            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp, lineHeight = 26.sp),184        )185        Divider()186        Text(187            "These 12 words can restore your vault if you forget your master password. Write them down somewhere safe.",188            color = peachColors.T1, style = PeachType.body,189        )190        Spacer(Modifier.size(PeachSpacing.lg))191        // 12-word grid, two columns.192        Column {193            for (rowStart in 0 until words.size step 2) {194                Row(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {195                    WordCell(rowStart + 1, words.getOrElse(rowStart) { "" }, Modifier.weight(1f))196                    if (rowStart + 1 < words.size) {197                        WordCell(rowStart + 2, words[rowStart + 1], Modifier.weight(1f))198                    } else {199                        Spacer(Modifier.weight(1f))200                    }201                }202            }203        }204        Divider()205        GhostButton("copy all words", accent) {206            PeachClipboard.copy(context, "peach recovery", joined)207        }208    }209210    Spacer(Modifier.size(PeachSpacing.sm))211    WarningCard(accent) {212        Text(213            "⚠ This file is unencrypted. The destination may be a synchronised cloud provider; store it securely and delete it after saving.",214            color = peachColors.T0, style = PeachType.body,215        )216        Spacer(Modifier.size(PeachSpacing.lg))217        CheckboxRow(218            label = "I understand this file is unencrypted",219            checked = acknowledged,220            accent = accent,221            onToggle = { acknowledged = !acknowledged },222        )223    }224    Spacer(Modifier.size(PeachSpacing.sm))225    GhostButton("save as text file", peachColors.T1, enabled = acknowledged) {226        onExpectTransientBackground()227        saveLauncher.launch("peach-recovery.txt")228    }229230231    Spacer(Modifier.size(PeachSpacing.lg))232    if (requireConfirm) {233        ConfirmBlock(words = words, onConfirm = onConfirm)234    } else {235        AccentButton("Done", onDone)236    }237}238239@Composable240private fun ConfirmBlock(words: List<String>, onConfirm: () -> Unit) {241    val accent = LocalPeachAccent.current242    val positions = listOf(3, 7, 11) // 1-indexed243    val inputs = remember { mutableStateListOf("", "", "") }244    // Both sides must be non-blank. Comparing raw values meant that if `words`245    // was ever empty, every blank input compared equal to "" and the whole246    // confirmation passed with nothing typed.247    fun correct(i: Int): Boolean {248        val expected = words.getOrElse(positions[i] - 1) { "" }249        val typed = inputs[i].trim().lowercase()250        return expected.isNotBlank() && typed == expected251    }252    val allCorrect = words.size >= positions.max() && positions.indices.all { correct(it) }253254    GlassCard {255        Text(256            "let's check.",257            color = peachColors.T0,258            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),259        )260        Divider()261        Text("Enter the words at positions 3, 7, and 11 from the list above.", color = peachColors.T1, style = PeachType.body)262        Spacer(Modifier.size(PeachSpacing.md))263        positions.forEachIndexed { i, pos ->264            val ok = correct(i)265            DesignPeachField(266                label = "Word #$pos",267                value = inputs[i],268                onValueChange = { inputs[i] = it },269                trailing = {270                    if (ok) Text("✓", color = accent, style = PeachType.bodyMono)271                },272            )273        }274    }275    Spacer(Modifier.size(PeachSpacing.lg))276    AccentButton("Confirm", onConfirm, enabled = allCorrect)277}278279@Composable280private fun WordCell(number: Int, word: String, modifier: Modifier = Modifier) {281    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {282        Text(283            "$number",284            color = LocalPeachAccent.current,285            style = PeachType.recoveryNumeral,286            modifier = Modifier.width(28.dp),287        )288        Text(word, color = peachColors.T0, style = PeachType.recoveryWord)289    }290}291292@Composable293private fun GlassCard(content: @Composable () -> Unit) {294    Column(295        modifier = Modifier296            .fillMaxWidth()297            .clip(RoundedCornerShape(18.dp))298            .background(peachColors.SurfaceElevated)299            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))300            .padding(20.dp),301        content = { content() },302    )303}304305@Composable306private fun Divider() {307    Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3))308}309310311@Composable312private fun WarningCard(accent: Color, content: @Composable () -> Unit) {313    Column(314        modifier = Modifier315            .fillMaxWidth()316            .clip(RoundedCornerShape(18.dp))317            .background(accent.copy(alpha = 0.08f))318            .border(1.dp, accent.copy(alpha = 0.35f), RoundedCornerShape(18.dp))319            .padding(20.dp),320        content = { content() },321    )322}323324@Composable325private fun CheckboxRow(label: String, checked: Boolean, accent: Color, onToggle: () -> Unit) {326    Row(327        verticalAlignment = Alignment.CenterVertically,328        modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp),329    ) {330        Box(331            modifier = Modifier332                .size(18.dp)333                .clip(RoundedCornerShape(5.dp))334                .background(if (checked) accent else Color.Transparent)335                .border(1.5.dp, if (checked) accent else peachColors.T2, RoundedCornerShape(5.dp)),336            contentAlignment = Alignment.Center,337        ) {338            if (checked) Text("✓", color = Color(0xFF0C0A06), style = PeachType.tinyMono.copy(fontSize = 12.sp))339        }340        Spacer(Modifier.width(12.dp))341        Text(label, color = peachColors.T0, style = PeachType.body)342    }343}344@Composable345private fun GhostButton(label: String, color: Color, enabled: Boolean = true, onClick: () -> Unit) {346    Box(347        modifier = Modifier348            .fillMaxWidth()349            .clip(RoundedCornerShape(12.dp))350            .border(1.dp, color.copy(alpha = if (enabled) 0.4f else 0.15f), RoundedCornerShape(12.dp))351            .clickable(enabled = enabled, onClick = onClick)352            .padding(vertical = 12.dp),353        contentAlignment = Alignment.Center,354    ) {355        Text(label, color = color.copy(alpha = if (enabled) 1f else 0.4f), style = PeachType.bodyMono)356    }357}358359@Composable360private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {361    val accent = LocalPeachAccent.current362    Box(363        modifier = Modifier364            .fillMaxWidth()365            .clip(RoundedCornerShape(12.dp))366            .background(if (enabled) accent else accent.copy(alpha = 0.3f))367            .clickable(enabled = enabled, onClick = onClick)368            .padding(vertical = 14.dp),369        contentAlignment = Alignment.Center,370    ) {371        Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)372    }373}