AutofillEntryPickerActivity.kt

30.9 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.autofill.AutofillId6import android.view.autofill.AutofillManager7import androidx.activity.compose.setContent8import androidx.compose.foundation.background9import 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.fillMaxWidth15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.lazy.LazyColumn18import androidx.compose.foundation.lazy.items19import androidx.compose.foundation.shape.RoundedCornerShape20import androidx.compose.material3.ExperimentalMaterial3Api21import androidx.compose.material3.ModalBottomSheet22import androidx.compose.material3.Text23import androidx.compose.material3.rememberModalBottomSheetState24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.text.KeyboardActions26import androidx.compose.foundation.text.KeyboardOptions27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableStateOf31import androidx.compose.runtime.remember32import androidx.compose.runtime.rememberCoroutineScope33import androidx.compose.runtime.setValue34import androidx.compose.ui.graphics.SolidColor35import androidx.compose.ui.text.TextStyle36import androidx.compose.ui.text.input.ImeAction37import androidx.compose.ui.text.input.KeyboardType38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.text.font.FontStyle42import androidx.compose.ui.text.style.TextOverflow43import androidx.compose.ui.unit.dp44import androidx.compose.ui.unit.sp45import androidx.fragment.app.FragmentActivity46import androidx.lifecycle.lifecycleScope47import com.peachpasswords.BuildConfig48import com.peachpasswords.MainActivity49import com.peachpasswords.shared.extractDomain50import com.peachpasswords.ui.lock.BiometricKeyStore51import com.peachpasswords.ui.lock.Biometrics52import com.peachpasswords.ui.components.FaviconInitials53import com.peachpasswords.ui.components.QaFlags54import com.peachpasswords.ui.components.SecureWindowPolicy55import com.peachpasswords.ui.theme.ACCENT_COLORS56import com.peachpasswords.ui.theme.DEFAULT_ACCENT57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.theme.PeachTheme60import com.peachpasswords.ui.theme.PeachType61import com.peachpasswords.vault.UnlockResult62import com.peachpasswords.vault.VaultEntry63import com.peachpasswords.vault.isCard64import com.peachpasswords.vault.VaultRepository65import dagger.hilt.android.AndroidEntryPoint66import kotlinx.coroutines.delay67import kotlinx.coroutines.launch68import javax.inject.Inject6970/**71 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"72 * in the system autofill drop-down. Lists matching vault entries using the same73 * row vocabulary as the vault list; picking one returns a filled74 * [android.service.autofill.Dataset] to the framework via75 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].76 *77 * Inline-mirror of the browser's selector: always-on search field, vault-wide78 * match against title/username/email/url, and a "+ Create new entry" affordance79 * in the header. The affordance cancels this activity and launches [MainActivity]80 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had81 * typed on the host page.82 *83 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity84 * and return RESULT_CANCELED, which produced a loop with no exit: the framework85 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the86 * user unlocked the app, came back to a field that still asked them to unlock,87 * and had no way to tell that anything had happened. Autofill is a modal88 * interruption of someone else's app — leaving it to go somewhere else is the89 * one thing it must not do.90 */91@AndroidEntryPoint92class AutofillEntryPickerActivity : FragmentActivity() {9394    @Inject lateinit var vault: VaultRepository95    private var debugSecureLease = false9697    @OptIn(ExperimentalMaterial3Api::class)98    override fun onCreate(savedInstanceState: Bundle?) {99        super.onCreate(savedInstanceState)100101        // H-05: separate window from MainActivity, so it does NOT inherit the102        // release-wide FLAG_SECURE set there. Show vault metadata and accept the103        // master password — protect against screenshots / recents thumbnails.104        if (!BuildConfig.DEBUG) {105            SecureWindowPolicy.setUserSecure(window, true)106        } else if (!qaSecureCaptureBypass()) {107            // Debug builds are secure by default too. QA must opt out explicitly108            // on this activity's own launch intent; MainActivity's window policy109            // cannot protect this separate picker window.110            SecureWindowPolicy.acquire(window)111            debugSecureLease = true112        }113114        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)115        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)116        // Origin trust is never read from the launch intent: a caller-supplied117        // trust extra could be forged or stale. Re-derive it in-process from118        // the raw authenticated inputs using the same authentication the119        // service used, so a tampered extra cannot elevate origin trust.120        val rawParsed = pickerParsedFromInputs(121            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),122            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),123            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),124            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),127            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),128            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),129            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),130            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),131            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),132            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),133        )134        val parsed = resolveAutofillOrigin(this, rawParsed)135        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT136137        setContent {138            PeachTheme(accent = accent) {139                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)140                val scope = rememberCoroutineScope()141                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }142                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }143                var error by remember { mutableStateOf<String?>(null) }144                var busy by remember { mutableStateOf(false) }145146                val allEntries = remember(unlocked) {147                    if (unlocked) vault.getEntries() else emptyList()148                }149                val matchedEntries = remember(unlocked, allEntries) {150                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()151                }152153                // Manual pick of an entry the origin didn't auto-match: offer to154                // remember the origin on the entry before filling.155                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }156                val pickNow: (VaultEntry) -> Unit = { entry ->157                    val epoch = unlockedEpoch158                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()159                    else returnDataset(parsed, entry)160                }161162                // Offer biometrics immediately — it is the whole point of163                // unlocking here rather than in the app, and a prompt the user164                // can dismiss straight onto the password field costs nothing.165                LaunchedEffect(unlocked) {166                    if (!unlocked && canBiometric()) {167                        tryBiometric(168                            onUnlocked = {169                                unlocked = true170                                unlockedEpoch = vault.unlockedSessionEpoch()171                            },172                            onFail = { },173                        )174                    }175                }176177                ModalBottomSheet(178                    onDismissRequest = { cancel() },179                    sheetState = sheetState,180                    containerColor = peachColors.S1,181                    dragHandle = { AccentDragHandle() },182                ) {183                    PickerBody(184                        origin = parsed.origin,185                        typedIdentity = typedUsername ?: typedEmail,186                        unlocked = unlocked,187                        matchedEntries = matchedEntries,188                        allEntries = allEntries,189                        rememberOffer = rememberOffer,190                        onRemember = { entry, domains ->191                            rememberOffer = null192                            scope.launch {193                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry194                                runCatching {195                                    vault.updateEntry(196                                        current.copy(197                                            additionalUrls = (current.additionalUrls.orEmpty() +198                                                domains.map { "https://$it" }).distinct(),199                                        ),200                                    )201                                }202                                pickNow(entry)203                            }204                        },205                        onFillOnce = { entry ->206                            rememberOffer = null207                            pickNow(entry)208                        },209                        onRememberDismiss = { rememberOffer = null },210                        error = error,211                        busy = busy,212                        canBiometric = canBiometric(),213                        onBiometric = {214                            tryBiometric(215                                onUnlocked = {216                                    unlocked = true217                                    unlockedEpoch = vault.unlockedSessionEpoch()218                                },219                                onFail = {},220                            )221                        },222                        onUnlock = { password ->223                            busy = true224                            error = null225                            scope.launch {226                                when (vault.unlock(password)) {227                                    is UnlockResult.Success -> {228                                        unlocked = true229                                        unlockedEpoch = vault.unlockedSessionEpoch()230                                    }231                                    UnlockResult.WrongPassword -> error = "Wrong master password."232                                    else -> error = "Couldn't unlock."233                                }234                                busy = false235                            }236                        },237                        onPick = { entry ->238                            val offer = rememberOfferDomains(entry, parsed)239                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer240                        },241                        onCreateNew = { searchText ->242                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)243                        },244                    )245                }246            }247        }248    }249250    override fun onDestroy() {251        if (debugSecureLease) {252            SecureWindowPolicy.release(window)253            debugSecureLease = false254        }255        super.onDestroy()256    }257258    private fun qaSecureCaptureBypass(): Boolean =259        BuildConfig.DEBUG && (260            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||261                QaFlags.disableSecureCaptureForQa262            )263264    private fun canBiometric(): Boolean =265        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)266267    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {268        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()269        Biometrics.authenticateCrypto(270            activity = this,271            title = "Unlock Peach",272            subtitle = "Fill your saved details",273            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),274            onSuccess = { result ->275                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()276                // M-05: unlockWithKey copies `key` into the session's derivedKey,277                // so the caller-owned reference is now redundant. Zero it whether278                // the unlock succeeds or fails so the unwrapped biometric key279                // doesn't outlive the auth prompt.280                lifecycleScope.launch {281                    // Unseal inside the launched ownership scope. If the282                    // lifecycle scope is cancelled before execution, no key is283                    // allocated before a finally can protect it.284                    var key: ByteArray? = null285                    try {286                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)287                        if (key == null) return@launch onFail()288                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()289                    } finally {290                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)291                    }292                }293            },294            onFail = onFail,295        )296    }297298    private fun returnDataset(299        parsed: ParsedStructure,300        entry: VaultEntry,301    ) {302        val dataset = if (entry.isCard()) {303            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)304        } else {305            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)306        }307        if (dataset == null) { cancel(); return }308        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)309        setResult(RESULT_OK, reply)310        finish()311    }312313    /**314     * Cancel the picker and fire MainActivity on the new-entry path. The picker315     * returns CANCELED on purpose — the user is choosing to leave the autofill316     * prompt, not to fill it — and the launcher extra carries the host's typed317     * identity and origin so the new-entry editor opens pre-filled.318     */319    private fun launchNewEntry(320        parsed: ParsedStructure,321        typedUsername: String?,322        typedEmail: String?,323        searchText: String?,324    ) {325        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin326        val intent = Intent(this, MainActivity::class.java).apply {327            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)328            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)329            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)330            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)331            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)332        }333        startActivity(intent)334        cancel()335    }336337    private fun cancel() {338        setResult(RESULT_CANCELED)339        finish()340    }341342    @Suppress("DEPRECATION")343    private fun intentAutofillId(key: String): AutofillId? =344        if (android.os.Build.VERSION.SDK_INT >= 33) {345            intent.getParcelableExtra(key, AutofillId::class.java)346        } else {347            intent.getParcelableExtra(key)348        }349350    private companion object {351        const val MAX_ROWS = 12352    }353}354355/**356 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin357 * domains and app title are resolved in-process via [resolveAutofillOrigin] from358 * the authenticated caller package + web domain — never from a caller extra.359 */360internal fun pickerParsedFromInputs(361    origin: String?,362    isWebOrigin: Boolean,363    callerPackage: String?,364    usernameId: AutofillId? = null,365    passwordId: AutofillId? = null,366    otpId: AutofillId? = null,367    cardNumberId: AutofillId? = null,368    cardExpMonthId: AutofillId? = null,369    cardExpYearId: AutofillId? = null,370    cardExpDateId: AutofillId? = null,371    cardCvcId: AutofillId? = null,372    cardNameId: AutofillId? = null,373): ParsedStructure = ParsedStructure(374    usernameId = usernameId,375    passwordId = passwordId,376    otpId = otpId,377    cardNumberId = cardNumberId,378    cardExpMonthId = cardExpMonthId,379    cardExpYearId = cardExpYearId,380    cardExpDateId = cardExpDateId,381    cardCvcId = cardCvcId,382    cardNameId = cardNameId,383    webDomain = origin.takeIf { isWebOrigin },384    packageName = if (isWebOrigin) callerPackage else origin,385)386387internal fun autofillPickerEntries(388    entries: List<VaultEntry>,389    parsed: ParsedStructure,390    limit: Int,391): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)392393/**394 * Case-insensitive substring match across the four user-facing identity columns395 * the browser uses (title, username, email, url). Exposed at file scope so the396 * picker can call it directly and the test suite can exercise the same code.397 */398internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {399    if (query.isEmpty()) return true400    val needle = query.lowercase()401    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)402        .any { it.lowercase().contains(needle) }403}404405@Composable406private fun AccentDragHandle() {407    val accent = LocalPeachAccent.current408    Row(409        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),410        horizontalArrangement = Arrangement.Center,411    ) {412        Box(413            modifier = Modifier414                .size(width = 32.dp, height = 4.dp)415                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),416        )417    }418}419420@Composable421private fun PickerBody(422    origin: String?,423    typedIdentity: String?,424    unlocked: Boolean,425    matchedEntries: List<VaultEntry>,426    allEntries: List<VaultEntry>,427    rememberOffer: Pair<VaultEntry, List<String>>?,428    onRemember: (VaultEntry, List<String>) -> Unit,429    onFillOnce: (VaultEntry) -> Unit,430    onRememberDismiss: () -> Unit,431    error: String?,432    busy: Boolean,433    canBiometric: Boolean,434    onBiometric: () -> Unit,435    onUnlock: (String) -> Unit,436    onPick: (VaultEntry) -> Unit,437    onCreateNew: (String?) -> Unit,438) {439    val accent = LocalPeachAccent.current440    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {441        Text(442            text = "peach",443            color = accent,444            fontFamily = PeachType.Display,445            fontStyle = FontStyle.Italic,446            fontSize = 15.sp,447        )448        Text(449            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",450            color = peachColors.T2,451            fontFamily = PeachType.Mono,452            fontSize = 9.sp,453            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),454        )455456        if (!unlocked) {457            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)458        } else if (rememberOffer != null) {459            RememberSitePrompt(460                entryTitle = rememberOffer!!.first.title,461                domains = rememberOffer!!.second,462                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },463                onFillOnce = { onFillOnce(rememberOffer!!.first) },464                onBack = onRememberDismiss,465            )466        } else {467            // The search field is a vault-wide substring search; an empty query468            // shows the origin's matched entries.469            var rawQuery by remember { mutableStateOf("") }470            var debouncedQuery by remember { mutableStateOf("") }471            LaunchedEffect(rawQuery) {472                delay(SEARCH_DEBOUNCE_MS)473                debouncedQuery = rawQuery474            }475            SearchField(476                value = rawQuery,477                onValueChange = { rawQuery = it },478                placeholder = when {479                    matchedEntries.isNotEmpty() -> "search for another entry…"480                    else -> "search vault…"481                },482            )483484            val visibleEntries = when {485                debouncedQuery.isBlank() -> matchedEntries486                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }487            }488            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489490            val emptyMessage = when {491                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->492                    "No saved entries match this site."493                showSearchEmpty ->494                    "No entries match \"${debouncedQuery.trim()}\"."495                else -> null496            }497            if (emptyMessage != null) {498                Text(499                    text = emptyMessage,500                    color = peachColors.T1,501                    fontFamily = PeachType.Body,502                    fontSize = 13.sp,503                    modifier = Modifier.padding(vertical = 6.dp),504                )505            }506507            if (visibleEntries.isNotEmpty()) {508                LazyColumn {509                    items(visibleEntries, key = { it.id }) { entry ->510                        PickerRow(entry, onClick = { onPick(entry) })511                    }512                }513            }514515            val createOrigin =516                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin517            CreateNewRow(518                origin = createOrigin,519                typedIdentity = typedIdentity,520                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },521            )522        }523    }524}525526@Composable527private fun SearchField(528    value: String,529    onValueChange: (String) -> Unit,530    placeholder: String,531) {532    val accent = LocalPeachAccent.current533    BasicTextField(534        value = value,535        onValueChange = onValueChange,536        singleLine = true,537        textStyle = TextStyle(538            color = peachColors.T0,539            fontFamily = PeachType.Mono,540            fontSize = 14.sp,541        ),542        cursorBrush = SolidColor(accent),543        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),544        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),545        modifier = Modifier546            .fillMaxWidth()547            .background(peachColors.S2, RoundedCornerShape(8.dp))548            .padding(horizontal = 12.dp, vertical = 10.dp),549        decorationBox = { inner ->550            Box(modifier = Modifier.fillMaxWidth()) {551                if (value.isEmpty()) {552                    Text(553                        text = placeholder,554                        color = peachColors.T2,555                        fontFamily = PeachType.Mono,556                        fontSize = 12.sp,557                    )558                }559                inner()560            }561        },562    )563}564565@Composable566private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {567    val accent = LocalPeachAccent.current568    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"569        else "Create new entry"570    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }571    Row(572        modifier = Modifier573            .fillMaxWidth()574            .clickable(onClick = onClick)575            .padding(vertical = 10.dp),576        verticalAlignment = Alignment.CenterVertically,577    ) {578        Box(579            modifier = Modifier580                .size(34.dp)581                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),582            contentAlignment = Alignment.Center,583        ) {584            Text(585                text = "+",586                color = accent,587                fontFamily = PeachType.Display,588                fontStyle = FontStyle.Italic,589                fontSize = 18.sp,590            )591        }592        Column(modifier = Modifier.padding(start = 12.dp)) {593            Text(594                text = title,595                color = accent,596                fontFamily = PeachType.Body,597                fontSize = 14.sp,598                maxLines = 1,599                overflow = TextOverflow.Ellipsis,600            )601            if (subtitle != null) {602                Text(603                    text = "prefill: $subtitle",604                    color = peachColors.T2,605                    fontFamily = PeachType.Mono,606                    fontSize = 10.sp,607                    maxLines = 1,608                    overflow = TextOverflow.Ellipsis,609                )610            }611        }612    }613}614615/**616 * Offered when the user manually picks an entry the origin didn't auto-match:617 * save the origin's domains onto the entry (additional URLs) so it is618 * suggested automatically next time — Android counterpart of the browser's619 * "Remember this site" toast.620 */621@Composable622private fun RememberSitePrompt(623    entryTitle: String,624    domains: List<String>,625    onRemember: () -> Unit,626    onFillOnce: () -> Unit,627    onBack: () -> Unit,628) {629    val accent = LocalPeachAccent.current630    Text(631        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",632        color = peachColors.T0,633        fontFamily = PeachType.Body,634        fontSize = 14.sp,635    )636    Text(637        text = "It will be suggested automatically the next time you fill here.",638        color = peachColors.T2,639        fontFamily = PeachType.Mono,640        fontSize = 10.sp,641        modifier = Modifier.padding(top = 4.dp),642    )643    Row(644        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),645        horizontalArrangement = Arrangement.spacedBy(18.dp),646        verticalAlignment = Alignment.CenterVertically,647    ) {648        Text(649            text = "Remember & fill",650            color = accent,651            fontFamily = PeachType.Mono,652            fontSize = 13.sp,653            modifier = Modifier654                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))655                .clickable(onClick = onRemember)656                .padding(horizontal = 12.dp, vertical = 8.dp),657        )658        Text(659            text = "Just fill",660            color = peachColors.T1,661            fontFamily = PeachType.Mono,662            fontSize = 13.sp,663            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),664        )665        Text(666            text = "Back",667            color = peachColors.T2,668            fontFamily = PeachType.Mono,669            fontSize = 13.sp,670            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),671        )672    }673}674675@Composable676private fun UnlockBody(677    error: String?,678    busy: Boolean,679    canBiometric: Boolean,680    onBiometric: () -> Unit,681    onUnlock: (String) -> Unit,682) {683    val accent = LocalPeachAccent.current684    var password by remember { mutableStateOf("") }685686    Text(687        text = "Unlock to fill",688        color = peachColors.T0,689        fontFamily = PeachType.Body,690        fontSize = 15.sp,691        modifier = Modifier.padding(bottom = 10.dp),692    )693    BasicTextField(694        value = password,695        onValueChange = { password = it },696        singleLine = true,697        enabled = !busy,698        textStyle = TextStyle(699            color = peachColors.T0,700            fontFamily = PeachType.Mono,701            fontSize = 16.sp,702        ),703        cursorBrush = SolidColor(accent),704        visualTransformation = PasswordVisualTransformation(),705        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),706        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),707        modifier = Modifier708            .fillMaxWidth()709            .background(peachColors.S2, RoundedCornerShape(8.dp))710            .padding(horizontal = 12.dp, vertical = 12.dp),711    )712    if (error != null) {713        Text(714            text = error,715            color = peachColors.Breach,716            fontFamily = PeachType.Body,717            fontSize = 12.sp,718            modifier = Modifier.padding(top = 8.dp),719        )720    }721    Row(722        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),723        horizontalArrangement = Arrangement.spacedBy(20.dp),724        verticalAlignment = Alignment.CenterVertically,725    ) {726        if (canBiometric) {727            Text(728                text = "Use biometrics",729                color = accent,730                fontFamily = PeachType.Mono,731                fontSize = 13.sp,732                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),733            )734        }735        Text(736            text = if (busy) "Unlocking…" else "Unlock",737            color = if (password.isEmpty() || busy) peachColors.T2 else accent,738            fontFamily = PeachType.Mono,739            fontSize = 13.sp,740            modifier = Modifier741                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }742                .padding(vertical = 8.dp),743        )744    }745}746747@Composable748private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {749    Row(750        modifier = Modifier751            .fillMaxWidth()752            .clickable(onClick = onClick)753            .padding(vertical = 9.dp),754        verticalAlignment = Alignment.CenterVertically,755    ) {756        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)757        Column(modifier = Modifier.padding(start = 12.dp)) {758            Text(759                text = entry.title,760                color = peachColors.T0,761                fontFamily = PeachType.Body,762                fontSize = 15.sp,763                maxLines = 1,764                overflow = TextOverflow.Ellipsis,765            )766            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""767            if (sub.isNotBlank()) {768                Text(769                    text = sub,770                    color = peachColors.T2,771                    fontFamily = PeachType.Mono,772                    fontSize = 11.sp,773                    maxLines = 1,774                    overflow = TextOverflow.Ellipsis,775                )776            }777        }778    }779}780781/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */782private const val SEARCH_DEBOUNCE_MS = 200L