ExportScreen.kt

20.0 KB · raw

1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import android.net.Uri6import androidx.activity.compose.rememberLauncherForActivityResult7import androidx.activity.result.contract.ActivityResultContracts8import androidx.compose.foundation.background9import androidx.compose.foundation.border10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxSize16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.height18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.rememberScrollState23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import 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.getValue32import androidx.compose.runtime.mutableStateOf33import androidx.compose.runtime.remember34import androidx.compose.runtime.setValue35import androidx.compose.ui.Alignment36import androidx.compose.ui.Modifier37import androidx.compose.ui.draw.clip38import androidx.compose.ui.graphics.Color39import androidx.compose.ui.graphics.SolidColor40import androidx.compose.ui.platform.LocalContext41import androidx.compose.ui.text.TextStyle42import androidx.compose.ui.text.font.FontStyle43import androidx.compose.ui.text.input.PasswordVisualTransformation44import androidx.compose.ui.text.input.VisualTransformation45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.sp47import androidx.hilt.navigation.compose.hiltViewModel48import androidx.lifecycle.ViewModel49import com.peachpasswords.license.LicenseRepository50import com.peachpasswords.ui.components.PeachEmptyOrEndState51import com.peachpasswords.ui.components.PeachField as DesignPeachField52import com.peachpasswords.ui.components.PeachSectionHeader53import com.peachpasswords.ui.components.SecureFlagEffect54import com.peachpasswords.ui.theme.LocalPeachAccent55import com.peachpasswords.ui.theme.PeachSpacing56import com.peachpasswords.ui.theme.peachColors57import com.peachpasswords.ui.theme.PeachType58import com.peachpasswords.ui.security.StrengthBar59import com.peachpasswords.ui.security.scorePassword60import com.peachpasswords.ui.security.strengthLabel61import com.peachpasswords.ui.theme.ShellBackground62import com.peachpasswords.vault.VaultEntry63import com.peachpasswords.vault.VaultRepository64import dagger.hilt.android.lifecycle.HiltViewModel65import dagger.hilt.android.qualifiers.ApplicationContext66import java.time.LocalDate67import javax.inject.Inject6869// ─────────────────────────────────────────────────────────────────────────────70// Password strength scorer (local, dependency-free)71// ─────────────────────────────────────────────────────────────────────────────7273/**74 * Lightweight 0–4 strength score from length + character-class variety. No75 * external lib, no network — this is a UI gate, not a security guarantee.76 *77 * Thresholds (deliberately simple and explainable):78 *   - +1 if length >= 879 *   - +1 if length >= 1280 *   - +1 if at least 3 of {lowercase, uppercase, digit, symbol} are present81 *   - +1 if all 4 classes are present82 * Empty input scores 0. Result is clamped to 0..4. The C2 gate requires >= 383 * ("good"); see [strengthLabel].84 */85// Password strength scoring now lives in com.peachpasswords.ui.security (E1, shared).8687// ─────────────────────────────────────────────────────────────────────────────88// ViewModel89// ─────────────────────────────────────────────────────────────────────────────9091@HiltViewModel92class ExportViewModel @Inject constructor(93    private val vault: VaultRepository,94    private val license: LicenseRepository,95    @ApplicationContext private val appContext: Context,96) : ViewModel() {9798    /** Active (non-deleted) entries to export. */99    fun entries(): List<VaultEntry> = vault.getEntries()100101    fun entryCount(): Int = vault.getEntries().size102103    /**104     * Call right before launching the SAF save dialog — it briefly takes the105     * whole process out of the foreground, which would otherwise trip the106     * app-wide background lock and wipe the vault before the save callback107     * (which reads live entries) ever runs. See VaultRepository docs.108     */109    fun expectTransientBackground() = vault.expectTransientBackground()110111    /** Encrypted PeachScript export (backup-password protected). */112    fun buildPeachScript(backupPassword: String): String =113        PeachScriptExport.buildEncrypted(114            entries = entries(),115            backupPassword = backupPassword,116            settings = vault.settings.value,117            licenseKey = license.getLicenseKey()?.takeIf { license.validateLicense(it) },118            licenseActivatedAt = license.getLicenseIssuedAtMillis(),119        )120121    /** Unencrypted CSV export. */122    fun buildCsv(): String = CsvExport.toCsv(entries())123124    /** Write [content] to the SAF [uri]; returns false on any IO failure. */125    fun writeToUri(uri: Uri, content: String): Boolean =126        runCatching {127            appContext.contentResolver.openOutputStream(uri)?.use {128                it.write(content.toByteArray(Charsets.UTF_8))129            } ?: return false130            true131        }.getOrDefault(false)132}133134// ─────────────────────────────────────────────────────────────────────────────135// Screen136// ─────────────────────────────────────────────────────────────────────────────137138object ExportStrings {139    const val EMPTY_VAULT = "Your vault is empty — nothing to export."140    const val WEAK_PASSWORD = "Backup password too weak (score < 3)"141    const val MISMATCH = "Passwords don't match."142    const val SAVE_FAILED = "Couldn't write that file."143    const val SAVED = "Saved."144    const val COPIED = "Copied to clipboard."145}146147internal fun exportFileName(prefix: String, extension: String, date: LocalDate = LocalDate.now()): String =148    "$prefix-${date}.$extension"149150private enum class ExportRoute { CHOOSE, PEACHSCRIPT, CSV }151152@Composable153fun ExportScreen(154    onBack: () -> Unit,155    vm: ExportViewModel = hiltViewModel(),156) {157    var route by remember { mutableStateOf(ExportRoute.CHOOSE) }158159    SecureFlagEffect() // MN-3: never screenshot the export backup blob160161    ShellBackground {162        Column(163            modifier = Modifier164                .fillMaxSize()165                .systemBarsPadding()166                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),167        ) {168            PeachSectionHeader(169                section = "export",170                onHome = { if (route != ExportRoute.CHOOSE) route = ExportRoute.CHOOSE else onBack() },171            )172173            Spacer(Modifier.size(PeachSpacing.lg))174175            if (route == ExportRoute.CHOOSE) {176                // Never needs to scroll — give it the real remaining height so177                // the prompt composes into it instead of trailing into dead178                // space below the two option cards.179                ChooseView(180                    onPeachScript = { route = ExportRoute.PEACHSCRIPT },181                    onCsv = { route = ExportRoute.CSV },182                    modifier = Modifier.weight(1f).fillMaxWidth(),183                )184            } else {185                Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {186                    when (route) {187                        ExportRoute.PEACHSCRIPT -> PeachScriptExportView(vm)188                        ExportRoute.CSV -> CsvExportView(vm)189                        ExportRoute.CHOOSE -> Unit // handled above, outside the scroll container190                    }191                }192            }193        }194    }195}196197// ---- Choose view ----------------------------------------------------------198199@Composable200private fun ChooseView(onPeachScript: () -> Unit, onCsv: () -> Unit, modifier: Modifier = Modifier) {201    Column(modifier) {202        OptionCard(203            title = "PeachScript (encrypted)",204            subtitle = "A backup encrypted with a separate password. Restores into Peach.",205            onClick = onPeachScript,206        )207        Spacer(Modifier.size(PeachSpacing.md))208        OptionCard(209            title = "CSV (unencrypted)",210            subtitle = "Plain text for moving into another manager. Not encrypted.",211            onClick = onCsv,212        )213        Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {214            PeachEmptyOrEndState(215                message = "Choose an export format.",216                detail = "Encrypted PeachScript is safest for Peach-to-Peach backups.",217            )218        }219    }220}221222@Composable223private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) {224    Column(225        modifier = Modifier226            .fillMaxWidth()227            .clip(RoundedCornerShape(18.dp))228            .background(peachColors.SurfaceElevated)229            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))230            .clickable(onClick = onClick)231            .padding(20.dp),232    ) {233        Text(234            title,235            color = peachColors.T0,236            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),237        )238        Spacer(Modifier.size(6.dp))239        Text(subtitle, color = peachColors.T1, style = PeachType.body)240    }241}242243// ---- PeachScript sub-view -------------------------------------------------244245@Composable246private fun PeachScriptExportView(vm: ExportViewModel) {247    val accent = LocalPeachAccent.current248    val context = LocalContext.current249    val isEmpty = remember { vm.entryCount() == 0 }250251    var password by remember { mutableStateOf("") }252    var confirm by remember { mutableStateOf("") }253254    // Generated artefact (the encrypted JSON string), set after [Generate backup].255    var output by remember { mutableStateOf<String?>(null) }256    var status by remember { mutableStateOf<String?>(null) }257258    val score = remember(password) { scorePassword(password) }259    val matches = password.isNotEmpty() && password == confirm260    val strongEnough = score >= 3261    val canGenerate = !isEmpty && strongEnough && matches262263    val saveLauncher = rememberLauncherForActivityResult(264        ActivityResultContracts.CreateDocument("*/*"),265    ) { uri ->266        val text = output267        if (uri != null && text != null) {268            status = if (vm.writeToUri(uri, text)) ExportStrings.SAVED else ExportStrings.SAVE_FAILED269        }270    }271272    GlassCard {273        Text(274            "create a\nbackup.",275            color = peachColors.T0,276            style = TextStyle(277                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic,278                fontSize = 24.sp, lineHeight = 26.sp,279            ),280        )281        Spacer(Modifier.size(PeachSpacing.sm))282        Text(283            "Your vault will be encrypted with a separate backup password.",284            color = peachColors.T1, style = PeachType.body,285        )286287        if (isEmpty) {288            Spacer(Modifier.size(PeachSpacing.lg))289            InlineError(ExportStrings.EMPTY_VAULT)290        }291292        Spacer(Modifier.size(PeachSpacing.lg))293        PeachUnderlineField(294            label = "Backup password",295            value = password,296            onValueChange = { password = it; output = null; status = null },297            isPassword = true,298        )299300        Spacer(Modifier.size(PeachSpacing.sm))301        PeachUnderlineField(302            label = "Confirm backup password",303            value = confirm,304            onValueChange = { confirm = it; output = null; status = null },305            isPassword = true,306        )307308        Spacer(Modifier.size(PeachSpacing.sm))309        StrengthBar(score = score)310        Spacer(Modifier.size(PeachSpacing.xs))311        Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {312            Text(313                "strength: ${strengthLabel(score)}",314                color = if (strongEnough) accent else peachColors.T2,315                style = PeachType.labelMono,316            )317        }318319        if (password.isNotEmpty() && !strongEnough) {320            Spacer(Modifier.size(PeachSpacing.sm))321            Text(ExportStrings.WEAK_PASSWORD, color = peachColors.Breach, style = PeachType.bodyMono)322        }323        if (confirm.isNotEmpty() && !matches) {324            Spacer(Modifier.size(PeachSpacing.sm))325            Text(ExportStrings.MISMATCH, color = peachColors.Breach, style = PeachType.bodyMono)326        }327    }328329    Spacer(Modifier.size(PeachSpacing.lg))330331    if (output == null) {332        AccentButton(333            label = "Generate backup",334            enabled = canGenerate,335            onClick = {336                output = vm.buildPeachScript(password)337                status = null338            },339        )340    } else {341        val text = output!!342        GhostButton("Copy to clipboard", accent) {343            PeachClipboard.copy(context, "peach backup", text)344            status = ExportStrings.COPIED345        }346        Spacer(Modifier.size(PeachSpacing.sm))347        GhostButton("Save to file", peachColors.T1) {348            vm.expectTransientBackground()349            saveLauncher.launch(exportFileName("peach-backup", "peach"))350        }351        status?.let {352            Spacer(Modifier.size(PeachSpacing.md))353            Text(354                it,355                color = if (it == ExportStrings.SAVE_FAILED) peachColors.Breach else accent,356                style = PeachType.labelMono,357            )358        }359    }360}361362// ---- CSV sub-view ---------------------------------------------------------363364@Composable365private fun CsvExportView(vm: ExportViewModel) {366    val accent = LocalPeachAccent.current367    val isEmpty = remember { vm.entryCount() == 0 }368369    var acknowledged by remember { mutableStateOf(false) }370    var status by remember { mutableStateOf<String?>(null) }371372    val saveLauncher = rememberLauncherForActivityResult(373        ActivityResultContracts.CreateDocument("text/csv"),374    ) { uri ->375        if (uri != null) {376            val csv = vm.buildCsv()377            status = if (vm.writeToUri(uri, csv)) ExportStrings.SAVED else ExportStrings.SAVE_FAILED378        }379    }380381    WarningCard(accent) {382        Text(383            "⚠ CSV exports are not encrypted. Store this file securely and delete it after importing.",384            color = peachColors.T0, style = PeachType.body,385        )386        Spacer(Modifier.size(PeachSpacing.lg))387        CheckboxRow(388            label = "I understand this file is unencrypted",389            checked = acknowledged,390            accent = accent,391            onToggle = { acknowledged = !acknowledged },392        )393    }394395    if (isEmpty) {396        Spacer(Modifier.size(PeachSpacing.lg))397        InlineError(ExportStrings.EMPTY_VAULT)398    }399400    Spacer(Modifier.size(PeachSpacing.lg))401    AccentButton(402        label = "Export CSV",403        enabled = acknowledged && !isEmpty,404        onClick = {405            vm.expectTransientBackground()406            saveLauncher.launch(exportFileName("peach-export", "csv"))407        },408    )409    status?.let {410        Spacer(Modifier.size(PeachSpacing.md))411        Text(412            it,413            color = if (it == ExportStrings.SAVE_FAILED) peachColors.Breach else accent,414            style = PeachType.labelMono,415        )416    }417}418419@Composable420private fun WarningCard(accent: Color, content: @Composable () -> Unit) {421    Column(422        modifier = Modifier423            .fillMaxWidth()424            .clip(RoundedCornerShape(18.dp))425            .background(accent.copy(alpha = 0.08f))426            .border(1.dp, accent.copy(alpha = 0.35f), RoundedCornerShape(18.dp))427            .padding(20.dp),428        content = { content() },429    )430}431432@Composable433private fun CheckboxRow(label: String, checked: Boolean, accent: Color, onToggle: () -> Unit) {434    Row(435        verticalAlignment = Alignment.CenterVertically,436        modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp),437    ) {438        Box(439            modifier = Modifier440                .size(18.dp)441                .clip(RoundedCornerShape(5.dp))442                .background(if (checked) accent else Color.Transparent)443                .border(1.5.dp, if (checked) accent else peachColors.T2, RoundedCornerShape(5.dp)),444            contentAlignment = Alignment.Center,445        ) {446            if (checked) Text("✓", color = Color(0xFF0C0A06), style = PeachType.tinyMono.copy(fontSize = 12.sp))447        }448        Spacer(Modifier.width(12.dp))449        Text(label, color = peachColors.T0, style = PeachType.body)450    }451}452453// ---- Shared building blocks (private to this draft) -----------------------454455@Composable456private fun InlineError(message: String) {457    Box(458        modifier = Modifier459            .fillMaxWidth()460            .clip(RoundedCornerShape(12.dp))461            .background(peachColors.BreachBg)462            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp))463            .padding(14.dp),464    ) {465        Text(message, color = peachColors.Breach, style = PeachType.bodyMono)466    }467}468469@Composable470private fun FieldLabel(text: String) {471    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono)472}473474@Composable475private fun PeachUnderlineField(476    label: String,477    value: String,478    onValueChange: (String) -> Unit,479    isPassword: Boolean = false,480) {481    DesignPeachField(482        label = label,483        value = value,484        onValueChange = onValueChange,485        visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,486    )487}488489@Composable490private fun GlassCard(content: @Composable () -> Unit) {491    Column(492        modifier = Modifier493            .fillMaxWidth()494            .clip(RoundedCornerShape(18.dp))495            .background(peachColors.SurfaceElevated)496            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))497            .padding(20.dp),498        content = { content() },499    )500}501502@Composable503private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {504    Box(505        modifier = Modifier506            .fillMaxWidth()507            .clip(RoundedCornerShape(12.dp))508            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp))509            .clickable(onClick = onClick)510            .padding(vertical = 12.dp),511        contentAlignment = Alignment.Center,512    ) {513        Text(label, color = color, style = PeachType.bodyMono)514    }515}516517@Composable518private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {519    val accent = LocalPeachAccent.current520    Box(521        modifier = Modifier522            .fillMaxWidth()523            .clip(RoundedCornerShape(12.dp))524            .background(if (enabled) accent else accent.copy(alpha = 0.3f))525            .clickable(enabled = enabled, onClick = onClick)526            .padding(vertical = 14.dp),527        contentAlignment = Alignment.Center,528    ) {529        Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)530    }531}