AutofillEntryPickerActivity.kt

35.6 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.QaFlags58import com.peachpasswords.ui.components.SecureWindowPolicy59import com.peachpasswords.ui.theme.ACCENT_COLORS60import com.peachpasswords.ui.theme.DEFAULT_ACCENT61import com.peachpasswords.ui.theme.LocalPeachAccent62import com.peachpasswords.ui.theme.peachColors63import com.peachpasswords.ui.theme.PeachTheme64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.vault.UnlockResult66import com.peachpasswords.vault.VaultEntry67import com.peachpasswords.vault.isCard68import com.peachpasswords.vault.VaultRepository69import dagger.hilt.android.AndroidEntryPoint70import kotlinx.coroutines.delay71import kotlinx.coroutines.launch72import javax.inject.Inject7374/**75 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"76 * in the system autofill drop-down. Lists matching vault entries using the same77 * row vocabulary as the vault list; picking one returns a filled78 * [android.service.autofill.Dataset] to the framework via79 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].80 *81 * Inline-mirror of the browser's selector: always-on search field, vault-wide82 * match against title/username/email/url, and a "+ Create new entry" affordance83 * in the header. The affordance cancels this activity and launches [MainActivity]84 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had85 * typed on the host page.86 *87 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity88 * and return RESULT_CANCELED, which produced a loop with no exit: the framework89 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the90 * user unlocked the app, came back to a field that still asked them to unlock,91 * and had no way to tell that anything had happened. Autofill is a modal92 * interruption of someone else's app — leaving it to go somewhere else is the93 * one thing it must not do.94 */95@AndroidEntryPoint96class AutofillEntryPickerActivity : FragmentActivity() {9798    @Inject lateinit var vault: VaultRepository99    @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons100    private var debugSecureLease = false101102    @OptIn(ExperimentalMaterial3Api::class)103    override fun onCreate(savedInstanceState: Bundle?) {104        super.onCreate(savedInstanceState)105106        // H-05: separate window from MainActivity, so it does NOT inherit the107        // release-wide FLAG_SECURE set there. Show vault metadata and accept the108        // master password — protect against screenshots / recents thumbnails.109        if (!BuildConfig.DEBUG) {110            SecureWindowPolicy.setUserSecure(window, true)111        } else if (!qaSecureCaptureBypass()) {112            // Debug builds are secure by default too. QA must opt out explicitly113            // on this activity's own launch intent; MainActivity's window policy114            // cannot protect this separate picker window.115            SecureWindowPolicy.acquire(window)116            debugSecureLease = true117        }118119        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)120        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)121        // Origin trust is never read from the launch intent: a caller-supplied122        // trust extra could be forged or stale. Re-derive it in-process from123        // the raw authenticated inputs using the same authentication the124        // service used, so a tampered extra cannot elevate origin trust.125        val rawParsed = pickerParsedFromInputs(126            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),127            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),128            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),129            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),130            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),131            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),132            otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {133                intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()134            } else {135                @Suppress("DEPRECATION")136                intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()137            },138            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),139            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),140            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),141            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),142            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),143            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),144        )145        val parsed = resolveAutofillOrigin(this, rawParsed)146        val settings = vault.settings.value147        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT148149        setContent {150            CompositionLocalProvider(LocalFavicons provides favicons) {151            PeachTheme(accent = accent, textSize = settings.textSize) {152                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)153                val scope = rememberCoroutineScope()154                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }155                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }156                var error by remember { mutableStateOf<String?>(null) }157                var busy by remember { mutableStateOf(false) }158159                val allEntries = remember(unlocked) {160                    if (unlocked) vault.getEntries() else emptyList()161                }162                val matchedEntries = remember(unlocked, allEntries) {163                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()164                }165166                // A4: signup forms with no saved matches offer derived identities167                // (deduplicated usernames/emails across the vault).168                val identities = remember(unlocked, allEntries, matchedEntries) {169                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()170                }171172                // Manual pick of an entry the origin didn't auto-match: offer to173                // remember the origin on the entry before filling.174                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }175                val pickNow: (VaultEntry) -> Unit = { entry ->176                    val epoch = unlockedEpoch177                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()178                    else returnDataset(parsed, entry)179                }180181                // Offer biometrics immediately — it is the whole point of182                // unlocking here rather than in the app, and a prompt the user183                // can dismiss straight onto the password field costs nothing.184                LaunchedEffect(unlocked) {185                    if (!unlocked && canBiometric()) {186                        tryBiometric(187                            onUnlocked = {188                                unlocked = true189                                unlockedEpoch = vault.unlockedSessionEpoch()190                            },191                            onFail = { },192                        )193                    }194                }195196                ModalBottomSheet(197                    onDismissRequest = { cancel() },198                    sheetState = sheetState,199                    containerColor = peachColors.S1,200                    dragHandle = { AccentDragHandle() },201                ) {202                    PickerBody(203                        origin = parsed.origin,204                        typedIdentity = typedUsername ?: typedEmail,205                        unlocked = unlocked,206                        matchedEntries = matchedEntries,207                        allEntries = allEntries,208                        rememberOffer = rememberOffer,209                        onRemember = { entry, domains ->210                            rememberOffer = null211                            scope.launch {212                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry213                                runCatching {214                                    vault.updateEntry(215                                        current.copy(216                                            additionalUrls = (current.additionalUrls.orEmpty() +217                                                domains.map { "https://$it" }).distinct(),218                                        ),219                                    )220                                }221                                pickNow(entry)222                            }223                        },224                        onFillOnce = { entry ->225                            rememberOffer = null226                            pickNow(entry)227                        },228                        onRememberDismiss = { rememberOffer = null },229                        identities = identities,230                        onPickIdentity = { identity ->231                            val epoch = unlockedEpoch232                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()233                            else finishWithDataset(234                                AutofillResponseBuilder.identityDatasetFor(235                                    this@AutofillEntryPickerActivity, parsed, identity,236                                ),237                            )238                        },239                        error = error,240                        busy = busy,241                        canBiometric = canBiometric(),242                        onBiometric = {243                            tryBiometric(244                                onUnlocked = {245                                    unlocked = true246                                    unlockedEpoch = vault.unlockedSessionEpoch()247                                },248                                onFail = {},249                            )250                        },251                        onUnlock = { password ->252                            busy = true253                            error = null254                            scope.launch {255                                when (vault.unlock(password)) {256                                    is UnlockResult.Success -> {257                                        unlocked = true258                                        unlockedEpoch = vault.unlockedSessionEpoch()259                                    }260                                    UnlockResult.WrongPassword -> error = "Wrong master password."261                                    else -> error = "Couldn't unlock."262                                }263                                busy = false264                            }265                        },266                        onPick = { entry ->267                            val offer = rememberOfferDomains(entry, parsed)268                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer269                        },270                        onCreateNew = { searchText ->271                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)272                        },273                    )274                }275            }276            }277        }278    }279280    override fun onDestroy() {281        if (debugSecureLease) {282            SecureWindowPolicy.release(window)283            debugSecureLease = false284        }285        super.onDestroy()286    }287288    private fun qaSecureCaptureBypass(): Boolean =289        BuildConfig.DEBUG && (290            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||291                QaFlags.disableSecureCaptureForQa292            )293294    private fun canBiometric(): Boolean =295        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)296297    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {298        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()299        Biometrics.authenticateCrypto(300            activity = this,301            title = "Unlock Peach",302            subtitle = "Fill your saved details",303            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),304            onSuccess = { result ->305                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()306                // M-05: unlockWithKey copies `key` into the session's derivedKey,307                // so the caller-owned reference is now redundant. Zero it whether308                // the unlock succeeds or fails so the unwrapped biometric key309                // doesn't outlive the auth prompt.310                lifecycleScope.launch {311                    // Unseal inside the launched ownership scope. If the312                    // lifecycle scope is cancelled before execution, no key is313                    // allocated before a finally can protect it.314                    var key: ByteArray? = null315                    try {316                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)317                        if (key == null) return@launch onFail()318                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()319                    } finally {320                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)321                    }322                }323            },324            onFail = onFail,325        )326    }327328    private fun returnDataset(329        parsed: ParsedStructure,330        entry: VaultEntry,331    ) {332        val dataset = if (entry.isCard()) {333            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)334        } else {335            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)336        }337        finishWithDataset(dataset)338    }339340    private fun finishWithDataset(dataset: Dataset?) {341        if (dataset == null) { cancel(); return }342        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)343        setResult(RESULT_OK, reply)344        finish()345    }346347    /**348     * Cancel the picker and fire MainActivity on the new-entry path. The picker349     * returns CANCELED on purpose — the user is choosing to leave the autofill350     * prompt, not to fill it — and the launcher extra carries the host's typed351     * identity and origin so the new-entry editor opens pre-filled.352     */353    private fun launchNewEntry(354        parsed: ParsedStructure,355        typedUsername: String?,356        typedEmail: String?,357        searchText: String?,358    ) {359        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin360        val intent = Intent(this, MainActivity::class.java).apply {361            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)362            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)363            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)364            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)365            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)366        }367        startActivity(intent)368        cancel()369    }370371    private fun cancel() {372        setResult(RESULT_CANCELED)373        finish()374    }375376    @Suppress("DEPRECATION")377    private fun intentAutofillId(key: String): AutofillId? =378        if (android.os.Build.VERSION.SDK_INT >= 33) {379            intent.getParcelableExtra(key, AutofillId::class.java)380        } else {381            intent.getParcelableExtra(key)382        }383384    private companion object {385        const val MAX_ROWS = 12386    }387}388389/**390 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin391 * domains and app title are resolved in-process via [resolveAutofillOrigin] from392 * the authenticated caller package + web domain — never from a caller extra.393 */394internal fun pickerParsedFromInputs(395    origin: String?,396    isWebOrigin: Boolean,397    callerPackage: String?,398    usernameId: AutofillId? = null,399    passwordId: AutofillId? = null,400    otpId: AutofillId? = null,401    otpDigitBoxIds: List<AutofillId> = emptyList(),402    cardNumberId: AutofillId? = null,403    cardExpMonthId: AutofillId? = null,404    cardExpYearId: AutofillId? = null,405    cardExpDateId: AutofillId? = null,406    cardCvcId: AutofillId? = null,407    cardNameId: AutofillId? = null,408): ParsedStructure = ParsedStructure(409    usernameId = usernameId,410    passwordId = passwordId,411    otpId = otpId,412    cardNumberId = cardNumberId,413    cardExpMonthId = cardExpMonthId,414    cardExpYearId = cardExpYearId,415    cardExpDateId = cardExpDateId,416    cardCvcId = cardCvcId,417    cardNameId = cardNameId,418    otpDigitBoxIds = otpDigitBoxIds,419    webDomain = origin.takeIf { isWebOrigin },420    packageName = if (isWebOrigin) callerPackage else origin,421)422423internal fun autofillPickerEntries(424    entries: List<VaultEntry>,425    parsed: ParsedStructure,426    limit: Int,427): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)428429/**430 * Case-insensitive substring match across the four user-facing identity columns431 * the browser uses (title, username, email, url). Exposed at file scope so the432 * picker can call it directly and the test suite can exercise the same code.433 */434internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {435    if (query.isEmpty()) return true436    val needle = query.lowercase()437    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)438        .any { it.lowercase().contains(needle) }439}440441@Composable442private fun AccentDragHandle() {443    val accent = LocalPeachAccent.current444    Row(445        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),446        horizontalArrangement = Arrangement.Center,447    ) {448        Box(449            modifier = Modifier450                .size(width = 32.dp, height = 4.dp)451                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),452        )453    }454}455456@Composable457private fun PickerBody(458    origin: String?,459    typedIdentity: String?,460    unlocked: Boolean,461    matchedEntries: List<VaultEntry>,462    allEntries: List<VaultEntry>,463    rememberOffer: Pair<VaultEntry, List<String>>?,464    onRemember: (VaultEntry, List<String>) -> Unit,465    onFillOnce: (VaultEntry) -> Unit,466    onRememberDismiss: () -> Unit,467    identities: List<SavedIdentity>,468    onPickIdentity: (SavedIdentity) -> Unit,469    error: String?,470    busy: Boolean,471    canBiometric: Boolean,472    onBiometric: () -> Unit,473    onUnlock: (String) -> Unit,474    onPick: (VaultEntry) -> Unit,475    onCreateNew: (String?) -> Unit,476) {477    val accent = LocalPeachAccent.current478    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {479        Text(480            text = "peach",481            color = accent,482            fontFamily = PeachType.Display,483            fontStyle = FontStyle.Italic,484            fontSize = 15.sp,485        )486        Text(487            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",488            color = peachColors.T2,489            fontFamily = PeachType.Mono,490            fontSize = 9.sp,491            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),492        )493494        if (!unlocked) {495            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)496        } else if (rememberOffer != null) {497            RememberSitePrompt(498                entryTitle = rememberOffer!!.first.title,499                domains = rememberOffer!!.second,500                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },501                onFillOnce = { onFillOnce(rememberOffer!!.first) },502                onBack = onRememberDismiss,503            )504        } else {505            // The search field is a vault-wide substring search; an empty query506            // shows the origin's matched entries.507            var rawQuery by remember { mutableStateOf("") }508            var debouncedQuery by remember { mutableStateOf("") }509            LaunchedEffect(rawQuery) {510                delay(SEARCH_DEBOUNCE_MS)511                debouncedQuery = rawQuery512            }513            SearchField(514                value = rawQuery,515                onValueChange = { rawQuery = it },516                placeholder = when {517                    matchedEntries.isNotEmpty() -> "search for another entry…"518                    else -> "search vault…"519                },520            )521522            val showIdentities =523                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()524            val visibleEntries = when {525                debouncedQuery.isBlank() -> matchedEntries526                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }527            }528            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()529530            val emptyMessage = when {531                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->532                    "No saved entries match this site."533                showSearchEmpty ->534                    "No entries match \"${debouncedQuery.trim()}\"."535                else -> null536            }537            if (emptyMessage != null) {538                Text(539                    text = emptyMessage,540                    color = peachColors.T1,541                    fontFamily = PeachType.Body,542                    fontSize = 13.sp,543                    modifier = Modifier.padding(vertical = 6.dp),544                )545            }546547            if (showIdentities) {548                IdentitySection(identities = identities, onPick = onPickIdentity)549            }550551            if (visibleEntries.isNotEmpty()) {552                LazyColumn {553                    items(visibleEntries, key = { it.id }) { entry ->554                        PickerRow(entry, onClick = { onPick(entry) })555                    }556                }557            }558559            val createOrigin =560                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin561            CreateNewRow(562                origin = createOrigin,563                typedIdentity = typedIdentity,564                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },565            )566        }567    }568}569570@Composable571private fun SearchField(572    value: String,573    onValueChange: (String) -> Unit,574    placeholder: String,575) {576    val accent = LocalPeachAccent.current577    BasicTextField(578        value = value,579        onValueChange = onValueChange,580        singleLine = true,581        textStyle = TextStyle(582            color = peachColors.T0,583            fontFamily = PeachType.Mono,584            fontSize = 14.sp,585        ),586        cursorBrush = SolidColor(accent),587        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),588        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),589        modifier = Modifier590            .fillMaxWidth()591            .background(peachColors.S2, RoundedCornerShape(8.dp))592            .padding(horizontal = 12.dp, vertical = 10.dp),593        decorationBox = { inner ->594            Box(modifier = Modifier.fillMaxWidth()) {595                if (value.isEmpty()) {596                    Text(597                        text = placeholder,598                        color = peachColors.T2,599                        fontFamily = PeachType.Mono,600                        fontSize = 12.sp,601                    )602                }603                inner()604            }605        },606    )607}608609@Composable610private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {611    val accent = LocalPeachAccent.current612    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"613        else "Create new entry"614    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }615    Row(616        modifier = Modifier617            .fillMaxWidth()618            .clickable(onClick = onClick)619            .padding(vertical = 10.dp),620        verticalAlignment = Alignment.CenterVertically,621    ) {622        Box(623            modifier = Modifier624                .size(34.dp)625                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),626            contentAlignment = Alignment.Center,627        ) {628            Text(629                text = "+",630                color = accent,631                fontFamily = PeachType.Display,632                fontStyle = FontStyle.Italic,633                fontSize = 18.sp,634            )635        }636        Column(modifier = Modifier.padding(start = 12.dp)) {637            Text(638                text = title,639                color = accent,640                fontFamily = PeachType.Body,641                fontSize = 14.sp,642                maxLines = 1,643                overflow = TextOverflow.Ellipsis,644            )645            if (subtitle != null) {646                Text(647                    text = "prefill: $subtitle",648                    color = peachColors.T2,649                    fontFamily = PeachType.Mono,650                    fontSize = 10.sp,651                    maxLines = 1,652                    overflow = TextOverflow.Ellipsis,653                )654            }655        }656    }657}658659/**660 * Offered when the user manually picks an entry the origin didn't auto-match:661 * save the origin's domains onto the entry (additional URLs) so it is662 * suggested automatically next time — Android counterpart of the browser's663 * "Remember this site" toast.664 */665@Composable666private fun RememberSitePrompt(667    entryTitle: String,668    domains: List<String>,669    onRemember: () -> Unit,670    onFillOnce: () -> Unit,671    onBack: () -> Unit,672) {673    val accent = LocalPeachAccent.current674    Text(675        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",676        color = peachColors.T0,677        fontFamily = PeachType.Body,678        fontSize = 14.sp,679    )680    Text(681        text = "It will be suggested automatically the next time you fill here.",682        color = peachColors.T2,683        fontFamily = PeachType.Mono,684        fontSize = 10.sp,685        modifier = Modifier.padding(top = 4.dp),686    )687    Row(688        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),689        horizontalArrangement = Arrangement.spacedBy(18.dp),690        verticalAlignment = Alignment.CenterVertically,691    ) {692        Text(693            text = "Remember & fill",694            color = accent,695            fontFamily = PeachType.Mono,696            fontSize = 13.sp,697            modifier = Modifier698                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))699                .clickable(onClick = onRemember)700                .padding(horizontal = 12.dp, vertical = 8.dp),701        )702        Text(703            text = "Just fill",704            color = peachColors.T1,705            fontFamily = PeachType.Mono,706            fontSize = 13.sp,707            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),708        )709        Text(710            text = "Back",711            color = peachColors.T2,712            fontFamily = PeachType.Mono,713            fontSize = 13.sp,714            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),715        )716    }717}718719/**720 * A4 identity helper: signup forms with no saved matches list the vault's721 * derived identities (deduplicated usernames/emails) with type filter chips —722 * Android counterpart of the browser's inline identity selector.723 */724@Composable725private fun IdentitySection(726    identities: List<SavedIdentity>,727    onPick: (SavedIdentity) -> Unit,728) {729    val accent = LocalPeachAccent.current730    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }731    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }732733    Text(734        text = "use a saved identity",735        color = peachColors.T2,736        fontFamily = PeachType.Mono,737        fontSize = 9.sp,738        modifier = Modifier.padding(bottom = 8.dp),739    )740    Row(741        horizontalArrangement = Arrangement.spacedBy(8.dp),742        modifier = Modifier.padding(bottom = 10.dp),743    ) {744        for (filterMode in IdentityFilterMode.values()) {745            val active = filterMode == mode746            Text(747                text = filterMode.label,748                color = if (active) peachColors.S1 else peachColors.T2,749                fontFamily = PeachType.Mono,750                fontSize = 10.sp,751                modifier = Modifier752                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))753                    .clickable { mode = filterMode }754                    .padding(horizontal = 10.dp, vertical = 5.dp),755            )756        }757    }758    LazyColumn {759        items(visible, key = { it.type.name + ":" + it.value }) { identity ->760            IdentityRow(identity, onClick = { onPick(identity) })761        }762    }763}764765@Composable766private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {767    Column(768        modifier = Modifier769            .fillMaxWidth()770            .clickable(onClick = onClick)771            .padding(vertical = 8.dp),772    ) {773        Text(774            text = identity.value,775            color = peachColors.T0,776            fontFamily = PeachType.Body,777            fontSize = 13.sp,778            maxLines = 1,779            overflow = TextOverflow.Ellipsis,780        )781        val meta = listOfNotNull(782            identity.exampleDomain,783            if (identity.type == IdentityType.EMAIL) "email" else "username",784        ).joinToString(" \u00b7 ")785        Text(786            text = meta,787            color = peachColors.T2,788            fontFamily = PeachType.Mono,789            fontSize = 9.sp,790            maxLines = 1,791            overflow = TextOverflow.Ellipsis,792        )793    }794}795796@Composable797private fun UnlockBody(798    error: String?,799    busy: Boolean,800    canBiometric: Boolean,801    onBiometric: () -> Unit,802    onUnlock: (String) -> Unit,803) {804    val accent = LocalPeachAccent.current805    var password by remember { mutableStateOf("") }806807    Text(808        text = "Unlock to fill",809        color = peachColors.T0,810        fontFamily = PeachType.Body,811        fontSize = 15.sp,812        modifier = Modifier.padding(bottom = 10.dp),813    )814    BasicTextField(815        value = password,816        onValueChange = { password = it },817        singleLine = true,818        enabled = !busy,819        textStyle = TextStyle(820            color = peachColors.T0,821            fontFamily = PeachType.Mono,822            fontSize = 16.sp,823        ),824        cursorBrush = SolidColor(accent),825        visualTransformation = PasswordVisualTransformation(),826        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),827        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),828        modifier = Modifier829            .fillMaxWidth()830            .background(peachColors.S2, RoundedCornerShape(8.dp))831            .padding(horizontal = 12.dp, vertical = 12.dp),832    )833    if (error != null) {834        Text(835            text = error,836            color = peachColors.Breach,837            fontFamily = PeachType.Body,838            fontSize = 12.sp,839            modifier = Modifier.padding(top = 8.dp),840        )841    }842    Row(843        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),844        horizontalArrangement = Arrangement.spacedBy(20.dp),845        verticalAlignment = Alignment.CenterVertically,846    ) {847        if (canBiometric) {848            Text(849                text = "Use biometrics",850                color = accent,851                fontFamily = PeachType.Mono,852                fontSize = 13.sp,853                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),854            )855        }856        Text(857            text = if (busy) "Unlocking…" else "Unlock",858            color = if (password.isEmpty() || busy) peachColors.T2 else accent,859            fontFamily = PeachType.Mono,860            fontSize = 13.sp,861            modifier = Modifier862                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }863                .padding(vertical = 8.dp),864        )865    }866}867868@Composable869private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {870    Row(871        modifier = Modifier872            .fillMaxWidth()873            .clickable(onClick = onClick)874            .padding(vertical = 9.dp),875        verticalAlignment = Alignment.CenterVertically,876    ) {877        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)878        Column(modifier = Modifier.padding(start = 12.dp)) {879            Text(880                text = entry.title,881                color = peachColors.T0,882                fontFamily = PeachType.Body,883                fontSize = 15.sp,884                maxLines = 1,885                overflow = TextOverflow.Ellipsis,886            )887            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""888            if (sub.isNotBlank()) {889                Text(890                    text = sub,891                    color = peachColors.T2,892                    fontFamily = PeachType.Mono,893                    fontSize = 11.sp,894                    maxLines = 1,895                    overflow = TextOverflow.Ellipsis,896                )897            }898        }899    }900}901902/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */903private const val SEARCH_DEBOUNCE_MS = 200L