package com.peachpasswords.ui.vault import android.content.Context 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.Arrangement 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.heightIn 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.collectAsState 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.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject // ───────────────────────────────────────────────────────────────────────────── // ViewModel // ───────────────────────────────────────────────────────────────────────────── /** How parsed entries land in the vault on confirm. */ enum class ImportMode { MERGE, REPLACE } /** * A staged, parsed import ready for preview + confirm. [entries] is the * ParsedEntry projection used by the preview UI (shared with CSV). [fullEntries] * is set only for PeachScript, carrying the complete VaultEntry field set so the * import preserves tags, card fields, canary, breach, timestamps, etc. */ data class ImportPreview( val sourceLabel: String, val entries: List, val fullEntries: List? = null, ) sealed interface ImportUiState { data object Idle : ImportUiState data object Working : ImportUiState data class Preview(val preview: ImportPreview) : ImportUiState data class Done(val count: Int) : ImportUiState data class Failed(val message: String) : ImportUiState } object ImportErrors { const val UNRECOGNISED = "This doesn't look like a PeachScript export." const val WRONG_PASSWORD = "Incorrect backup password." const val CORRUPTED = "This export appears to be corrupted." const val NO_ENTRIES = "No entries found in this export." const val UNREADABLE_FILE = "Couldn't read that file." const val UNRECOGNISED_FILE = "Unrecognised file format." } @HiltViewModel class ImportViewModel @Inject constructor( private val vault: VaultRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(ImportUiState.Idle) val state: StateFlow = _state.asStateFlow() /** Detected provider label for the file sub-view ("Detected: X export"). */ private val _detectedProvider = MutableStateFlow(null) val detectedProvider: StateFlow = _detectedProvider.asStateFlow() fun reset() { _state.value = ImportUiState.Idle _detectedProvider.value = null } fun back(onBack: () -> Unit) { // Preview/error back returns to the input view; otherwise leave the screen. when (_state.value) { is ImportUiState.Preview, is ImportUiState.Failed, is ImportUiState.Done -> reset() else -> onBack() } } // ---- PeachScript paste ------------------------------------------------ fun importPeachScript(text: String, backupPassword: String) { val trimmed = text.trim() if (trimmed.isEmpty() || !PeachScriptImport.looksLikePeachScript(trimmed)) { _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED) return } _state.value = ImportUiState.Working viewModelScope.launch { val result = withContext(Dispatchers.Default) { PeachScriptImport.decryptAndParse(trimmed, backupPassword) } _state.value = when (result) { is PeachScriptImport.DecryptResult.Success -> if (result.entries.isEmpty()) { ImportUiState.Failed(ImportErrors.NO_ENTRIES) } else { val parsed = result.entries.map { e -> ParsedEntry( title = e.title, username = e.username ?: e.email, password = e.password, url = e.url, notes = e.notes, totp = e.totp, ) } ImportUiState.Preview(ImportPreview("PeachScript", parsed, result.entries)) } PeachScriptImport.DecryptResult.WrongPassword -> ImportUiState.Failed(ImportErrors.WRONG_PASSWORD) PeachScriptImport.DecryptResult.Corrupted -> ImportUiState.Failed(ImportErrors.NO_ENTRIES) } } } /** True when the pasted text is a PeachScript export that needs a password. */ fun peachScriptNeedsPassword(text: String): Boolean { val t = text.trim() return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t) } // ---- File import ------------------------------------------------------ fun importFile(uri: Uri) { _state.value = ImportUiState.Working _detectedProvider.value = null viewModelScope.launch { val content = withContext(Dispatchers.IO) { runCatching { appContext.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() } if (content.isNullOrBlank()) { _state.value = ImportUiState.Failed(ImportErrors.UNREADABLE_FILE) return@launch } val result = withContext(Dispatchers.Default) { CsvImport.detectAndParse(content) } when (result) { is ImportParseResult.Success -> { _detectedProvider.value = result.provider _state.value = ImportUiState.Preview(ImportPreview(result.provider, result.entries)) } is ImportParseResult.Failure -> { _state.value = ImportUiState.Failed( when (result.reason) { ImportParseResult.Reason.UNRECOGNISED -> ImportErrors.UNRECOGNISED_FILE ImportParseResult.Reason.CORRUPTED -> ImportErrors.CORRUPTED ImportParseResult.Reason.NO_ENTRIES -> ImportErrors.NO_ENTRIES }, ) } } } } // ---- Confirm ---------------------------------------------------------- /** * Persist the previewed entries. No batch API exists on VaultRepository, so * import loops [VaultRepository.addEntry]. REPLACE soft-deletes the current * active set first; MERGE simply appends. */ fun confirmImport(preview: ImportPreview, mode: ImportMode) { _state.value = ImportUiState.Working viewModelScope.launch { try { if (mode == ImportMode.REPLACE) { vault.getEntries().forEach { existing -> runCatching { vault.deleteEntry(existing.id) } } } val now = System.currentTimeMillis() var imported = 0 val fulls = preview.fullEntries if (fulls != null) { // PeachScript: import the complete entries (all fields, all types). // Re-id so an import can never overwrite an existing entry. for (e in fulls) { runCatching { vault.addEntry(e.copy(id = "")) }.onSuccess { imported++ } } } else { for (p in preview.entries) { val draft = VaultEntry( id = "", type = "login", title = p.title, username = p.username, password = p.password, url = p.url, notes = p.notes, totp = p.totp, createdAt = now, modifiedAt = now, ) runCatching { vault.addEntry(draft) }.onSuccess { imported++ } } } _state.value = ImportUiState.Done(imported) } catch (e: Exception) { _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED) } } } } // ───────────────────────────────────────────────────────────────────────────── // Screen // ───────────────────────────────────────────────────────────────────────────── private enum class ImportRoute { CHOOSE, PEACHSCRIPT, FILE } @Composable fun ImportScreen( onBack: () -> Unit, vm: ImportViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() var route by remember { mutableStateOf(ImportRoute.CHOOSE) } ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = 20.dp, vertical = 16.dp), ) { // Top bar: back · "Import" Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0, modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(8.dp)) .clickable { when { state !is ImportUiState.Idle -> vm.back(onBack) route != ImportRoute.CHOOSE -> route = ImportRoute.CHOOSE else -> onBack() } }, ) Spacer(Modifier.width(14.dp)) Text( "Import", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp), ) } Spacer(Modifier.size(18.dp)) Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { when (val s = state) { is ImportUiState.Preview -> PreviewView( preview = s.preview, onConfirm = { mode -> vm.confirmImport(s.preview, mode) }, onCancel = { vm.reset() }, ) is ImportUiState.Done -> DoneView(count = s.count, onDone = onBack) is ImportUiState.Failed -> { ErrorBanner(s.message) Spacer(Modifier.size(16.dp)) when (route) { ImportRoute.PEACHSCRIPT -> PeachScriptView(vm) ImportRoute.FILE -> FileView(vm) ImportRoute.CHOOSE -> ChooseView( onPeachScript = { route = ImportRoute.PEACHSCRIPT }, onFile = { route = ImportRoute.FILE }, ) } } ImportUiState.Working -> WorkingView() ImportUiState.Idle -> when (route) { ImportRoute.CHOOSE -> ChooseView( onPeachScript = { route = ImportRoute.PEACHSCRIPT }, onFile = { route = ImportRoute.FILE }, ) ImportRoute.PEACHSCRIPT -> PeachScriptView(vm) ImportRoute.FILE -> FileView(vm) } } } } } } // ---- Choose view ---------------------------------------------------------- @Composable private fun ChooseView(onPeachScript: () -> Unit, onFile: () -> Unit) { OptionCard( title = "From PeachScript", subtitle = "Paste an encrypted Peach export", onClick = onPeachScript, ) Spacer(Modifier.size(12.dp)) OptionCard( title = "From file", subtitle = "Bitwarden, 1Password, Chrome, Safari, Edge CSV", onClick = onFile, ) } @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 PeachScriptView(vm: ImportViewModel) { var paste by remember { mutableStateOf("") } var backupPassword by remember { mutableStateOf("") } val needsPassword = remember(paste) { vm.peachScriptNeedsPassword(paste) } GlassCard { FieldLabel("Paste your PeachScript export") Spacer(Modifier.size(8.dp)) PeachMultilineField( value = paste, onValueChange = { paste = it }, minLines = 6, ) if (needsPassword) { Spacer(Modifier.size(16.dp)) FieldLabel("Backup password") Spacer(Modifier.size(6.dp)) PeachUnderlineField( value = backupPassword, onValueChange = { backupPassword = it }, isPassword = true, ) } } Spacer(Modifier.size(18.dp)) AccentButton( label = "Import", enabled = paste.isNotBlank(), onClick = { vm.importPeachScript(paste, backupPassword) }, ) } // ---- File sub-view -------------------------------------------------------- @Composable private fun FileView(vm: ImportViewModel) { val detected by vm.detectedProvider.collectAsState() val picker = rememberLauncherForActivityResult( ActivityResultContracts.GetContent(), ) { uri -> if (uri != null) vm.importFile(uri) } GlassCard { Text( "Import from a CSV or JSON export", color = peachColors.T1, style = PeachType.body, ) Spacer(Modifier.size(6.dp)) Text( "Supports Bitwarden, 1Password, Chrome/Edge, and Safari exports.", color = peachColors.T2, style = PeachType.hint, ) Spacer(Modifier.size(16.dp)) GhostButton("Choose file", LocalPeachAccent.current) { // GetContent takes a single MIME type; the chooser still lists CSV // and JSON. "*/*" is intentional so providers exporting odd MIME // types for .csv are not filtered out by the system picker. picker.launch("*/*") } if (detected != null) { Spacer(Modifier.size(12.dp)) Text( "Detected: $detected export", color = LocalPeachAccent.current, style = PeachType.labelMono, ) } } } // ---- Preview -------------------------------------------------------------- @Composable private fun PreviewView( preview: ImportPreview, onConfirm: (ImportMode) -> Unit, onCancel: () -> Unit, ) { var mode by remember { mutableStateOf(ImportMode.MERGE) } GlassCard { Text( "Detected: ${preview.sourceLabel} export", color = LocalPeachAccent.current, style = PeachType.labelMono, ) Spacer(Modifier.size(10.dp)) Text( "Found ${preview.entries.size} ${if (preview.entries.size == 1) "entry" else "entries"}", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp), ) Spacer(Modifier.size(12.dp)) // Up to five entry names, scrollable if more. Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 150.dp) .verticalScroll(rememberScrollState()), ) { preview.entries.take(5).forEach { e -> Text( e.title, color = peachColors.T1, style = PeachType.entryNameList, modifier = Modifier.padding(vertical = 4.dp), ) } if (preview.entries.size > 5) { Text( "+${preview.entries.size - 5} more", color = peachColors.T2, style = PeachType.tinyMono, modifier = Modifier.padding(top = 4.dp), ) } } Divider() RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE } Spacer(Modifier.size(8.dp)) RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE } } Spacer(Modifier.size(18.dp)) AccentButton(label = "Confirm Import", onClick = { onConfirm(mode) }) Spacer(Modifier.size(8.dp)) GhostButton("Cancel", peachColors.T1, onCancel) } @Composable private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), ) { Box( modifier = Modifier .size(16.dp) .clip(RoundedCornerShape(999.dp)) .border(1.5.dp, if (selected) accent else peachColors.T2, RoundedCornerShape(999.dp)), contentAlignment = Alignment.Center, ) { if (selected) { Box(Modifier.size(8.dp).clip(RoundedCornerShape(999.dp)).background(accent)) } } Spacer(Modifier.width(12.dp)) Text(label, color = peachColors.T0, style = PeachType.body) } } // ---- Done / Working / Error ---------------------------------------------- @Composable private fun DoneView(count: Int, onDone: () -> Unit) { GlassCard { Text( "imported.", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp), ) Spacer(Modifier.size(8.dp)) Text( "$count ${if (count == 1) "entry" else "entries"} imported.", color = peachColors.T1, style = PeachType.body, ) } Spacer(Modifier.size(18.dp)) AccentButton(label = "Done", onClick = onDone) } @Composable private fun WorkingView() { Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) { Text( "working…", color = peachColors.T1, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp), ) } } @Composable private fun ErrorBanner(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) } } // ---- Shared building blocks (private to this draft) ----------------------- @Composable private fun FieldLabel(text: String) { Text( text.uppercase(), color = peachColors.T2, style = PeachType.labelMono, ) } @Composable private fun PeachMultilineField( value: String, onValueChange: (String) -> Unit, minLines: Int, ) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(peachColors.S2) .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)) .padding(14.dp), ) { BasicTextField( value = value, onValueChange = onValueChange, textStyle = PeachType.bodyMono.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), minLines = minLines, modifier = Modifier.fillMaxWidth(), ) if (value.isEmpty()) { Text( "{ \"peachscript\": 1, … }", color = peachColors.T2, style = PeachType.bodyMono, ) } } } @Composable private fun PeachUnderlineField( value: String, onValueChange: (String) -> Unit, isPassword: Boolean = false, ) { val accent = LocalPeachAccent.current Column(Modifier.fillMaxWidth()) { BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, textStyle = PeachType.bodyMono.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), ) Box(Modifier.fillMaxWidth().size(1.dp).background(peachColors.T3)) } } @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 Divider() { Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3)) } @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) } }