AutofillEntryPickerActivity.kt

28.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.VaultRepository64import dagger.hilt.android.AndroidEntryPoint65import kotlinx.coroutines.delay66import kotlinx.coroutines.launch67import javax.inject.Inject6869/**70 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"71 * in the system autofill drop-down. Lists matching vault entries using the same72 * row vocabulary as the vault list; picking one returns a filled73 * [android.service.autofill.Dataset] to the framework via74 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].75 *76 * Inline-mirror of the browser's selector: always-on search field, vault-wide77 * match against title/username/email/url, and a "+ Create new entry" affordance78 * in the header. The affordance cancels this activity and launches [MainActivity]79 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had80 * typed on the host page.81 *82 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity83 * and return RESULT_CANCELED, which produced a loop with no exit: the framework84 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the85 * user unlocked the app, came back to a field that still asked them to unlock,86 * and had no way to tell that anything had happened. Autofill is a modal87 * interruption of someone else's app — leaving it to go somewhere else is the88 * one thing it must not do.89 */90@AndroidEntryPoint91class AutofillEntryPickerActivity : FragmentActivity() {9293    @Inject lateinit var vault: VaultRepository94    private var debugSecureLease = false9596    @OptIn(ExperimentalMaterial3Api::class)97    override fun onCreate(savedInstanceState: Bundle?) {98        super.onCreate(savedInstanceState)99100        // H-05: separate window from MainActivity, so it does NOT inherit the101        // release-wide FLAG_SECURE set there. Show vault metadata and accept the102        // master password — protect against screenshots / recents thumbnails.103        if (!BuildConfig.DEBUG) {104            SecureWindowPolicy.installRelease(window)105        } else if (!qaSecureCaptureBypass()) {106            // Debug builds are secure by default too. QA must opt out explicitly107            // on this activity's own launch intent; MainActivity's window policy108            // cannot protect this separate picker window.109            SecureWindowPolicy.acquire(window)110            debugSecureLease = true111        }112113        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)114        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)115        // Origin trust is never read from the launch intent: a caller-supplied116        // trust extra could be forged or stale. Re-derive it in-process from117        // the raw authenticated inputs using the same authentication the118        // service used, so a tampered extra cannot elevate origin trust.119        val rawParsed = pickerParsedFromInputs(120            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),121            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),122            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),123            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),124            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),127        )128        val parsed = authenticateAutofillOrigin(this, rawParsed)129        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT130131        setContent {132            PeachTheme(accent = accent) {133                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)134                val scope = rememberCoroutineScope()135                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }136                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }137                var error by remember { mutableStateOf<String?>(null) }138                var busy by remember { mutableStateOf(false) }139140                // Trusted origins may search the vault manually. Unknown origins stay141                // empty until the user types a registrable target domain in the picker.142                val allEntries = remember(unlocked) {143                    if (unlocked) vault.getEntries() else emptyList()144                }145                val matchedEntries = remember(unlocked, allEntries) {146                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()147                }148149                // Offer biometrics immediately — it is the whole point of150                // unlocking here rather than in the app, and a prompt the user151                // can dismiss straight onto the password field costs nothing.152                LaunchedEffect(unlocked) {153                    if (!unlocked && canBiometric()) {154                        tryBiometric(155                            onUnlocked = {156                                unlocked = true157                                unlockedEpoch = vault.unlockedSessionEpoch()158                            },159                            onFail = { },160                        )161                    }162                }163164                ModalBottomSheet(165                    onDismissRequest = { cancel() },166                    sheetState = sheetState,167                    containerColor = peachColors.S1,168                    dragHandle = { AccentDragHandle() },169                ) {170                    PickerBody(171                        origin = parsed.origin,172                        originTrust = parsed.originTrust,173                        typedIdentity = typedUsername ?: typedEmail,174                        unlocked = unlocked,175                        matchedEntries = matchedEntries,176                        allEntries = allEntries,177                        error = error,178                        busy = busy,179                        canBiometric = canBiometric(),180                        onBiometric = {181                            tryBiometric(182                                onUnlocked = {183                                    unlocked = true184                                    unlockedEpoch = vault.unlockedSessionEpoch()185                                },186                                onFail = {},187                            )188                        },189                        onUnlock = { password ->190                            busy = true191                            error = null192                            scope.launch {193                                when (vault.unlock(password)) {194                                    is UnlockResult.Success -> {195                                        unlocked = true196                                        unlockedEpoch = vault.unlockedSessionEpoch()197                                    }198                                    UnlockResult.WrongPassword -> error = "Wrong master password."199                                    else -> error = "Couldn't unlock."200                                }201                                busy = false202                            }203                        },204                        onPick = { entry, confirmedTarget ->205                            val epoch = unlockedEpoch206                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {207                                cancel()208                            } else {209                                returnDataset(parsed, entry, confirmedTarget)210                            }211                        },212                        onCreateNew = { confirmedTarget ->213                            launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)214                        },215                    )216                }217            }218        }219    }220221    override fun onDestroy() {222        if (debugSecureLease) {223            SecureWindowPolicy.release(window)224            debugSecureLease = false225        }226        super.onDestroy()227    }228229    private fun qaSecureCaptureBypass(): Boolean =230        BuildConfig.DEBUG && (231            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||232                QaFlags.disableSecureCaptureForQa233            )234235    private fun canBiometric(): Boolean =236        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)237238    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {239        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()240        Biometrics.authenticateCrypto(241            activity = this,242            title = "Unlock Peach",243            subtitle = "Fill your saved details",244            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),245            onSuccess = { result ->246                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()247                // M-05: unlockWithKey copies `key` into the session's derivedKey,248                // so the caller-owned reference is now redundant. Zero it whether249                // the unlock succeeds or fails so the unwrapped biometric key250                // doesn't outlive the auth prompt.251                lifecycleScope.launch {252                    // Unseal inside the launched ownership scope. If the253                    // lifecycle scope is cancelled before execution, no key is254                    // allocated before a finally can protect it.255                    var key: ByteArray? = null256                    try {257                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)258                        if (key == null) return@launch onFail()259                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()260                    } finally {261                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)262                    }263                }264            },265            onFail = onFail,266        )267    }268269    private fun returnDataset(270        parsed: ParsedStructure,271        entry: VaultEntry,272        confirmedTarget: String?,273    ) {274        if (275            parsed.originTrust == AutofillOriginTrust.UNKNOWN &&276            AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()277        ) {278            cancel()279            return280        }281        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)282        if (dataset == null) { cancel(); return }283        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)284        setResult(RESULT_OK, reply)285        finish()286    }287288    /**289     * Cancel the picker and fire MainActivity on the new-entry path. The picker290     * returns CANCELED on purpose — the user is choosing to leave the autofill291     * prompt, not to fill it — and the launcher extra carries the host's typed292     * identity and origin so the new-entry editor opens pre-filled.293     */294    private fun launchNewEntry(295        parsed: ParsedStructure,296        typedUsername: String?,297        typedEmail: String?,298        confirmedTarget: String?,299    ) {300        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {301            confirmedTarget?.let(::extractDomain) ?: return302        } else {303            parsed.origin304        }305        val intent = Intent(this, MainActivity::class.java).apply {306            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)307            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)308            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)309            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)310            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)311        }312        startActivity(intent)313        cancel()314    }315316    private fun cancel() {317        setResult(RESULT_CANCELED)318        finish()319    }320321    @Suppress("DEPRECATION")322    private fun intentAutofillId(key: String): AutofillId? =323        if (android.os.Build.VERSION.SDK_INT >= 33) {324            intent.getParcelableExtra(key, AutofillId::class.java)325        } else {326            intent.getParcelableExtra(key)327        }328329    private companion object {330        const val MAX_ROWS = 12331    }332}333334/**335 * Build the initial [ParsedStructure] from raw picker intent inputs with336 * originTrust always UNKNOWN. The picker must re-derive trust in-process via337 * [authenticateAutofillOrigin] — a caller-supplied trust extra is never338 * authoritative. For web origins, the authenticated caller package is carried339 * separately so the re-verification can check the browser signing pin.340 */341internal fun pickerParsedFromInputs(342    origin: String?,343    isWebOrigin: Boolean,344    callerPackage: String?,345    verifiedDomains: List<String>,346    usernameId: AutofillId? = null,347    passwordId: AutofillId? = null,348    otpId: AutofillId? = null,349): ParsedStructure = ParsedStructure(350    usernameId = usernameId,351    passwordId = passwordId,352    otpId = otpId,353    webDomain = origin.takeIf { isWebOrigin },354    packageName = if (isWebOrigin) callerPackage else origin,355    // Always UNKNOWN here — the picker re-derives trust from these raw inputs.356    originTrust = AutofillOriginTrust.UNKNOWN,357    verifiedDomains = verifiedDomains,358)359360internal fun autofillPickerEntries(361    entries: List<VaultEntry>,362    parsed: ParsedStructure,363    limit: Int,364): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)365366/**367 * Case-insensitive substring match across the four user-facing identity columns368 * the browser uses (title, username, email, url). Exposed at file scope so the369 * picker can call it directly and the test suite can exercise the same code.370 */371internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {372    if (query.isEmpty()) return true373    val needle = query.lowercase()374    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)375        .any { it.lowercase().contains(needle) }376}377378@Composable379private fun AccentDragHandle() {380    val accent = LocalPeachAccent.current381    Row(382        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),383        horizontalArrangement = Arrangement.Center,384    ) {385        Box(386            modifier = Modifier387                .size(width = 32.dp, height = 4.dp)388                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),389        )390    }391}392393@Composable394private fun PickerBody(395    origin: String?,396    originTrust: AutofillOriginTrust,397    typedIdentity: String?,398    unlocked: Boolean,399    matchedEntries: List<VaultEntry>,400    allEntries: List<VaultEntry>,401    error: String?,402    busy: Boolean,403    canBiometric: Boolean,404    onBiometric: () -> Unit,405    onUnlock: (String) -> Unit,406    onPick: (VaultEntry, String?) -> Unit,407    onCreateNew: (String?) -> Unit,408) {409    val accent = LocalPeachAccent.current410    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN411    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {412        Text(413            text = "peach",414            color = accent,415            fontFamily = PeachType.Display,416            fontStyle = FontStyle.Italic,417            fontSize = 15.sp,418        )419        Text(420            text = when {421                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"422                origin.isNullOrBlank() -> "your vault"423                else -> "Suggested for $origin"424            },425            color = peachColors.T2,426            fontFamily = PeachType.Mono,427            fontSize = 9.sp,428            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),429        )430431        if (originTrust == AutofillOriginTrust.UNKNOWN) {432            Box(433                modifier = Modifier434                    .fillMaxWidth()435                    .padding(bottom = 14.dp)436                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))437                    .padding(12.dp),438            ) {439                Text(440                    text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",441                    color = peachColors.Breach,442                    fontFamily = PeachType.Body,443                    fontSize = 12.sp,444                )445            }446        }447448        if (!unlocked) {449            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)450        } else {451            // For an unverified caller, this field is a domain confirmation boundary,452            // not a vault-wide search box controlled by attacker-provided origin data.453            var rawQuery by remember { mutableStateOf("") }454            var debouncedQuery by remember { mutableStateOf("") }455            LaunchedEffect(rawQuery) {456                delay(SEARCH_DEBOUNCE_MS)457                debouncedQuery = rawQuery458            }459            val confirmedTarget = if (untrustedOrigin) {460                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)461            } else {462                null463            }464            SearchField(465                value = rawQuery,466                onValueChange = { rawQuery = it },467                placeholder = when {468                    untrustedOrigin -> "enter site domain, e.g. example.com"469                    matchedEntries.isNotEmpty() -> "search for another entry…"470                    else -> "search vault…"471                },472            )473474            val visibleEntries = when {475                untrustedOrigin && confirmedTarget == null -> emptyList()476                untrustedOrigin -> AutofillResponseBuilder477                    .matchEntries(allEntries, confirmedTarget)478                    .hits479                debouncedQuery.isBlank() -> matchedEntries480                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }481            }482            val showSearchEmpty = debouncedQuery.isNotBlank() &&483                visibleEntries.isEmpty() &&484                (!untrustedOrigin || confirmedTarget != null)485486            val emptyMessage = when {487                untrustedOrigin && debouncedQuery.isBlank() ->488                    "Type the site domain to choose a saved login."489                untrustedOrigin && confirmedTarget == null ->490                    "Enter a full public domain such as example.com."491                untrustedOrigin && visibleEntries.isEmpty() ->492                    "No saved entries match $confirmedTarget."493                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->494                    "No saved entries match this site."495                showSearchEmpty ->496                    "No entries match \"${debouncedQuery.trim()}\"."497                else -> null498            }499            if (emptyMessage != null) {500                Text(501                    text = emptyMessage,502                    color = peachColors.T1,503                    fontFamily = PeachType.Body,504                    fontSize = 13.sp,505                    modifier = Modifier.padding(vertical = 6.dp),506                )507            }508509            if (visibleEntries.isNotEmpty()) {510                LazyColumn {511                    items(visibleEntries, key = { it.id }) { entry ->512                        PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })513                    }514                }515            }516517            val createOrigin = if (untrustedOrigin) confirmedTarget else origin518            if (!untrustedOrigin || createOrigin != null) {519                CreateNewRow(520                    origin = createOrigin,521                    typedIdentity = typedIdentity,522                    onClick = { onCreateNew(confirmedTarget) },523                )524            }525        }526    }527}528529@Composable530private fun SearchField(531    value: String,532    onValueChange: (String) -> Unit,533    placeholder: String,534) {535    val accent = LocalPeachAccent.current536    BasicTextField(537        value = value,538        onValueChange = onValueChange,539        singleLine = true,540        textStyle = TextStyle(541            color = peachColors.T0,542            fontFamily = PeachType.Mono,543            fontSize = 14.sp,544        ),545        cursorBrush = SolidColor(accent),546        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),547        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),548        modifier = Modifier549            .fillMaxWidth()550            .background(peachColors.S2, RoundedCornerShape(8.dp))551            .padding(horizontal = 12.dp, vertical = 10.dp),552        decorationBox = { inner ->553            Box(modifier = Modifier.fillMaxWidth()) {554                if (value.isEmpty()) {555                    Text(556                        text = placeholder,557                        color = peachColors.T2,558                        fontFamily = PeachType.Mono,559                        fontSize = 12.sp,560                    )561                }562                inner()563            }564        },565    )566}567568@Composable569private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {570    val accent = LocalPeachAccent.current571    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"572        else "Create new entry"573    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }574    Row(575        modifier = Modifier576            .fillMaxWidth()577            .clickable(onClick = onClick)578            .padding(vertical = 10.dp),579        verticalAlignment = Alignment.CenterVertically,580    ) {581        Box(582            modifier = Modifier583                .size(34.dp)584                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),585            contentAlignment = Alignment.Center,586        ) {587            Text(588                text = "+",589                color = accent,590                fontFamily = PeachType.Display,591                fontStyle = FontStyle.Italic,592                fontSize = 18.sp,593            )594        }595        Column(modifier = Modifier.padding(start = 12.dp)) {596            Text(597                text = title,598                color = accent,599                fontFamily = PeachType.Body,600                fontSize = 14.sp,601                maxLines = 1,602                overflow = TextOverflow.Ellipsis,603            )604            if (subtitle != null) {605                Text(606                    text = "prefill: $subtitle",607                    color = peachColors.T2,608                    fontFamily = PeachType.Mono,609                    fontSize = 10.sp,610                    maxLines = 1,611                    overflow = TextOverflow.Ellipsis,612                )613            }614        }615    }616}617618@Composable619private fun UnlockBody(620    error: String?,621    busy: Boolean,622    canBiometric: Boolean,623    onBiometric: () -> Unit,624    onUnlock: (String) -> Unit,625) {626    val accent = LocalPeachAccent.current627    var password by remember { mutableStateOf("") }628629    Text(630        text = "Unlock to fill",631        color = peachColors.T0,632        fontFamily = PeachType.Body,633        fontSize = 15.sp,634        modifier = Modifier.padding(bottom = 10.dp),635    )636    BasicTextField(637        value = password,638        onValueChange = { password = it },639        singleLine = true,640        enabled = !busy,641        textStyle = TextStyle(642            color = peachColors.T0,643            fontFamily = PeachType.Mono,644            fontSize = 16.sp,645        ),646        cursorBrush = SolidColor(accent),647        visualTransformation = PasswordVisualTransformation(),648        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),649        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),650        modifier = Modifier651            .fillMaxWidth()652            .background(peachColors.S2, RoundedCornerShape(8.dp))653            .padding(horizontal = 12.dp, vertical = 12.dp),654    )655    if (error != null) {656        Text(657            text = error,658            color = peachColors.Breach,659            fontFamily = PeachType.Body,660            fontSize = 12.sp,661            modifier = Modifier.padding(top = 8.dp),662        )663    }664    Row(665        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),666        horizontalArrangement = Arrangement.spacedBy(20.dp),667        verticalAlignment = Alignment.CenterVertically,668    ) {669        if (canBiometric) {670            Text(671                text = "Use biometrics",672                color = accent,673                fontFamily = PeachType.Mono,674                fontSize = 13.sp,675                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),676            )677        }678        Text(679            text = if (busy) "Unlocking…" else "Unlock",680            color = if (password.isEmpty() || busy) peachColors.T2 else accent,681            fontFamily = PeachType.Mono,682            fontSize = 13.sp,683            modifier = Modifier684                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }685                .padding(vertical = 8.dp),686        )687    }688}689690@Composable691private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {692    Row(693        modifier = Modifier694            .fillMaxWidth()695            .clickable(onClick = onClick)696            .padding(vertical = 9.dp),697        verticalAlignment = Alignment.CenterVertically,698    ) {699        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)700        Column(modifier = Modifier.padding(start = 12.dp)) {701            Text(702                text = entry.title,703                color = peachColors.T0,704                fontFamily = PeachType.Body,705                fontSize = 15.sp,706                maxLines = 1,707                overflow = TextOverflow.Ellipsis,708            )709            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""710            if (sub.isNotBlank()) {711                Text(712                    text = sub,713                    color = peachColors.T2,714                    fontFamily = PeachType.Mono,715                    fontSize = 11.sp,716                    maxLines = 1,717                    overflow = TextOverflow.Ellipsis,718                )719            }720        }721    }722}723724/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */725private const val SEARCH_DEBOUNCE_MS = 200L