AutofillEntryPickerActivity.kt

27.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            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                // Trusted origins may search the vault manually. Unknown origins stay147                // empty until the user types a registrable target domain in the picker.148                val allEntries = remember(unlocked) {149                    if (unlocked) vault.getEntries() else emptyList()150                }151                val matchedEntries = remember(unlocked, allEntries) {152                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()153                }154155                // Offer biometrics immediately — it is the whole point of156                // unlocking here rather than in the app, and a prompt the user157                // can dismiss straight onto the password field costs nothing.158                LaunchedEffect(unlocked) {159                    if (!unlocked && canBiometric()) {160                        tryBiometric(161                            onUnlocked = {162                                unlocked = true163                                unlockedEpoch = vault.unlockedSessionEpoch()164                            },165                            onFail = { },166                        )167                    }168                }169170                ModalBottomSheet(171                    onDismissRequest = { cancel() },172                    sheetState = sheetState,173                    containerColor = peachColors.S1,174                    dragHandle = { AccentDragHandle() },175                ) {176                    PickerBody(177                        origin = parsed.origin,178                        typedIdentity = typedUsername ?: typedEmail,179                        unlocked = unlocked,180                        matchedEntries = matchedEntries,181                        allEntries = allEntries,182                        error = error,183                        busy = busy,184                        canBiometric = canBiometric(),185                        onBiometric = {186                            tryBiometric(187                                onUnlocked = {188                                    unlocked = true189                                    unlockedEpoch = vault.unlockedSessionEpoch()190                                },191                                onFail = {},192                            )193                        },194                        onUnlock = { password ->195                            busy = true196                            error = null197                            scope.launch {198                                when (vault.unlock(password)) {199                                    is UnlockResult.Success -> {200                                        unlocked = true201                                        unlockedEpoch = vault.unlockedSessionEpoch()202                                    }203                                    UnlockResult.WrongPassword -> error = "Wrong master password."204                                    else -> error = "Couldn't unlock."205                                }206                                busy = false207                            }208                        },209                        onPick = { entry ->210                            val epoch = unlockedEpoch211                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {212                                cancel()213                            } else {214                                returnDataset(parsed, entry)215                            }216                        },217                        onCreateNew = { searchText ->218                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)219                        },220                    )221                }222            }223        }224    }225226    override fun onDestroy() {227        if (debugSecureLease) {228            SecureWindowPolicy.release(window)229            debugSecureLease = false230        }231        super.onDestroy()232    }233234    private fun qaSecureCaptureBypass(): Boolean =235        BuildConfig.DEBUG && (236            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||237                QaFlags.disableSecureCaptureForQa238            )239240    private fun canBiometric(): Boolean =241        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)242243    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {244        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()245        Biometrics.authenticateCrypto(246            activity = this,247            title = "Unlock Peach",248            subtitle = "Fill your saved details",249            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),250            onSuccess = { result ->251                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()252                // M-05: unlockWithKey copies `key` into the session's derivedKey,253                // so the caller-owned reference is now redundant. Zero it whether254                // the unlock succeeds or fails so the unwrapped biometric key255                // doesn't outlive the auth prompt.256                lifecycleScope.launch {257                    // Unseal inside the launched ownership scope. If the258                    // lifecycle scope is cancelled before execution, no key is259                    // allocated before a finally can protect it.260                    var key: ByteArray? = null261                    try {262                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)263                        if (key == null) return@launch onFail()264                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()265                    } finally {266                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)267                    }268                }269            },270            onFail = onFail,271        )272    }273274    private fun returnDataset(275        parsed: ParsedStructure,276        entry: VaultEntry,277    ) {278        val dataset = if (entry.isCard()) {279            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)280        } else {281            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)282        }283        if (dataset == null) { cancel(); return }284        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)285        setResult(RESULT_OK, reply)286        finish()287    }288289    /**290     * Cancel the picker and fire MainActivity on the new-entry path. The picker291     * returns CANCELED on purpose — the user is choosing to leave the autofill292     * prompt, not to fill it — and the launcher extra carries the host's typed293     * identity and origin so the new-entry editor opens pre-filled.294     */295    private fun launchNewEntry(296        parsed: ParsedStructure,297        typedUsername: String?,298        typedEmail: String?,299        searchText: String?,300    ) {301        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin302        val intent = Intent(this, MainActivity::class.java).apply {303            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)304            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)305            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)306            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)307            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)308        }309        startActivity(intent)310        cancel()311    }312313    private fun cancel() {314        setResult(RESULT_CANCELED)315        finish()316    }317318    @Suppress("DEPRECATION")319    private fun intentAutofillId(key: String): AutofillId? =320        if (android.os.Build.VERSION.SDK_INT >= 33) {321            intent.getParcelableExtra(key, AutofillId::class.java)322        } else {323            intent.getParcelableExtra(key)324        }325326    private companion object {327        const val MAX_ROWS = 12328    }329}330331/**332 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin333 * domains and app title are resolved in-process via [resolveAutofillOrigin] from334 * the authenticated caller package + web domain — never from a caller extra.335 */336internal fun pickerParsedFromInputs(337    origin: String?,338    isWebOrigin: Boolean,339    callerPackage: String?,340    usernameId: AutofillId? = null,341    passwordId: AutofillId? = null,342    otpId: AutofillId? = null,343    cardNumberId: AutofillId? = null,344    cardExpMonthId: AutofillId? = null,345    cardExpYearId: AutofillId? = null,346    cardExpDateId: AutofillId? = null,347    cardCvcId: AutofillId? = null,348    cardNameId: AutofillId? = null,349): ParsedStructure = ParsedStructure(350    usernameId = usernameId,351    passwordId = passwordId,352    otpId = otpId,353    cardNumberId = cardNumberId,354    cardExpMonthId = cardExpMonthId,355    cardExpYearId = cardExpYearId,356    cardExpDateId = cardExpDateId,357    cardCvcId = cardCvcId,358    cardNameId = cardNameId,359    webDomain = origin.takeIf { isWebOrigin },360    packageName = if (isWebOrigin) callerPackage else origin,361)362363internal fun autofillPickerEntries(364    entries: List<VaultEntry>,365    parsed: ParsedStructure,366    limit: Int,367): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)368369/**370 * Case-insensitive substring match across the four user-facing identity columns371 * the browser uses (title, username, email, url). Exposed at file scope so the372 * picker can call it directly and the test suite can exercise the same code.373 */374internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {375    if (query.isEmpty()) return true376    val needle = query.lowercase()377    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)378        .any { it.lowercase().contains(needle) }379}380381@Composable382private fun AccentDragHandle() {383    val accent = LocalPeachAccent.current384    Row(385        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),386        horizontalArrangement = Arrangement.Center,387    ) {388        Box(389            modifier = Modifier390                .size(width = 32.dp, height = 4.dp)391                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),392        )393    }394}395396@Composable397private fun PickerBody(398    origin: String?,399    typedIdentity: String?,400    unlocked: Boolean,401    matchedEntries: List<VaultEntry>,402    allEntries: List<VaultEntry>,403    error: String?,404    busy: Boolean,405    canBiometric: Boolean,406    onBiometric: () -> Unit,407    onUnlock: (String) -> Unit,408    onPick: (VaultEntry) -> Unit,409    onCreateNew: (String?) -> Unit,410) {411    val accent = LocalPeachAccent.current412    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {413        Text(414            text = "peach",415            color = accent,416            fontFamily = PeachType.Display,417            fontStyle = FontStyle.Italic,418            fontSize = 15.sp,419        )420        Text(421            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",422            color = peachColors.T2,423            fontFamily = PeachType.Mono,424            fontSize = 9.sp,425            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),426        )427428        if (!unlocked) {429            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)430        } else {431            // The search field is a vault-wide substring search; an empty query432            // shows the origin's matched entries.433            var rawQuery by remember { mutableStateOf("") }434            var debouncedQuery by remember { mutableStateOf("") }435            LaunchedEffect(rawQuery) {436                delay(SEARCH_DEBOUNCE_MS)437                debouncedQuery = rawQuery438            }439            SearchField(440                value = rawQuery,441                onValueChange = { rawQuery = it },442                placeholder = when {443                    matchedEntries.isNotEmpty() -> "search for another entry…"444                    else -> "search vault…"445                },446            )447448            val visibleEntries = when {449                debouncedQuery.isBlank() -> matchedEntries450                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }451            }452            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()453454            val emptyMessage = when {455                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->456                    "No saved entries match this site."457                showSearchEmpty ->458                    "No entries match \"${debouncedQuery.trim()}\"."459                else -> null460            }461            if (emptyMessage != null) {462                Text(463                    text = emptyMessage,464                    color = peachColors.T1,465                    fontFamily = PeachType.Body,466                    fontSize = 13.sp,467                    modifier = Modifier.padding(vertical = 6.dp),468                )469            }470471            if (visibleEntries.isNotEmpty()) {472                LazyColumn {473                    items(visibleEntries, key = { it.id }) { entry ->474                        PickerRow(entry, onClick = { onPick(entry) })475                    }476                }477            }478479            val createOrigin =480                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin481            CreateNewRow(482                origin = createOrigin,483                typedIdentity = typedIdentity,484                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },485            )486        }487    }488}489490@Composable491private fun SearchField(492    value: String,493    onValueChange: (String) -> Unit,494    placeholder: String,495) {496    val accent = LocalPeachAccent.current497    BasicTextField(498        value = value,499        onValueChange = onValueChange,500        singleLine = true,501        textStyle = TextStyle(502            color = peachColors.T0,503            fontFamily = PeachType.Mono,504            fontSize = 14.sp,505        ),506        cursorBrush = SolidColor(accent),507        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),508        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),509        modifier = Modifier510            .fillMaxWidth()511            .background(peachColors.S2, RoundedCornerShape(8.dp))512            .padding(horizontal = 12.dp, vertical = 10.dp),513        decorationBox = { inner ->514            Box(modifier = Modifier.fillMaxWidth()) {515                if (value.isEmpty()) {516                    Text(517                        text = placeholder,518                        color = peachColors.T2,519                        fontFamily = PeachType.Mono,520                        fontSize = 12.sp,521                    )522                }523                inner()524            }525        },526    )527}528529@Composable530private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {531    val accent = LocalPeachAccent.current532    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"533        else "Create new entry"534    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }535    Row(536        modifier = Modifier537            .fillMaxWidth()538            .clickable(onClick = onClick)539            .padding(vertical = 10.dp),540        verticalAlignment = Alignment.CenterVertically,541    ) {542        Box(543            modifier = Modifier544                .size(34.dp)545                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),546            contentAlignment = Alignment.Center,547        ) {548            Text(549                text = "+",550                color = accent,551                fontFamily = PeachType.Display,552                fontStyle = FontStyle.Italic,553                fontSize = 18.sp,554            )555        }556        Column(modifier = Modifier.padding(start = 12.dp)) {557            Text(558                text = title,559                color = accent,560                fontFamily = PeachType.Body,561                fontSize = 14.sp,562                maxLines = 1,563                overflow = TextOverflow.Ellipsis,564            )565            if (subtitle != null) {566                Text(567                    text = "prefill: $subtitle",568                    color = peachColors.T2,569                    fontFamily = PeachType.Mono,570                    fontSize = 10.sp,571                    maxLines = 1,572                    overflow = TextOverflow.Ellipsis,573                )574            }575        }576    }577}578579@Composable580private fun UnlockBody(581    error: String?,582    busy: Boolean,583    canBiometric: Boolean,584    onBiometric: () -> Unit,585    onUnlock: (String) -> Unit,586) {587    val accent = LocalPeachAccent.current588    var password by remember { mutableStateOf("") }589590    Text(591        text = "Unlock to fill",592        color = peachColors.T0,593        fontFamily = PeachType.Body,594        fontSize = 15.sp,595        modifier = Modifier.padding(bottom = 10.dp),596    )597    BasicTextField(598        value = password,599        onValueChange = { password = it },600        singleLine = true,601        enabled = !busy,602        textStyle = TextStyle(603            color = peachColors.T0,604            fontFamily = PeachType.Mono,605            fontSize = 16.sp,606        ),607        cursorBrush = SolidColor(accent),608        visualTransformation = PasswordVisualTransformation(),609        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),610        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),611        modifier = Modifier612            .fillMaxWidth()613            .background(peachColors.S2, RoundedCornerShape(8.dp))614            .padding(horizontal = 12.dp, vertical = 12.dp),615    )616    if (error != null) {617        Text(618            text = error,619            color = peachColors.Breach,620            fontFamily = PeachType.Body,621            fontSize = 12.sp,622            modifier = Modifier.padding(top = 8.dp),623        )624    }625    Row(626        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),627        horizontalArrangement = Arrangement.spacedBy(20.dp),628        verticalAlignment = Alignment.CenterVertically,629    ) {630        if (canBiometric) {631            Text(632                text = "Use biometrics",633                color = accent,634                fontFamily = PeachType.Mono,635                fontSize = 13.sp,636                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),637            )638        }639        Text(640            text = if (busy) "Unlocking…" else "Unlock",641            color = if (password.isEmpty() || busy) peachColors.T2 else accent,642            fontFamily = PeachType.Mono,643            fontSize = 13.sp,644            modifier = Modifier645                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }646                .padding(vertical = 8.dp),647        )648    }649}650651@Composable652private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {653    Row(654        modifier = Modifier655            .fillMaxWidth()656            .clickable(onClick = onClick)657            .padding(vertical = 9.dp),658        verticalAlignment = Alignment.CenterVertically,659    ) {660        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)661        Column(modifier = Modifier.padding(start = 12.dp)) {662            Text(663                text = entry.title,664                color = peachColors.T0,665                fontFamily = PeachType.Body,666                fontSize = 15.sp,667                maxLines = 1,668                overflow = TextOverflow.Ellipsis,669            )670            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""671            if (sub.isNotBlank()) {672                Text(673                    text = sub,674                    color = peachColors.T2,675                    fontFamily = PeachType.Mono,676                    fontSize = 11.sp,677                    maxLines = 1,678                    overflow = TextOverflow.Ellipsis,679                )680            }681        }682    }683}684685/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */686private const val SEARCH_DEBOUNCE_MS = 200L