AutofillEntryPickerActivity.kt

34.8 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 accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT137138        setContent {139            PeachTheme(accent = accent) {140                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)141                val scope = rememberCoroutineScope()142                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }143                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }144                var error by remember { mutableStateOf<String?>(null) }145                var busy by remember { mutableStateOf(false) }146147                val allEntries = remember(unlocked) {148                    if (unlocked) vault.getEntries() else emptyList()149                }150                val matchedEntries = remember(unlocked, allEntries) {151                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()152                }153154                // A4: signup forms with no saved matches offer derived identities155                // (deduplicated usernames/emails across the vault).156                val identities = remember(unlocked, allEntries, matchedEntries) {157                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()158                }159160                // Manual pick of an entry the origin didn't auto-match: offer to161                // remember the origin on the entry before filling.162                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }163                val pickNow: (VaultEntry) -> Unit = { entry ->164                    val epoch = unlockedEpoch165                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()166                    else returnDataset(parsed, entry)167                }168169                // Offer biometrics immediately — it is the whole point of170                // unlocking here rather than in the app, and a prompt the user171                // can dismiss straight onto the password field costs nothing.172                LaunchedEffect(unlocked) {173                    if (!unlocked && canBiometric()) {174                        tryBiometric(175                            onUnlocked = {176                                unlocked = true177                                unlockedEpoch = vault.unlockedSessionEpoch()178                            },179                            onFail = { },180                        )181                    }182                }183184                ModalBottomSheet(185                    onDismissRequest = { cancel() },186                    sheetState = sheetState,187                    containerColor = peachColors.S1,188                    dragHandle = { AccentDragHandle() },189                ) {190                    PickerBody(191                        origin = parsed.origin,192                        typedIdentity = typedUsername ?: typedEmail,193                        unlocked = unlocked,194                        matchedEntries = matchedEntries,195                        allEntries = allEntries,196                        rememberOffer = rememberOffer,197                        onRemember = { entry, domains ->198                            rememberOffer = null199                            scope.launch {200                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry201                                runCatching {202                                    vault.updateEntry(203                                        current.copy(204                                            additionalUrls = (current.additionalUrls.orEmpty() +205                                                domains.map { "https://$it" }).distinct(),206                                        ),207                                    )208                                }209                                pickNow(entry)210                            }211                        },212                        onFillOnce = { entry ->213                            rememberOffer = null214                            pickNow(entry)215                        },216                        onRememberDismiss = { rememberOffer = null },217                        identities = identities,218                        onPickIdentity = { identity ->219                            val epoch = unlockedEpoch220                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()221                            else finishWithDataset(222                                AutofillResponseBuilder.identityDatasetFor(223                                    this@AutofillEntryPickerActivity, parsed, identity,224                                ),225                            )226                        },227                        error = error,228                        busy = busy,229                        canBiometric = canBiometric(),230                        onBiometric = {231                            tryBiometric(232                                onUnlocked = {233                                    unlocked = true234                                    unlockedEpoch = vault.unlockedSessionEpoch()235                                },236                                onFail = {},237                            )238                        },239                        onUnlock = { password ->240                            busy = true241                            error = null242                            scope.launch {243                                when (vault.unlock(password)) {244                                    is UnlockResult.Success -> {245                                        unlocked = true246                                        unlockedEpoch = vault.unlockedSessionEpoch()247                                    }248                                    UnlockResult.WrongPassword -> error = "Wrong master password."249                                    else -> error = "Couldn't unlock."250                                }251                                busy = false252                            }253                        },254                        onPick = { entry ->255                            val offer = rememberOfferDomains(entry, parsed)256                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer257                        },258                        onCreateNew = { searchText ->259                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)260                        },261                    )262                }263            }264        }265    }266267    override fun onDestroy() {268        if (debugSecureLease) {269            SecureWindowPolicy.release(window)270            debugSecureLease = false271        }272        super.onDestroy()273    }274275    private fun qaSecureCaptureBypass(): Boolean =276        BuildConfig.DEBUG && (277            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||278                QaFlags.disableSecureCaptureForQa279            )280281    private fun canBiometric(): Boolean =282        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)283284    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {285        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()286        Biometrics.authenticateCrypto(287            activity = this,288            title = "Unlock Peach",289            subtitle = "Fill your saved details",290            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),291            onSuccess = { result ->292                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()293                // M-05: unlockWithKey copies `key` into the session's derivedKey,294                // so the caller-owned reference is now redundant. Zero it whether295                // the unlock succeeds or fails so the unwrapped biometric key296                // doesn't outlive the auth prompt.297                lifecycleScope.launch {298                    // Unseal inside the launched ownership scope. If the299                    // lifecycle scope is cancelled before execution, no key is300                    // allocated before a finally can protect it.301                    var key: ByteArray? = null302                    try {303                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)304                        if (key == null) return@launch onFail()305                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()306                    } finally {307                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)308                    }309                }310            },311            onFail = onFail,312        )313    }314315    private fun returnDataset(316        parsed: ParsedStructure,317        entry: VaultEntry,318    ) {319        val dataset = if (entry.isCard()) {320            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)321        } else {322            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)323        }324        finishWithDataset(dataset)325    }326327    private fun finishWithDataset(dataset: Dataset?) {328        if (dataset == null) { cancel(); return }329        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)330        setResult(RESULT_OK, reply)331        finish()332    }333334    /**335     * Cancel the picker and fire MainActivity on the new-entry path. The picker336     * returns CANCELED on purpose — the user is choosing to leave the autofill337     * prompt, not to fill it — and the launcher extra carries the host's typed338     * identity and origin so the new-entry editor opens pre-filled.339     */340    private fun launchNewEntry(341        parsed: ParsedStructure,342        typedUsername: String?,343        typedEmail: String?,344        searchText: String?,345    ) {346        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin347        val intent = Intent(this, MainActivity::class.java).apply {348            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)349            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)350            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)351            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)352            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)353        }354        startActivity(intent)355        cancel()356    }357358    private fun cancel() {359        setResult(RESULT_CANCELED)360        finish()361    }362363    @Suppress("DEPRECATION")364    private fun intentAutofillId(key: String): AutofillId? =365        if (android.os.Build.VERSION.SDK_INT >= 33) {366            intent.getParcelableExtra(key, AutofillId::class.java)367        } else {368            intent.getParcelableExtra(key)369        }370371    private companion object {372        const val MAX_ROWS = 12373    }374}375376/**377 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin378 * domains and app title are resolved in-process via [resolveAutofillOrigin] from379 * the authenticated caller package + web domain — never from a caller extra.380 */381internal fun pickerParsedFromInputs(382    origin: String?,383    isWebOrigin: Boolean,384    callerPackage: String?,385    usernameId: AutofillId? = null,386    passwordId: AutofillId? = null,387    otpId: AutofillId? = null,388    cardNumberId: AutofillId? = null,389    cardExpMonthId: AutofillId? = null,390    cardExpYearId: AutofillId? = null,391    cardExpDateId: AutofillId? = null,392    cardCvcId: AutofillId? = null,393    cardNameId: AutofillId? = null,394): ParsedStructure = ParsedStructure(395    usernameId = usernameId,396    passwordId = passwordId,397    otpId = otpId,398    cardNumberId = cardNumberId,399    cardExpMonthId = cardExpMonthId,400    cardExpYearId = cardExpYearId,401    cardExpDateId = cardExpDateId,402    cardCvcId = cardCvcId,403    cardNameId = cardNameId,404    webDomain = origin.takeIf { isWebOrigin },405    packageName = if (isWebOrigin) callerPackage else origin,406)407408internal fun autofillPickerEntries(409    entries: List<VaultEntry>,410    parsed: ParsedStructure,411    limit: Int,412): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)413414/**415 * Case-insensitive substring match across the four user-facing identity columns416 * the browser uses (title, username, email, url). Exposed at file scope so the417 * picker can call it directly and the test suite can exercise the same code.418 */419internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {420    if (query.isEmpty()) return true421    val needle = query.lowercase()422    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)423        .any { it.lowercase().contains(needle) }424}425426@Composable427private fun AccentDragHandle() {428    val accent = LocalPeachAccent.current429    Row(430        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),431        horizontalArrangement = Arrangement.Center,432    ) {433        Box(434            modifier = Modifier435                .size(width = 32.dp, height = 4.dp)436                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),437        )438    }439}440441@Composable442private fun PickerBody(443    origin: String?,444    typedIdentity: String?,445    unlocked: Boolean,446    matchedEntries: List<VaultEntry>,447    allEntries: List<VaultEntry>,448    rememberOffer: Pair<VaultEntry, List<String>>?,449    onRemember: (VaultEntry, List<String>) -> Unit,450    onFillOnce: (VaultEntry) -> Unit,451    onRememberDismiss: () -> Unit,452    identities: List<SavedIdentity>,453    onPickIdentity: (SavedIdentity) -> Unit,454    error: String?,455    busy: Boolean,456    canBiometric: Boolean,457    onBiometric: () -> Unit,458    onUnlock: (String) -> Unit,459    onPick: (VaultEntry) -> Unit,460    onCreateNew: (String?) -> Unit,461) {462    val accent = LocalPeachAccent.current463    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {464        Text(465            text = "peach",466            color = accent,467            fontFamily = PeachType.Display,468            fontStyle = FontStyle.Italic,469            fontSize = 15.sp,470        )471        Text(472            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",473            color = peachColors.T2,474            fontFamily = PeachType.Mono,475            fontSize = 9.sp,476            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),477        )478479        if (!unlocked) {480            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)481        } else if (rememberOffer != null) {482            RememberSitePrompt(483                entryTitle = rememberOffer!!.first.title,484                domains = rememberOffer!!.second,485                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },486                onFillOnce = { onFillOnce(rememberOffer!!.first) },487                onBack = onRememberDismiss,488            )489        } else {490            // The search field is a vault-wide substring search; an empty query491            // shows the origin's matched entries.492            var rawQuery by remember { mutableStateOf("") }493            var debouncedQuery by remember { mutableStateOf("") }494            LaunchedEffect(rawQuery) {495                delay(SEARCH_DEBOUNCE_MS)496                debouncedQuery = rawQuery497            }498            SearchField(499                value = rawQuery,500                onValueChange = { rawQuery = it },501                placeholder = when {502                    matchedEntries.isNotEmpty() -> "search for another entry…"503                    else -> "search vault…"504                },505            )506507            val showIdentities =508                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()509            val visibleEntries = when {510                debouncedQuery.isBlank() -> matchedEntries511                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }512            }513            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()514515            val emptyMessage = when {516                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->517                    "No saved entries match this site."518                showSearchEmpty ->519                    "No entries match \"${debouncedQuery.trim()}\"."520                else -> null521            }522            if (emptyMessage != null) {523                Text(524                    text = emptyMessage,525                    color = peachColors.T1,526                    fontFamily = PeachType.Body,527                    fontSize = 13.sp,528                    modifier = Modifier.padding(vertical = 6.dp),529                )530            }531532            if (showIdentities) {533                IdentitySection(identities = identities, onPick = onPickIdentity)534            }535536            if (visibleEntries.isNotEmpty()) {537                LazyColumn {538                    items(visibleEntries, key = { it.id }) { entry ->539                        PickerRow(entry, onClick = { onPick(entry) })540                    }541                }542            }543544            val createOrigin =545                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin546            CreateNewRow(547                origin = createOrigin,548                typedIdentity = typedIdentity,549                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },550            )551        }552    }553}554555@Composable556private fun SearchField(557    value: String,558    onValueChange: (String) -> Unit,559    placeholder: String,560) {561    val accent = LocalPeachAccent.current562    BasicTextField(563        value = value,564        onValueChange = onValueChange,565        singleLine = true,566        textStyle = TextStyle(567            color = peachColors.T0,568            fontFamily = PeachType.Mono,569            fontSize = 14.sp,570        ),571        cursorBrush = SolidColor(accent),572        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),573        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),574        modifier = Modifier575            .fillMaxWidth()576            .background(peachColors.S2, RoundedCornerShape(8.dp))577            .padding(horizontal = 12.dp, vertical = 10.dp),578        decorationBox = { inner ->579            Box(modifier = Modifier.fillMaxWidth()) {580                if (value.isEmpty()) {581                    Text(582                        text = placeholder,583                        color = peachColors.T2,584                        fontFamily = PeachType.Mono,585                        fontSize = 12.sp,586                    )587                }588                inner()589            }590        },591    )592}593594@Composable595private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {596    val accent = LocalPeachAccent.current597    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"598        else "Create new entry"599    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }600    Row(601        modifier = Modifier602            .fillMaxWidth()603            .clickable(onClick = onClick)604            .padding(vertical = 10.dp),605        verticalAlignment = Alignment.CenterVertically,606    ) {607        Box(608            modifier = Modifier609                .size(34.dp)610                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),611            contentAlignment = Alignment.Center,612        ) {613            Text(614                text = "+",615                color = accent,616                fontFamily = PeachType.Display,617                fontStyle = FontStyle.Italic,618                fontSize = 18.sp,619            )620        }621        Column(modifier = Modifier.padding(start = 12.dp)) {622            Text(623                text = title,624                color = accent,625                fontFamily = PeachType.Body,626                fontSize = 14.sp,627                maxLines = 1,628                overflow = TextOverflow.Ellipsis,629            )630            if (subtitle != null) {631                Text(632                    text = "prefill: $subtitle",633                    color = peachColors.T2,634                    fontFamily = PeachType.Mono,635                    fontSize = 10.sp,636                    maxLines = 1,637                    overflow = TextOverflow.Ellipsis,638                )639            }640        }641    }642}643644/**645 * Offered when the user manually picks an entry the origin didn't auto-match:646 * save the origin's domains onto the entry (additional URLs) so it is647 * suggested automatically next time — Android counterpart of the browser's648 * "Remember this site" toast.649 */650@Composable651private fun RememberSitePrompt(652    entryTitle: String,653    domains: List<String>,654    onRemember: () -> Unit,655    onFillOnce: () -> Unit,656    onBack: () -> Unit,657) {658    val accent = LocalPeachAccent.current659    Text(660        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",661        color = peachColors.T0,662        fontFamily = PeachType.Body,663        fontSize = 14.sp,664    )665    Text(666        text = "It will be suggested automatically the next time you fill here.",667        color = peachColors.T2,668        fontFamily = PeachType.Mono,669        fontSize = 10.sp,670        modifier = Modifier.padding(top = 4.dp),671    )672    Row(673        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),674        horizontalArrangement = Arrangement.spacedBy(18.dp),675        verticalAlignment = Alignment.CenterVertically,676    ) {677        Text(678            text = "Remember & fill",679            color = accent,680            fontFamily = PeachType.Mono,681            fontSize = 13.sp,682            modifier = Modifier683                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))684                .clickable(onClick = onRemember)685                .padding(horizontal = 12.dp, vertical = 8.dp),686        )687        Text(688            text = "Just fill",689            color = peachColors.T1,690            fontFamily = PeachType.Mono,691            fontSize = 13.sp,692            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),693        )694        Text(695            text = "Back",696            color = peachColors.T2,697            fontFamily = PeachType.Mono,698            fontSize = 13.sp,699            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),700        )701    }702}703704/**705 * A4 identity helper: signup forms with no saved matches list the vault's706 * derived identities (deduplicated usernames/emails) with type filter chips —707 * Android counterpart of the browser's inline identity selector.708 */709@Composable710private fun IdentitySection(711    identities: List<SavedIdentity>,712    onPick: (SavedIdentity) -> Unit,713) {714    val accent = LocalPeachAccent.current715    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }716    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }717718    Text(719        text = "use a saved identity",720        color = peachColors.T2,721        fontFamily = PeachType.Mono,722        fontSize = 9.sp,723        modifier = Modifier.padding(bottom = 8.dp),724    )725    Row(726        horizontalArrangement = Arrangement.spacedBy(8.dp),727        modifier = Modifier.padding(bottom = 10.dp),728    ) {729        for (filterMode in IdentityFilterMode.values()) {730            val active = filterMode == mode731            Text(732                text = filterMode.label,733                color = if (active) peachColors.S1 else peachColors.T2,734                fontFamily = PeachType.Mono,735                fontSize = 10.sp,736                modifier = Modifier737                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))738                    .clickable { mode = filterMode }739                    .padding(horizontal = 10.dp, vertical = 5.dp),740            )741        }742    }743    LazyColumn {744        items(visible, key = { it.type.name + ":" + it.value }) { identity ->745            IdentityRow(identity, onClick = { onPick(identity) })746        }747    }748}749750@Composable751private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {752    Column(753        modifier = Modifier754            .fillMaxWidth()755            .clickable(onClick = onClick)756            .padding(vertical = 8.dp),757    ) {758        Text(759            text = identity.value,760            color = peachColors.T0,761            fontFamily = PeachType.Body,762            fontSize = 13.sp,763            maxLines = 1,764            overflow = TextOverflow.Ellipsis,765        )766        val meta = listOfNotNull(767            identity.exampleDomain,768            if (identity.type == IdentityType.EMAIL) "email" else "username",769        ).joinToString(" \u00b7 ")770        Text(771            text = meta,772            color = peachColors.T2,773            fontFamily = PeachType.Mono,774            fontSize = 9.sp,775            maxLines = 1,776            overflow = TextOverflow.Ellipsis,777        )778    }779}780781@Composable782private fun UnlockBody(783    error: String?,784    busy: Boolean,785    canBiometric: Boolean,786    onBiometric: () -> Unit,787    onUnlock: (String) -> Unit,788) {789    val accent = LocalPeachAccent.current790    var password by remember { mutableStateOf("") }791792    Text(793        text = "Unlock to fill",794        color = peachColors.T0,795        fontFamily = PeachType.Body,796        fontSize = 15.sp,797        modifier = Modifier.padding(bottom = 10.dp),798    )799    BasicTextField(800        value = password,801        onValueChange = { password = it },802        singleLine = true,803        enabled = !busy,804        textStyle = TextStyle(805            color = peachColors.T0,806            fontFamily = PeachType.Mono,807            fontSize = 16.sp,808        ),809        cursorBrush = SolidColor(accent),810        visualTransformation = PasswordVisualTransformation(),811        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),812        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),813        modifier = Modifier814            .fillMaxWidth()815            .background(peachColors.S2, RoundedCornerShape(8.dp))816            .padding(horizontal = 12.dp, vertical = 12.dp),817    )818    if (error != null) {819        Text(820            text = error,821            color = peachColors.Breach,822            fontFamily = PeachType.Body,823            fontSize = 12.sp,824            modifier = Modifier.padding(top = 8.dp),825        )826    }827    Row(828        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),829        horizontalArrangement = Arrangement.spacedBy(20.dp),830        verticalAlignment = Alignment.CenterVertically,831    ) {832        if (canBiometric) {833            Text(834                text = "Use biometrics",835                color = accent,836                fontFamily = PeachType.Mono,837                fontSize = 13.sp,838                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),839            )840        }841        Text(842            text = if (busy) "Unlocking…" else "Unlock",843            color = if (password.isEmpty() || busy) peachColors.T2 else accent,844            fontFamily = PeachType.Mono,845            fontSize = 13.sp,846            modifier = Modifier847                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }848                .padding(vertical = 8.dp),849        )850    }851}852853@Composable854private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {855    Row(856        modifier = Modifier857            .fillMaxWidth()858            .clickable(onClick = onClick)859            .padding(vertical = 9.dp),860        verticalAlignment = Alignment.CenterVertically,861    ) {862        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)863        Column(modifier = Modifier.padding(start = 12.dp)) {864            Text(865                text = entry.title,866                color = peachColors.T0,867                fontFamily = PeachType.Body,868                fontSize = 15.sp,869                maxLines = 1,870                overflow = TextOverflow.Ellipsis,871            )872            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""873            if (sub.isNotBlank()) {874                Text(875                    text = sub,876                    color = peachColors.T2,877                    fontFamily = PeachType.Mono,878                    fontSize = 11.sp,879                    maxLines = 1,880                    overflow = TextOverflow.Ellipsis,881                )882            }883        }884    }885}886887/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */888private const val SEARCH_DEBOUNCE_MS = 200L