ImportScreen.kt

26.0 KB · raw

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.Inject6465// ─────────────────────────────────────────────────────────────────────────────66// ViewModel67// ─────────────────────────────────────────────────────────────────────────────6869/** How parsed entries land in the vault on confirm. */70enum class ImportMode { MERGE, REPLACE }7172/**73 * A staged, parsed import ready for preview + confirm. [entries] is the74 * ParsedEntry projection used by the preview UI (shared with CSV). [fullEntries]75 * is set only for PeachScript, carrying the complete VaultEntry field set so the76 * import preserves tags, card fields, canary, breach, timestamps, etc.77 */78data 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    /** Detected provider label for the file sub-view ("Detected: X export"). */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        // Preview/error back returns to the input view; otherwise leave the screen.121        when (_state.value) {122            is ImportUiState.Preview, is ImportUiState.Failed, is ImportUiState.Done -> reset()123            else -> onBack()124        }125    }126127    // ---- PeachScript paste ------------------------------------------------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    /** True when the pasted text is a PeachScript export that needs a password. */166    fun peachScriptNeedsPassword(text: String): Boolean {167        val t = text.trim()168        return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t)169    }170171    // ---- File import ------------------------------------------------------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    // ---- Confirm ----------------------------------------------------------208209    /**210     * Persist the previewed entries. No batch API exists on VaultRepository, so211     * import loops [VaultRepository.addEntry]. REPLACE soft-deletes the current212     * active set first; MERGE simply appends.213     */214    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                    // PeachScript: import the complete entries (all fields, all types).228                    // Re-id so an import can never overwrite an existing entry.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}256257// ─────────────────────────────────────────────────────────────────────────────258// Screen259// ─────────────────────────────────────────────────────────────────────────────260261private 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            // Top bar: back · "Import"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}339340// ---- Choose view ----------------------------------------------------------341342@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}377378// ---- PeachScript sub-view -------------------------------------------------379380@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}412413// ---- File sub-view --------------------------------------------------------414415@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            // GetContent takes a single MIME type; the chooser still lists CSV437            // and JSON. "*/*" is intentional so providers exporting odd MIME438            // types for .csv are not filtered out by the system picker.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}451452// ---- Preview --------------------------------------------------------------453454@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        // Up to five entry names, scrollable if more.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}532533// ---- Done / Working / Error ----------------------------------------------534535@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}578579// ---- Shared building blocks (private to this draft) -----------------------580581@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}