AutofillEntryPickerActivity.kt

34.9 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.service.autofill.Dataset6import 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.components.QaFlags55import com.peachpasswords.ui.components.SecureWindowPolicy56import com.peachpasswords.ui.theme.ACCENT_COLORS57import com.peachpasswords.ui.theme.DEFAULT_ACCENT58import com.peachpasswords.ui.theme.LocalPeachAccent59import com.peachpasswords.ui.theme.peachColors60import com.peachpasswords.ui.theme.PeachTheme61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.vault.UnlockResult63import com.peachpasswords.vault.VaultEntry64import com.peachpasswords.vault.isCard65import com.peachpasswords.vault.VaultRepository66import dagger.hilt.android.AndroidEntryPoint67import kotlinx.coroutines.delay68import kotlinx.coroutines.launch69import javax.inject.Inject7071/**72 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"73 * in the system autofill drop-down. Lists matching vault entries using the same74 * row vocabulary as the vault list; picking one returns a filled75 * [android.service.autofill.Dataset] to the framework via76 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].77 *78 * Inline-mirror of the browser's selector: always-on search field, vault-wide79 * match against title/username/email/url, and a "+ Create new entry" affordance80 * in the header. The affordance cancels this activity and launches [MainActivity]81 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had82 * typed on the host page.83 *84 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity85 * and return RESULT_CANCELED, which produced a loop with no exit: the framework86 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the87 * user unlocked the app, came back to a field that still asked them to unlock,88 * and had no way to tell that anything had happened. Autofill is a modal89 * interruption of someone else's app — leaving it to go somewhere else is the90 * one thing it must not do.91 */92@AndroidEntryPoint93class AutofillEntryPickerActivity : FragmentActivity() {9495    @Inject lateinit var vault: VaultRepository96    private var debugSecureLease = false9798    @OptIn(ExperimentalMaterial3Api::class)99    override fun onCreate(savedInstanceState: Bundle?) {100        super.onCreate(savedInstanceState)101102        // H-05: separate window from MainActivity, so it does NOT inherit the103        // release-wide FLAG_SECURE set there. Show vault metadata and accept the104        // master password — protect against screenshots / recents thumbnails.105        if (!BuildConfig.DEBUG) {106            SecureWindowPolicy.setUserSecure(window, true)107        } else if (!qaSecureCaptureBypass()) {108            // Debug builds are secure by default too. QA must opt out explicitly109            // on this activity's own launch intent; MainActivity's window policy110            // cannot protect this separate picker window.111            SecureWindowPolicy.acquire(window)112            debugSecureLease = true113        }114115        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117        // Origin trust is never read from the launch intent: a caller-supplied118        // trust extra could be forged or stale. Re-derive it in-process from119        // the raw authenticated inputs using the same authentication the120        // service used, so a tampered extra cannot elevate origin trust.121        val rawParsed = pickerParsedFromInputs(122            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),123            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),124            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),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 = resolveAutofillOrigin(this, rawParsed)136        val settings = vault.settings.value137        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT138139        setContent {140            PeachTheme(accent = accent, textSize = settings.textSize) {141                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)142                val scope = rememberCoroutineScope()143                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }144                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }145                var error by remember { mutableStateOf<String?>(null) }146                var busy by remember { mutableStateOf(false) }147148                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                // A4: signup forms with no saved matches offer derived identities156                // (deduplicated usernames/emails across the vault).157                val identities = remember(unlocked, allEntries, matchedEntries) {158                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()159                }160161                // Manual pick of an entry the origin didn't auto-match: offer to162                // remember the origin on the entry before filling.163                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }164                val pickNow: (VaultEntry) -> Unit = { entry ->165                    val epoch = unlockedEpoch166                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()167                    else returnDataset(parsed, entry)168                }169170                // Offer biometrics immediately — it is the whole point of171                // unlocking here rather than in the app, and a prompt the user172                // can dismiss straight onto the password field costs nothing.173                LaunchedEffect(unlocked) {174                    if (!unlocked && canBiometric()) {175                        tryBiometric(176                            onUnlocked = {177                                unlocked = true178                                unlockedEpoch = vault.unlockedSessionEpoch()179                            },180                            onFail = { },181                        )182                    }183                }184185                ModalBottomSheet(186                    onDismissRequest = { cancel() },187                    sheetState = sheetState,188                    containerColor = peachColors.S1,189                    dragHandle = { AccentDragHandle() },190                ) {191                    PickerBody(192                        origin = parsed.origin,193                        typedIdentity = typedUsername ?: typedEmail,194                        unlocked = unlocked,195                        matchedEntries = matchedEntries,196                        allEntries = allEntries,197                        rememberOffer = rememberOffer,198                        onRemember = { entry, domains ->199                            rememberOffer = null200                            scope.launch {201                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry202                                runCatching {203                                    vault.updateEntry(204                                        current.copy(205                                            additionalUrls = (current.additionalUrls.orEmpty() +206                                                domains.map { "https://$it" }).distinct(),207                                        ),208                                    )209                                }210                                pickNow(entry)211                            }212                        },213                        onFillOnce = { entry ->214                            rememberOffer = null215                            pickNow(entry)216                        },217                        onRememberDismiss = { rememberOffer = null },218                        identities = identities,219                        onPickIdentity = { identity ->220                            val epoch = unlockedEpoch221                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()222                            else finishWithDataset(223                                AutofillResponseBuilder.identityDatasetFor(224                                    this@AutofillEntryPickerActivity, parsed, identity,225                                ),226                            )227                        },228                        error = error,229                        busy = busy,230                        canBiometric = canBiometric(),231                        onBiometric = {232                            tryBiometric(233                                onUnlocked = {234                                    unlocked = true235                                    unlockedEpoch = vault.unlockedSessionEpoch()236                                },237                                onFail = {},238                            )239                        },240                        onUnlock = { password ->241                            busy = true242                            error = null243                            scope.launch {244                                when (vault.unlock(password)) {245                                    is UnlockResult.Success -> {246                                        unlocked = true247                                        unlockedEpoch = vault.unlockedSessionEpoch()248                                    }249                                    UnlockResult.WrongPassword -> error = "Wrong master password."250                                    else -> error = "Couldn't unlock."251                                }252                                busy = false253                            }254                        },255                        onPick = { entry ->256                            val offer = rememberOfferDomains(entry, parsed)257                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer258                        },259                        onCreateNew = { searchText ->260                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)261                        },262                    )263                }264            }265        }266    }267268    override fun onDestroy() {269        if (debugSecureLease) {270            SecureWindowPolicy.release(window)271            debugSecureLease = false272        }273        super.onDestroy()274    }275276    private fun qaSecureCaptureBypass(): Boolean =277        BuildConfig.DEBUG && (278            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||279                QaFlags.disableSecureCaptureForQa280            )281282    private fun canBiometric(): Boolean =283        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)284285    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {286        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()287        Biometrics.authenticateCrypto(288            activity = this,289            title = "Unlock Peach",290            subtitle = "Fill your saved details",291            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),292            onSuccess = { result ->293                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()294                // M-05: unlockWithKey copies `key` into the session's derivedKey,295                // so the caller-owned reference is now redundant. Zero it whether296                // the unlock succeeds or fails so the unwrapped biometric key297                // doesn't outlive the auth prompt.298                lifecycleScope.launch {299                    // Unseal inside the launched ownership scope. If the300                    // lifecycle scope is cancelled before execution, no key is301                    // allocated before a finally can protect it.302                    var key: ByteArray? = null303                    try {304                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)305                        if (key == null) return@launch onFail()306                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()307                    } finally {308                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)309                    }310                }311            },312            onFail = onFail,313        )314    }315316    private fun returnDataset(317        parsed: ParsedStructure,318        entry: VaultEntry,319    ) {320        val dataset = if (entry.isCard()) {321            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)322        } else {323            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)324        }325        finishWithDataset(dataset)326    }327328    private fun finishWithDataset(dataset: Dataset?) {329        if (dataset == null) { cancel(); return }330        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)331        setResult(RESULT_OK, reply)332        finish()333    }334335    /**336     * Cancel the picker and fire MainActivity on the new-entry path. The picker337     * returns CANCELED on purpose — the user is choosing to leave the autofill338     * prompt, not to fill it — and the launcher extra carries the host's typed339     * identity and origin so the new-entry editor opens pre-filled.340     */341    private fun launchNewEntry(342        parsed: ParsedStructure,343        typedUsername: String?,344        typedEmail: String?,345        searchText: String?,346    ) {347        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin348        val intent = Intent(this, MainActivity::class.java).apply {349            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)350            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)351            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)352            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)353            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)354        }355        startActivity(intent)356        cancel()357    }358359    private fun cancel() {360        setResult(RESULT_CANCELED)361        finish()362    }363364    @Suppress("DEPRECATION")365    private fun intentAutofillId(key: String): AutofillId? =366        if (android.os.Build.VERSION.SDK_INT >= 33) {367            intent.getParcelableExtra(key, AutofillId::class.java)368        } else {369            intent.getParcelableExtra(key)370        }371372    private companion object {373        const val MAX_ROWS = 12374    }375}376377/**378 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin379 * domains and app title are resolved in-process via [resolveAutofillOrigin] from380 * the authenticated caller package + web domain — never from a caller extra.381 */382internal fun pickerParsedFromInputs(383    origin: String?,384    isWebOrigin: Boolean,385    callerPackage: String?,386    usernameId: AutofillId? = null,387    passwordId: AutofillId? = null,388    otpId: AutofillId? = null,389    cardNumberId: AutofillId? = null,390    cardExpMonthId: AutofillId? = null,391    cardExpYearId: AutofillId? = null,392    cardExpDateId: AutofillId? = null,393    cardCvcId: AutofillId? = null,394    cardNameId: AutofillId? = null,395): ParsedStructure = ParsedStructure(396    usernameId = usernameId,397    passwordId = passwordId,398    otpId = otpId,399    cardNumberId = cardNumberId,400    cardExpMonthId = cardExpMonthId,401    cardExpYearId = cardExpYearId,402    cardExpDateId = cardExpDateId,403    cardCvcId = cardCvcId,404    cardNameId = cardNameId,405    webDomain = origin.takeIf { isWebOrigin },406    packageName = if (isWebOrigin) callerPackage else origin,407)408409internal fun autofillPickerEntries(410    entries: List<VaultEntry>,411    parsed: ParsedStructure,412    limit: Int,413): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)414415/**416 * Case-insensitive substring match across the four user-facing identity columns417 * the browser uses (title, username, email, url). Exposed at file scope so the418 * picker can call it directly and the test suite can exercise the same code.419 */420internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {421    if (query.isEmpty()) return true422    val needle = query.lowercase()423    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)424        .any { it.lowercase().contains(needle) }425}426427@Composable428private fun AccentDragHandle() {429    val accent = LocalPeachAccent.current430    Row(431        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),432        horizontalArrangement = Arrangement.Center,433    ) {434        Box(435            modifier = Modifier436                .size(width = 32.dp, height = 4.dp)437                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),438        )439    }440}441442@Composable443private fun PickerBody(444    origin: String?,445    typedIdentity: String?,446    unlocked: Boolean,447    matchedEntries: List<VaultEntry>,448    allEntries: List<VaultEntry>,449    rememberOffer: Pair<VaultEntry, List<String>>?,450    onRemember: (VaultEntry, List<String>) -> Unit,451    onFillOnce: (VaultEntry) -> Unit,452    onRememberDismiss: () -> Unit,453    identities: List<SavedIdentity>,454    onPickIdentity: (SavedIdentity) -> Unit,455    error: String?,456    busy: Boolean,457    canBiometric: Boolean,458    onBiometric: () -> Unit,459    onUnlock: (String) -> Unit,460    onPick: (VaultEntry) -> Unit,461    onCreateNew: (String?) -> Unit,462) {463    val accent = LocalPeachAccent.current464    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {465        Text(466            text = "peach",467            color = accent,468            fontFamily = PeachType.Display,469            fontStyle = FontStyle.Italic,470            fontSize = 15.sp,471        )472        Text(473            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",474            color = peachColors.T2,475            fontFamily = PeachType.Mono,476            fontSize = 9.sp,477            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),478        )479480        if (!unlocked) {481            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)482        } else if (rememberOffer != null) {483            RememberSitePrompt(484                entryTitle = rememberOffer!!.first.title,485                domains = rememberOffer!!.second,486                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },487                onFillOnce = { onFillOnce(rememberOffer!!.first) },488                onBack = onRememberDismiss,489            )490        } else {491            // The search field is a vault-wide substring search; an empty query492            // shows the origin's matched entries.493            var rawQuery by remember { mutableStateOf("") }494            var debouncedQuery by remember { mutableStateOf("") }495            LaunchedEffect(rawQuery) {496                delay(SEARCH_DEBOUNCE_MS)497                debouncedQuery = rawQuery498            }499            SearchField(500                value = rawQuery,501                onValueChange = { rawQuery = it },502                placeholder = when {503                    matchedEntries.isNotEmpty() -> "search for another entry…"504                    else -> "search vault…"505                },506            )507508            val showIdentities =509                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()510            val visibleEntries = when {511                debouncedQuery.isBlank() -> matchedEntries512                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }513            }514            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()515516            val emptyMessage = when {517                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->518                    "No saved entries match this site."519                showSearchEmpty ->520                    "No entries match \"${debouncedQuery.trim()}\"."521                else -> null522            }523            if (emptyMessage != null) {524                Text(525                    text = emptyMessage,526                    color = peachColors.T1,527                    fontFamily = PeachType.Body,528                    fontSize = 13.sp,529                    modifier = Modifier.padding(vertical = 6.dp),530                )531            }532533            if (showIdentities) {534                IdentitySection(identities = identities, onPick = onPickIdentity)535            }536537            if (visibleEntries.isNotEmpty()) {538                LazyColumn {539                    items(visibleEntries, key = { it.id }) { entry ->540                        PickerRow(entry, onClick = { onPick(entry) })541                    }542                }543            }544545            val createOrigin =546                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin547            CreateNewRow(548                origin = createOrigin,549                typedIdentity = typedIdentity,550                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },551            )552        }553    }554}555556@Composable557private fun SearchField(558    value: String,559    onValueChange: (String) -> Unit,560    placeholder: String,561) {562    val accent = LocalPeachAccent.current563    BasicTextField(564        value = value,565        onValueChange = onValueChange,566        singleLine = true,567        textStyle = TextStyle(568            color = peachColors.T0,569            fontFamily = PeachType.Mono,570            fontSize = 14.sp,571        ),572        cursorBrush = SolidColor(accent),573        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),574        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),575        modifier = Modifier576            .fillMaxWidth()577            .background(peachColors.S2, RoundedCornerShape(8.dp))578            .padding(horizontal = 12.dp, vertical = 10.dp),579        decorationBox = { inner ->580            Box(modifier = Modifier.fillMaxWidth()) {581                if (value.isEmpty()) {582                    Text(583                        text = placeholder,584                        color = peachColors.T2,585                        fontFamily = PeachType.Mono,586                        fontSize = 12.sp,587                    )588                }589                inner()590            }591        },592    )593}594595@Composable596private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {597    val accent = LocalPeachAccent.current598    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"599        else "Create new entry"600    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }601    Row(602        modifier = Modifier603            .fillMaxWidth()604            .clickable(onClick = onClick)605            .padding(vertical = 10.dp),606        verticalAlignment = Alignment.CenterVertically,607    ) {608        Box(609            modifier = Modifier610                .size(34.dp)611                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),612            contentAlignment = Alignment.Center,613        ) {614            Text(615                text = "+",616                color = accent,617                fontFamily = PeachType.Display,618                fontStyle = FontStyle.Italic,619                fontSize = 18.sp,620            )621        }622        Column(modifier = Modifier.padding(start = 12.dp)) {623            Text(624                text = title,625                color = accent,626                fontFamily = PeachType.Body,627                fontSize = 14.sp,628                maxLines = 1,629                overflow = TextOverflow.Ellipsis,630            )631            if (subtitle != null) {632                Text(633                    text = "prefill: $subtitle",634                    color = peachColors.T2,635                    fontFamily = PeachType.Mono,636                    fontSize = 10.sp,637                    maxLines = 1,638                    overflow = TextOverflow.Ellipsis,639                )640            }641        }642    }643}644645/**646 * Offered when the user manually picks an entry the origin didn't auto-match:647 * save the origin's domains onto the entry (additional URLs) so it is648 * suggested automatically next time — Android counterpart of the browser's649 * "Remember this site" toast.650 */651@Composable652private fun RememberSitePrompt(653    entryTitle: String,654    domains: List<String>,655    onRemember: () -> Unit,656    onFillOnce: () -> Unit,657    onBack: () -> Unit,658) {659    val accent = LocalPeachAccent.current660    Text(661        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",662        color = peachColors.T0,663        fontFamily = PeachType.Body,664        fontSize = 14.sp,665    )666    Text(667        text = "It will be suggested automatically the next time you fill here.",668        color = peachColors.T2,669        fontFamily = PeachType.Mono,670        fontSize = 10.sp,671        modifier = Modifier.padding(top = 4.dp),672    )673    Row(674        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),675        horizontalArrangement = Arrangement.spacedBy(18.dp),676        verticalAlignment = Alignment.CenterVertically,677    ) {678        Text(679            text = "Remember & fill",680            color = accent,681            fontFamily = PeachType.Mono,682            fontSize = 13.sp,683            modifier = Modifier684                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))685                .clickable(onClick = onRemember)686                .padding(horizontal = 12.dp, vertical = 8.dp),687        )688        Text(689            text = "Just fill",690            color = peachColors.T1,691            fontFamily = PeachType.Mono,692            fontSize = 13.sp,693            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),694        )695        Text(696            text = "Back",697            color = peachColors.T2,698            fontFamily = PeachType.Mono,699            fontSize = 13.sp,700            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),701        )702    }703}704705/**706 * A4 identity helper: signup forms with no saved matches list the vault's707 * derived identities (deduplicated usernames/emails) with type filter chips —708 * Android counterpart of the browser's inline identity selector.709 */710@Composable711private fun IdentitySection(712    identities: List<SavedIdentity>,713    onPick: (SavedIdentity) -> Unit,714) {715    val accent = LocalPeachAccent.current716    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }717    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }718719    Text(720        text = "use a saved identity",721        color = peachColors.T2,722        fontFamily = PeachType.Mono,723        fontSize = 9.sp,724        modifier = Modifier.padding(bottom = 8.dp),725    )726    Row(727        horizontalArrangement = Arrangement.spacedBy(8.dp),728        modifier = Modifier.padding(bottom = 10.dp),729    ) {730        for (filterMode in IdentityFilterMode.values()) {731            val active = filterMode == mode732            Text(733                text = filterMode.label,734                color = if (active) peachColors.S1 else peachColors.T2,735                fontFamily = PeachType.Mono,736                fontSize = 10.sp,737                modifier = Modifier738                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))739                    .clickable { mode = filterMode }740                    .padding(horizontal = 10.dp, vertical = 5.dp),741            )742        }743    }744    LazyColumn {745        items(visible, key = { it.type.name + ":" + it.value }) { identity ->746            IdentityRow(identity, onClick = { onPick(identity) })747        }748    }749}750751@Composable752private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {753    Column(754        modifier = Modifier755            .fillMaxWidth()756            .clickable(onClick = onClick)757            .padding(vertical = 8.dp),758    ) {759        Text(760            text = identity.value,761            color = peachColors.T0,762            fontFamily = PeachType.Body,763            fontSize = 13.sp,764            maxLines = 1,765            overflow = TextOverflow.Ellipsis,766        )767        val meta = listOfNotNull(768            identity.exampleDomain,769            if (identity.type == IdentityType.EMAIL) "email" else "username",770        ).joinToString(" \u00b7 ")771        Text(772            text = meta,773            color = peachColors.T2,774            fontFamily = PeachType.Mono,775            fontSize = 9.sp,776            maxLines = 1,777            overflow = TextOverflow.Ellipsis,778        )779    }780}781782@Composable783private fun UnlockBody(784    error: String?,785    busy: Boolean,786    canBiometric: Boolean,787    onBiometric: () -> Unit,788    onUnlock: (String) -> Unit,789) {790    val accent = LocalPeachAccent.current791    var password by remember { mutableStateOf("") }792793    Text(794        text = "Unlock to fill",795        color = peachColors.T0,796        fontFamily = PeachType.Body,797        fontSize = 15.sp,798        modifier = Modifier.padding(bottom = 10.dp),799    )800    BasicTextField(801        value = password,802        onValueChange = { password = it },803        singleLine = true,804        enabled = !busy,805        textStyle = TextStyle(806            color = peachColors.T0,807            fontFamily = PeachType.Mono,808            fontSize = 16.sp,809        ),810        cursorBrush = SolidColor(accent),811        visualTransformation = PasswordVisualTransformation(),812        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),813        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),814        modifier = Modifier815            .fillMaxWidth()816            .background(peachColors.S2, RoundedCornerShape(8.dp))817            .padding(horizontal = 12.dp, vertical = 12.dp),818    )819    if (error != null) {820        Text(821            text = error,822            color = peachColors.Breach,823            fontFamily = PeachType.Body,824            fontSize = 12.sp,825            modifier = Modifier.padding(top = 8.dp),826        )827    }828    Row(829        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),830        horizontalArrangement = Arrangement.spacedBy(20.dp),831        verticalAlignment = Alignment.CenterVertically,832    ) {833        if (canBiometric) {834            Text(835                text = "Use biometrics",836                color = accent,837                fontFamily = PeachType.Mono,838                fontSize = 13.sp,839                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),840            )841        }842        Text(843            text = if (busy) "Unlocking…" else "Unlock",844            color = if (password.isEmpty() || busy) peachColors.T2 else accent,845            fontFamily = PeachType.Mono,846            fontSize = 13.sp,847            modifier = Modifier848                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }849                .padding(vertical = 8.dp),850        )851    }852}853854@Composable855private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {856    Row(857        modifier = Modifier858            .fillMaxWidth()859            .clickable(onClick = onClick)860            .padding(vertical = 9.dp),861        verticalAlignment = Alignment.CenterVertically,862    ) {863        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)864        Column(modifier = Modifier.padding(start = 12.dp)) {865            Text(866                text = entry.title,867                color = peachColors.T0,868                fontFamily = PeachType.Body,869                fontSize = 15.sp,870                maxLines = 1,871                overflow = TextOverflow.Ellipsis,872            )873            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""874            if (sub.isNotBlank()) {875                Text(876                    text = sub,877                    color = peachColors.T2,878                    fontFamily = PeachType.Mono,879                    fontSize = 11.sp,880                    maxLines = 1,881                    overflow = TextOverflow.Ellipsis,882                )883            }884        }885    }886}887888/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */889private const val SEARCH_DEBOUNCE_MS = 200L