AutofillEntryPickerActivity.kt

27.0 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.WindowManager6import android.view.autofill.AutofillId7import android.view.autofill.AutofillManager8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.lazy.LazyColumn19import androidx.compose.foundation.lazy.items20import androidx.compose.foundation.shape.RoundedCornerShape21import androidx.compose.material3.ExperimentalMaterial3Api22import androidx.compose.material3.ModalBottomSheet23import androidx.compose.material3.Text24import androidx.compose.material3.rememberModalBottomSheetState25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.foundation.text.KeyboardActions27import androidx.compose.foundation.text.KeyboardOptions28import androidx.compose.runtime.Composable29import androidx.compose.runtime.LaunchedEffect30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.rememberCoroutineScope34import androidx.compose.runtime.setValue35import androidx.compose.ui.graphics.SolidColor36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.input.ImeAction38import androidx.compose.ui.text.input.KeyboardType39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.text.font.FontStyle43import androidx.compose.ui.text.style.TextOverflow44import androidx.compose.ui.unit.dp45import androidx.compose.ui.unit.sp46import androidx.fragment.app.FragmentActivity47import androidx.lifecycle.lifecycleScope48import com.peachpasswords.BuildConfig49import com.peachpasswords.MainActivity50import com.peachpasswords.shared.extractDomain51import com.peachpasswords.ui.lock.BiometricKeyStore52import com.peachpasswords.ui.lock.Biometrics53import com.peachpasswords.ui.components.FaviconInitials54import com.peachpasswords.ui.theme.ACCENT_COLORS55import com.peachpasswords.ui.theme.DEFAULT_ACCENT56import com.peachpasswords.ui.theme.LocalPeachAccent57import com.peachpasswords.ui.theme.peachColors58import com.peachpasswords.ui.theme.PeachTheme59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.vault.UnlockResult61import com.peachpasswords.vault.VaultEntry62import com.peachpasswords.vault.VaultRepository63import dagger.hilt.android.AndroidEntryPoint64import kotlinx.coroutines.delay65import kotlinx.coroutines.launch66import javax.inject.Inject6768/**69 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"70 * in the system autofill drop-down. Lists matching vault entries using the same71 * row vocabulary as the vault list; picking one returns a filled72 * [android.service.autofill.Dataset] to the framework via73 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].74 *75 * Inline-mirror of the browser's selector: always-on search field, vault-wide76 * match against title/username/email/url, and a "+ Create new entry" affordance77 * in the header. The affordance cancels this activity and launches [MainActivity]78 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had79 * typed on the host page.80 *81 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity82 * and return RESULT_CANCELED, which produced a loop with no exit: the framework83 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the84 * user unlocked the app, came back to a field that still asked them to unlock,85 * and had no way to tell that anything had happened. Autofill is a modal86 * interruption of someone else's app — leaving it to go somewhere else is the87 * one thing it must not do.88 */89@AndroidEntryPoint90class AutofillEntryPickerActivity : FragmentActivity() {9192    @Inject lateinit var vault: VaultRepository9394    @OptIn(ExperimentalMaterial3Api::class)95    override fun onCreate(savedInstanceState: Bundle?) {96        super.onCreate(savedInstanceState)9798        // H-05: separate window from MainActivity, so it does NOT inherit the99        // release-wide FLAG_SECURE set there. Show vault metadata and accept the100        // master password — protect against screenshots / recents thumbnails.101        if (!BuildConfig.DEBUG) {102            window.setFlags(103                WindowManager.LayoutParams.FLAG_SECURE,104                WindowManager.LayoutParams.FLAG_SECURE,105            )106        }107108        val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)109        val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)110        val originTrust = runCatching {111            AutofillOriginTrust.valueOf(112                intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN_TRUST).orEmpty(),113            )114        }.getOrDefault(AutofillOriginTrust.UNKNOWN)115        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117        val parsed = ParsedStructure(118            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),119            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),120            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),121            webDomain = origin.takeIf { isWebOrigin },122            packageName = origin.takeUnless { isWebOrigin },123            originTrust = originTrust,124            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125        )126        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT127128        setContent {129            PeachTheme(accent = accent) {130                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)131                val scope = rememberCoroutineScope()132                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }133                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }134                var error by remember { mutableStateOf<String?>(null) }135                var busy by remember { mutableStateOf(false) }136137                // Trusted origins may search the vault manually. Unknown origins stay138                // empty until the user types a registrable target domain in the picker.139                val allEntries = remember(unlocked) {140                    if (unlocked) vault.getEntries() else emptyList()141                }142                val matchedEntries = remember(unlocked, allEntries) {143                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()144                }145146                // Offer biometrics immediately — it is the whole point of147                // unlocking here rather than in the app, and a prompt the user148                // can dismiss straight onto the password field costs nothing.149                LaunchedEffect(unlocked) {150                    if (!unlocked && canBiometric()) {151                        tryBiometric(152                            onUnlocked = {153                                unlocked = true154                                unlockedEpoch = vault.unlockedSessionEpoch()155                            },156                            onFail = { },157                        )158                    }159                }160161                ModalBottomSheet(162                    onDismissRequest = { cancel() },163                    sheetState = sheetState,164                    containerColor = peachColors.S1,165                    dragHandle = { AccentDragHandle() },166                ) {167                    PickerBody(168                        origin = parsed.origin,169                        originTrust = parsed.originTrust,170                        typedIdentity = typedUsername ?: typedEmail,171                        unlocked = unlocked,172                        matchedEntries = matchedEntries,173                        allEntries = allEntries,174                        error = error,175                        busy = busy,176                        canBiometric = canBiometric(),177                        onBiometric = {178                            tryBiometric(179                                onUnlocked = {180                                    unlocked = true181                                    unlockedEpoch = vault.unlockedSessionEpoch()182                                },183                                onFail = {},184                            )185                        },186                        onUnlock = { password ->187                            busy = true188                            error = null189                            scope.launch {190                                when (vault.unlock(password)) {191                                    is UnlockResult.Success -> {192                                        unlocked = true193                                        unlockedEpoch = vault.unlockedSessionEpoch()194                                    }195                                    UnlockResult.WrongPassword -> error = "Wrong master password."196                                    else -> error = "Couldn't unlock."197                                }198                                busy = false199                            }200                        },201                        onPick = { entry, confirmedTarget ->202                            val epoch = unlockedEpoch203                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {204                                cancel()205                            } else {206                                returnDataset(parsed, entry, confirmedTarget)207                            }208                        },209                        onCreateNew = { confirmedTarget ->210                            launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)211                        },212                    )213                }214            }215        }216    }217218    private fun canBiometric(): Boolean =219        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)220221    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {222        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()223        Biometrics.authenticateCrypto(224            activity = this,225            title = "Unlock Peach",226            subtitle = "Fill your saved details",227            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),228            onSuccess = { result ->229                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()230                // M-05: unlockWithKey copies `key` into the session's derivedKey,231                // so the caller-owned reference is now redundant. Zero it whether232                // the unlock succeeds or fails so the unwrapped biometric key233                // doesn't outlive the auth prompt.234                lifecycleScope.launch {235                    // Unseal inside the launched ownership scope. If the236                    // lifecycle scope is cancelled before execution, no key is237                    // allocated before a finally can protect it.238                    var key: ByteArray? = null239                    try {240                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)241                        if (key == null) return@launch onFail()242                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()243                    } finally {244                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)245                    }246                }247            },248            onFail = onFail,249        )250    }251252    private fun returnDataset(253        parsed: ParsedStructure,254        entry: VaultEntry,255        confirmedTarget: String?,256    ) {257        if (258            parsed.originTrust == AutofillOriginTrust.UNKNOWN &&259            AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()260        ) {261            cancel()262            return263        }264        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)265        if (dataset == null) { cancel(); return }266        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)267        setResult(RESULT_OK, reply)268        finish()269    }270271    /**272     * Cancel the picker and fire MainActivity on the new-entry path. The picker273     * returns CANCELED on purpose — the user is choosing to leave the autofill274     * prompt, not to fill it — and the launcher extra carries the host's typed275     * identity and origin so the new-entry editor opens pre-filled.276     */277    private fun launchNewEntry(278        parsed: ParsedStructure,279        typedUsername: String?,280        typedEmail: String?,281        confirmedTarget: String?,282    ) {283        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {284            confirmedTarget?.let(::extractDomain) ?: return285        } else {286            parsed.origin287        }288        val intent = Intent(this, MainActivity::class.java).apply {289            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)290            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)291            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)292            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)293            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)294        }295        startActivity(intent)296        cancel()297    }298299    private fun cancel() {300        setResult(RESULT_CANCELED)301        finish()302    }303304    @Suppress("DEPRECATION")305    private fun intentAutofillId(key: String): AutofillId? =306        if (android.os.Build.VERSION.SDK_INT >= 33) {307            intent.getParcelableExtra(key, AutofillId::class.java)308        } else {309            intent.getParcelableExtra(key)310        }311312    private companion object {313        const val MAX_ROWS = 12314    }315}316317internal fun autofillPickerEntries(318    entries: List<VaultEntry>,319    parsed: ParsedStructure,320    limit: Int,321): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)322323/**324 * Case-insensitive substring match across the four user-facing identity columns325 * the browser uses (title, username, email, url). Exposed at file scope so the326 * picker can call it directly and the test suite can exercise the same code.327 */328internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {329    if (query.isEmpty()) return true330    val needle = query.lowercase()331    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)332        .any { it.lowercase().contains(needle) }333}334335@Composable336private fun AccentDragHandle() {337    val accent = LocalPeachAccent.current338    Row(339        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),340        horizontalArrangement = Arrangement.Center,341    ) {342        Box(343            modifier = Modifier344                .size(width = 32.dp, height = 4.dp)345                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),346        )347    }348}349350@Composable351private fun PickerBody(352    origin: String?,353    originTrust: AutofillOriginTrust,354    typedIdentity: String?,355    unlocked: Boolean,356    matchedEntries: List<VaultEntry>,357    allEntries: List<VaultEntry>,358    error: String?,359    busy: Boolean,360    canBiometric: Boolean,361    onBiometric: () -> Unit,362    onUnlock: (String) -> Unit,363    onPick: (VaultEntry, String?) -> Unit,364    onCreateNew: (String?) -> Unit,365) {366    val accent = LocalPeachAccent.current367    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN368    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {369        Text(370            text = "peach",371            color = accent,372            fontFamily = PeachType.Display,373            fontStyle = FontStyle.Italic,374            fontSize = 15.sp,375        )376        Text(377            text = when {378                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"379                origin.isNullOrBlank() -> "your vault"380                else -> "Suggested for $origin"381            },382            color = peachColors.T2,383            fontFamily = PeachType.Mono,384            fontSize = 9.sp,385            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),386        )387388        if (originTrust == AutofillOriginTrust.UNKNOWN) {389            Box(390                modifier = Modifier391                    .fillMaxWidth()392                    .padding(bottom = 14.dp)393                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))394                    .padding(12.dp),395            ) {396                Text(397                    text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",398                    color = peachColors.Breach,399                    fontFamily = PeachType.Body,400                    fontSize = 12.sp,401                )402            }403        }404405        if (!unlocked) {406            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)407        } else {408            // For an unverified caller, this field is a domain confirmation boundary,409            // not a vault-wide search box controlled by attacker-provided origin data.410            var rawQuery by remember { mutableStateOf("") }411            var debouncedQuery by remember { mutableStateOf("") }412            LaunchedEffect(rawQuery) {413                delay(SEARCH_DEBOUNCE_MS)414                debouncedQuery = rawQuery415            }416            val confirmedTarget = if (untrustedOrigin) {417                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)418            } else {419                null420            }421            SearchField(422                value = rawQuery,423                onValueChange = { rawQuery = it },424                placeholder = when {425                    untrustedOrigin -> "enter site domain, e.g. example.com"426                    matchedEntries.isNotEmpty() -> "search for another entry…"427                    else -> "search vault…"428                },429            )430431            val visibleEntries = when {432                untrustedOrigin && confirmedTarget == null -> emptyList()433                untrustedOrigin -> AutofillResponseBuilder434                    .matchEntries(allEntries, confirmedTarget)435                    .hits436                debouncedQuery.isBlank() -> matchedEntries437                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }438            }439            val showSearchEmpty = debouncedQuery.isNotBlank() &&440                visibleEntries.isEmpty() &&441                (!untrustedOrigin || confirmedTarget != null)442443            val emptyMessage = when {444                untrustedOrigin && debouncedQuery.isBlank() ->445                    "Type the site domain to choose a saved login."446                untrustedOrigin && confirmedTarget == null ->447                    "Enter a full public domain such as example.com."448                untrustedOrigin && visibleEntries.isEmpty() ->449                    "No saved entries match $confirmedTarget."450                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->451                    "No saved entries match this site."452                showSearchEmpty ->453                    "No entries match \"${debouncedQuery.trim()}\"."454                else -> null455            }456            if (emptyMessage != null) {457                Text(458                    text = emptyMessage,459                    color = peachColors.T1,460                    fontFamily = PeachType.Body,461                    fontSize = 13.sp,462                    modifier = Modifier.padding(vertical = 6.dp),463                )464            }465466            if (visibleEntries.isNotEmpty()) {467                LazyColumn {468                    items(visibleEntries, key = { it.id }) { entry ->469                        PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })470                    }471                }472            }473474            val createOrigin = if (untrustedOrigin) confirmedTarget else origin475            if (!untrustedOrigin || createOrigin != null) {476                CreateNewRow(477                    origin = createOrigin,478                    typedIdentity = typedIdentity,479                    onClick = { onCreateNew(confirmedTarget) },480                )481            }482        }483    }484}485486@Composable487private fun SearchField(488    value: String,489    onValueChange: (String) -> Unit,490    placeholder: String,491) {492    val accent = LocalPeachAccent.current493    BasicTextField(494        value = value,495        onValueChange = onValueChange,496        singleLine = true,497        textStyle = TextStyle(498            color = peachColors.T0,499            fontFamily = PeachType.Mono,500            fontSize = 14.sp,501        ),502        cursorBrush = SolidColor(accent),503        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),504        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),505        modifier = Modifier506            .fillMaxWidth()507            .background(peachColors.S2, RoundedCornerShape(8.dp))508            .padding(horizontal = 12.dp, vertical = 10.dp),509        decorationBox = { inner ->510            Box(modifier = Modifier.fillMaxWidth()) {511                if (value.isEmpty()) {512                    Text(513                        text = placeholder,514                        color = peachColors.T2,515                        fontFamily = PeachType.Mono,516                        fontSize = 12.sp,517                    )518                }519                inner()520            }521        },522    )523}524525@Composable526private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {527    val accent = LocalPeachAccent.current528    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"529        else "Create new entry"530    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }531    Row(532        modifier = Modifier533            .fillMaxWidth()534            .clickable(onClick = onClick)535            .padding(vertical = 10.dp),536        verticalAlignment = Alignment.CenterVertically,537    ) {538        Box(539            modifier = Modifier540                .size(34.dp)541                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),542            contentAlignment = Alignment.Center,543        ) {544            Text(545                text = "+",546                color = accent,547                fontFamily = PeachType.Display,548                fontStyle = FontStyle.Italic,549                fontSize = 18.sp,550            )551        }552        Column(modifier = Modifier.padding(start = 12.dp)) {553            Text(554                text = title,555                color = accent,556                fontFamily = PeachType.Body,557                fontSize = 14.sp,558                maxLines = 1,559                overflow = TextOverflow.Ellipsis,560            )561            if (subtitle != null) {562                Text(563                    text = "prefill: $subtitle",564                    color = peachColors.T2,565                    fontFamily = PeachType.Mono,566                    fontSize = 10.sp,567                    maxLines = 1,568                    overflow = TextOverflow.Ellipsis,569                )570            }571        }572    }573}574575@Composable576private fun UnlockBody(577    error: String?,578    busy: Boolean,579    canBiometric: Boolean,580    onBiometric: () -> Unit,581    onUnlock: (String) -> Unit,582) {583    val accent = LocalPeachAccent.current584    var password by remember { mutableStateOf("") }585586    Text(587        text = "Unlock to fill",588        color = peachColors.T0,589        fontFamily = PeachType.Body,590        fontSize = 15.sp,591        modifier = Modifier.padding(bottom = 10.dp),592    )593    BasicTextField(594        value = password,595        onValueChange = { password = it },596        singleLine = true,597        enabled = !busy,598        textStyle = TextStyle(599            color = peachColors.T0,600            fontFamily = PeachType.Mono,601            fontSize = 16.sp,602        ),603        cursorBrush = SolidColor(accent),604        visualTransformation = PasswordVisualTransformation(),605        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),606        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),607        modifier = Modifier608            .fillMaxWidth()609            .background(peachColors.S2, RoundedCornerShape(8.dp))610            .padding(horizontal = 12.dp, vertical = 12.dp),611    )612    if (error != null) {613        Text(614            text = error,615            color = peachColors.Breach,616            fontFamily = PeachType.Body,617            fontSize = 12.sp,618            modifier = Modifier.padding(top = 8.dp),619        )620    }621    Row(622        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),623        horizontalArrangement = Arrangement.spacedBy(20.dp),624        verticalAlignment = Alignment.CenterVertically,625    ) {626        if (canBiometric) {627            Text(628                text = "Use biometrics",629                color = accent,630                fontFamily = PeachType.Mono,631                fontSize = 13.sp,632                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),633            )634        }635        Text(636            text = if (busy) "Unlocking…" else "Unlock",637            color = if (password.isEmpty() || busy) peachColors.T2 else accent,638            fontFamily = PeachType.Mono,639            fontSize = 13.sp,640            modifier = Modifier641                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }642                .padding(vertical = 8.dp),643        )644    }645}646647@Composable648private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {649    Row(650        modifier = Modifier651            .fillMaxWidth()652            .clickable(onClick = onClick)653            .padding(vertical = 9.dp),654        verticalAlignment = Alignment.CenterVertically,655    ) {656        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)657        Column(modifier = Modifier.padding(start = 12.dp)) {658            Text(659                text = entry.title,660                color = peachColors.T0,661                fontFamily = PeachType.Body,662                fontSize = 15.sp,663                maxLines = 1,664                overflow = TextOverflow.Ellipsis,665            )666            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""667            if (sub.isNotBlank()) {668                Text(669                    text = sub,670                    color = peachColors.T2,671                    fontFamily = PeachType.Mono,672                    fontSize = 11.sp,673                    maxLines = 1,674                    overflow = TextOverflow.Ellipsis,675                )676            }677        }678    }679}680681/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */682private const val SEARCH_DEBOUNCE_MS = 200L