AutofillEntryPickerActivity.kt

36.3 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 com.peachpasswords.vault.faviconsAllowed71import dagger.hilt.android.AndroidEntryPoint72import kotlinx.coroutines.delay73import kotlinx.coroutines.launch74import javax.inject.Inject7576/**77 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"78 * in the system autofill drop-down. Lists matching vault entries using the same79 * row vocabulary as the vault list; picking one returns a filled80 * [android.service.autofill.Dataset] to the framework via81 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].82 *83 * Inline-mirror of the browser's selector: always-on search field, vault-wide84 * match against title/username/email/url, and a "+ Create new entry" affordance85 * in the header. The affordance cancels this activity and launches [MainActivity]86 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had87 * typed on the host page.88 *89 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity90 * and return RESULT_CANCELED, which produced a loop with no exit: the framework91 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the92 * user unlocked the app, came back to a field that still asked them to unlock,93 * and had no way to tell that anything had happened. Autofill is a modal94 * interruption of someone else's app — leaving it to go somewhere else is the95 * one thing it must not do.96 */97@AndroidEntryPoint98class AutofillEntryPickerActivity : FragmentActivity() {99100    @Inject lateinit var vault: VaultRepository101    @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons102    private var debugSecureLease = false103104    @OptIn(ExperimentalMaterial3Api::class)105    override fun onCreate(savedInstanceState: Bundle?) {106        super.onCreate(savedInstanceState)107108        // H-05: separate window from MainActivity, so it does NOT inherit the109        // release-wide FLAG_SECURE set there. Show vault metadata and accept the110        // master password — protect against screenshots / recents thumbnails.111        if (!BuildConfig.DEBUG) {112            SecureWindowPolicy.setUserSecure(window, true)113        } else if (!qaSecureCaptureBypass()) {114            // Debug builds are secure by default too. QA must opt out explicitly115            // on this activity's own launch intent; MainActivity's window policy116            // cannot protect this separate picker window.117            SecureWindowPolicy.acquire(window)118            debugSecureLease = true119        }120121        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)122        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)123        // Origin trust is never read from the launch intent: a caller-supplied124        // trust extra could be forged or stale. Re-derive it in-process from125        // the raw authenticated inputs using the same authentication the126        // service used, so a tampered extra cannot elevate origin trust.127        val rawParsed = pickerParsedFromInputs(128            origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),129            isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),130            callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),131            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),132            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),133            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),134            otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {135                intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()136            } else {137                @Suppress("DEPRECATION")138                intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()139            },140            cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),141            cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),142            cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),143            cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),144            cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),145            cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),146        )147        val parsed = resolveAutofillOrigin(this, rawParsed)148        if (!parsed.credentialReleaseAllowed) {149            cancel()150            return151        }152        val settings = vault.settings.value153        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT154155        setContent {156            CompositionLocalProvider(157                LocalFavicons provides favicons,158                LocalFaviconsEnabled provides faviconsAllowed(settings),159            ) {160            PeachTheme(accent = accent, textSize = settings.textSize) {161                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)162                val scope = rememberCoroutineScope()163                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }164                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }165                var error by remember { mutableStateOf<String?>(null) }166                var busy by remember { mutableStateOf(false) }167168                val allEntries = remember(unlocked) {169                    if (unlocked) vault.getEntries() else emptyList()170                }171                val matchedEntries = remember(unlocked, allEntries) {172                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()173                }174175                // A4: signup forms with no saved matches offer derived identities176                // (deduplicated usernames/emails across the vault).177                val identities = remember(unlocked, allEntries, matchedEntries) {178                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()179                }180181                // Manual pick of an entry the origin didn't auto-match: offer to182                // remember the origin on the entry before filling.183                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }184                val pickNow: (VaultEntry) -> Unit = { entry ->185                    val epoch = unlockedEpoch186                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()187                    else returnDataset(parsed, entry)188                }189190                // Offer biometrics immediately — it is the whole point of191                // unlocking here rather than in the app, and a prompt the user192                // can dismiss straight onto the password field costs nothing.193                LaunchedEffect(unlocked) {194                    if (!unlocked && canBiometric()) {195                        tryBiometric(196                            onUnlocked = {197                                unlocked = true198                                unlockedEpoch = vault.unlockedSessionEpoch()199                            },200                            onFail = { },201                        )202                    }203                }204205                ModalBottomSheet(206                    onDismissRequest = { cancel() },207                    sheetState = sheetState,208                    containerColor = peachColors.S1,209                    dragHandle = { AccentDragHandle() },210                ) {211                    PickerBody(212                        origin = parsed.origin,213                        typedIdentity = typedUsername ?: typedEmail,214                        unlocked = unlocked,215                        matchedEntries = matchedEntries,216                        allEntries = allEntries,217                        rememberOffer = rememberOffer,218                        onRemember = { entry, domains ->219                            rememberOffer = null220                            scope.launch {221                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry222                                runCatching {223                                    vault.updateEntry(224                                        current.copy(225                                            additionalUrls = (current.additionalUrls.orEmpty() +226                                                domains.map { "https://$it" }).distinct(),227                                        ),228                                    )229                                }230                                pickNow(entry)231                            }232                        },233                        onFillOnce = { entry ->234                            rememberOffer = null235                            pickNow(entry)236                        },237                        onRememberDismiss = { rememberOffer = null },238                        identities = identities,239                        onPickIdentity = { identity ->240                            val epoch = unlockedEpoch241                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()242                            else finishWithDataset(243                                AutofillResponseBuilder.identityDatasetFor(244                                    this@AutofillEntryPickerActivity, parsed, identity,245                                ),246                            )247                        },248                        error = error,249                        busy = busy,250                        canBiometric = canBiometric(),251                        onBiometric = {252                            tryBiometric(253                                onUnlocked = {254                                    unlocked = true255                                    unlockedEpoch = vault.unlockedSessionEpoch()256                                },257                                onFail = {},258                            )259                        },260                        onUnlock = { password ->261                            busy = true262                            error = null263                            scope.launch {264                                when (vault.unlock(password)) {265                                    is UnlockResult.Success -> {266                                        unlocked = true267                                        unlockedEpoch = vault.unlockedSessionEpoch()268                                    }269                                    UnlockResult.WrongPassword -> error = "Wrong master password."270                                    else -> error = "Couldn't unlock."271                                }272                                busy = false273                            }274                        },275                        onPick = { entry ->276                            val offer = rememberOfferDomains(entry, parsed)277                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer278                        },279                        onCreateNew = { searchText ->280                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)281                        },282                    )283                }284            }285            }286        }287    }288289    override fun onDestroy() {290        if (debugSecureLease) {291            SecureWindowPolicy.release(window)292            debugSecureLease = false293        }294        super.onDestroy()295    }296297    private fun qaSecureCaptureBypass(): Boolean =298        BuildConfig.DEBUG && (299            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||300                QaFlags.disableSecureCaptureForQa301            )302303    private fun canBiometric(): Boolean =304        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)305306    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {307        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()308        Biometrics.authenticateCrypto(309            activity = this,310            title = "Unlock Peach",311            subtitle = "Fill your saved details",312            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),313            onSuccess = { result ->314                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()315                // M-05: unlockWithKey copies `key` into the session's derivedKey,316                // so the caller-owned reference is now redundant. Zero it whether317                // the unlock succeeds or fails so the unwrapped biometric key318                // doesn't outlive the auth prompt.319                lifecycleScope.launch {320                    // Unseal inside the launched ownership scope. If the321                    // lifecycle scope is cancelled before execution, no key is322                    // allocated before a finally can protect it.323                    var key: ByteArray? = null324                    try {325                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)326                        if (key == null) return@launch onFail()327                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()328                    } finally {329                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)330                    }331                }332            },333            onFail = onFail,334        )335    }336337    private fun returnDataset(338        parsed: ParsedStructure,339        entry: VaultEntry,340    ) {341        if (!parsed.credentialReleaseAllowed) {342            cancel()343            return344        }345        val privatePreview = vault.settings.value.hideAutofillPreviews346        val dataset = if (entry.isCard()) {347            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)348        } else {349            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)350        }351        finishWithDataset(dataset)352    }353354    private fun finishWithDataset(dataset: Dataset?) {355        if (dataset == null) { cancel(); return }356        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)357        setResult(RESULT_OK, reply)358        finish()359    }360361    /**362     * Cancel the picker and fire MainActivity on the new-entry path. The picker363     * returns CANCELED on purpose — the user is choosing to leave the autofill364     * prompt, not to fill it — and the launcher extra carries the host's typed365     * identity and origin so the new-entry editor opens pre-filled.366     */367    private fun launchNewEntry(368        parsed: ParsedStructure,369        typedUsername: String?,370        typedEmail: String?,371        searchText: String?,372    ) {373        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin374        val intent = Intent(this, MainActivity::class.java).apply {375            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)376            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())377            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)378            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)379            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)380            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)381        }382        startActivity(intent)383        cancel()384    }385386    private fun cancel() {387        setResult(RESULT_CANCELED)388        finish()389    }390391    @Suppress("DEPRECATION")392    private fun intentAutofillId(key: String): AutofillId? =393        if (android.os.Build.VERSION.SDK_INT >= 33) {394            intent.getParcelableExtra(key, AutofillId::class.java)395        } else {396            intent.getParcelableExtra(key)397        }398399    private companion object {400        const val MAX_ROWS = 12401    }402}403404/**405 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin406 * domains and app title are resolved in-process via [resolveAutofillOrigin] from407 * the authenticated caller package + web domain — never from a caller extra.408 */409internal fun pickerParsedFromInputs(410    origin: String?,411    isWebOrigin: Boolean,412    callerPackage: String?,413    usernameId: AutofillId? = null,414    passwordId: AutofillId? = null,415    otpId: AutofillId? = null,416    otpDigitBoxIds: List<AutofillId> = emptyList(),417    cardNumberId: AutofillId? = null,418    cardExpMonthId: AutofillId? = null,419    cardExpYearId: AutofillId? = null,420    cardExpDateId: AutofillId? = null,421    cardCvcId: AutofillId? = null,422    cardNameId: AutofillId? = null,423): ParsedStructure = ParsedStructure(424    usernameId = usernameId,425    passwordId = passwordId,426    otpId = otpId,427    cardNumberId = cardNumberId,428    cardExpMonthId = cardExpMonthId,429    cardExpYearId = cardExpYearId,430    cardExpDateId = cardExpDateId,431    cardCvcId = cardCvcId,432    cardNameId = cardNameId,433    otpDigitBoxIds = otpDigitBoxIds,434    webDomain = origin.takeIf { isWebOrigin },435    packageName = if (isWebOrigin) callerPackage else origin,436)437438internal fun autofillPickerEntries(439    entries: List<VaultEntry>,440    parsed: ParsedStructure,441    limit: Int,442): List<VaultEntry> = if (parsed.credentialReleaseAllowed) {443    AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)444} else {445    emptyList()446}447448/**449 * Case-insensitive substring match across the four user-facing identity columns450 * the browser uses (title, username, email, url). Exposed at file scope so the451 * picker can call it directly and the test suite can exercise the same code.452 */453internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {454    if (query.isEmpty()) return true455    val needle = query.lowercase()456    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)457        .any { it.lowercase().contains(needle) }458}459460@Composable461private fun AccentDragHandle() {462    val accent = LocalPeachAccent.current463    Row(464        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),465        horizontalArrangement = Arrangement.Center,466    ) {467        Box(468            modifier = Modifier469                .size(width = 32.dp, height = 4.dp)470                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),471        )472    }473}474475@Composable476private fun PickerBody(477    origin: String?,478    typedIdentity: String?,479    unlocked: Boolean,480    matchedEntries: List<VaultEntry>,481    allEntries: List<VaultEntry>,482    rememberOffer: Pair<VaultEntry, List<String>>?,483    onRemember: (VaultEntry, List<String>) -> Unit,484    onFillOnce: (VaultEntry) -> Unit,485    onRememberDismiss: () -> Unit,486    identities: List<SavedIdentity>,487    onPickIdentity: (SavedIdentity) -> Unit,488    error: String?,489    busy: Boolean,490    canBiometric: Boolean,491    onBiometric: () -> Unit,492    onUnlock: (String) -> Unit,493    onPick: (VaultEntry) -> Unit,494    onCreateNew: (String?) -> Unit,495) {496    val accent = LocalPeachAccent.current497    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {498        Text(499            text = "peach",500            color = accent,501            fontFamily = PeachType.Display,502            fontStyle = FontStyle.Italic,503            fontSize = 15.sp,504        )505        Text(506            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",507            color = peachColors.T2,508            fontFamily = PeachType.Mono,509            fontSize = 9.sp,510            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),511        )512513        if (!unlocked) {514            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)515        } else if (rememberOffer != null) {516            RememberSitePrompt(517                entryTitle = rememberOffer!!.first.title,518                domains = rememberOffer!!.second,519                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },520                onFillOnce = { onFillOnce(rememberOffer!!.first) },521                onBack = onRememberDismiss,522            )523        } else {524            // The search field is a vault-wide substring search; an empty query525            // shows the origin's matched entries.526            var rawQuery by remember { mutableStateOf("") }527            var debouncedQuery by remember { mutableStateOf("") }528            LaunchedEffect(rawQuery) {529                delay(SEARCH_DEBOUNCE_MS)530                debouncedQuery = rawQuery531            }532            SearchField(533                value = rawQuery,534                onValueChange = { rawQuery = it },535                placeholder = when {536                    matchedEntries.isNotEmpty() -> "search for another entry…"537                    else -> "search vault…"538                },539            )540541            val showIdentities =542                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()543            val visibleEntries = when {544                debouncedQuery.isBlank() -> matchedEntries545                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }546            }547            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()548549            val emptyMessage = when {550                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->551                    "No saved entries match this site."552                showSearchEmpty ->553                    "No entries match \"${debouncedQuery.trim()}\"."554                else -> null555            }556            if (emptyMessage != null) {557                Text(558                    text = emptyMessage,559                    color = peachColors.T1,560                    fontFamily = PeachType.Body,561                    fontSize = 13.sp,562                    modifier = Modifier.padding(vertical = 6.dp),563                )564            }565566            if (showIdentities) {567                IdentitySection(identities = identities, onPick = onPickIdentity)568            }569570            if (visibleEntries.isNotEmpty()) {571                LazyColumn {572                    items(visibleEntries, key = { it.id }) { entry ->573                        PickerRow(entry, onClick = { onPick(entry) })574                    }575                }576            }577578            val createOrigin =579                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin580            CreateNewRow(581                origin = createOrigin,582                typedIdentity = typedIdentity,583                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },584            )585        }586    }587}588589@Composable590private fun SearchField(591    value: String,592    onValueChange: (String) -> Unit,593    placeholder: String,594) {595    val accent = LocalPeachAccent.current596    BasicTextField(597        value = value,598        onValueChange = onValueChange,599        singleLine = true,600        textStyle = TextStyle(601            color = peachColors.T0,602            fontFamily = PeachType.Mono,603            fontSize = 14.sp,604        ),605        cursorBrush = SolidColor(accent),606        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),607        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),608        modifier = Modifier609            .fillMaxWidth()610            .background(peachColors.S2, RoundedCornerShape(8.dp))611            .padding(horizontal = 12.dp, vertical = 10.dp),612        decorationBox = { inner ->613            Box(modifier = Modifier.fillMaxWidth()) {614                if (value.isEmpty()) {615                    Text(616                        text = placeholder,617                        color = peachColors.T2,618                        fontFamily = PeachType.Mono,619                        fontSize = 12.sp,620                    )621                }622                inner()623            }624        },625    )626}627628@Composable629private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {630    val accent = LocalPeachAccent.current631    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"632        else "Create new entry"633    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }634    Row(635        modifier = Modifier636            .fillMaxWidth()637            .clickable(onClick = onClick)638            .padding(vertical = 10.dp),639        verticalAlignment = Alignment.CenterVertically,640    ) {641        Box(642            modifier = Modifier643                .size(34.dp)644                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),645            contentAlignment = Alignment.Center,646        ) {647            Text(648                text = "+",649                color = accent,650                fontFamily = PeachType.Display,651                fontStyle = FontStyle.Italic,652                fontSize = 18.sp,653            )654        }655        Column(modifier = Modifier.padding(start = 12.dp)) {656            Text(657                text = title,658                color = accent,659                fontFamily = PeachType.Body,660                fontSize = 14.sp,661                maxLines = 1,662                overflow = TextOverflow.Ellipsis,663            )664            if (subtitle != null) {665                Text(666                    text = "prefill: $subtitle",667                    color = peachColors.T2,668                    fontFamily = PeachType.Mono,669                    fontSize = 10.sp,670                    maxLines = 1,671                    overflow = TextOverflow.Ellipsis,672                )673            }674        }675    }676}677678/**679 * Offered when the user manually picks an entry the origin didn't auto-match:680 * save the origin's domains onto the entry (additional URLs) so it is681 * suggested automatically next time — Android counterpart of the browser's682 * "Remember this site" toast.683 */684@Composable685private fun RememberSitePrompt(686    entryTitle: String,687    domains: List<String>,688    onRemember: () -> Unit,689    onFillOnce: () -> Unit,690    onBack: () -> Unit,691) {692    val accent = LocalPeachAccent.current693    Text(694        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",695        color = peachColors.T0,696        fontFamily = PeachType.Body,697        fontSize = 14.sp,698    )699    Text(700        text = "It will be suggested automatically the next time you fill here.",701        color = peachColors.T2,702        fontFamily = PeachType.Mono,703        fontSize = 10.sp,704        modifier = Modifier.padding(top = 4.dp),705    )706    Row(707        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),708        horizontalArrangement = Arrangement.spacedBy(18.dp),709        verticalAlignment = Alignment.CenterVertically,710    ) {711        Text(712            text = "Remember & fill",713            color = accent,714            fontFamily = PeachType.Mono,715            fontSize = 13.sp,716            modifier = Modifier717                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))718                .clickable(onClick = onRemember)719                .padding(horizontal = 12.dp, vertical = 8.dp),720        )721        Text(722            text = "Just fill",723            color = peachColors.T1,724            fontFamily = PeachType.Mono,725            fontSize = 13.sp,726            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),727        )728        Text(729            text = "Back",730            color = peachColors.T2,731            fontFamily = PeachType.Mono,732            fontSize = 13.sp,733            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),734        )735    }736}737738/**739 * A4 identity helper: signup forms with no saved matches list the vault's740 * derived identities (deduplicated usernames/emails) with type filter chips —741 * Android counterpart of the browser's inline identity selector.742 */743@Composable744private fun IdentitySection(745    identities: List<SavedIdentity>,746    onPick: (SavedIdentity) -> Unit,747) {748    val accent = LocalPeachAccent.current749    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }750    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }751752    Text(753        text = "use a saved identity",754        color = peachColors.T2,755        fontFamily = PeachType.Mono,756        fontSize = 9.sp,757        modifier = Modifier.padding(bottom = 8.dp),758    )759    Row(760        horizontalArrangement = Arrangement.spacedBy(8.dp),761        modifier = Modifier.padding(bottom = 10.dp),762    ) {763        for (filterMode in IdentityFilterMode.values()) {764            val active = filterMode == mode765            Text(766                text = filterMode.label,767                color = if (active) peachColors.S1 else peachColors.T2,768                fontFamily = PeachType.Mono,769                fontSize = 10.sp,770                modifier = Modifier771                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))772                    .clickable { mode = filterMode }773                    .padding(horizontal = 10.dp, vertical = 5.dp),774            )775        }776    }777    LazyColumn {778        items(visible, key = { it.type.name + ":" + it.value }) { identity ->779            IdentityRow(identity, onClick = { onPick(identity) })780        }781    }782}783784@Composable785private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {786    Column(787        modifier = Modifier788            .fillMaxWidth()789            .clickable(onClick = onClick)790            .padding(vertical = 8.dp),791    ) {792        Text(793            text = identity.value,794            color = peachColors.T0,795            fontFamily = PeachType.Body,796            fontSize = 13.sp,797            maxLines = 1,798            overflow = TextOverflow.Ellipsis,799        )800        val meta = listOfNotNull(801            identity.exampleDomain,802            if (identity.type == IdentityType.EMAIL) "email" else "username",803        ).joinToString(" \u00b7 ")804        Text(805            text = meta,806            color = peachColors.T2,807            fontFamily = PeachType.Mono,808            fontSize = 9.sp,809            maxLines = 1,810            overflow = TextOverflow.Ellipsis,811        )812    }813}814815@Composable816private fun UnlockBody(817    error: String?,818    busy: Boolean,819    canBiometric: Boolean,820    onBiometric: () -> Unit,821    onUnlock: (String) -> Unit,822) {823    val accent = LocalPeachAccent.current824    var password by remember { mutableStateOf("") }825826    Text(827        text = "Unlock to fill",828        color = peachColors.T0,829        fontFamily = PeachType.Body,830        fontSize = 15.sp,831        modifier = Modifier.padding(bottom = 10.dp),832    )833    BasicTextField(834        value = password,835        onValueChange = { password = it },836        singleLine = true,837        enabled = !busy,838        textStyle = TextStyle(839            color = peachColors.T0,840            fontFamily = PeachType.Mono,841            fontSize = 16.sp,842        ),843        cursorBrush = SolidColor(accent),844        visualTransformation = PasswordVisualTransformation(),845        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),846        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),847        modifier = Modifier848            .fillMaxWidth()849            .background(peachColors.S2, RoundedCornerShape(8.dp))850            .padding(horizontal = 12.dp, vertical = 12.dp),851    )852    if (error != null) {853        Text(854            text = error,855            color = peachColors.Breach,856            fontFamily = PeachType.Body,857            fontSize = 12.sp,858            modifier = Modifier.padding(top = 8.dp),859        )860    }861    Row(862        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),863        horizontalArrangement = Arrangement.spacedBy(20.dp),864        verticalAlignment = Alignment.CenterVertically,865    ) {866        if (canBiometric) {867            Text(868                text = "Use biometrics",869                color = accent,870                fontFamily = PeachType.Mono,871                fontSize = 13.sp,872                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),873            )874        }875        Text(876            text = if (busy) "Unlocking…" else "Unlock",877            color = if (password.isEmpty() || busy) peachColors.T2 else accent,878            fontFamily = PeachType.Mono,879            fontSize = 13.sp,880            modifier = Modifier881                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }882                .padding(vertical = 8.dp),883        )884    }885}886887@Composable888private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {889    Row(890        modifier = Modifier891            .fillMaxWidth()892            .clickable(onClick = onClick)893            .padding(vertical = 9.dp),894        verticalAlignment = Alignment.CenterVertically,895    ) {896        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)897        Column(modifier = Modifier.padding(start = 12.dp)) {898            Text(899                text = entry.title,900                color = peachColors.T0,901                fontFamily = PeachType.Body,902                fontSize = 15.sp,903                maxLines = 1,904                overflow = TextOverflow.Ellipsis,905            )906            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""907            if (sub.isNotBlank()) {908                Text(909                    text = sub,910                    color = peachColors.T2,911                    fontFamily = PeachType.Mono,912                    fontSize = 11.sp,913                    maxLines = 1,914                    overflow = TextOverflow.Ellipsis,915                )916            }917        }918    }919}920921/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */922private const val SEARCH_DEBOUNCE_MS = 200L