AutofillEntryPickerActivity.kt

36.0 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Build5import android.os.Bundle6import android.service.autofill.Dataset7import android.view.autofill.AutofillId8import android.view.autofill.AutofillManager9import androidx.activity.compose.setContent10import androidx.compose.foundation.background11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Arrangement13import androidx.compose.foundation.layout.Box14import androidx.compose.foundation.layout.Column15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.size19import androidx.compose.foundation.lazy.LazyColumn20import androidx.compose.foundation.lazy.items21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.material3.ExperimentalMaterial3Api23import androidx.compose.material3.ModalBottomSheet24import androidx.compose.material3.Text25import androidx.compose.material3.rememberModalBottomSheetState26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.foundation.text.KeyboardActions28import androidx.compose.foundation.text.KeyboardOptions29import androidx.compose.runtime.Composable30import androidx.compose.runtime.CompositionLocalProvider31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.rememberCoroutineScope36import androidx.compose.runtime.setValue37import androidx.compose.ui.graphics.SolidColor38import androidx.compose.ui.text.TextStyle39import androidx.compose.ui.text.input.ImeAction40import androidx.compose.ui.text.input.KeyboardType41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.text.font.FontStyle45import androidx.compose.ui.text.style.TextOverflow46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.sp48import androidx.fragment.app.FragmentActivity49import androidx.lifecycle.lifecycleScope50import com.peachpasswords.BuildConfig51import com.peachpasswords.MainActivity52import com.peachpasswords.shared.extractDomain53import com.peachpasswords.ui.lock.BiometricKeyStore54import com.peachpasswords.ui.lock.Biometrics55import com.peachpasswords.ui.components.FaviconInitials56import com.peachpasswords.ui.components.LocalFavicons57import com.peachpasswords.ui.components.LocalFaviconsEnabled58import com.peachpasswords.ui.components.QaFlags59import com.peachpasswords.ui.components.SecureWindowPolicy60import com.peachpasswords.ui.theme.ACCENT_COLORS61import com.peachpasswords.ui.theme.DEFAULT_ACCENT62import com.peachpasswords.ui.theme.LocalPeachAccent63import com.peachpasswords.ui.theme.peachColors64import com.peachpasswords.ui.theme.PeachTheme65import com.peachpasswords.ui.theme.PeachType66import com.peachpasswords.vault.UnlockResult67import com.peachpasswords.vault.VaultEntry68import com.peachpasswords.vault.isCard69import com.peachpasswords.vault.VaultRepository70import dagger.hilt.android.AndroidEntryPoint71import kotlinx.coroutines.delay72import kotlinx.coroutines.launch73import javax.inject.Inject7475/**76 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"77 * in the system autofill drop-down. Lists matching vault entries using the same78 * row vocabulary as the vault list; picking one returns a filled79 * [android.service.autofill.Dataset] to the framework via80 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].81 *82 * Inline-mirror of the browser's selector: always-on search field, vault-wide83 * match against title/username/email/url, and a "+ Create new entry" affordance84 * in the header. The affordance cancels this activity and launches [MainActivity]85 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had86 * typed on the host page.87 *88 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity89 * and return RESULT_CANCELED, which produced a loop with no exit: the framework90 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the91 * user unlocked the app, came back to a field that still asked them to unlock,92 * and had no way to tell that anything had happened. Autofill is a modal93 * interruption of someone else's app — leaving it to go somewhere else is the94 * one thing it must not do.95 */96@AndroidEntryPoint97class AutofillEntryPickerActivity : FragmentActivity() {9899    @Inject lateinit var vault: VaultRepository100    @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons101    private var debugSecureLease = false102103    @OptIn(ExperimentalMaterial3Api::class)104    override fun onCreate(savedInstanceState: Bundle?) {105        super.onCreate(savedInstanceState)106107        // H-05: separate window from MainActivity, so it does NOT inherit the108        // release-wide FLAG_SECURE set there. Show vault metadata and accept the109        // master password — protect against screenshots / recents thumbnails.110        if (!BuildConfig.DEBUG) {111            SecureWindowPolicy.setUserSecure(window, true)112        } else if (!qaSecureCaptureBypass()) {113            // Debug builds are secure by default too. QA must opt out explicitly114            // on this activity's own launch intent; MainActivity's window policy115            // cannot protect this separate picker window.116            SecureWindowPolicy.acquire(window)117            debugSecureLease = true118        }119120        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)121        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)122        // Origin trust is never read from the launch intent: a caller-supplied123        // trust extra could be forged or stale. Re-derive it in-process from124        // the raw authenticated inputs using the same authentication the125        // service used, so a tampered extra cannot elevate origin trust.126        val rawParsed = pickerParsedFromInputs(127            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),128            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),129            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),130            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),131            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),132            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),133            otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {134                intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()135            } else {136                @Suppress("DEPRECATION")137                intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()138            },139            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),140            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),141            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),142            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),143            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),144            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),145        )146        val parsed = resolveAutofillOrigin(this, rawParsed)147        val settings = vault.settings.value148        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT149150        setContent {151            CompositionLocalProvider(152                LocalFavicons provides favicons,153                LocalFaviconsEnabled provides settings.faviconsEnabled,154            ) {155            PeachTheme(accent = accent, textSize = settings.textSize) {156                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)157                val scope = rememberCoroutineScope()158                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }159                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }160                var error by remember { mutableStateOf<String?>(null) }161                var busy by remember { mutableStateOf(false) }162163                val allEntries = remember(unlocked) {164                    if (unlocked) vault.getEntries() else emptyList()165                }166                val matchedEntries = remember(unlocked, allEntries) {167                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()168                }169170                // A4: signup forms with no saved matches offer derived identities171                // (deduplicated usernames/emails across the vault).172                val identities = remember(unlocked, allEntries, matchedEntries) {173                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()174                }175176                // Manual pick of an entry the origin didn't auto-match: offer to177                // remember the origin on the entry before filling.178                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }179                val pickNow: (VaultEntry) -> Unit = { entry ->180                    val epoch = unlockedEpoch181                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()182                    else returnDataset(parsed, entry)183                }184185                // Offer biometrics immediately — it is the whole point of186                // unlocking here rather than in the app, and a prompt the user187                // can dismiss straight onto the password field costs nothing.188                LaunchedEffect(unlocked) {189                    if (!unlocked && canBiometric()) {190                        tryBiometric(191                            onUnlocked = {192                                unlocked = true193                                unlockedEpoch = vault.unlockedSessionEpoch()194                            },195                            onFail = { },196                        )197                    }198                }199200                ModalBottomSheet(201                    onDismissRequest = { cancel() },202                    sheetState = sheetState,203                    containerColor = peachColors.S1,204                    dragHandle = { AccentDragHandle() },205                ) {206                    PickerBody(207                        origin = parsed.origin,208                        typedIdentity = typedUsername ?: typedEmail,209                        unlocked = unlocked,210                        matchedEntries = matchedEntries,211                        allEntries = allEntries,212                        rememberOffer = rememberOffer,213                        onRemember = { entry, domains ->214                            rememberOffer = null215                            scope.launch {216                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry217                                runCatching {218                                    vault.updateEntry(219                                        current.copy(220                                            additionalUrls = (current.additionalUrls.orEmpty() +221                                                domains.map { "https://$it" }).distinct(),222                                        ),223                                    )224                                }225                                pickNow(entry)226                            }227                        },228                        onFillOnce = { entry ->229                            rememberOffer = null230                            pickNow(entry)231                        },232                        onRememberDismiss = { rememberOffer = null },233                        identities = identities,234                        onPickIdentity = { identity ->235                            val epoch = unlockedEpoch236                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()237                            else finishWithDataset(238                                AutofillResponseBuilder.identityDatasetFor(239                                    this@AutofillEntryPickerActivity, parsed, identity,240                                ),241                            )242                        },243                        error = error,244                        busy = busy,245                        canBiometric = canBiometric(),246                        onBiometric = {247                            tryBiometric(248                                onUnlocked = {249                                    unlocked = true250                                    unlockedEpoch = vault.unlockedSessionEpoch()251                                },252                                onFail = {},253                            )254                        },255                        onUnlock = { password ->256                            busy = true257                            error = null258                            scope.launch {259                                when (vault.unlock(password)) {260                                    is UnlockResult.Success -> {261                                        unlocked = true262                                        unlockedEpoch = vault.unlockedSessionEpoch()263                                    }264                                    UnlockResult.WrongPassword -> error = "Wrong master password."265                                    else -> error = "Couldn't unlock."266                                }267                                busy = false268                            }269                        },270                        onPick = { entry ->271                            val offer = rememberOfferDomains(entry, parsed)272                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer273                        },274                        onCreateNew = { searchText ->275                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)276                        },277                    )278                }279            }280            }281        }282    }283284    override fun onDestroy() {285        if (debugSecureLease) {286            SecureWindowPolicy.release(window)287            debugSecureLease = false288        }289        super.onDestroy()290    }291292    private fun qaSecureCaptureBypass(): Boolean =293        BuildConfig.DEBUG && (294            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||295                QaFlags.disableSecureCaptureForQa296            )297298    private fun canBiometric(): Boolean =299        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)300301    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {302        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()303        Biometrics.authenticateCrypto(304            activity = this,305            title = "Unlock Peach",306            subtitle = "Fill your saved details",307            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),308            onSuccess = { result ->309                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()310                // M-05: unlockWithKey copies `key` into the session's derivedKey,311                // so the caller-owned reference is now redundant. Zero it whether312                // the unlock succeeds or fails so the unwrapped biometric key313                // doesn't outlive the auth prompt.314                lifecycleScope.launch {315                    // Unseal inside the launched ownership scope. If the316                    // lifecycle scope is cancelled before execution, no key is317                    // allocated before a finally can protect it.318                    var key: ByteArray? = null319                    try {320                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)321                        if (key == null) return@launch onFail()322                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()323                    } finally {324                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)325                    }326                }327            },328            onFail = onFail,329        )330    }331332    private fun returnDataset(333        parsed: ParsedStructure,334        entry: VaultEntry,335    ) {336        val privatePreview = vault.settings.value.hideAutofillPreviews337        val dataset = if (entry.isCard()) {338            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)339        } else {340            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)341        }342        finishWithDataset(dataset)343    }344345    private fun finishWithDataset(dataset: Dataset?) {346        if (dataset == null) { cancel(); return }347        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)348        setResult(RESULT_OK, reply)349        finish()350    }351352    /**353     * Cancel the picker and fire MainActivity on the new-entry path. The picker354     * returns CANCELED on purpose — the user is choosing to leave the autofill355     * prompt, not to fill it — and the launcher extra carries the host's typed356     * identity and origin so the new-entry editor opens pre-filled.357     */358    private fun launchNewEntry(359        parsed: ParsedStructure,360        typedUsername: String?,361        typedEmail: String?,362        searchText: String?,363    ) {364        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin365        val intent = Intent(this, MainActivity::class.java).apply {366            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)367            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())368            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)369            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)370            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)371            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)372        }373        startActivity(intent)374        cancel()375    }376377    private fun cancel() {378        setResult(RESULT_CANCELED)379        finish()380    }381382    @Suppress("DEPRECATION")383    private fun intentAutofillId(key: String): AutofillId? =384        if (android.os.Build.VERSION.SDK_INT >= 33) {385            intent.getParcelableExtra(key, AutofillId::class.java)386        } else {387            intent.getParcelableExtra(key)388        }389390    private companion object {391        const val MAX_ROWS = 12392    }393}394395/**396 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin397 * domains and app title are resolved in-process via [resolveAutofillOrigin] from398 * the authenticated caller package + web domain — never from a caller extra.399 */400internal fun pickerParsedFromInputs(401    origin: String?,402    isWebOrigin: Boolean,403    callerPackage: String?,404    usernameId: AutofillId? = null,405    passwordId: AutofillId? = null,406    otpId: AutofillId? = null,407    otpDigitBoxIds: List<AutofillId> = emptyList(),408    cardNumberId: AutofillId? = null,409    cardExpMonthId: AutofillId? = null,410    cardExpYearId: AutofillId? = null,411    cardExpDateId: AutofillId? = null,412    cardCvcId: AutofillId? = null,413    cardNameId: AutofillId? = null,414): ParsedStructure = ParsedStructure(415    usernameId = usernameId,416    passwordId = passwordId,417    otpId = otpId,418    cardNumberId = cardNumberId,419    cardExpMonthId = cardExpMonthId,420    cardExpYearId = cardExpYearId,421    cardExpDateId = cardExpDateId,422    cardCvcId = cardCvcId,423    cardNameId = cardNameId,424    otpDigitBoxIds = otpDigitBoxIds,425    webDomain = origin.takeIf { isWebOrigin },426    packageName = if (isWebOrigin) callerPackage else origin,427)428429internal fun autofillPickerEntries(430    entries: List<VaultEntry>,431    parsed: ParsedStructure,432    limit: Int,433): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)434435/**436 * Case-insensitive substring match across the four user-facing identity columns437 * the browser uses (title, username, email, url). Exposed at file scope so the438 * picker can call it directly and the test suite can exercise the same code.439 */440internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {441    if (query.isEmpty()) return true442    val needle = query.lowercase()443    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)444        .any { it.lowercase().contains(needle) }445}446447@Composable448private fun AccentDragHandle() {449    val accent = LocalPeachAccent.current450    Row(451        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),452        horizontalArrangement = Arrangement.Center,453    ) {454        Box(455            modifier = Modifier456                .size(width = 32.dp, height = 4.dp)457                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),458        )459    }460}461462@Composable463private fun PickerBody(464    origin: String?,465    typedIdentity: String?,466    unlocked: Boolean,467    matchedEntries: List<VaultEntry>,468    allEntries: List<VaultEntry>,469    rememberOffer: Pair<VaultEntry, List<String>>?,470    onRemember: (VaultEntry, List<String>) -> Unit,471    onFillOnce: (VaultEntry) -> Unit,472    onRememberDismiss: () -> Unit,473    identities: List<SavedIdentity>,474    onPickIdentity: (SavedIdentity) -> Unit,475    error: String?,476    busy: Boolean,477    canBiometric: Boolean,478    onBiometric: () -> Unit,479    onUnlock: (String) -> Unit,480    onPick: (VaultEntry) -> Unit,481    onCreateNew: (String?) -> Unit,482) {483    val accent = LocalPeachAccent.current484    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {485        Text(486            text = "peach",487            color = accent,488            fontFamily = PeachType.Display,489            fontStyle = FontStyle.Italic,490            fontSize = 15.sp,491        )492        Text(493            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",494            color = peachColors.T2,495            fontFamily = PeachType.Mono,496            fontSize = 9.sp,497            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),498        )499500        if (!unlocked) {501            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)502        } else if (rememberOffer != null) {503            RememberSitePrompt(504                entryTitle = rememberOffer!!.first.title,505                domains = rememberOffer!!.second,506                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },507                onFillOnce = { onFillOnce(rememberOffer!!.first) },508                onBack = onRememberDismiss,509            )510        } else {511            // The search field is a vault-wide substring search; an empty query512            // shows the origin's matched entries.513            var rawQuery by remember { mutableStateOf("") }514            var debouncedQuery by remember { mutableStateOf("") }515            LaunchedEffect(rawQuery) {516                delay(SEARCH_DEBOUNCE_MS)517                debouncedQuery = rawQuery518            }519            SearchField(520                value = rawQuery,521                onValueChange = { rawQuery = it },522                placeholder = when {523                    matchedEntries.isNotEmpty() -> "search for another entry…"524                    else -> "search vault…"525                },526            )527528            val showIdentities =529                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()530            val visibleEntries = when {531                debouncedQuery.isBlank() -> matchedEntries532                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }533            }534            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()535536            val emptyMessage = when {537                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->538                    "No saved entries match this site."539                showSearchEmpty ->540                    "No entries match \"${debouncedQuery.trim()}\"."541                else -> null542            }543            if (emptyMessage != null) {544                Text(545                    text = emptyMessage,546                    color = peachColors.T1,547                    fontFamily = PeachType.Body,548                    fontSize = 13.sp,549                    modifier = Modifier.padding(vertical = 6.dp),550                )551            }552553            if (showIdentities) {554                IdentitySection(identities = identities, onPick = onPickIdentity)555            }556557            if (visibleEntries.isNotEmpty()) {558                LazyColumn {559                    items(visibleEntries, key = { it.id }) { entry ->560                        PickerRow(entry, onClick = { onPick(entry) })561                    }562                }563            }564565            val createOrigin =566                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin567            CreateNewRow(568                origin = createOrigin,569                typedIdentity = typedIdentity,570                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },571            )572        }573    }574}575576@Composable577private fun SearchField(578    value: String,579    onValueChange: (String) -> Unit,580    placeholder: String,581) {582    val accent = LocalPeachAccent.current583    BasicTextField(584        value = value,585        onValueChange = onValueChange,586        singleLine = true,587        textStyle = TextStyle(588            color = peachColors.T0,589            fontFamily = PeachType.Mono,590            fontSize = 14.sp,591        ),592        cursorBrush = SolidColor(accent),593        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),594        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),595        modifier = Modifier596            .fillMaxWidth()597            .background(peachColors.S2, RoundedCornerShape(8.dp))598            .padding(horizontal = 12.dp, vertical = 10.dp),599        decorationBox = { inner ->600            Box(modifier = Modifier.fillMaxWidth()) {601                if (value.isEmpty()) {602                    Text(603                        text = placeholder,604                        color = peachColors.T2,605                        fontFamily = PeachType.Mono,606                        fontSize = 12.sp,607                    )608                }609                inner()610            }611        },612    )613}614615@Composable616private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {617    val accent = LocalPeachAccent.current618    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"619        else "Create new entry"620    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }621    Row(622        modifier = Modifier623            .fillMaxWidth()624            .clickable(onClick = onClick)625            .padding(vertical = 10.dp),626        verticalAlignment = Alignment.CenterVertically,627    ) {628        Box(629            modifier = Modifier630                .size(34.dp)631                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),632            contentAlignment = Alignment.Center,633        ) {634            Text(635                text = "+",636                color = accent,637                fontFamily = PeachType.Display,638                fontStyle = FontStyle.Italic,639                fontSize = 18.sp,640            )641        }642        Column(modifier = Modifier.padding(start = 12.dp)) {643            Text(644                text = title,645                color = accent,646                fontFamily = PeachType.Body,647                fontSize = 14.sp,648                maxLines = 1,649                overflow = TextOverflow.Ellipsis,650            )651            if (subtitle != null) {652                Text(653                    text = "prefill: $subtitle",654                    color = peachColors.T2,655                    fontFamily = PeachType.Mono,656                    fontSize = 10.sp,657                    maxLines = 1,658                    overflow = TextOverflow.Ellipsis,659                )660            }661        }662    }663}664665/**666 * Offered when the user manually picks an entry the origin didn't auto-match:667 * save the origin's domains onto the entry (additional URLs) so it is668 * suggested automatically next time — Android counterpart of the browser's669 * "Remember this site" toast.670 */671@Composable672private fun RememberSitePrompt(673    entryTitle: String,674    domains: List<String>,675    onRemember: () -> Unit,676    onFillOnce: () -> Unit,677    onBack: () -> Unit,678) {679    val accent = LocalPeachAccent.current680    Text(681        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",682        color = peachColors.T0,683        fontFamily = PeachType.Body,684        fontSize = 14.sp,685    )686    Text(687        text = "It will be suggested automatically the next time you fill here.",688        color = peachColors.T2,689        fontFamily = PeachType.Mono,690        fontSize = 10.sp,691        modifier = Modifier.padding(top = 4.dp),692    )693    Row(694        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),695        horizontalArrangement = Arrangement.spacedBy(18.dp),696        verticalAlignment = Alignment.CenterVertically,697    ) {698        Text(699            text = "Remember & fill",700            color = accent,701            fontFamily = PeachType.Mono,702            fontSize = 13.sp,703            modifier = Modifier704                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))705                .clickable(onClick = onRemember)706                .padding(horizontal = 12.dp, vertical = 8.dp),707        )708        Text(709            text = "Just fill",710            color = peachColors.T1,711            fontFamily = PeachType.Mono,712            fontSize = 13.sp,713            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),714        )715        Text(716            text = "Back",717            color = peachColors.T2,718            fontFamily = PeachType.Mono,719            fontSize = 13.sp,720            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),721        )722    }723}724725/**726 * A4 identity helper: signup forms with no saved matches list the vault's727 * derived identities (deduplicated usernames/emails) with type filter chips —728 * Android counterpart of the browser's inline identity selector.729 */730@Composable731private fun IdentitySection(732    identities: List<SavedIdentity>,733    onPick: (SavedIdentity) -> Unit,734) {735    val accent = LocalPeachAccent.current736    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }737    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }738739    Text(740        text = "use a saved identity",741        color = peachColors.T2,742        fontFamily = PeachType.Mono,743        fontSize = 9.sp,744        modifier = Modifier.padding(bottom = 8.dp),745    )746    Row(747        horizontalArrangement = Arrangement.spacedBy(8.dp),748        modifier = Modifier.padding(bottom = 10.dp),749    ) {750        for (filterMode in IdentityFilterMode.values()) {751            val active = filterMode == mode752            Text(753                text = filterMode.label,754                color = if (active) peachColors.S1 else peachColors.T2,755                fontFamily = PeachType.Mono,756                fontSize = 10.sp,757                modifier = Modifier758                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))759                    .clickable { mode = filterMode }760                    .padding(horizontal = 10.dp, vertical = 5.dp),761            )762        }763    }764    LazyColumn {765        items(visible, key = { it.type.name + ":" + it.value }) { identity ->766            IdentityRow(identity, onClick = { onPick(identity) })767        }768    }769}770771@Composable772private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {773    Column(774        modifier = Modifier775            .fillMaxWidth()776            .clickable(onClick = onClick)777            .padding(vertical = 8.dp),778    ) {779        Text(780            text = identity.value,781            color = peachColors.T0,782            fontFamily = PeachType.Body,783            fontSize = 13.sp,784            maxLines = 1,785            overflow = TextOverflow.Ellipsis,786        )787        val meta = listOfNotNull(788            identity.exampleDomain,789            if (identity.type == IdentityType.EMAIL) "email" else "username",790        ).joinToString(" \u00b7 ")791        Text(792            text = meta,793            color = peachColors.T2,794            fontFamily = PeachType.Mono,795            fontSize = 9.sp,796            maxLines = 1,797            overflow = TextOverflow.Ellipsis,798        )799    }800}801802@Composable803private fun UnlockBody(804    error: String?,805    busy: Boolean,806    canBiometric: Boolean,807    onBiometric: () -> Unit,808    onUnlock: (String) -> Unit,809) {810    val accent = LocalPeachAccent.current811    var password by remember { mutableStateOf("") }812813    Text(814        text = "Unlock to fill",815        color = peachColors.T0,816        fontFamily = PeachType.Body,817        fontSize = 15.sp,818        modifier = Modifier.padding(bottom = 10.dp),819    )820    BasicTextField(821        value = password,822        onValueChange = { password = it },823        singleLine = true,824        enabled = !busy,825        textStyle = TextStyle(826            color = peachColors.T0,827            fontFamily = PeachType.Mono,828            fontSize = 16.sp,829        ),830        cursorBrush = SolidColor(accent),831        visualTransformation = PasswordVisualTransformation(),832        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),833        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),834        modifier = Modifier835            .fillMaxWidth()836            .background(peachColors.S2, RoundedCornerShape(8.dp))837            .padding(horizontal = 12.dp, vertical = 12.dp),838    )839    if (error != null) {840        Text(841            text = error,842            color = peachColors.Breach,843            fontFamily = PeachType.Body,844            fontSize = 12.sp,845            modifier = Modifier.padding(top = 8.dp),846        )847    }848    Row(849        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),850        horizontalArrangement = Arrangement.spacedBy(20.dp),851        verticalAlignment = Alignment.CenterVertically,852    ) {853        if (canBiometric) {854            Text(855                text = "Use biometrics",856                color = accent,857                fontFamily = PeachType.Mono,858                fontSize = 13.sp,859                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),860            )861        }862        Text(863            text = if (busy) "Unlocking…" else "Unlock",864            color = if (password.isEmpty() || busy) peachColors.T2 else accent,865            fontFamily = PeachType.Mono,866            fontSize = 13.sp,867            modifier = Modifier868                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }869                .padding(vertical = 8.dp),870        )871    }872}873874@Composable875private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {876    Row(877        modifier = Modifier878            .fillMaxWidth()879            .clickable(onClick = onClick)880            .padding(vertical = 9.dp),881        verticalAlignment = Alignment.CenterVertically,882    ) {883        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)884        Column(modifier = Modifier.padding(start = 12.dp)) {885            Text(886                text = entry.title,887                color = peachColors.T0,888                fontFamily = PeachType.Body,889                fontSize = 15.sp,890                maxLines = 1,891                overflow = TextOverflow.Ellipsis,892            )893            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""894            if (sub.isNotBlank()) {895                Text(896                    text = sub,897                    color = peachColors.T2,898                    fontFamily = PeachType.Mono,899                    fontSize = 11.sp,900                    maxLines = 1,901                    overflow = TextOverflow.Ellipsis,902                )903            }904        }905    }906}907908/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */909private const val SEARCH_DEBOUNCE_MS = 200L