AutofillEntryPickerActivity.kt

28.0 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 ->205                            val epoch = unlockedEpoch206                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {207                                cancel()208                            } else {209                                returnDataset(parsed, entry)210                            }211                        },212                        onCreateNew = { searchText ->213                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)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    ) {273        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)274        if (dataset == null) { cancel(); return }275        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)276        setResult(RESULT_OK, reply)277        finish()278    }279280    /**281     * Cancel the picker and fire MainActivity on the new-entry path. The picker282     * returns CANCELED on purpose — the user is choosing to leave the autofill283     * prompt, not to fill it — and the launcher extra carries the host's typed284     * identity and origin so the new-entry editor opens pre-filled.285     */286    private fun launchNewEntry(287        parsed: ParsedStructure,288        typedUsername: String?,289        typedEmail: String?,290        searchText: String?,291    ) {292        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {293            searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin294        } else {295            parsed.origin296        }297        val intent = Intent(this, MainActivity::class.java).apply {298            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)299            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)300            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)301            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)302            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)303        }304        startActivity(intent)305        cancel()306    }307308    private fun cancel() {309        setResult(RESULT_CANCELED)310        finish()311    }312313    @Suppress("DEPRECATION")314    private fun intentAutofillId(key: String): AutofillId? =315        if (android.os.Build.VERSION.SDK_INT >= 33) {316            intent.getParcelableExtra(key, AutofillId::class.java)317        } else {318            intent.getParcelableExtra(key)319        }320321    private companion object {322        const val MAX_ROWS = 12323    }324}325326/**327 * Build the initial [ParsedStructure] from raw picker intent inputs with328 * originTrust always UNKNOWN. The picker must re-derive trust in-process via329 * [authenticateAutofillOrigin] — a caller-supplied trust extra is never330 * authoritative. For web origins, the authenticated caller package is carried331 * separately so the re-verification can check the browser signing pin.332 */333internal fun pickerParsedFromInputs(334    origin: String?,335    isWebOrigin: Boolean,336    callerPackage: String?,337    verifiedDomains: List<String>,338    usernameId: AutofillId? = null,339    passwordId: AutofillId? = null,340    otpId: AutofillId? = null,341): ParsedStructure = ParsedStructure(342    usernameId = usernameId,343    passwordId = passwordId,344    otpId = otpId,345    webDomain = origin.takeIf { isWebOrigin },346    packageName = if (isWebOrigin) callerPackage else origin,347    // Always UNKNOWN here — the picker re-derives trust from these raw inputs.348    originTrust = AutofillOriginTrust.UNKNOWN,349    verifiedDomains = verifiedDomains,350)351352internal fun autofillPickerEntries(353    entries: List<VaultEntry>,354    parsed: ParsedStructure,355    limit: Int,356): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)357358/**359 * Case-insensitive substring match across the four user-facing identity columns360 * the browser uses (title, username, email, url). Exposed at file scope so the361 * picker can call it directly and the test suite can exercise the same code.362 */363internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {364    if (query.isEmpty()) return true365    val needle = query.lowercase()366    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)367        .any { it.lowercase().contains(needle) }368}369370@Composable371private fun AccentDragHandle() {372    val accent = LocalPeachAccent.current373    Row(374        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),375        horizontalArrangement = Arrangement.Center,376    ) {377        Box(378            modifier = Modifier379                .size(width = 32.dp, height = 4.dp)380                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),381        )382    }383}384385@Composable386private fun PickerBody(387    origin: String?,388    originTrust: AutofillOriginTrust,389    typedIdentity: String?,390    unlocked: Boolean,391    matchedEntries: List<VaultEntry>,392    allEntries: List<VaultEntry>,393    error: String?,394    busy: Boolean,395    canBiometric: Boolean,396    onBiometric: () -> Unit,397    onUnlock: (String) -> Unit,398    onPick: (VaultEntry) -> Unit,399    onCreateNew: (String?) -> Unit,400) {401    val accent = LocalPeachAccent.current402    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN403    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {404        Text(405            text = "peach",406            color = accent,407            fontFamily = PeachType.Display,408            fontStyle = FontStyle.Italic,409            fontSize = 15.sp,410        )411        Text(412            text = when {413                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"414                origin.isNullOrBlank() -> "your vault"415                else -> "Suggested for $origin"416            },417            color = peachColors.T2,418            fontFamily = PeachType.Mono,419            fontSize = 9.sp,420            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),421        )422423        if (originTrust == AutofillOriginTrust.UNKNOWN) {424            Box(425                modifier = Modifier426                    .fillMaxWidth()427                    .padding(bottom = 14.dp)428                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))429                    .padding(12.dp),430            ) {431                Text(432                    text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",433                    color = peachColors.Breach,434                    fontFamily = PeachType.Body,435                    fontSize = 12.sp,436                )437            }438        }439440        if (!unlocked) {441            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)442        } else {443            // The search field is a vault-wide substring search. For unverified origins,444            // the user must type to initiate — empty query shows no entries.445            var rawQuery by remember { mutableStateOf("") }446            var debouncedQuery by remember { mutableStateOf("") }447            LaunchedEffect(rawQuery) {448                delay(SEARCH_DEBOUNCE_MS)449                debouncedQuery = rawQuery450            }451            SearchField(452                value = rawQuery,453                onValueChange = { rawQuery = it },454                placeholder = when {455                    matchedEntries.isNotEmpty() -> "search for another entry…"456                    else -> "search vault…"457                },458            )459460            val visibleEntries = when {461                untrustedOrigin && debouncedQuery.isBlank() -> emptyList()462                debouncedQuery.isBlank() -> matchedEntries463                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }464            }465            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()466467            val emptyMessage = when {468                untrustedOrigin && debouncedQuery.isBlank() ->469                    "Search your vault to choose a login."470                untrustedOrigin && visibleEntries.isEmpty() ->471                    "No entries match \"${debouncedQuery.trim()}\"."472                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->473                    "No saved entries match this site."474                showSearchEmpty ->475                    "No entries match \"${debouncedQuery.trim()}\"."476                else -> null477            }478            if (emptyMessage != null) {479                Text(480                    text = emptyMessage,481                    color = peachColors.T1,482                    fontFamily = PeachType.Body,483                    fontSize = 13.sp,484                    modifier = Modifier.padding(vertical = 6.dp),485                )486            }487488            if (visibleEntries.isNotEmpty()) {489                LazyColumn {490                    items(visibleEntries, key = { it.id }) { entry ->491                        PickerRow(entry, onClick = { onPick(entry) })492                    }493                }494            }495496            val createOrigin = if (untrustedOrigin) {497                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin498            } else {499                origin500            }501            CreateNewRow(502                origin = createOrigin,503                typedIdentity = typedIdentity,504                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },505            )506        }507    }508}509510@Composable511private fun SearchField(512    value: String,513    onValueChange: (String) -> Unit,514    placeholder: String,515) {516    val accent = LocalPeachAccent.current517    BasicTextField(518        value = value,519        onValueChange = onValueChange,520        singleLine = true,521        textStyle = TextStyle(522            color = peachColors.T0,523            fontFamily = PeachType.Mono,524            fontSize = 14.sp,525        ),526        cursorBrush = SolidColor(accent),527        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),528        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),529        modifier = Modifier530            .fillMaxWidth()531            .background(peachColors.S2, RoundedCornerShape(8.dp))532            .padding(horizontal = 12.dp, vertical = 10.dp),533        decorationBox = { inner ->534            Box(modifier = Modifier.fillMaxWidth()) {535                if (value.isEmpty()) {536                    Text(537                        text = placeholder,538                        color = peachColors.T2,539                        fontFamily = PeachType.Mono,540                        fontSize = 12.sp,541                    )542                }543                inner()544            }545        },546    )547}548549@Composable550private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {551    val accent = LocalPeachAccent.current552    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"553        else "Create new entry"554    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }555    Row(556        modifier = Modifier557            .fillMaxWidth()558            .clickable(onClick = onClick)559            .padding(vertical = 10.dp),560        verticalAlignment = Alignment.CenterVertically,561    ) {562        Box(563            modifier = Modifier564                .size(34.dp)565                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),566            contentAlignment = Alignment.Center,567        ) {568            Text(569                text = "+",570                color = accent,571                fontFamily = PeachType.Display,572                fontStyle = FontStyle.Italic,573                fontSize = 18.sp,574            )575        }576        Column(modifier = Modifier.padding(start = 12.dp)) {577            Text(578                text = title,579                color = accent,580                fontFamily = PeachType.Body,581                fontSize = 14.sp,582                maxLines = 1,583                overflow = TextOverflow.Ellipsis,584            )585            if (subtitle != null) {586                Text(587                    text = "prefill: $subtitle",588                    color = peachColors.T2,589                    fontFamily = PeachType.Mono,590                    fontSize = 10.sp,591                    maxLines = 1,592                    overflow = TextOverflow.Ellipsis,593                )594            }595        }596    }597}598599@Composable600private fun UnlockBody(601    error: String?,602    busy: Boolean,603    canBiometric: Boolean,604    onBiometric: () -> Unit,605    onUnlock: (String) -> Unit,606) {607    val accent = LocalPeachAccent.current608    var password by remember { mutableStateOf("") }609610    Text(611        text = "Unlock to fill",612        color = peachColors.T0,613        fontFamily = PeachType.Body,614        fontSize = 15.sp,615        modifier = Modifier.padding(bottom = 10.dp),616    )617    BasicTextField(618        value = password,619        onValueChange = { password = it },620        singleLine = true,621        enabled = !busy,622        textStyle = TextStyle(623            color = peachColors.T0,624            fontFamily = PeachType.Mono,625            fontSize = 16.sp,626        ),627        cursorBrush = SolidColor(accent),628        visualTransformation = PasswordVisualTransformation(),629        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),630        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),631        modifier = Modifier632            .fillMaxWidth()633            .background(peachColors.S2, RoundedCornerShape(8.dp))634            .padding(horizontal = 12.dp, vertical = 12.dp),635    )636    if (error != null) {637        Text(638            text = error,639            color = peachColors.Breach,640            fontFamily = PeachType.Body,641            fontSize = 12.sp,642            modifier = Modifier.padding(top = 8.dp),643        )644    }645    Row(646        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),647        horizontalArrangement = Arrangement.spacedBy(20.dp),648        verticalAlignment = Alignment.CenterVertically,649    ) {650        if (canBiometric) {651            Text(652                text = "Use biometrics",653                color = accent,654                fontFamily = PeachType.Mono,655                fontSize = 13.sp,656                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),657            )658        }659        Text(660            text = if (busy) "Unlocking…" else "Unlock",661            color = if (password.isEmpty() || busy) peachColors.T2 else accent,662            fontFamily = PeachType.Mono,663            fontSize = 13.sp,664            modifier = Modifier665                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }666                .padding(vertical = 8.dp),667        )668    }669}670671@Composable672private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {673    Row(674        modifier = Modifier675            .fillMaxWidth()676            .clickable(onClick = onClick)677            .padding(vertical = 9.dp),678        verticalAlignment = Alignment.CenterVertically,679    ) {680        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)681        Column(modifier = Modifier.padding(start = 12.dp)) {682            Text(683                text = entry.title,684                color = peachColors.T0,685                fontFamily = PeachType.Body,686                fontSize = 15.sp,687                maxLines = 1,688                overflow = TextOverflow.Ellipsis,689            )690            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""691            if (sub.isNotBlank()) {692                Text(693                    text = sub,694                    color = peachColors.T2,695                    fontFamily = PeachType.Mono,696                    fontSize = 11.sp,697                    maxLines = 1,698                    overflow = TextOverflow.Ellipsis,699                )700            }701        }702    }703}704705/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */706private const val SEARCH_DEBOUNCE_MS = 200L