AutofillEntryPickerActivity.kt

29.1 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            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),126            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),127            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),128            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),129            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),130            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),131            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),132            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),133            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),134        )135        val parsed = authenticateAutofillOrigin(this, rawParsed)136        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT137138        setContent {139            PeachTheme(accent = accent) {140                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)141                val scope = rememberCoroutineScope()142                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }143                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }144                var error by remember { mutableStateOf<String?>(null) }145                var busy by remember { mutableStateOf(false) }146147                // Trusted origins may search the vault manually. Unknown origins stay148                // empty until the user types a registrable target domain in the picker.149                val allEntries = remember(unlocked) {150                    if (unlocked) vault.getEntries() else emptyList()151                }152                val matchedEntries = remember(unlocked, allEntries) {153                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()154                }155156                // Offer biometrics immediately — it is the whole point of157                // unlocking here rather than in the app, and a prompt the user158                // can dismiss straight onto the password field costs nothing.159                LaunchedEffect(unlocked) {160                    if (!unlocked && canBiometric()) {161                        tryBiometric(162                            onUnlocked = {163                                unlocked = true164                                unlockedEpoch = vault.unlockedSessionEpoch()165                            },166                            onFail = { },167                        )168                    }169                }170171                ModalBottomSheet(172                    onDismissRequest = { cancel() },173                    sheetState = sheetState,174                    containerColor = peachColors.S1,175                    dragHandle = { AccentDragHandle() },176                ) {177                    PickerBody(178                        origin = parsed.origin,179                        originTrust = parsed.originTrust,180                        typedIdentity = typedUsername ?: typedEmail,181                        unlocked = unlocked,182                        matchedEntries = matchedEntries,183                        allEntries = allEntries,184                        error = error,185                        busy = busy,186                        canBiometric = canBiometric(),187                        onBiometric = {188                            tryBiometric(189                                onUnlocked = {190                                    unlocked = true191                                    unlockedEpoch = vault.unlockedSessionEpoch()192                                },193                                onFail = {},194                            )195                        },196                        onUnlock = { password ->197                            busy = true198                            error = null199                            scope.launch {200                                when (vault.unlock(password)) {201                                    is UnlockResult.Success -> {202                                        unlocked = true203                                        unlockedEpoch = vault.unlockedSessionEpoch()204                                    }205                                    UnlockResult.WrongPassword -> error = "Wrong master password."206                                    else -> error = "Couldn't unlock."207                                }208                                busy = false209                            }210                        },211                        onPick = { entry ->212                            val epoch = unlockedEpoch213                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {214                                cancel()215                            } else {216                                returnDataset(parsed, entry)217                            }218                        },219                        onCreateNew = { searchText ->220                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)221                        },222                    )223                }224            }225        }226    }227228    override fun onDestroy() {229        if (debugSecureLease) {230            SecureWindowPolicy.release(window)231            debugSecureLease = false232        }233        super.onDestroy()234    }235236    private fun qaSecureCaptureBypass(): Boolean =237        BuildConfig.DEBUG && (238            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||239                QaFlags.disableSecureCaptureForQa240            )241242    private fun canBiometric(): Boolean =243        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)244245    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {246        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()247        Biometrics.authenticateCrypto(248            activity = this,249            title = "Unlock Peach",250            subtitle = "Fill your saved details",251            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),252            onSuccess = { result ->253                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()254                // M-05: unlockWithKey copies `key` into the session's derivedKey,255                // so the caller-owned reference is now redundant. Zero it whether256                // the unlock succeeds or fails so the unwrapped biometric key257                // doesn't outlive the auth prompt.258                lifecycleScope.launch {259                    // Unseal inside the launched ownership scope. If the260                    // lifecycle scope is cancelled before execution, no key is261                    // allocated before a finally can protect it.262                    var key: ByteArray? = null263                    try {264                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)265                        if (key == null) return@launch onFail()266                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()267                    } finally {268                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)269                    }270                }271            },272            onFail = onFail,273        )274    }275276    private fun returnDataset(277        parsed: ParsedStructure,278        entry: VaultEntry,279    ) {280        val dataset = if (entry.isCard()) {281            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)282        } else {283            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)284        }285        if (dataset == null) { cancel(); return }286        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)287        setResult(RESULT_OK, reply)288        finish()289    }290291    /**292     * Cancel the picker and fire MainActivity on the new-entry path. The picker293     * returns CANCELED on purpose — the user is choosing to leave the autofill294     * prompt, not to fill it — and the launcher extra carries the host's typed295     * identity and origin so the new-entry editor opens pre-filled.296     */297    private fun launchNewEntry(298        parsed: ParsedStructure,299        typedUsername: String?,300        typedEmail: String?,301        searchText: String?,302    ) {303        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {304            searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin305        } else {306            parsed.origin307        }308        val intent = Intent(this, MainActivity::class.java).apply {309            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)310            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)311            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)312            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)313            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)314        }315        startActivity(intent)316        cancel()317    }318319    private fun cancel() {320        setResult(RESULT_CANCELED)321        finish()322    }323324    @Suppress("DEPRECATION")325    private fun intentAutofillId(key: String): AutofillId? =326        if (android.os.Build.VERSION.SDK_INT >= 33) {327            intent.getParcelableExtra(key, AutofillId::class.java)328        } else {329            intent.getParcelableExtra(key)330        }331332    private companion object {333        const val MAX_ROWS = 12334    }335}336337/**338 * Build the initial [ParsedStructure] from raw picker intent inputs with339 * originTrust always UNKNOWN. The picker must re-derive trust in-process via340 * [authenticateAutofillOrigin] — a caller-supplied trust extra is never341 * authoritative. For web origins, the authenticated caller package is carried342 * separately so the re-verification can check the browser signing pin.343 */344internal fun pickerParsedFromInputs(345    origin: String?,346    isWebOrigin: Boolean,347    callerPackage: String?,348    verifiedDomains: List<String>,349    usernameId: AutofillId? = null,350    passwordId: AutofillId? = null,351    otpId: AutofillId? = null,352    cardNumberId: AutofillId? = null,353    cardExpMonthId: AutofillId? = null,354    cardExpYearId: AutofillId? = null,355    cardExpDateId: AutofillId? = null,356    cardCvcId: AutofillId? = null,357    cardNameId: AutofillId? = null,358): ParsedStructure = ParsedStructure(359    usernameId = usernameId,360    passwordId = passwordId,361    otpId = otpId,362    cardNumberId = cardNumberId,363    cardExpMonthId = cardExpMonthId,364    cardExpYearId = cardExpYearId,365    cardExpDateId = cardExpDateId,366    cardCvcId = cardCvcId,367    cardNameId = cardNameId,368    webDomain = origin.takeIf { isWebOrigin },369    packageName = if (isWebOrigin) callerPackage else origin,370    // Always UNKNOWN here — the picker re-derives trust from these raw inputs.371    originTrust = AutofillOriginTrust.UNKNOWN,372    verifiedDomains = verifiedDomains,373)374375internal fun autofillPickerEntries(376    entries: List<VaultEntry>,377    parsed: ParsedStructure,378    limit: Int,379): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)380381/**382 * Case-insensitive substring match across the four user-facing identity columns383 * the browser uses (title, username, email, url). Exposed at file scope so the384 * picker can call it directly and the test suite can exercise the same code.385 */386internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {387    if (query.isEmpty()) return true388    val needle = query.lowercase()389    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)390        .any { it.lowercase().contains(needle) }391}392393@Composable394private fun AccentDragHandle() {395    val accent = LocalPeachAccent.current396    Row(397        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),398        horizontalArrangement = Arrangement.Center,399    ) {400        Box(401            modifier = Modifier402                .size(width = 32.dp, height = 4.dp)403                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),404        )405    }406}407408@Composable409private fun PickerBody(410    origin: String?,411    originTrust: AutofillOriginTrust,412    typedIdentity: String?,413    unlocked: Boolean,414    matchedEntries: List<VaultEntry>,415    allEntries: List<VaultEntry>,416    error: String?,417    busy: Boolean,418    canBiometric: Boolean,419    onBiometric: () -> Unit,420    onUnlock: (String) -> Unit,421    onPick: (VaultEntry) -> Unit,422    onCreateNew: (String?) -> Unit,423) {424    val accent = LocalPeachAccent.current425    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN426    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {427        Text(428            text = "peach",429            color = accent,430            fontFamily = PeachType.Display,431            fontStyle = FontStyle.Italic,432            fontSize = 15.sp,433        )434        Text(435            text = when {436                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"437                origin.isNullOrBlank() -> "your vault"438                else -> "Suggested for $origin"439            },440            color = peachColors.T2,441            fontFamily = PeachType.Mono,442            fontSize = 9.sp,443            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),444        )445446        if (originTrust == AutofillOriginTrust.UNKNOWN) {447            Box(448                modifier = Modifier449                    .fillMaxWidth()450                    .padding(bottom = 14.dp)451                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))452                    .padding(12.dp),453            ) {454                Text(455                    text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",456                    color = peachColors.Breach,457                    fontFamily = PeachType.Body,458                    fontSize = 12.sp,459                )460            }461        }462463        if (!unlocked) {464            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)465        } else {466            // The search field is a vault-wide substring search. For unverified origins,467            // the user must type to initiate — empty query shows no entries.468            var rawQuery by remember { mutableStateOf("") }469            var debouncedQuery by remember { mutableStateOf("") }470            LaunchedEffect(rawQuery) {471                delay(SEARCH_DEBOUNCE_MS)472                debouncedQuery = rawQuery473            }474            SearchField(475                value = rawQuery,476                onValueChange = { rawQuery = it },477                placeholder = when {478                    matchedEntries.isNotEmpty() -> "search for another entry…"479                    else -> "search vault…"480                },481            )482483            val visibleEntries = when {484                untrustedOrigin && debouncedQuery.isBlank() -> emptyList()485                debouncedQuery.isBlank() -> matchedEntries486                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }487            }488            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489490            val emptyMessage = when {491                untrustedOrigin && debouncedQuery.isBlank() ->492                    "Search your vault to choose a login."493                untrustedOrigin && visibleEntries.isEmpty() ->494                    "No entries match \"${debouncedQuery.trim()}\"."495                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->496                    "No saved entries match this site."497                showSearchEmpty ->498                    "No entries match \"${debouncedQuery.trim()}\"."499                else -> null500            }501            if (emptyMessage != null) {502                Text(503                    text = emptyMessage,504                    color = peachColors.T1,505                    fontFamily = PeachType.Body,506                    fontSize = 13.sp,507                    modifier = Modifier.padding(vertical = 6.dp),508                )509            }510511            if (visibleEntries.isNotEmpty()) {512                LazyColumn {513                    items(visibleEntries, key = { it.id }) { entry ->514                        PickerRow(entry, onClick = { onPick(entry) })515                    }516                }517            }518519            val createOrigin = if (untrustedOrigin) {520                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin521            } else {522                origin523            }524            CreateNewRow(525                origin = createOrigin,526                typedIdentity = typedIdentity,527                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },528            )529        }530    }531}532533@Composable534private fun SearchField(535    value: String,536    onValueChange: (String) -> Unit,537    placeholder: String,538) {539    val accent = LocalPeachAccent.current540    BasicTextField(541        value = value,542        onValueChange = onValueChange,543        singleLine = true,544        textStyle = TextStyle(545            color = peachColors.T0,546            fontFamily = PeachType.Mono,547            fontSize = 14.sp,548        ),549        cursorBrush = SolidColor(accent),550        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),551        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),552        modifier = Modifier553            .fillMaxWidth()554            .background(peachColors.S2, RoundedCornerShape(8.dp))555            .padding(horizontal = 12.dp, vertical = 10.dp),556        decorationBox = { inner ->557            Box(modifier = Modifier.fillMaxWidth()) {558                if (value.isEmpty()) {559                    Text(560                        text = placeholder,561                        color = peachColors.T2,562                        fontFamily = PeachType.Mono,563                        fontSize = 12.sp,564                    )565                }566                inner()567            }568        },569    )570}571572@Composable573private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {574    val accent = LocalPeachAccent.current575    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"576        else "Create new entry"577    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }578    Row(579        modifier = Modifier580            .fillMaxWidth()581            .clickable(onClick = onClick)582            .padding(vertical = 10.dp),583        verticalAlignment = Alignment.CenterVertically,584    ) {585        Box(586            modifier = Modifier587                .size(34.dp)588                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),589            contentAlignment = Alignment.Center,590        ) {591            Text(592                text = "+",593                color = accent,594                fontFamily = PeachType.Display,595                fontStyle = FontStyle.Italic,596                fontSize = 18.sp,597            )598        }599        Column(modifier = Modifier.padding(start = 12.dp)) {600            Text(601                text = title,602                color = accent,603                fontFamily = PeachType.Body,604                fontSize = 14.sp,605                maxLines = 1,606                overflow = TextOverflow.Ellipsis,607            )608            if (subtitle != null) {609                Text(610                    text = "prefill: $subtitle",611                    color = peachColors.T2,612                    fontFamily = PeachType.Mono,613                    fontSize = 10.sp,614                    maxLines = 1,615                    overflow = TextOverflow.Ellipsis,616                )617            }618        }619    }620}621622@Composable623private fun UnlockBody(624    error: String?,625    busy: Boolean,626    canBiometric: Boolean,627    onBiometric: () -> Unit,628    onUnlock: (String) -> Unit,629) {630    val accent = LocalPeachAccent.current631    var password by remember { mutableStateOf("") }632633    Text(634        text = "Unlock to fill",635        color = peachColors.T0,636        fontFamily = PeachType.Body,637        fontSize = 15.sp,638        modifier = Modifier.padding(bottom = 10.dp),639    )640    BasicTextField(641        value = password,642        onValueChange = { password = it },643        singleLine = true,644        enabled = !busy,645        textStyle = TextStyle(646            color = peachColors.T0,647            fontFamily = PeachType.Mono,648            fontSize = 16.sp,649        ),650        cursorBrush = SolidColor(accent),651        visualTransformation = PasswordVisualTransformation(),652        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),653        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),654        modifier = Modifier655            .fillMaxWidth()656            .background(peachColors.S2, RoundedCornerShape(8.dp))657            .padding(horizontal = 12.dp, vertical = 12.dp),658    )659    if (error != null) {660        Text(661            text = error,662            color = peachColors.Breach,663            fontFamily = PeachType.Body,664            fontSize = 12.sp,665            modifier = Modifier.padding(top = 8.dp),666        )667    }668    Row(669        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),670        horizontalArrangement = Arrangement.spacedBy(20.dp),671        verticalAlignment = Alignment.CenterVertically,672    ) {673        if (canBiometric) {674            Text(675                text = "Use biometrics",676                color = accent,677                fontFamily = PeachType.Mono,678                fontSize = 13.sp,679                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),680            )681        }682        Text(683            text = if (busy) "Unlocking…" else "Unlock",684            color = if (password.isEmpty() || busy) peachColors.T2 else accent,685            fontFamily = PeachType.Mono,686            fontSize = 13.sp,687            modifier = Modifier688                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }689                .padding(vertical = 8.dp),690        )691    }692}693694@Composable695private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {696    Row(697        modifier = Modifier698            .fillMaxWidth()699            .clickable(onClick = onClick)700            .padding(vertical = 9.dp),701        verticalAlignment = Alignment.CenterVertically,702    ) {703        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)704        Column(modifier = Modifier.padding(start = 12.dp)) {705            Text(706                text = entry.title,707                color = peachColors.T0,708                fontFamily = PeachType.Body,709                fontSize = 15.sp,710                maxLines = 1,711                overflow = TextOverflow.Ellipsis,712            )713            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""714            if (sub.isNotBlank()) {715                Text(716                    text = sub,717                    color = peachColors.T2,718                    fontFamily = PeachType.Mono,719                    fontSize = 11.sp,720                    maxLines = 1,721                    overflow = TextOverflow.Ellipsis,722                )723            }724        }725    }726}727728/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */729private const val SEARCH_DEBOUNCE_MS = 200L