ImportScreen.kt

40.4 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.LinearProgressIndicator30import androidx.compose.material3.Text31import androidx.compose.runtime.Composable32import androidx.compose.runtime.LaunchedEffect33import androidx.compose.runtime.collectAsState34import androidx.compose.runtime.getValue35import androidx.compose.runtime.mutableStateOf36import androidx.compose.runtime.remember37import androidx.compose.runtime.setValue38import androidx.compose.ui.Alignment39import androidx.compose.ui.Modifier40import androidx.compose.ui.draw.clip41import androidx.compose.ui.graphics.Color42import androidx.compose.ui.graphics.SolidColor43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.font.FontStyle45import androidx.compose.ui.text.input.PasswordVisualTransformation46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.sp48import androidx.hilt.navigation.compose.hiltViewModel49import androidx.lifecycle.ViewModel50import androidx.lifecycle.viewModelScope51import com.peachpasswords.ui.components.PeachEmptyOrEndState52import com.peachpasswords.ui.components.PeachField as DesignPeachField53import com.peachpasswords.ui.components.PeachSectionHeader54import com.peachpasswords.ui.lock.canSubmitCreateVault55import com.peachpasswords.ui.security.scorePassword56import com.peachpasswords.ui.security.strengthColor57import com.peachpasswords.ui.security.strengthLabel58import com.peachpasswords.ui.theme.LocalPeachAccent59import com.peachpasswords.ui.theme.PeachSpacing60import com.peachpasswords.ui.theme.peachColors61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.ui.theme.ShellBackground63import com.peachpasswords.vault.CreateVaultResult64import com.peachpasswords.vault.VaultEntry65import com.peachpasswords.vault.VaultRepository66import com.peachpasswords.vault.applyImportedPresentationSettings67import dagger.hilt.android.lifecycle.HiltViewModel68import dagger.hilt.android.qualifiers.ApplicationContext69import kotlinx.coroutines.Dispatchers70import kotlinx.coroutines.flow.MutableStateFlow71import kotlinx.coroutines.flow.StateFlow72import kotlinx.coroutines.flow.asStateFlow73import kotlinx.coroutines.launch74import kotlinx.coroutines.withContext75import javax.inject.Inject76import com.peachpasswords.license.LicenseRepository7778// ─────────────────────────────────────────────────────────────────────────────79// ViewModel80// ─────────────────────────────────────────────────────────────────────────────8182/** How parsed entries land in the vault on confirm. */83enum class ImportMode { MERGE, REPLACE }8485/**86 * A staged, parsed import ready for preview + confirm. [entries] is the87 * ParsedEntry projection used by the preview UI (shared with CSV). [fullEntries]88 * is set only for PeachScript, carrying the complete VaultEntry field set so the89 * import preserves tags, card fields, canary, breach, timestamps, etc.90 */91data class ImportPreview(92    val sourceLabel: String,93    val entries: List<ParsedEntry>,94    val fullEntries: List<VaultEntry>? = null,95    val settings: com.peachpasswords.vault.VaultSettings? = null,96    val licenseKey: String? = null,97)9899sealed interface ImportUiState {100    data object Idle : ImportUiState101    /** [step] is user-facing progress copy; null while there's nothing to say yet. */102    data class Working(val step: String? = null) : ImportUiState103    data class PeachScriptFile(val text: String) : ImportUiState104    data class Preview(val preview: ImportPreview) : ImportUiState105    data class Done(val count: Int, val createdVault: Boolean = false) : ImportUiState106    data class Failed(val message: String) : ImportUiState107}108109object ImportErrors {110    const val UNRECOGNISED = "This doesn't look like PeachScript. Make sure you copied the entire backup string."111    const val WRONG_PASSWORD = "Wrong backup password. This is the password you chose when you created the backup."112    const val CORRUPTED = "This Peach backup file looks corrupted or incomplete."113    const val NO_ENTRIES = "No importable password entries were found in that file."114    const val UNREADABLE_FILE = "Couldn't read that file."115    const val UNRECOGNISED_FILE = "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup."116    const val NO_MASTER_PASSWORD = "Choose a master password for the new vault first."117    const val TOO_LARGE = "That file is too big to import. Peach accepts up to 50 MB and 10,000 entries."118}119120internal fun shouldRouteFileToPeachScript(path: String?, content: String): Boolean {121    val path = path?.lowercase().orEmpty()122    return path.endsWith(".peach") || PeachScriptImport.looksLikePeachScript(content.trim())123}124125@HiltViewModel126class ImportViewModel @Inject constructor(127    private val vault: VaultRepository,128    private val license: LicenseRepository,129    @ApplicationContext private val appContext: Context,130) : ViewModel() {131132    private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Idle)133    val state: StateFlow<ImportUiState> = _state.asStateFlow()134135    /** Detected provider label for the file sub-view ("Detected: X export"). */136    private val _detectedProvider = MutableStateFlow<String?>(null)137    val detectedProvider: StateFlow<String?> = _detectedProvider.asStateFlow()138139    /**140     * False when this screen was reached with no vault to import into yet — fresh141     * install ("restore instead") or "forgot your password" recovery. The preview142     * step then collects a new master password and [confirmImport] creates the143     * vault before adding entries, instead of every addEntry silently failing.144     */145    val vaultUnlocked: StateFlow<Boolean> = vault.isUnlocked146147    fun reset() {148        _state.value = ImportUiState.Idle149        _detectedProvider.value = null150    }151152    /** See VaultRepository.expectTransientBackground — call before launching the SAF picker. */153    fun expectTransientBackground() = vault.expectTransientBackground()154155    fun back(onBack: () -> Unit) {156        // Preview/error back returns to the input view; otherwise leave the screen.157        when (_state.value) {158            is ImportUiState.Preview,159            is ImportUiState.Failed,160            is ImportUiState.Done,161            is ImportUiState.PeachScriptFile,162            -> reset()163            else -> onBack()164        }165    }166167    // ---- PeachScript paste ------------------------------------------------168169    fun importPeachScript(text: String, backupPassword: String) {170        val trimmed = text.trim()171        if (trimmed.isEmpty() || !PeachScriptImport.looksLikePeachScript(trimmed)) {172            _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED)173            return174        }175        _state.value = ImportUiState.Working()176        viewModelScope.launch {177            val result = withContext(Dispatchers.Default) {178                PeachScriptImport.decryptAndParse(trimmed, backupPassword)179            }180            _state.value = when (result) {181                is PeachScriptImport.DecryptResult.Success ->182                    if (result.entries.isEmpty()) {183                        ImportUiState.Failed(ImportErrors.NO_ENTRIES)184                    } else {185                        val parsed = result.entries.map { e ->186                            ParsedEntry(187                                title = e.title,188                                username = e.username ?: e.email,189                                password = e.password,190                                url = e.url,191                                notes = e.notes,192                                totp = e.totp,193                            )194                        }195                        ImportUiState.Preview(196                            ImportPreview(197                                sourceLabel = "PeachScript",198                                entries = parsed,199                                fullEntries = result.entries,200                                settings = result.settings,201                                licenseKey = result.licenseKey,202                            ),203                        )204                    }205                PeachScriptImport.DecryptResult.WrongPassword ->206                    ImportUiState.Failed(ImportErrors.WRONG_PASSWORD)207                PeachScriptImport.DecryptResult.Corrupted ->208                    ImportUiState.Failed(ImportErrors.CORRUPTED)209            }210        }211    }212213    /** True when the pasted text is a PeachScript export that needs a password. */214    fun peachScriptNeedsPassword(text: String): Boolean {215        val t = text.trim()216        return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t)217    }218219    // ---- File import ------------------------------------------------------220221    fun importFile(uri: Uri) {222        _state.value = ImportUiState.Working()223        _detectedProvider.value = null224        viewModelScope.launch {225            // M-01: a content URI is an arbitrary provider stream — read it226            // bounded so an oversize (or endless) file is refused instead of227            // filling the heap. null distinguishes "over the cap" from "unreadable".228            val read: Result<String?> = withContext(Dispatchers.IO) {229                runCatching {230                    val stream = appContext.contentResolver.openInputStream(uri)231                        ?: throw java.io.IOException("no_stream")232                    stream.use { CsvImport.readBoundedUtf8(it) }233                }234            }235            if (read.isSuccess && read.getOrNull() == null) {236                _state.value = ImportUiState.Failed(ImportErrors.TOO_LARGE)237                return@launch238            }239            val content = read.getOrNull()240            if (content.isNullOrBlank()) {241                _state.value = ImportUiState.Failed(ImportErrors.UNREADABLE_FILE)242                return@launch243            }244            if (shouldRouteFileToPeachScript(uri.lastPathSegment, content)) {245                _state.value = ImportUiState.PeachScriptFile(content)246                return@launch247            }248            val result = withContext(Dispatchers.Default) {249                runCatching { CsvImport.detectAndParse(content) }250                    .getOrElse { ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE) }251            }252            when (result) {253                is ImportParseResult.Success -> {254                    _detectedProvider.value = result.provider255                    _state.value = ImportUiState.Preview(ImportPreview(result.provider, result.entries))256                }257                is ImportParseResult.Failure -> {258                    _state.value = ImportUiState.Failed(259                        when (result.reason) {260                            ImportParseResult.Reason.UNRECOGNISED -> ImportErrors.UNRECOGNISED_FILE261                            ImportParseResult.Reason.CORRUPTED -> ImportErrors.CORRUPTED262                            ImportParseResult.Reason.NO_ENTRIES -> ImportErrors.NO_ENTRIES263                            ImportParseResult.Reason.TOO_LARGE -> ImportErrors.TOO_LARGE264                        },265                    )266                }267            }268        }269    }270271    // ---- Confirm ----------------------------------------------------------272273    /**274     * Persist the previewed entries in one write: MERGE appends via275     * [VaultRepository.addEntries], REPLACE swaps the active set via276     * [VaultRepository.replaceEntries] so a failure cannot leave the old277     * entries tombstoned with nothing imported.278     *279     * [newVaultPassword] is required when [vaultUnlocked] is false: there's no280     * vault yet (fresh install "restore instead", or "forgot password" recovery)281     * so one is created from it before any entry can be added. Without this,282     * every addEntry threw requireUnlocked() and got swallowed by runCatching,283     * silently reporting "0 entries imported" as if it had succeeded.284     */285    fun confirmImport(preview: ImportPreview, mode: ImportMode, newVaultPassword: String? = null) {286        _state.value = ImportUiState.Working()287        viewModelScope.launch {288            // Declared outside the try so the failure path knows whether this289            // vault only exists to hold the import (M-01 rollback below).290            var createdForImport = false291            try {292                val createdVault: Boolean293                if (!vault.isUnlocked.value) {294                    if (newVaultPassword.isNullOrBlank()) {295                        _state.value = ImportUiState.Failed(ImportErrors.NO_MASTER_PASSWORD)296                        return@launch297                    }298                    _state.value = ImportUiState.Working("creating your vault…")299                    // Key derivation is deliberately expensive; off the main300                    // thread or it blocks the frame loop on its own.301                    val created = withContext(Dispatchers.Default) { vault.createVault(newVaultPassword) }302                    when (created) {303                        is CreateVaultResult.Success -> {304                            createdVault = true305                            createdForImport = true306                        }307                        is CreateVaultResult.Error -> {308                            _state.value = ImportUiState.Failed(created.reason)309                            return@launch310                        }311                    }312                } else {313                    createdVault = false314                }315                val now = System.currentTimeMillis()316                val fulls = preview.fullEntries317                val drafts = if (fulls != null) {318                    // PeachScript: the complete entries (all fields, all types).319                    // M-03: REPLACE is a restore of *these* entries, so keep their320                    // ids — re-iding turned a restore into delete-plus-new321                    // identities that paired devices then re-synced as churn.322                    // MERGE folds into an existing set, so re-id there: it is the323                    // explicit collision resolution (never overwrite a live entry).324                    if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }325                } else {326                    preview.entries.flatMap { p ->327                        // A passkey-only entry (CXF) carries no login fields; spawning328                        // an empty login for it would leave junk the browser never creates.329                        val passkeyOnly = p.passkeys.isNotEmpty() &&330                            p.password == null && p.username == null &&331                            p.totp == null && p.notes == null332                        val login = if (passkeyOnly) null else VaultEntry(333                            id = "", type = "login", title = p.title, username = p.username,334                            password = p.password, url = p.url, notes = p.notes, totp = p.totp,335                            createdAt = now, modifiedAt = now,336                        )337                        val passkeyEntries = p.passkeys.map { pk ->338                            VaultEntry(339                                id = "", type = "login", title = p.title,340                                username = p.username, url = "https://${pk.rpId}",341                                rpId = pk.rpId, rpName = pk.rpName,342                                credentialId = pk.credentialId,343                                privateKeyJwk = pk.privateKeyJwk,344                                algorithm = pk.algorithm,345                                userHandle = pk.userHandle,346                                userName = pk.userName,347                                userDisplayName = pk.userDisplayName,348                                signCount = pk.signCount,349                                createdAt = now, modifiedAt = now,350                            )351                        }352                        listOfNotNull(login) + passkeyEntries353                    }354                }355                _state.value = ImportUiState.Working("saving ${drafts.size} entries…")356                // One batched write on a background thread. Looping addEntry357                // re-encrypted and rewrote the whole vault per entry, which on358                // a real 806-entry vault hung the app until Android offered to359                // kill it. M-01: REPLACE goes through replaceEntries so the360                // tombstoning of the old set and the insert of the new one are361                // a single write that rolls back together.362                val imported = withContext(Dispatchers.Default) {363                    if (mode == ImportMode.REPLACE) vault.replaceEntries(drafts) else vault.addEntries(drafts)364                }365                withContext(Dispatchers.Default) {366                    preview.settings?.let { importedSettings ->367                        vault.updateSettings(368                            applyImportedPresentationSettings(vault.settings.value, importedSettings),369                        )370                    }371                    preview.licenseKey?.let { license.storeLicense(it) }372                }373                _state.value = ImportUiState.Done(imported, createdVault = createdVault)374            } catch (e: Exception) {375                // M-01: a vault created purely to receive this import is not376                // wanted if the import failed — leaving it behind puts the user377                // in an empty vault they never chose a purpose for, and blocks378                // the "restore instead" path from being retried.379                if (createdForImport) runCatching { vault.destroyVault() }380                _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)381            }382        }383    }384}385386// ─────────────────────────────────────────────────────────────────────────────387// Screen388// ─────────────────────────────────────────────────────────────────────────────389390private enum class ImportRoute { CHOOSE, PEACHSCRIPT, FILE }391392@Composable393fun ImportScreen(394    onBack: () -> Unit,395    // Codex is the third restore format (browser RestoreTab: symbols|codex|csv).396    // It has its own screen, so this hands off rather than rendering inline.397    onCodex: () -> Unit = {},398    // Only reached when confirmImport had to create a new vault first (fresh399    // install or "forgot password" recovery) — routes to recovery-word setup,400    // same as LockScreen.create, instead of popping back to a stale Lock/Import401    // screen that still thinks there's nothing to unlock.402    onVaultCreated: () -> Unit = onBack,403    vm: ImportViewModel = hiltViewModel(),404) {405    val state by vm.state.collectAsState()406    val vaultUnlocked by vm.vaultUnlocked.collectAsState()407    var route by remember { mutableStateOf(ImportRoute.CHOOSE) }408    // Latches once the rune resolve has played, so recomposition of a settled409    // Done state doesn't replay it.410    var resolveDone by remember(state is ImportUiState.Done) { mutableStateOf(false) }411412    ShellBackground {413        Column(414            modifier = Modifier415                .fillMaxSize()416                .systemBarsPadding()417                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),418        ) {419            PeachSectionHeader(420                section = "import",421                onHome = {422                    when {423                        state !is ImportUiState.Idle -> vm.back(onBack)424                        route != ImportRoute.CHOOSE -> route = ImportRoute.CHOOSE425                        else -> onBack()426                    }427                },428            )429430            Spacer(Modifier.size(PeachSpacing.lg))431432            if (state == ImportUiState.Idle && route == ImportRoute.CHOOSE) {433                // The default landing state (two option cards + a prompt) never434                // needs to scroll — give it the real remaining height so the435                // prompt composes into it instead of trailing into dead space.436                ChooseView(437                    onPeachScript = { route = ImportRoute.PEACHSCRIPT },438                    onFile = { route = ImportRoute.FILE },439                    onCodex = onCodex,440                    modifier = Modifier.weight(1f).fillMaxWidth(),441                    composeRemainingSpace = true,442                )443            } else {444                Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {445                    when (val s = state) {446                        is ImportUiState.Preview -> PreviewView(447                            preview = s.preview,448                            vaultUnlocked = vaultUnlocked,449                            onConfirm = { mode, newPassword -> vm.confirmImport(s.preview, mode, newPassword) },450                            onCancel = { vm.reset() },451                        )452                        is ImportUiState.Done -> {453                            // PeachScript lands through the rune resolve (browser454                            // RestoreSymbols): snap the drifting glyphs into a grid,455                            // bloom the mark, then show the count.456                            if (route == ImportRoute.PEACHSCRIPT && !resolveDone) {457                                var bloomed by remember { mutableStateOf(false) }458                                RuneResolve(459                                    phase = if (bloomed) RunePhase.BLOOM else RunePhase.SNAP,460                                    onFinished = { if (bloomed) resolveDone = true else bloomed = true },461                                )462                            } else {463                                DoneView(464                                    count = s.count,465                                    onDone = if (s.createdVault) onVaultCreated else onBack,466                                )467                            }468                        }469                        is ImportUiState.Failed -> {470                            ErrorBanner(s.message)471                            Spacer(Modifier.size(16.dp))472                            when (route) {473                                ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)474                                ImportRoute.FILE -> FileView(vm)475                                ImportRoute.CHOOSE -> ChooseView(476                                    onPeachScript = { route = ImportRoute.PEACHSCRIPT },477                                    onFile = { route = ImportRoute.FILE },478                                    onCodex = onCodex,479                                )480                            }481                        }482                        is ImportUiState.Working -> {483                            if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS)484                            WorkingView(s.step)485                        }486                        ImportUiState.Idle -> when (route) {487                            ImportRoute.CHOOSE -> Unit // handled above, outside the scroll container488                            ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)489                            ImportRoute.FILE -> FileView(vm)490                        }491                        is ImportUiState.PeachScriptFile -> {492                            LaunchedEffect(s.text) { route = ImportRoute.PEACHSCRIPT }493                            PeachScriptView(vm, initialPaste = s.text)494                        }495                    }496                }497            }498        }499    }500}501502// ---- Choose view ----------------------------------------------------------503504@Composable505private fun ChooseView(506    onPeachScript: () -> Unit,507    onFile: () -> Unit,508    onCodex: () -> Unit,509    modifier: Modifier = Modifier,510    // Only true from the non-scrolling call site, where `modifier` carries a511    // Column-scope weight and the remaining height is real and boundable.512    // The scrolling call site (inside a verticalScroll Column, unbounded513    // height) must keep the plain flowing layout or `weight()` would crash.514    composeRemainingSpace: Boolean = false,515) {516    Column(modifier) {517        OptionCard(518            title = "From PeachScript",519            subtitle = "Paste an encrypted Peach export",520            onClick = onPeachScript,521        )522        Spacer(Modifier.size(12.dp))523        OptionCard(524            title = "From file",525            subtitle = "Bitwarden, Proton Pass, 1Password, Chrome, Safari, Edge CSV",526            onClick = onFile,527        )528        Spacer(Modifier.size(12.dp))529        OptionCard(530            title = "From Peach Codex",531            subtitle = "Scan or open a Codex PDF you printed",532            onClick = onCodex,533        )534        if (composeRemainingSpace) {535            Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {536                PeachEmptyOrEndState(537                    message = "Choose an import source.",538                    detail = "Peach will preview everything before it changes your vault.",539                )540            }541        } else {542            PeachEmptyOrEndState(543                message = "Choose an import source.",544                detail = "Peach will preview everything before it changes your vault.",545            )546        }547    }548}549550@Composable551private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) {552    Column(553        modifier = Modifier554            .fillMaxWidth()555            .clip(RoundedCornerShape(18.dp))556            .background(peachColors.SurfaceElevated)557            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))558            .clickable(onClick = onClick)559            .padding(20.dp),560    ) {561        Text(562            title,563            color = peachColors.T0,564            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),565        )566        Spacer(Modifier.size(6.dp))567        Text(subtitle, color = peachColors.T1, style = PeachType.body)568    }569}570571// ---- PeachScript sub-view -------------------------------------------------572573@Composable574private fun PeachScriptView(vm: ImportViewModel, initialPaste: String = "") {575    // A backup loaded from a file never goes through the text field. A real576    // vault's export is hundreds of thousands of PUA characters (June's is577    // 262k) and pushing that into a BasicTextField bogs the UI down badly578    // enough that the import looks broken. The field is only for hand-pasted579    // text; file content is held aside and handed straight to the decoder.580    val fromFile = initialPaste.isNotEmpty()581    var paste by remember(initialPaste) { mutableStateOf(if (fromFile) "" else initialPaste) }582    var backupPassword by remember { mutableStateOf("") }583    val source = if (fromFile) initialPaste else paste584    val needsPassword = remember(source) { vm.peachScriptNeedsPassword(source) }585586    GlassCard {587        if (fromFile) {588            FieldLabel("Backup loaded from file")589            Spacer(Modifier.size(PeachSpacing.sm))590            Text(591                "%,d characters of PeachScript ready to import.".format(initialPaste.length),592                color = peachColors.T1,593                style = PeachType.body,594            )595        } else {596            FieldLabel("Paste your PeachScript export")597            Spacer(Modifier.size(PeachSpacing.sm))598            PeachMultilineField(599                value = paste,600                onValueChange = { paste = it },601                minLines = 6,602            )603        }604        if (needsPassword) {605            Spacer(Modifier.size(PeachSpacing.lg))606            PeachUnderlineField(607                label = "Backup password",608                value = backupPassword,609                onValueChange = { backupPassword = it },610                isPassword = true,611            )612        }613    }614    Spacer(Modifier.size(PeachSpacing.lg))615    AccentButton(616        label = "Import",617        enabled = source.isNotBlank(),618        onClick = { vm.importPeachScript(source, backupPassword) },619    )620}621622// ---- File sub-view --------------------------------------------------------623624@Composable625private fun FileView(vm: ImportViewModel) {626    val detected by vm.detectedProvider.collectAsState()627    val picker = rememberLauncherForActivityResult(628        ActivityResultContracts.GetContent(),629    ) { uri -> if (uri != null) vm.importFile(uri) }630631    GlassCard {632        Text(633            "Import from a CSV or JSON export",634            color = peachColors.T1,635            style = PeachType.body,636        )637        Spacer(Modifier.size(PeachSpacing.sm))638        Text(639            "Supports Bitwarden, Proton Pass, 1Password, Chrome/Edge, and Safari exports.",640            color = peachColors.T2,641            style = PeachType.hint,642        )643        Spacer(Modifier.size(PeachSpacing.lg))644        GhostButton("Choose file", LocalPeachAccent.current) {645            // GetContent takes a single MIME type; the chooser still lists CSV646            // and JSON. "*/*" is intentional so providers exporting odd MIME647            // types for .csv are not filtered out by the system picker.648            vm.expectTransientBackground()649            picker.launch("*/*")650        }651        if (detected != null) {652            Spacer(Modifier.size(PeachSpacing.md))653            Text(654                "Detected: $detected export",655                color = LocalPeachAccent.current,656                style = PeachType.labelMono,657            )658        }659    }660}661662// ---- Preview --------------------------------------------------------------663664@Composable665private fun PreviewView(666    preview: ImportPreview,667    vaultUnlocked: Boolean,668    onConfirm: (ImportMode, String?) -> Unit,669    onCancel: () -> Unit,670) {671    var mode by remember { mutableStateOf(ImportMode.MERGE) }672    var newPassword by remember { mutableStateOf("") }673    var newConfirm by remember { mutableStateOf("") }674    val createScore = remember(newPassword) { scorePassword(newPassword) }675676    GlassCard {677        Text(678            "Detected: ${preview.sourceLabel} export",679            color = LocalPeachAccent.current,680            style = PeachType.labelMono,681        )682        Spacer(Modifier.size(PeachSpacing.sm))683        Text(684            "Found ${preview.entries.size} ${if (preview.entries.size == 1) "entry" else "entries"}",685            color = peachColors.T0,686            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),687        )688        Spacer(Modifier.size(PeachSpacing.md))689        // Up to five entry names, scrollable if more.690        Column(691            modifier = Modifier692                .fillMaxWidth()693                .heightIn(max = 150.dp)694                .verticalScroll(rememberScrollState()),695        ) {696            preview.entries.take(5).forEach { e ->697                Text(698                    e.title,699                    color = peachColors.T1,700                    style = PeachType.entryNameList,701                    modifier = Modifier.padding(vertical = 4.dp),702                )703            }704            if (preview.entries.size > 5) {705                Text(706                    "+${preview.entries.size - 5} more",707                    color = peachColors.T2,708                    style = PeachType.tinyMono,709                    modifier = Modifier.padding(top = 4.dp),710                )711            }712        }713        Divider()714        if (vaultUnlocked) {715            RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE }716            Spacer(Modifier.size(PeachSpacing.sm))717            RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE }718        } else {719            FieldLabel("No vault yet — create one to restore into")720            Spacer(Modifier.size(PeachSpacing.sm))721            PeachUnderlineField(722                label = "Master password",723                value = newPassword,724                onValueChange = { newPassword = it },725                isPassword = true,726            )727            Spacer(Modifier.size(PeachSpacing.md))728            PeachUnderlineField(729                label = "Confirm password",730                value = newConfirm,731                onValueChange = { newConfirm = it },732                isPassword = true,733            )734            if (newPassword.isNotEmpty()) {735                Spacer(Modifier.size(PeachSpacing.sm))736                Text(737                    "strength: ${strengthLabel(createScore)}",738                    color = strengthColor(createScore),739                    style = PeachType.labelMono,740                )741            }742        }743    }744    Spacer(Modifier.size(PeachSpacing.lg))745    val confirmEnabled = vaultUnlocked ||746        canSubmitCreateVault(newPassword, newConfirm, createScore)747    AccentButton(748        label = "Confirm Import",749        enabled = confirmEnabled,750        onClick = { onConfirm(mode, if (vaultUnlocked) null else newPassword) },751    )752    Spacer(Modifier.size(PeachSpacing.sm))753    GhostButton("Cancel", peachColors.T1, onCancel)754}755756@Composable757private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) {758    val accent = LocalPeachAccent.current759    Row(760        verticalAlignment = Alignment.CenterVertically,761        modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),762    ) {763        Box(764            modifier = Modifier765                .size(16.dp)766                .clip(RoundedCornerShape(999.dp))767                .border(1.5.dp, if (selected) accent else peachColors.T2, RoundedCornerShape(999.dp)),768            contentAlignment = Alignment.Center,769        ) {770            if (selected) {771                Box(Modifier.size(8.dp).clip(RoundedCornerShape(999.dp)).background(accent))772            }773        }774        Spacer(Modifier.width(12.dp))775        Text(label, color = peachColors.T0, style = PeachType.body)776    }777}778779// ---- Done / Working / Error ----------------------------------------------780781@Composable782private fun DoneView(count: Int, onDone: () -> Unit) {783    GlassCard {784        Text(785            "imported.",786            color = peachColors.T0,787            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),788        )789        Spacer(Modifier.size(PeachSpacing.sm))790        Text(791            "$count ${if (count == 1) "entry" else "entries"} imported.",792            color = peachColors.T1,793            style = PeachType.body,794        )795    }796    Spacer(Modifier.size(PeachSpacing.lg))797    AccentButton(label = "Done", onClick = onDone)798}799800@Composable801private fun WorkingView(step: String? = null) {802    Column(803        Modifier.fillMaxWidth().padding(vertical = 24.dp),804        horizontalAlignment = Alignment.CenterHorizontally,805    ) {806        Text(807            step ?: "working…",808            color = peachColors.T1,809            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp),810        )811        Spacer(Modifier.size(PeachSpacing.md))812        // Indeterminate: a real vault's write is one batched operation, so813        // there is no honest per-entry percentage to report.814        LinearProgressIndicator(815            modifier = Modifier.fillMaxWidth(),816            color = LocalPeachAccent.current,817            trackColor = peachColors.T3,818        )819        Spacer(Modifier.size(PeachSpacing.sm))820        Text(821            "keep Peach open — this can take a moment for a large vault.",822            color = peachColors.T2,823            style = PeachType.hint,824        )825    }826}827828@Composable829private fun ErrorBanner(message: String) {830    Box(831        modifier = Modifier832            .fillMaxWidth()833            .clip(RoundedCornerShape(12.dp))834            .background(peachColors.BreachBg)835            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp))836            .padding(14.dp),837    ) {838        Text(message, color = peachColors.Breach, style = PeachType.bodyMono)839    }840}841842// ---- Shared building blocks (private to this draft) -----------------------843844@Composable845private fun FieldLabel(text: String) {846    Text(847        text.uppercase(),848        color = peachColors.T2,849        style = PeachType.labelMono,850    )851}852853@Composable854private fun PeachMultilineField(855    value: String,856    onValueChange: (String) -> Unit,857    minLines: Int,858) {859    val accent = LocalPeachAccent.current860    Box(861        modifier = Modifier862            .fillMaxWidth()863            .clip(RoundedCornerShape(12.dp))864            .background(peachColors.S2)865            .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp))866            .padding(14.dp),867    ) {868        BasicTextField(869            value = value,870            onValueChange = onValueChange,871            // PeachScript lives in the PUA block (U+E000..U+E0FF). Without the872            // PeachScript font Android falls back to a font that also maps that873            // block — Noto CJK — so a pasted backup rendered as Chinese glyphs.874            textStyle = PeachType.bodyMono.copy(color = peachColors.T0, fontFamily = PeachType.Script),875            cursorBrush = SolidColor(accent),876            minLines = minLines,877            modifier = Modifier.fillMaxWidth(),878        )879        if (value.isEmpty()) {880            Text(881                "{ \"peachscript\": 1, … }",882                color = peachColors.T2,883                style = PeachType.bodyMono,884            )885        }886    }887}888889@Composable890private fun PeachUnderlineField(891    label: String,892    value: String,893    onValueChange: (String) -> Unit,894    isPassword: Boolean = false,895) {896    DesignPeachField(897        label = label,898        value = value,899        onValueChange = onValueChange,900        visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,901    )902}903904@Composable905private fun GlassCard(content: @Composable () -> Unit) {906    Column(907        modifier = Modifier908            .fillMaxWidth()909            .clip(RoundedCornerShape(18.dp))910            .background(peachColors.SurfaceElevated)911            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))912            .padding(20.dp),913        content = { content() },914    )915}916917@Composable918private fun Divider() {919    Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3))920}921922@Composable923private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {924    Box(925        modifier = Modifier926            .fillMaxWidth()927            .clip(RoundedCornerShape(12.dp))928            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp))929            .clickable(onClick = onClick)930            .padding(vertical = 12.dp),931        contentAlignment = Alignment.Center,932    ) {933        Text(label, color = color, style = PeachType.bodyMono)934    }935}936937@Composable938private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {939    val accent = LocalPeachAccent.current940    Box(941        modifier = Modifier942            .fillMaxWidth()943            .clip(RoundedCornerShape(12.dp))944            .background(if (enabled) accent else accent.copy(alpha = 0.3f))945            .clickable(enabled = enabled, onClick = onClick)946            .padding(vertical = 14.dp),947        contentAlignment = Alignment.Center,948    ) {949        Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)950    }951}