AutofillEntryPickerActivity.kt

35.1 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.CompositionLocalProvider30import androidx.compose.runtime.LaunchedEffect31import androidx.compose.runtime.getValue32import androidx.compose.runtime.mutableStateOf33import androidx.compose.runtime.remember34import androidx.compose.runtime.rememberCoroutineScope35import androidx.compose.runtime.setValue36import androidx.compose.ui.graphics.SolidColor37import androidx.compose.ui.text.TextStyle38import androidx.compose.ui.text.input.ImeAction39import androidx.compose.ui.text.input.KeyboardType40import androidx.compose.ui.text.input.PasswordVisualTransformation41import androidx.compose.ui.Alignment42import androidx.compose.ui.Modifier43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.text.style.TextOverflow45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.sp47import androidx.fragment.app.FragmentActivity48import androidx.lifecycle.lifecycleScope49import com.peachpasswords.BuildConfig50import com.peachpasswords.MainActivity51import com.peachpasswords.shared.extractDomain52import com.peachpasswords.ui.lock.BiometricKeyStore53import com.peachpasswords.ui.lock.Biometrics54import com.peachpasswords.ui.components.FaviconInitials55import com.peachpasswords.ui.components.LocalFavicons56import com.peachpasswords.ui.components.QaFlags57import com.peachpasswords.ui.components.SecureWindowPolicy58import com.peachpasswords.ui.theme.ACCENT_COLORS59import com.peachpasswords.ui.theme.DEFAULT_ACCENT60import com.peachpasswords.ui.theme.LocalPeachAccent61import com.peachpasswords.ui.theme.peachColors62import com.peachpasswords.ui.theme.PeachTheme63import com.peachpasswords.ui.theme.PeachType64import com.peachpasswords.vault.UnlockResult65import com.peachpasswords.vault.VaultEntry66import com.peachpasswords.vault.isCard67import com.peachpasswords.vault.VaultRepository68import dagger.hilt.android.AndroidEntryPoint69import kotlinx.coroutines.delay70import kotlinx.coroutines.launch71import javax.inject.Inject7273/**74 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"75 * in the system autofill drop-down. Lists matching vault entries using the same76 * row vocabulary as the vault list; picking one returns a filled77 * [android.service.autofill.Dataset] to the framework via78 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].79 *80 * Inline-mirror of the browser's selector: always-on search field, vault-wide81 * match against title/username/email/url, and a "+ Create new entry" affordance82 * in the header. The affordance cancels this activity and launches [MainActivity]83 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had84 * typed on the host page.85 *86 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity87 * and return RESULT_CANCELED, which produced a loop with no exit: the framework88 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the89 * user unlocked the app, came back to a field that still asked them to unlock,90 * and had no way to tell that anything had happened. Autofill is a modal91 * interruption of someone else's app — leaving it to go somewhere else is the92 * one thing it must not do.93 */94@AndroidEntryPoint95class AutofillEntryPickerActivity : FragmentActivity() {9697    @Inject lateinit var vault: VaultRepository98    @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons99    private var debugSecureLease = false100101    @OptIn(ExperimentalMaterial3Api::class)102    override fun onCreate(savedInstanceState: Bundle?) {103        super.onCreate(savedInstanceState)104105        // H-05: separate window from MainActivity, so it does NOT inherit the106        // release-wide FLAG_SECURE set there. Show vault metadata and accept the107        // master password — protect against screenshots / recents thumbnails.108        if (!BuildConfig.DEBUG) {109            SecureWindowPolicy.setUserSecure(window, true)110        } else if (!qaSecureCaptureBypass()) {111            // Debug builds are secure by default too. QA must opt out explicitly112            // on this activity's own launch intent; MainActivity's window policy113            // cannot protect this separate picker window.114            SecureWindowPolicy.acquire(window)115            debugSecureLease = true116        }117118        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)119        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)120        // Origin trust is never read from the launch intent: a caller-supplied121        // trust extra could be forged or stale. Re-derive it in-process from122        // the raw authenticated inputs using the same authentication the123        // service used, so a tampered extra cannot elevate origin trust.124        val rawParsed = pickerParsedFromInputs(125            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),126            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),127            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),128            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),129            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),130            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),131            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),132            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),133            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),134            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),135            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),136            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),137        )138        val parsed = resolveAutofillOrigin(this, rawParsed)139        val settings = vault.settings.value140        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT141142        setContent {143            CompositionLocalProvider(LocalFavicons provides favicons) {144            PeachTheme(accent = accent, textSize = settings.textSize) {145                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)146                val scope = rememberCoroutineScope()147                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }148                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }149                var error by remember { mutableStateOf<String?>(null) }150                var busy by remember { mutableStateOf(false) }151152                val allEntries = remember(unlocked) {153                    if (unlocked) vault.getEntries() else emptyList()154                }155                val matchedEntries = remember(unlocked, allEntries) {156                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()157                }158159                // A4: signup forms with no saved matches offer derived identities160                // (deduplicated usernames/emails across the vault).161                val identities = remember(unlocked, allEntries, matchedEntries) {162                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()163                }164165                // Manual pick of an entry the origin didn't auto-match: offer to166                // remember the origin on the entry before filling.167                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }168                val pickNow: (VaultEntry) -> Unit = { entry ->169                    val epoch = unlockedEpoch170                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()171                    else returnDataset(parsed, entry)172                }173174                // Offer biometrics immediately — it is the whole point of175                // unlocking here rather than in the app, and a prompt the user176                // can dismiss straight onto the password field costs nothing.177                LaunchedEffect(unlocked) {178                    if (!unlocked && canBiometric()) {179                        tryBiometric(180                            onUnlocked = {181                                unlocked = true182                                unlockedEpoch = vault.unlockedSessionEpoch()183                            },184                            onFail = { },185                        )186                    }187                }188189                ModalBottomSheet(190                    onDismissRequest = { cancel() },191                    sheetState = sheetState,192                    containerColor = peachColors.S1,193                    dragHandle = { AccentDragHandle() },194                ) {195                    PickerBody(196                        origin = parsed.origin,197                        typedIdentity = typedUsername ?: typedEmail,198                        unlocked = unlocked,199                        matchedEntries = matchedEntries,200                        allEntries = allEntries,201                        rememberOffer = rememberOffer,202                        onRemember = { entry, domains ->203                            rememberOffer = null204                            scope.launch {205                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry206                                runCatching {207                                    vault.updateEntry(208                                        current.copy(209                                            additionalUrls = (current.additionalUrls.orEmpty() +210                                                domains.map { "https://$it" }).distinct(),211                                        ),212                                    )213                                }214                                pickNow(entry)215                            }216                        },217                        onFillOnce = { entry ->218                            rememberOffer = null219                            pickNow(entry)220                        },221                        onRememberDismiss = { rememberOffer = null },222                        identities = identities,223                        onPickIdentity = { identity ->224                            val epoch = unlockedEpoch225                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()226                            else finishWithDataset(227                                AutofillResponseBuilder.identityDatasetFor(228                                    this@AutofillEntryPickerActivity, parsed, identity,229                                ),230                            )231                        },232                        error = error,233                        busy = busy,234                        canBiometric = canBiometric(),235                        onBiometric = {236                            tryBiometric(237                                onUnlocked = {238                                    unlocked = true239                                    unlockedEpoch = vault.unlockedSessionEpoch()240                                },241                                onFail = {},242                            )243                        },244                        onUnlock = { password ->245                            busy = true246                            error = null247                            scope.launch {248                                when (vault.unlock(password)) {249                                    is UnlockResult.Success -> {250                                        unlocked = true251                                        unlockedEpoch = vault.unlockedSessionEpoch()252                                    }253                                    UnlockResult.WrongPassword -> error = "Wrong master password."254                                    else -> error = "Couldn't unlock."255                                }256                                busy = false257                            }258                        },259                        onPick = { entry ->260                            val offer = rememberOfferDomains(entry, parsed)261                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer262                        },263                        onCreateNew = { searchText ->264                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)265                        },266                    )267                }268            }269            }270        }271    }272273    override fun onDestroy() {274        if (debugSecureLease) {275            SecureWindowPolicy.release(window)276            debugSecureLease = false277        }278        super.onDestroy()279    }280281    private fun qaSecureCaptureBypass(): Boolean =282        BuildConfig.DEBUG && (283            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||284                QaFlags.disableSecureCaptureForQa285            )286287    private fun canBiometric(): Boolean =288        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)289290    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {291        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()292        Biometrics.authenticateCrypto(293            activity = this,294            title = "Unlock Peach",295            subtitle = "Fill your saved details",296            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),297            onSuccess = { result ->298                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()299                // M-05: unlockWithKey copies `key` into the session's derivedKey,300                // so the caller-owned reference is now redundant. Zero it whether301                // the unlock succeeds or fails so the unwrapped biometric key302                // doesn't outlive the auth prompt.303                lifecycleScope.launch {304                    // Unseal inside the launched ownership scope. If the305                    // lifecycle scope is cancelled before execution, no key is306                    // allocated before a finally can protect it.307                    var key: ByteArray? = null308                    try {309                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)310                        if (key == null) return@launch onFail()311                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()312                    } finally {313                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)314                    }315                }316            },317            onFail = onFail,318        )319    }320321    private fun returnDataset(322        parsed: ParsedStructure,323        entry: VaultEntry,324    ) {325        val dataset = if (entry.isCard()) {326            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)327        } else {328            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)329        }330        finishWithDataset(dataset)331    }332333    private fun finishWithDataset(dataset: Dataset?) {334        if (dataset == null) { cancel(); return }335        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)336        setResult(RESULT_OK, reply)337        finish()338    }339340    /**341     * Cancel the picker and fire MainActivity on the new-entry path. The picker342     * returns CANCELED on purpose — the user is choosing to leave the autofill343     * prompt, not to fill it — and the launcher extra carries the host's typed344     * identity and origin so the new-entry editor opens pre-filled.345     */346    private fun launchNewEntry(347        parsed: ParsedStructure,348        typedUsername: String?,349        typedEmail: String?,350        searchText: String?,351    ) {352        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin353        val intent = Intent(this, MainActivity::class.java).apply {354            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)355            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)356            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)357            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)358            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)359        }360        startActivity(intent)361        cancel()362    }363364    private fun cancel() {365        setResult(RESULT_CANCELED)366        finish()367    }368369    @Suppress("DEPRECATION")370    private fun intentAutofillId(key: String): AutofillId? =371        if (android.os.Build.VERSION.SDK_INT >= 33) {372            intent.getParcelableExtra(key, AutofillId::class.java)373        } else {374            intent.getParcelableExtra(key)375        }376377    private companion object {378        const val MAX_ROWS = 12379    }380}381382/**383 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin384 * domains and app title are resolved in-process via [resolveAutofillOrigin] from385 * the authenticated caller package + web domain — never from a caller extra.386 */387internal fun pickerParsedFromInputs(388    origin: String?,389    isWebOrigin: Boolean,390    callerPackage: String?,391    usernameId: AutofillId? = null,392    passwordId: AutofillId? = null,393    otpId: AutofillId? = null,394    cardNumberId: AutofillId? = null,395    cardExpMonthId: AutofillId? = null,396    cardExpYearId: AutofillId? = null,397    cardExpDateId: AutofillId? = null,398    cardCvcId: AutofillId? = null,399    cardNameId: AutofillId? = null,400): ParsedStructure = ParsedStructure(401    usernameId = usernameId,402    passwordId = passwordId,403    otpId = otpId,404    cardNumberId = cardNumberId,405    cardExpMonthId = cardExpMonthId,406    cardExpYearId = cardExpYearId,407    cardExpDateId = cardExpDateId,408    cardCvcId = cardCvcId,409    cardNameId = cardNameId,410    webDomain = origin.takeIf { isWebOrigin },411    packageName = if (isWebOrigin) callerPackage else origin,412)413414internal fun autofillPickerEntries(415    entries: List<VaultEntry>,416    parsed: ParsedStructure,417    limit: Int,418): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)419420/**421 * Case-insensitive substring match across the four user-facing identity columns422 * the browser uses (title, username, email, url). Exposed at file scope so the423 * picker can call it directly and the test suite can exercise the same code.424 */425internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {426    if (query.isEmpty()) return true427    val needle = query.lowercase()428    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)429        .any { it.lowercase().contains(needle) }430}431432@Composable433private fun AccentDragHandle() {434    val accent = LocalPeachAccent.current435    Row(436        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),437        horizontalArrangement = Arrangement.Center,438    ) {439        Box(440            modifier = Modifier441                .size(width = 32.dp, height = 4.dp)442                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),443        )444    }445}446447@Composable448private fun PickerBody(449    origin: String?,450    typedIdentity: String?,451    unlocked: Boolean,452    matchedEntries: List<VaultEntry>,453    allEntries: List<VaultEntry>,454    rememberOffer: Pair<VaultEntry, List<String>>?,455    onRemember: (VaultEntry, List<String>) -> Unit,456    onFillOnce: (VaultEntry) -> Unit,457    onRememberDismiss: () -> Unit,458    identities: List<SavedIdentity>,459    onPickIdentity: (SavedIdentity) -> Unit,460    error: String?,461    busy: Boolean,462    canBiometric: Boolean,463    onBiometric: () -> Unit,464    onUnlock: (String) -> Unit,465    onPick: (VaultEntry) -> Unit,466    onCreateNew: (String?) -> Unit,467) {468    val accent = LocalPeachAccent.current469    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {470        Text(471            text = "peach",472            color = accent,473            fontFamily = PeachType.Display,474            fontStyle = FontStyle.Italic,475            fontSize = 15.sp,476        )477        Text(478            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",479            color = peachColors.T2,480            fontFamily = PeachType.Mono,481            fontSize = 9.sp,482            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),483        )484485        if (!unlocked) {486            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)487        } else if (rememberOffer != null) {488            RememberSitePrompt(489                entryTitle = rememberOffer!!.first.title,490                domains = rememberOffer!!.second,491                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },492                onFillOnce = { onFillOnce(rememberOffer!!.first) },493                onBack = onRememberDismiss,494            )495        } else {496            // The search field is a vault-wide substring search; an empty query497            // shows the origin's matched entries.498            var rawQuery by remember { mutableStateOf("") }499            var debouncedQuery by remember { mutableStateOf("") }500            LaunchedEffect(rawQuery) {501                delay(SEARCH_DEBOUNCE_MS)502                debouncedQuery = rawQuery503            }504            SearchField(505                value = rawQuery,506                onValueChange = { rawQuery = it },507                placeholder = when {508                    matchedEntries.isNotEmpty() -> "search for another entry…"509                    else -> "search vault…"510                },511            )512513            val showIdentities =514                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()515            val visibleEntries = when {516                debouncedQuery.isBlank() -> matchedEntries517                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }518            }519            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()520521            val emptyMessage = when {522                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->523                    "No saved entries match this site."524                showSearchEmpty ->525                    "No entries match \"${debouncedQuery.trim()}\"."526                else -> null527            }528            if (emptyMessage != null) {529                Text(530                    text = emptyMessage,531                    color = peachColors.T1,532                    fontFamily = PeachType.Body,533                    fontSize = 13.sp,534                    modifier = Modifier.padding(vertical = 6.dp),535                )536            }537538            if (showIdentities) {539                IdentitySection(identities = identities, onPick = onPickIdentity)540            }541542            if (visibleEntries.isNotEmpty()) {543                LazyColumn {544                    items(visibleEntries, key = { it.id }) { entry ->545                        PickerRow(entry, onClick = { onPick(entry) })546                    }547                }548            }549550            val createOrigin =551                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin552            CreateNewRow(553                origin = createOrigin,554                typedIdentity = typedIdentity,555                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },556            )557        }558    }559}560561@Composable562private fun SearchField(563    value: String,564    onValueChange: (String) -> Unit,565    placeholder: String,566) {567    val accent = LocalPeachAccent.current568    BasicTextField(569        value = value,570        onValueChange = onValueChange,571        singleLine = true,572        textStyle = TextStyle(573            color = peachColors.T0,574            fontFamily = PeachType.Mono,575            fontSize = 14.sp,576        ),577        cursorBrush = SolidColor(accent),578        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),579        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),580        modifier = Modifier581            .fillMaxWidth()582            .background(peachColors.S2, RoundedCornerShape(8.dp))583            .padding(horizontal = 12.dp, vertical = 10.dp),584        decorationBox = { inner ->585            Box(modifier = Modifier.fillMaxWidth()) {586                if (value.isEmpty()) {587                    Text(588                        text = placeholder,589                        color = peachColors.T2,590                        fontFamily = PeachType.Mono,591                        fontSize = 12.sp,592                    )593                }594                inner()595            }596        },597    )598}599600@Composable601private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {602    val accent = LocalPeachAccent.current603    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"604        else "Create new entry"605    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }606    Row(607        modifier = Modifier608            .fillMaxWidth()609            .clickable(onClick = onClick)610            .padding(vertical = 10.dp),611        verticalAlignment = Alignment.CenterVertically,612    ) {613        Box(614            modifier = Modifier615                .size(34.dp)616                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),617            contentAlignment = Alignment.Center,618        ) {619            Text(620                text = "+",621                color = accent,622                fontFamily = PeachType.Display,623                fontStyle = FontStyle.Italic,624                fontSize = 18.sp,625            )626        }627        Column(modifier = Modifier.padding(start = 12.dp)) {628            Text(629                text = title,630                color = accent,631                fontFamily = PeachType.Body,632                fontSize = 14.sp,633                maxLines = 1,634                overflow = TextOverflow.Ellipsis,635            )636            if (subtitle != null) {637                Text(638                    text = "prefill: $subtitle",639                    color = peachColors.T2,640                    fontFamily = PeachType.Mono,641                    fontSize = 10.sp,642                    maxLines = 1,643                    overflow = TextOverflow.Ellipsis,644                )645            }646        }647    }648}649650/**651 * Offered when the user manually picks an entry the origin didn't auto-match:652 * save the origin's domains onto the entry (additional URLs) so it is653 * suggested automatically next time — Android counterpart of the browser's654 * "Remember this site" toast.655 */656@Composable657private fun RememberSitePrompt(658    entryTitle: String,659    domains: List<String>,660    onRemember: () -> Unit,661    onFillOnce: () -> Unit,662    onBack: () -> Unit,663) {664    val accent = LocalPeachAccent.current665    Text(666        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",667        color = peachColors.T0,668        fontFamily = PeachType.Body,669        fontSize = 14.sp,670    )671    Text(672        text = "It will be suggested automatically the next time you fill here.",673        color = peachColors.T2,674        fontFamily = PeachType.Mono,675        fontSize = 10.sp,676        modifier = Modifier.padding(top = 4.dp),677    )678    Row(679        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),680        horizontalArrangement = Arrangement.spacedBy(18.dp),681        verticalAlignment = Alignment.CenterVertically,682    ) {683        Text(684            text = "Remember & fill",685            color = accent,686            fontFamily = PeachType.Mono,687            fontSize = 13.sp,688            modifier = Modifier689                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))690                .clickable(onClick = onRemember)691                .padding(horizontal = 12.dp, vertical = 8.dp),692        )693        Text(694            text = "Just fill",695            color = peachColors.T1,696            fontFamily = PeachType.Mono,697            fontSize = 13.sp,698            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),699        )700        Text(701            text = "Back",702            color = peachColors.T2,703            fontFamily = PeachType.Mono,704            fontSize = 13.sp,705            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),706        )707    }708}709710/**711 * A4 identity helper: signup forms with no saved matches list the vault's712 * derived identities (deduplicated usernames/emails) with type filter chips —713 * Android counterpart of the browser's inline identity selector.714 */715@Composable716private fun IdentitySection(717    identities: List<SavedIdentity>,718    onPick: (SavedIdentity) -> Unit,719) {720    val accent = LocalPeachAccent.current721    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }722    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }723724    Text(725        text = "use a saved identity",726        color = peachColors.T2,727        fontFamily = PeachType.Mono,728        fontSize = 9.sp,729        modifier = Modifier.padding(bottom = 8.dp),730    )731    Row(732        horizontalArrangement = Arrangement.spacedBy(8.dp),733        modifier = Modifier.padding(bottom = 10.dp),734    ) {735        for (filterMode in IdentityFilterMode.values()) {736            val active = filterMode == mode737            Text(738                text = filterMode.label,739                color = if (active) peachColors.S1 else peachColors.T2,740                fontFamily = PeachType.Mono,741                fontSize = 10.sp,742                modifier = Modifier743                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))744                    .clickable { mode = filterMode }745                    .padding(horizontal = 10.dp, vertical = 5.dp),746            )747        }748    }749    LazyColumn {750        items(visible, key = { it.type.name + ":" + it.value }) { identity ->751            IdentityRow(identity, onClick = { onPick(identity) })752        }753    }754}755756@Composable757private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {758    Column(759        modifier = Modifier760            .fillMaxWidth()761            .clickable(onClick = onClick)762            .padding(vertical = 8.dp),763    ) {764        Text(765            text = identity.value,766            color = peachColors.T0,767            fontFamily = PeachType.Body,768            fontSize = 13.sp,769            maxLines = 1,770            overflow = TextOverflow.Ellipsis,771        )772        val meta = listOfNotNull(773            identity.exampleDomain,774            if (identity.type == IdentityType.EMAIL) "email" else "username",775        ).joinToString(" \u00b7 ")776        Text(777            text = meta,778            color = peachColors.T2,779            fontFamily = PeachType.Mono,780            fontSize = 9.sp,781            maxLines = 1,782            overflow = TextOverflow.Ellipsis,783        )784    }785}786787@Composable788private fun UnlockBody(789    error: String?,790    busy: Boolean,791    canBiometric: Boolean,792    onBiometric: () -> Unit,793    onUnlock: (String) -> Unit,794) {795    val accent = LocalPeachAccent.current796    var password by remember { mutableStateOf("") }797798    Text(799        text = "Unlock to fill",800        color = peachColors.T0,801        fontFamily = PeachType.Body,802        fontSize = 15.sp,803        modifier = Modifier.padding(bottom = 10.dp),804    )805    BasicTextField(806        value = password,807        onValueChange = { password = it },808        singleLine = true,809        enabled = !busy,810        textStyle = TextStyle(811            color = peachColors.T0,812            fontFamily = PeachType.Mono,813            fontSize = 16.sp,814        ),815        cursorBrush = SolidColor(accent),816        visualTransformation = PasswordVisualTransformation(),817        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),818        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),819        modifier = Modifier820            .fillMaxWidth()821            .background(peachColors.S2, RoundedCornerShape(8.dp))822            .padding(horizontal = 12.dp, vertical = 12.dp),823    )824    if (error != null) {825        Text(826            text = error,827            color = peachColors.Breach,828            fontFamily = PeachType.Body,829            fontSize = 12.sp,830            modifier = Modifier.padding(top = 8.dp),831        )832    }833    Row(834        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),835        horizontalArrangement = Arrangement.spacedBy(20.dp),836        verticalAlignment = Alignment.CenterVertically,837    ) {838        if (canBiometric) {839            Text(840                text = "Use biometrics",841                color = accent,842                fontFamily = PeachType.Mono,843                fontSize = 13.sp,844                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),845            )846        }847        Text(848            text = if (busy) "Unlocking…" else "Unlock",849            color = if (password.isEmpty() || busy) peachColors.T2 else accent,850            fontFamily = PeachType.Mono,851            fontSize = 13.sp,852            modifier = Modifier853                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }854                .padding(vertical = 8.dp),855        )856    }857}858859@Composable860private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {861    Row(862        modifier = Modifier863            .fillMaxWidth()864            .clickable(onClick = onClick)865            .padding(vertical = 9.dp),866        verticalAlignment = Alignment.CenterVertically,867    ) {868        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)869        Column(modifier = Modifier.padding(start = 12.dp)) {870            Text(871                text = entry.title,872                color = peachColors.T0,873                fontFamily = PeachType.Body,874                fontSize = 15.sp,875                maxLines = 1,876                overflow = TextOverflow.Ellipsis,877            )878            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""879            if (sub.isNotBlank()) {880                Text(881                    text = sub,882                    color = peachColors.T2,883                    fontFamily = PeachType.Mono,884                    fontSize = 11.sp,885                    maxLines = 1,886                    overflow = TextOverflow.Ellipsis,887                )888            }889        }890    }891}892893/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */894private const val SEARCH_DEBOUNCE_MS = 200L