AutofillEntryPickerActivity.kt

35.9 KB · raw

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