1package com.peachpasswords.ui.vault23import android.content.Context4import android.net.Uri5import androidx.activity.compose.rememberLauncherForActivityResult6import androidx.activity.result.contract.ActivityResultContracts7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import 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.heightIn18import 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.collectAsState32import androidx.compose.runtime.getValue33import 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.text.TextStyle42import androidx.compose.ui.text.font.FontStyle43import androidx.compose.ui.text.input.PasswordVisualTransformation44import androidx.compose.ui.unit.dp45import androidx.compose.ui.unit.sp46import androidx.hilt.navigation.compose.hiltViewModel47import androidx.lifecycle.ViewModel48import androidx.lifecycle.viewModelScope49import com.peachpasswords.ui.theme.LocalPeachAccent50import com.peachpasswords.ui.theme.peachColors51import com.peachpasswords.ui.theme.PeachType52import com.peachpasswords.ui.theme.ShellBackground53import com.peachpasswords.vault.VaultEntry54import com.peachpasswords.vault.VaultRepository55import dagger.hilt.android.lifecycle.HiltViewModel56import dagger.hilt.android.qualifiers.ApplicationContext57import kotlinx.coroutines.Dispatchers58import kotlinx.coroutines.flow.MutableStateFlow59import kotlinx.coroutines.flow.StateFlow60import kotlinx.coroutines.flow.asStateFlow61import kotlinx.coroutines.launch62import kotlinx.coroutines.withContext63import javax.inject.Inject64656667686970enum class ImportMode { MERGE, REPLACE }7172737475767778data class ImportPreview(79 val sourceLabel: String,80 val entries: List<ParsedEntry>,81 val fullEntries: List<VaultEntry>? = null,82)8384sealed interface ImportUiState {85 data object Idle : ImportUiState86 data object Working : ImportUiState87 data class Preview(val preview: ImportPreview) : ImportUiState88 data class Done(val count: Int) : ImportUiState89 data class Failed(val message: String) : ImportUiState90}9192object ImportErrors {93 const val UNRECOGNISED = "This doesn't look like a PeachScript export."94 const val WRONG_PASSWORD = "Incorrect backup password."95 const val CORRUPTED = "This export appears to be corrupted."96 const val NO_ENTRIES = "No entries found in this export."97 const val UNREADABLE_FILE = "Couldn't read that file."98 const val UNRECOGNISED_FILE = "Unrecognised file format."99}100101@HiltViewModel102class ImportViewModel @Inject constructor(103 private val vault: VaultRepository,104 @ApplicationContext private val appContext: Context,105) : ViewModel() {106107 private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Idle)108 val state: StateFlow<ImportUiState> = _state.asStateFlow()109110 111 private val _detectedProvider = MutableStateFlow<String?>(null)112 val detectedProvider: StateFlow<String?> = _detectedProvider.asStateFlow()113114 fun reset() {115 _state.value = ImportUiState.Idle116 _detectedProvider.value = null117 }118119 fun back(onBack: () -> Unit) {120 121 when (_state.value) {122 is ImportUiState.Preview, is ImportUiState.Failed, is ImportUiState.Done -> reset()123 else -> onBack()124 }125 }126127 128129 fun importPeachScript(text: String, backupPassword: String) {130 val trimmed = text.trim()131 if (trimmed.isEmpty() || !PeachScriptImport.looksLikePeachScript(trimmed)) {132 _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED)133 return134 }135 _state.value = ImportUiState.Working136 viewModelScope.launch {137 val result = withContext(Dispatchers.Default) {138 PeachScriptImport.decryptAndParse(trimmed, backupPassword)139 }140 _state.value = when (result) {141 is PeachScriptImport.DecryptResult.Success ->142 if (result.entries.isEmpty()) {143 ImportUiState.Failed(ImportErrors.NO_ENTRIES)144 } else {145 val parsed = result.entries.map { e ->146 ParsedEntry(147 title = e.title,148 username = e.username ?: e.email,149 password = e.password,150 url = e.url,151 notes = e.notes,152 totp = e.totp,153 )154 }155 ImportUiState.Preview(ImportPreview("PeachScript", parsed, result.entries))156 }157 PeachScriptImport.DecryptResult.WrongPassword ->158 ImportUiState.Failed(ImportErrors.WRONG_PASSWORD)159 PeachScriptImport.DecryptResult.Corrupted ->160 ImportUiState.Failed(ImportErrors.NO_ENTRIES)161 }162 }163 }164165 166 fun peachScriptNeedsPassword(text: String): Boolean {167 val t = text.trim()168 return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t)169 }170171 172173 fun importFile(uri: Uri) {174 _state.value = ImportUiState.Working175 _detectedProvider.value = null176 viewModelScope.launch {177 val content = withContext(Dispatchers.IO) {178 runCatching {179 appContext.contentResolver.openInputStream(uri)?.use {180 it.readBytes().toString(Charsets.UTF_8)181 }182 }.getOrNull()183 }184 if (content.isNullOrBlank()) {185 _state.value = ImportUiState.Failed(ImportErrors.UNREADABLE_FILE)186 return@launch187 }188 val result = withContext(Dispatchers.Default) { CsvImport.detectAndParse(content) }189 when (result) {190 is ImportParseResult.Success -> {191 _detectedProvider.value = result.provider192 _state.value = ImportUiState.Preview(ImportPreview(result.provider, result.entries))193 }194 is ImportParseResult.Failure -> {195 _state.value = ImportUiState.Failed(196 when (result.reason) {197 ImportParseResult.Reason.UNRECOGNISED -> ImportErrors.UNRECOGNISED_FILE198 ImportParseResult.Reason.CORRUPTED -> ImportErrors.CORRUPTED199 ImportParseResult.Reason.NO_ENTRIES -> ImportErrors.NO_ENTRIES200 },201 )202 }203 }204 }205 }206207 208209 210211212213214 fun confirmImport(preview: ImportPreview, mode: ImportMode) {215 _state.value = ImportUiState.Working216 viewModelScope.launch {217 try {218 if (mode == ImportMode.REPLACE) {219 vault.getEntries().forEach { existing ->220 runCatching { vault.deleteEntry(existing.id) }221 }222 }223 val now = System.currentTimeMillis()224 var imported = 0225 val fulls = preview.fullEntries226 if (fulls != null) {227 228 229 for (e in fulls) {230 runCatching { vault.addEntry(e.copy(id = "")) }.onSuccess { imported++ }231 }232 } else {233 for (p in preview.entries) {234 val draft = VaultEntry(235 id = "",236 type = "login",237 title = p.title,238 username = p.username,239 password = p.password,240 url = p.url,241 notes = p.notes,242 totp = p.totp,243 createdAt = now,244 modifiedAt = now,245 )246 runCatching { vault.addEntry(draft) }.onSuccess { imported++ }247 }248 }249 _state.value = ImportUiState.Done(imported)250 } catch (e: Exception) {251 _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)252 }253 }254 }255}256257258259260261private enum class ImportRoute { CHOOSE, PEACHSCRIPT, FILE }262263@Composable264fun ImportScreen(265 onBack: () -> Unit,266 vm: ImportViewModel = hiltViewModel(),267) {268 val state by vm.state.collectAsState()269 var route by remember { mutableStateOf(ImportRoute.CHOOSE) }270271 ShellBackground {272 Column(273 modifier = Modifier274 .fillMaxSize()275 .systemBarsPadding()276 .padding(horizontal = 20.dp, vertical = 16.dp),277 ) {278 279 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {280 Icon(281 Icons.AutoMirrored.Rounded.ArrowBack,282 contentDescription = "back",283 tint = peachColors.T0,284 modifier = Modifier285 .size(22.dp)286 .clip(RoundedCornerShape(8.dp))287 .clickable {288 when {289 state !is ImportUiState.Idle -> vm.back(onBack)290 route != ImportRoute.CHOOSE -> route = ImportRoute.CHOOSE291 else -> onBack()292 }293 },294 )295 Spacer(Modifier.width(14.dp))296 Text(297 "Import",298 color = peachColors.T0,299 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),300 )301 }302303 Spacer(Modifier.size(18.dp))304305 Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {306 when (val s = state) {307 is ImportUiState.Preview -> PreviewView(308 preview = s.preview,309 onConfirm = { mode -> vm.confirmImport(s.preview, mode) },310 onCancel = { vm.reset() },311 )312 is ImportUiState.Done -> DoneView(count = s.count, onDone = onBack)313 is ImportUiState.Failed -> {314 ErrorBanner(s.message)315 Spacer(Modifier.size(16.dp))316 when (route) {317 ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)318 ImportRoute.FILE -> FileView(vm)319 ImportRoute.CHOOSE -> ChooseView(320 onPeachScript = { route = ImportRoute.PEACHSCRIPT },321 onFile = { route = ImportRoute.FILE },322 )323 }324 }325 ImportUiState.Working -> WorkingView()326 ImportUiState.Idle -> when (route) {327 ImportRoute.CHOOSE -> ChooseView(328 onPeachScript = { route = ImportRoute.PEACHSCRIPT },329 onFile = { route = ImportRoute.FILE },330 )331 ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)332 ImportRoute.FILE -> FileView(vm)333 }334 }335 }336 }337 }338}339340341342@Composable343private fun ChooseView(onPeachScript: () -> Unit, onFile: () -> Unit) {344 OptionCard(345 title = "From PeachScript",346 subtitle = "Paste an encrypted Peach export",347 onClick = onPeachScript,348 )349 Spacer(Modifier.size(12.dp))350 OptionCard(351 title = "From file",352 subtitle = "Bitwarden, 1Password, Chrome, Safari, Edge CSV",353 onClick = onFile,354 )355}356357@Composable358private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) {359 Column(360 modifier = Modifier361 .fillMaxWidth()362 .clip(RoundedCornerShape(18.dp))363 .background(peachColors.SurfaceElevated)364 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))365 .clickable(onClick = onClick)366 .padding(20.dp),367 ) {368 Text(369 title,370 color = peachColors.T0,371 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),372 )373 Spacer(Modifier.size(6.dp))374 Text(subtitle, color = peachColors.T1, style = PeachType.body)375 }376}377378379380@Composable381private fun PeachScriptView(vm: ImportViewModel) {382 var paste by remember { mutableStateOf("") }383 var backupPassword by remember { mutableStateOf("") }384 val needsPassword = remember(paste) { vm.peachScriptNeedsPassword(paste) }385386 GlassCard {387 FieldLabel("Paste your PeachScript export")388 Spacer(Modifier.size(8.dp))389 PeachMultilineField(390 value = paste,391 onValueChange = { paste = it },392 minLines = 6,393 )394 if (needsPassword) {395 Spacer(Modifier.size(16.dp))396 FieldLabel("Backup password")397 Spacer(Modifier.size(6.dp))398 PeachUnderlineField(399 value = backupPassword,400 onValueChange = { backupPassword = it },401 isPassword = true,402 )403 }404 }405 Spacer(Modifier.size(18.dp))406 AccentButton(407 label = "Import",408 enabled = paste.isNotBlank(),409 onClick = { vm.importPeachScript(paste, backupPassword) },410 )411}412413414415@Composable416private fun FileView(vm: ImportViewModel) {417 val detected by vm.detectedProvider.collectAsState()418 val picker = rememberLauncherForActivityResult(419 ActivityResultContracts.GetContent(),420 ) { uri -> if (uri != null) vm.importFile(uri) }421422 GlassCard {423 Text(424 "Import from a CSV or JSON export",425 color = peachColors.T1,426 style = PeachType.body,427 )428 Spacer(Modifier.size(6.dp))429 Text(430 "Supports Bitwarden, 1Password, Chrome/Edge, and Safari exports.",431 color = peachColors.T2,432 style = PeachType.hint,433 )434 Spacer(Modifier.size(16.dp))435 GhostButton("Choose file", LocalPeachAccent.current) {436 437 438 439 picker.launch("*/*")440 }441 if (detected != null) {442 Spacer(Modifier.size(12.dp))443 Text(444 "Detected: $detected export",445 color = LocalPeachAccent.current,446 style = PeachType.labelMono,447 )448 }449 }450}451452453454@Composable455private fun PreviewView(456 preview: ImportPreview,457 onConfirm: (ImportMode) -> Unit,458 onCancel: () -> Unit,459) {460 var mode by remember { mutableStateOf(ImportMode.MERGE) }461462 GlassCard {463 Text(464 "Detected: ${preview.sourceLabel} export",465 color = LocalPeachAccent.current,466 style = PeachType.labelMono,467 )468 Spacer(Modifier.size(10.dp))469 Text(470 "Found ${preview.entries.size} ${if (preview.entries.size == 1) "entry" else "entries"}",471 color = peachColors.T0,472 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),473 )474 Spacer(Modifier.size(12.dp))475 476 Column(477 modifier = Modifier478 .fillMaxWidth()479 .heightIn(max = 150.dp)480 .verticalScroll(rememberScrollState()),481 ) {482 preview.entries.take(5).forEach { e ->483 Text(484 e.title,485 color = peachColors.T1,486 style = PeachType.entryNameList,487 modifier = Modifier.padding(vertical = 4.dp),488 )489 }490 if (preview.entries.size > 5) {491 Text(492 "+${preview.entries.size - 5} more",493 color = peachColors.T2,494 style = PeachType.tinyMono,495 modifier = Modifier.padding(top = 4.dp),496 )497 }498 }499 Divider()500 RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE }501 Spacer(Modifier.size(8.dp))502 RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE }503 }504 Spacer(Modifier.size(18.dp))505 AccentButton(label = "Confirm Import", onClick = { onConfirm(mode) })506 Spacer(Modifier.size(8.dp))507 GhostButton("Cancel", peachColors.T1, onCancel)508}509510@Composable511private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) {512 val accent = LocalPeachAccent.current513 Row(514 verticalAlignment = Alignment.CenterVertically,515 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),516 ) {517 Box(518 modifier = Modifier519 .size(16.dp)520 .clip(RoundedCornerShape(999.dp))521 .border(1.5.dp, if (selected) accent else peachColors.T2, RoundedCornerShape(999.dp)),522 contentAlignment = Alignment.Center,523 ) {524 if (selected) {525 Box(Modifier.size(8.dp).clip(RoundedCornerShape(999.dp)).background(accent))526 }527 }528 Spacer(Modifier.width(12.dp))529 Text(label, color = peachColors.T0, style = PeachType.body)530 }531}532533534535@Composable536private fun DoneView(count: Int, onDone: () -> Unit) {537 GlassCard {538 Text(539 "imported.",540 color = peachColors.T0,541 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),542 )543 Spacer(Modifier.size(8.dp))544 Text(545 "$count ${if (count == 1) "entry" else "entries"} imported.",546 color = peachColors.T1,547 style = PeachType.body,548 )549 }550 Spacer(Modifier.size(18.dp))551 AccentButton(label = "Done", onClick = onDone)552}553554@Composable555private fun WorkingView() {556 Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {557 Text(558 "working…",559 color = peachColors.T1,560 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp),561 )562 }563}564565@Composable566private fun ErrorBanner(message: String) {567 Box(568 modifier = Modifier569 .fillMaxWidth()570 .clip(RoundedCornerShape(12.dp))571 .background(peachColors.BreachBg)572 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp))573 .padding(14.dp),574 ) {575 Text(message, color = peachColors.Breach, style = PeachType.bodyMono)576 }577}578579580581@Composable582private fun FieldLabel(text: String) {583 Text(584 text.uppercase(),585 color = peachColors.T2,586 style = PeachType.labelMono,587 )588}589590@Composable591private fun PeachMultilineField(592 value: String,593 onValueChange: (String) -> Unit,594 minLines: Int,595) {596 val accent = LocalPeachAccent.current597 Box(598 modifier = Modifier599 .fillMaxWidth()600 .clip(RoundedCornerShape(12.dp))601 .background(peachColors.S2)602 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp))603 .padding(14.dp),604 ) {605 BasicTextField(606 value = value,607 onValueChange = onValueChange,608 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),609 cursorBrush = SolidColor(accent),610 minLines = minLines,611 modifier = Modifier.fillMaxWidth(),612 )613 if (value.isEmpty()) {614 Text(615 "{ \"peachscript\": 1, … }",616 color = peachColors.T2,617 style = PeachType.bodyMono,618 )619 }620 }621}622623@Composable624private fun PeachUnderlineField(625 value: String,626 onValueChange: (String) -> Unit,627 isPassword: Boolean = false,628) {629 val accent = LocalPeachAccent.current630 Column(Modifier.fillMaxWidth()) {631 BasicTextField(632 value = value,633 onValueChange = onValueChange,634 singleLine = true,635 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),636 cursorBrush = SolidColor(accent),637 visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,638 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),639 )640 Box(Modifier.fillMaxWidth().size(1.dp).background(peachColors.T3))641 }642}643644@Composable645private fun GlassCard(content: @Composable () -> Unit) {646 Column(647 modifier = Modifier648 .fillMaxWidth()649 .clip(RoundedCornerShape(18.dp))650 .background(peachColors.SurfaceElevated)651 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))652 .padding(20.dp),653 content = { content() },654 )655}656657@Composable658private fun Divider() {659 Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3))660}661662@Composable663private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {664 Box(665 modifier = Modifier666 .fillMaxWidth()667 .clip(RoundedCornerShape(12.dp))668 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp))669 .clickable(onClick = onClick)670 .padding(vertical = 12.dp),671 contentAlignment = Alignment.Center,672 ) {673 Text(label, color = color, style = PeachType.bodyMono)674 }675}676677@Composable678private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {679 val accent = LocalPeachAccent.current680 Box(681 modifier = Modifier682 .fillMaxWidth()683 .clip(RoundedCornerShape(12.dp))684 .background(if (enabled) accent else accent.copy(alpha = 0.3f))685 .clickable(enabled = enabled, onClick = onClick)686 .padding(vertical = 14.dp),687 contentAlignment = Alignment.Center,688 ) {689 Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)690 }691}