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.Inject686970717273747576777879808182838485868788899091@HiltViewModel92class ExportViewModel @Inject constructor(93 private val vault: VaultRepository,94 private val license: LicenseRepository,95 @ApplicationContext private val appContext: Context,96) : ViewModel() {9798 99 fun entries(): List<VaultEntry> = vault.getEntries()100101 fun entryCount(): Int = vault.getEntries().size102103 104105106107108109 fun expectTransientBackground() = vault.expectTransientBackground()110111 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 122 fun buildCsv(): String = CsvExport.toCsv(entries())123124 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}133134135136137138object 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() 160161 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 177 178 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 190 }191 }192 }193 }194 }195}196197198199@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}242243244245@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 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}361362363364@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}452453454455@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}