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 727374757677787980 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 9596979899100 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 111 fun expectTransientBackground() = vault.expectTransientBackground()112113 114 fun confirmAndComplete(onDone: () -> Unit) = viewModelScope.launch {115 vault.setRecoveryWords(_words.value)116 onDone()117 }118}119120121122123124125@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() 135136 ShellBackground {137 Column(138 modifier = Modifier139 .fillMaxSize()140 .systemBarsPadding()141 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md)142 .verticalScroll(rememberScrollState()),143 ) {144 145 146 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 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) 243 val inputs = remember { mutableStateListOf("", "", "") }244 245 246 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}