package com.peachpasswords.ui.vault import android.content.Context import com.peachpasswords.shared.PeachClipboard import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.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.getValue 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.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import com.peachpasswords.license.LicenseRepository import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachField as DesignPeachField import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.components.SecureFlagEffect 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.security.StrengthBar import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthLabel import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import java.time.LocalDate import javax.inject.Inject // ───────────────────────────────────────────────────────────────────────────── // Password strength scorer (local, dependency-free) // ───────────────────────────────────────────────────────────────────────────── /** * Lightweight 0–4 strength score from length + character-class variety. No * external lib, no network — this is a UI gate, not a security guarantee. * * Thresholds (deliberately simple and explainable): * - +1 if length >= 8 * - +1 if length >= 12 * - +1 if at least 3 of {lowercase, uppercase, digit, symbol} are present * - +1 if all 4 classes are present * Empty input scores 0. Result is clamped to 0..4. The C2 gate requires >= 3 * ("good"); see [strengthLabel]. */ // Password strength scoring now lives in com.peachpasswords.ui.security (E1, shared). // ───────────────────────────────────────────────────────────────────────────── // ViewModel // ───────────────────────────────────────────────────────────────────────────── @HiltViewModel class ExportViewModel @Inject constructor( private val vault: VaultRepository, private val license: LicenseRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { /** Active (non-deleted) entries to export. */ fun entries(): List = vault.getEntries() fun entryCount(): Int = vault.getEntries().size /** * Call right before launching the SAF save dialog — it briefly takes the * whole process out of the foreground, which would otherwise trip the * app-wide background lock and wipe the vault before the save callback * (which reads live entries) ever runs. See VaultRepository docs. */ fun expectTransientBackground() = vault.expectTransientBackground() /** Encrypted PeachScript export (backup-password protected). */ fun buildPeachScript(backupPassword: String): String = PeachScriptExport.buildEncrypted( entries = entries(), backupPassword = backupPassword, settings = vault.settings.value, licenseKey = license.getLicenseKey()?.takeIf { license.validateLicense(it) }, licenseActivatedAt = license.getLicenseIssuedAtMillis(), ) /** Unencrypted CSV export. */ fun buildCsv(): String = CsvExport.toCsv(entries()) /** Write [content] to the SAF [uri]; returns false on any IO failure. */ fun writeToUri(uri: Uri, content: String): Boolean = runCatching { appContext.contentResolver.openOutputStream(uri)?.use { it.write(content.toByteArray(Charsets.UTF_8)) } ?: return false true }.getOrDefault(false) } // ───────────────────────────────────────────────────────────────────────────── // Screen // ───────────────────────────────────────────────────────────────────────────── object ExportStrings { const val EMPTY_VAULT = "Your vault is empty — nothing to export." const val WEAK_PASSWORD = "Backup password too weak (score < 3)" const val MISMATCH = "Passwords don't match." const val SAVE_FAILED = "Couldn't write that file." const val SAVED = "Saved." const val COPIED = "Copied to clipboard." } internal fun exportFileName(prefix: String, extension: String, date: LocalDate = LocalDate.now()): String = "$prefix-${date}.$extension" private enum class ExportRoute { CHOOSE, PEACHSCRIPT, CSV } @Composable fun ExportScreen( onBack: () -> Unit, vm: ExportViewModel = hiltViewModel(), ) { var route by remember { mutableStateOf(ExportRoute.CHOOSE) } SecureFlagEffect() // MN-3: never screenshot the export backup blob ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { PeachSectionHeader( section = "export", onHome = { if (route != ExportRoute.CHOOSE) route = ExportRoute.CHOOSE else onBack() }, ) Spacer(Modifier.size(PeachSpacing.lg)) if (route == ExportRoute.CHOOSE) { // Never needs to scroll — give it the real remaining height so // the prompt composes into it instead of trailing into dead // space below the two option cards. ChooseView( onPeachScript = { route = ExportRoute.PEACHSCRIPT }, onCsv = { route = ExportRoute.CSV }, modifier = Modifier.weight(1f).fillMaxWidth(), ) } else { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { when (route) { ExportRoute.PEACHSCRIPT -> PeachScriptExportView(vm) ExportRoute.CSV -> CsvExportView(vm) ExportRoute.CHOOSE -> Unit // handled above, outside the scroll container } } } } } } // ---- Choose view ---------------------------------------------------------- @Composable private fun ChooseView(onPeachScript: () -> Unit, onCsv: () -> Unit, modifier: Modifier = Modifier) { Column(modifier) { OptionCard( title = "PeachScript (encrypted)", subtitle = "A backup encrypted with a separate password. Restores into Peach.", onClick = onPeachScript, ) Spacer(Modifier.size(PeachSpacing.md)) OptionCard( title = "CSV (unencrypted)", subtitle = "Plain text for moving into another manager. Not encrypted.", onClick = onCsv, ) Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { PeachEmptyOrEndState( message = "Choose an export format.", detail = "Encrypted PeachScript is safest for Peach-to-Peach backups.", ) } } } @Composable private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .clickable(onClick = onClick) .padding(20.dp), ) { Text( title, color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp), ) Spacer(Modifier.size(6.dp)) Text(subtitle, color = peachColors.T1, style = PeachType.body) } } // ---- PeachScript sub-view ------------------------------------------------- @Composable private fun PeachScriptExportView(vm: ExportViewModel) { val accent = LocalPeachAccent.current val context = LocalContext.current val isEmpty = remember { vm.entryCount() == 0 } var password by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } // Generated artefact (the encrypted JSON string), set after [Generate backup]. var output by remember { mutableStateOf(null) } var status by remember { mutableStateOf(null) } val score = remember(password) { scorePassword(password) } val matches = password.isNotEmpty() && password == confirm val strongEnough = score >= 3 val canGenerate = !isEmpty && strongEnough && matches val saveLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("*/*"), ) { uri -> val text = output if (uri != null && text != null) { status = if (vm.writeToUri(uri, text)) ExportStrings.SAVED else ExportStrings.SAVE_FAILED } } GlassCard { Text( "create a\nbackup.", color = peachColors.T0, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp, lineHeight = 26.sp, ), ) Spacer(Modifier.size(PeachSpacing.sm)) Text( "Your vault will be encrypted with a separate backup password.", color = peachColors.T1, style = PeachType.body, ) if (isEmpty) { Spacer(Modifier.size(PeachSpacing.lg)) InlineError(ExportStrings.EMPTY_VAULT) } Spacer(Modifier.size(PeachSpacing.lg)) PeachUnderlineField( label = "Backup password", value = password, onValueChange = { password = it; output = null; status = null }, isPassword = true, ) Spacer(Modifier.size(PeachSpacing.sm)) PeachUnderlineField( label = "Confirm backup password", value = confirm, onValueChange = { confirm = it; output = null; status = null }, isPassword = true, ) Spacer(Modifier.size(PeachSpacing.sm)) StrengthBar(score = score) Spacer(Modifier.size(PeachSpacing.xs)) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( "strength: ${strengthLabel(score)}", color = if (strongEnough) accent else peachColors.T2, style = PeachType.labelMono, ) } if (password.isNotEmpty() && !strongEnough) { Spacer(Modifier.size(PeachSpacing.sm)) Text(ExportStrings.WEAK_PASSWORD, color = peachColors.Breach, style = PeachType.bodyMono) } if (confirm.isNotEmpty() && !matches) { Spacer(Modifier.size(PeachSpacing.sm)) Text(ExportStrings.MISMATCH, color = peachColors.Breach, style = PeachType.bodyMono) } } Spacer(Modifier.size(PeachSpacing.lg)) if (output == null) { AccentButton( label = "Generate backup", enabled = canGenerate, onClick = { output = vm.buildPeachScript(password) status = null }, ) } else { val text = output!! GhostButton("Copy to clipboard", accent) { PeachClipboard.copy(context, "peach backup", text) status = ExportStrings.COPIED } Spacer(Modifier.size(PeachSpacing.sm)) GhostButton("Save to file", peachColors.T1) { vm.expectTransientBackground() saveLauncher.launch(exportFileName("peach-backup", "peach")) } status?.let { Spacer(Modifier.size(PeachSpacing.md)) Text( it, color = if (it == ExportStrings.SAVE_FAILED) peachColors.Breach else accent, style = PeachType.labelMono, ) } } } // ---- CSV sub-view --------------------------------------------------------- @Composable private fun CsvExportView(vm: ExportViewModel) { val accent = LocalPeachAccent.current val isEmpty = remember { vm.entryCount() == 0 } var acknowledged by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } val saveLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("text/csv"), ) { uri -> if (uri != null) { val csv = vm.buildCsv() status = if (vm.writeToUri(uri, csv)) ExportStrings.SAVED else ExportStrings.SAVE_FAILED } } WarningCard(accent) { Text( "⚠ CSV exports are not encrypted. Store this file securely and delete it after importing.", color = peachColors.T0, style = PeachType.body, ) Spacer(Modifier.size(PeachSpacing.lg)) CheckboxRow( label = "I understand this file is unencrypted", checked = acknowledged, accent = accent, onToggle = { acknowledged = !acknowledged }, ) } if (isEmpty) { Spacer(Modifier.size(PeachSpacing.lg)) InlineError(ExportStrings.EMPTY_VAULT) } Spacer(Modifier.size(PeachSpacing.lg)) AccentButton( label = "Export CSV", enabled = acknowledged && !isEmpty, onClick = { vm.expectTransientBackground() saveLauncher.launch(exportFileName("peach-export", "csv")) }, ) status?.let { Spacer(Modifier.size(PeachSpacing.md)) Text( it, color = if (it == ExportStrings.SAVE_FAILED) peachColors.Breach else accent, style = PeachType.labelMono, ) } } @Composable private fun WarningCard(accent: Color, content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(accent.copy(alpha = 0.08f)) .border(1.dp, accent.copy(alpha = 0.35f), RoundedCornerShape(18.dp)) .padding(20.dp), content = { content() }, ) } @Composable private fun CheckboxRow(label: String, checked: Boolean, accent: Color, onToggle: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), ) { Box( modifier = Modifier .size(18.dp) .clip(RoundedCornerShape(5.dp)) .background(if (checked) accent else Color.Transparent) .border(1.5.dp, if (checked) accent else peachColors.T2, RoundedCornerShape(5.dp)), contentAlignment = Alignment.Center, ) { if (checked) Text("✓", color = Color(0xFF0C0A06), style = PeachType.tinyMono.copy(fontSize = 12.sp)) } Spacer(Modifier.width(12.dp)) Text(label, color = peachColors.T0, style = PeachType.body) } } // ---- Shared building blocks (private to this draft) ----------------------- @Composable private fun InlineError(message: String) { Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(peachColors.BreachBg) .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp)) .padding(14.dp), ) { Text(message, color = peachColors.Breach, style = PeachType.bodyMono) } } @Composable private fun FieldLabel(text: String) { Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono) } @Composable private fun PeachUnderlineField( label: String, value: String, onValueChange: (String) -> Unit, isPassword: Boolean = false, ) { DesignPeachField( label = label, value = value, onValueChange = onValueChange, visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, ) } @Composable private fun GlassCard(content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(20.dp), content = { content() }, ) } @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 = 12.dp), contentAlignment = Alignment.Center, ) { Text(label, color = color, style = PeachType.bodyMono) } } @Composable private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) { 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 = 14.dp), contentAlignment = Alignment.Center, ) { Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono) } }