AutofillEntryPickerActivity.kt

36.2 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        if (!parsed.credentialReleaseAllowed) {148            cancel()149            return150        }151        val settings = vault.settings.value152        val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT153154        setContent {155            CompositionLocalProvider(156                LocalFavicons provides favicons,157                LocalFaviconsEnabled provides settings.faviconsEnabled,158            ) {159            PeachTheme(accent = accent, textSize = settings.textSize) {160                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)161                val scope = rememberCoroutineScope()162                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }163                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }164                var error by remember { mutableStateOf<String?>(null) }165                var busy by remember { mutableStateOf(false) }166167                val allEntries = remember(unlocked) {168                    if (unlocked) vault.getEntries() else emptyList()169                }170                val matchedEntries = remember(unlocked, allEntries) {171                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()172                }173174                // A4: signup forms with no saved matches offer derived identities175                // (deduplicated usernames/emails across the vault).176                val identities = remember(unlocked, allEntries, matchedEntries) {177                    if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()178                }179180                // Manual pick of an entry the origin didn't auto-match: offer to181                // remember the origin on the entry before filling.182                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }183                val pickNow: (VaultEntry) -> Unit = { entry ->184                    val epoch = unlockedEpoch185                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()186                    else returnDataset(parsed, entry)187                }188189                // Offer biometrics immediately — it is the whole point of190                // unlocking here rather than in the app, and a prompt the user191                // can dismiss straight onto the password field costs nothing.192                LaunchedEffect(unlocked) {193                    if (!unlocked && canBiometric()) {194                        tryBiometric(195                            onUnlocked = {196                                unlocked = true197                                unlockedEpoch = vault.unlockedSessionEpoch()198                            },199                            onFail = { },200                        )201                    }202                }203204                ModalBottomSheet(205                    onDismissRequest = { cancel() },206                    sheetState = sheetState,207                    containerColor = peachColors.S1,208                    dragHandle = { AccentDragHandle() },209                ) {210                    PickerBody(211                        origin = parsed.origin,212                        typedIdentity = typedUsername ?: typedEmail,213                        unlocked = unlocked,214                        matchedEntries = matchedEntries,215                        allEntries = allEntries,216                        rememberOffer = rememberOffer,217                        onRemember = { entry, domains ->218                            rememberOffer = null219                            scope.launch {220                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry221                                runCatching {222                                    vault.updateEntry(223                                        current.copy(224                                            additionalUrls = (current.additionalUrls.orEmpty() +225                                                domains.map { "https://$it" }).distinct(),226                                        ),227                                    )228                                }229                                pickNow(entry)230                            }231                        },232                        onFillOnce = { entry ->233                            rememberOffer = null234                            pickNow(entry)235                        },236                        onRememberDismiss = { rememberOffer = null },237                        identities = identities,238                        onPickIdentity = { identity ->239                            val epoch = unlockedEpoch240                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()241                            else finishWithDataset(242                                AutofillResponseBuilder.identityDatasetFor(243                                    this@AutofillEntryPickerActivity, parsed, identity,244                                ),245                            )246                        },247                        error = error,248                        busy = busy,249                        canBiometric = canBiometric(),250                        onBiometric = {251                            tryBiometric(252                                onUnlocked = {253                                    unlocked = true254                                    unlockedEpoch = vault.unlockedSessionEpoch()255                                },256                                onFail = {},257                            )258                        },259                        onUnlock = { password ->260                            busy = true261                            error = null262                            scope.launch {263                                when (vault.unlock(password)) {264                                    is UnlockResult.Success -> {265                                        unlocked = true266                                        unlockedEpoch = vault.unlockedSessionEpoch()267                                    }268                                    UnlockResult.WrongPassword -> error = "Wrong master password."269                                    else -> error = "Couldn't unlock."270                                }271                                busy = false272                            }273                        },274                        onPick = { entry ->275                            val offer = rememberOfferDomains(entry, parsed)276                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer277                        },278                        onCreateNew = { searchText ->279                            launchNewEntry(parsed, typedUsername, typedEmail, searchText)280                        },281                    )282                }283            }284            }285        }286    }287288    override fun onDestroy() {289        if (debugSecureLease) {290            SecureWindowPolicy.release(window)291            debugSecureLease = false292        }293        super.onDestroy()294    }295296    private fun qaSecureCaptureBypass(): Boolean =297        BuildConfig.DEBUG && (298            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||299                QaFlags.disableSecureCaptureForQa300            )301302    private fun canBiometric(): Boolean =303        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)304305    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {306        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()307        Biometrics.authenticateCrypto(308            activity = this,309            title = "Unlock Peach",310            subtitle = "Fill your saved details",311            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),312            onSuccess = { result ->313                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()314                // M-05: unlockWithKey copies `key` into the session's derivedKey,315                // so the caller-owned reference is now redundant. Zero it whether316                // the unlock succeeds or fails so the unwrapped biometric key317                // doesn't outlive the auth prompt.318                lifecycleScope.launch {319                    // Unseal inside the launched ownership scope. If the320                    // lifecycle scope is cancelled before execution, no key is321                    // allocated before a finally can protect it.322                    var key: ByteArray? = null323                    try {324                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)325                        if (key == null) return@launch onFail()326                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()327                    } finally {328                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)329                    }330                }331            },332            onFail = onFail,333        )334    }335336    private fun returnDataset(337        parsed: ParsedStructure,338        entry: VaultEntry,339    ) {340        if (!parsed.credentialReleaseAllowed) {341            cancel()342            return343        }344        val privatePreview = vault.settings.value.hideAutofillPreviews345        val dataset = if (entry.isCard()) {346            AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)347        } else {348            AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)349        }350        finishWithDataset(dataset)351    }352353    private fun finishWithDataset(dataset: Dataset?) {354        if (dataset == null) { cancel(); return }355        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)356        setResult(RESULT_OK, reply)357        finish()358    }359360    /**361     * Cancel the picker and fire MainActivity on the new-entry path. The picker362     * returns CANCELED on purpose — the user is choosing to leave the autofill363     * prompt, not to fill it — and the launcher extra carries the host's typed364     * identity and origin so the new-entry editor opens pre-filled.365     */366    private fun launchNewEntry(367        parsed: ParsedStructure,368        typedUsername: String?,369        typedEmail: String?,370        searchText: String?,371    ) {372        val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin373        val intent = Intent(this, MainActivity::class.java).apply {374            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)375            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())376            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)377            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)378            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)379            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)380        }381        startActivity(intent)382        cancel()383    }384385    private fun cancel() {386        setResult(RESULT_CANCELED)387        finish()388    }389390    @Suppress("DEPRECATION")391    private fun intentAutofillId(key: String): AutofillId? =392        if (android.os.Build.VERSION.SDK_INT >= 33) {393            intent.getParcelableExtra(key, AutofillId::class.java)394        } else {395            intent.getParcelableExtra(key)396        }397398    private companion object {399        const val MAX_ROWS = 12400    }401}402403/**404 * Build the initial [ParsedStructure] from raw picker intent inputs. Origin405 * domains and app title are resolved in-process via [resolveAutofillOrigin] from406 * the authenticated caller package + web domain — never from a caller extra.407 */408internal fun pickerParsedFromInputs(409    origin: String?,410    isWebOrigin: Boolean,411    callerPackage: String?,412    usernameId: AutofillId? = null,413    passwordId: AutofillId? = null,414    otpId: AutofillId? = null,415    otpDigitBoxIds: List<AutofillId> = emptyList(),416    cardNumberId: AutofillId? = null,417    cardExpMonthId: AutofillId? = null,418    cardExpYearId: AutofillId? = null,419    cardExpDateId: AutofillId? = null,420    cardCvcId: AutofillId? = null,421    cardNameId: AutofillId? = null,422): ParsedStructure = ParsedStructure(423    usernameId = usernameId,424    passwordId = passwordId,425    otpId = otpId,426    cardNumberId = cardNumberId,427    cardExpMonthId = cardExpMonthId,428    cardExpYearId = cardExpYearId,429    cardExpDateId = cardExpDateId,430    cardCvcId = cardCvcId,431    cardNameId = cardNameId,432    otpDigitBoxIds = otpDigitBoxIds,433    webDomain = origin.takeIf { isWebOrigin },434    packageName = if (isWebOrigin) callerPackage else origin,435)436437internal fun autofillPickerEntries(438    entries: List<VaultEntry>,439    parsed: ParsedStructure,440    limit: Int,441): List<VaultEntry> = if (parsed.credentialReleaseAllowed) {442    AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)443} else {444    emptyList()445}446447/**448 * Case-insensitive substring match across the four user-facing identity columns449 * the browser uses (title, username, email, url). Exposed at file scope so the450 * picker can call it directly and the test suite can exercise the same code.451 */452internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {453    if (query.isEmpty()) return true454    val needle = query.lowercase()455    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)456        .any { it.lowercase().contains(needle) }457}458459@Composable460private fun AccentDragHandle() {461    val accent = LocalPeachAccent.current462    Row(463        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),464        horizontalArrangement = Arrangement.Center,465    ) {466        Box(467            modifier = Modifier468                .size(width = 32.dp, height = 4.dp)469                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),470        )471    }472}473474@Composable475private fun PickerBody(476    origin: String?,477    typedIdentity: String?,478    unlocked: Boolean,479    matchedEntries: List<VaultEntry>,480    allEntries: List<VaultEntry>,481    rememberOffer: Pair<VaultEntry, List<String>>?,482    onRemember: (VaultEntry, List<String>) -> Unit,483    onFillOnce: (VaultEntry) -> Unit,484    onRememberDismiss: () -> Unit,485    identities: List<SavedIdentity>,486    onPickIdentity: (SavedIdentity) -> Unit,487    error: String?,488    busy: Boolean,489    canBiometric: Boolean,490    onBiometric: () -> Unit,491    onUnlock: (String) -> Unit,492    onPick: (VaultEntry) -> Unit,493    onCreateNew: (String?) -> Unit,494) {495    val accent = LocalPeachAccent.current496    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {497        Text(498            text = "peach",499            color = accent,500            fontFamily = PeachType.Display,501            fontStyle = FontStyle.Italic,502            fontSize = 15.sp,503        )504        Text(505            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",506            color = peachColors.T2,507            fontFamily = PeachType.Mono,508            fontSize = 9.sp,509            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),510        )511512        if (!unlocked) {513            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)514        } else if (rememberOffer != null) {515            RememberSitePrompt(516                entryTitle = rememberOffer!!.first.title,517                domains = rememberOffer!!.second,518                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },519                onFillOnce = { onFillOnce(rememberOffer!!.first) },520                onBack = onRememberDismiss,521            )522        } else {523            // The search field is a vault-wide substring search; an empty query524            // shows the origin's matched entries.525            var rawQuery by remember { mutableStateOf("") }526            var debouncedQuery by remember { mutableStateOf("") }527            LaunchedEffect(rawQuery) {528                delay(SEARCH_DEBOUNCE_MS)529                debouncedQuery = rawQuery530            }531            SearchField(532                value = rawQuery,533                onValueChange = { rawQuery = it },534                placeholder = when {535                    matchedEntries.isNotEmpty() -> "search for another entry…"536                    else -> "search vault…"537                },538            )539540            val showIdentities =541                matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()542            val visibleEntries = when {543                debouncedQuery.isBlank() -> matchedEntries544                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }545            }546            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()547548            val emptyMessage = when {549                matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->550                    "No saved entries match this site."551                showSearchEmpty ->552                    "No entries match \"${debouncedQuery.trim()}\"."553                else -> null554            }555            if (emptyMessage != null) {556                Text(557                    text = emptyMessage,558                    color = peachColors.T1,559                    fontFamily = PeachType.Body,560                    fontSize = 13.sp,561                    modifier = Modifier.padding(vertical = 6.dp),562                )563            }564565            if (showIdentities) {566                IdentitySection(identities = identities, onPick = onPickIdentity)567            }568569            if (visibleEntries.isNotEmpty()) {570                LazyColumn {571                    items(visibleEntries, key = { it.id }) { entry ->572                        PickerRow(entry, onClick = { onPick(entry) })573                    }574                }575            }576577            val createOrigin =578                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin579            CreateNewRow(580                origin = createOrigin,581                typedIdentity = typedIdentity,582                onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },583            )584        }585    }586}587588@Composable589private fun SearchField(590    value: String,591    onValueChange: (String) -> Unit,592    placeholder: String,593) {594    val accent = LocalPeachAccent.current595    BasicTextField(596        value = value,597        onValueChange = onValueChange,598        singleLine = true,599        textStyle = TextStyle(600            color = peachColors.T0,601            fontFamily = PeachType.Mono,602            fontSize = 14.sp,603        ),604        cursorBrush = SolidColor(accent),605        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),606        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),607        modifier = Modifier608            .fillMaxWidth()609            .background(peachColors.S2, RoundedCornerShape(8.dp))610            .padding(horizontal = 12.dp, vertical = 10.dp),611        decorationBox = { inner ->612            Box(modifier = Modifier.fillMaxWidth()) {613                if (value.isEmpty()) {614                    Text(615                        text = placeholder,616                        color = peachColors.T2,617                        fontFamily = PeachType.Mono,618                        fontSize = 12.sp,619                    )620                }621                inner()622            }623        },624    )625}626627@Composable628private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {629    val accent = LocalPeachAccent.current630    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"631        else "Create new entry"632    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }633    Row(634        modifier = Modifier635            .fillMaxWidth()636            .clickable(onClick = onClick)637            .padding(vertical = 10.dp),638        verticalAlignment = Alignment.CenterVertically,639    ) {640        Box(641            modifier = Modifier642                .size(34.dp)643                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),644            contentAlignment = Alignment.Center,645        ) {646            Text(647                text = "+",648                color = accent,649                fontFamily = PeachType.Display,650                fontStyle = FontStyle.Italic,651                fontSize = 18.sp,652            )653        }654        Column(modifier = Modifier.padding(start = 12.dp)) {655            Text(656                text = title,657                color = accent,658                fontFamily = PeachType.Body,659                fontSize = 14.sp,660                maxLines = 1,661                overflow = TextOverflow.Ellipsis,662            )663            if (subtitle != null) {664                Text(665                    text = "prefill: $subtitle",666                    color = peachColors.T2,667                    fontFamily = PeachType.Mono,668                    fontSize = 10.sp,669                    maxLines = 1,670                    overflow = TextOverflow.Ellipsis,671                )672            }673        }674    }675}676677/**678 * Offered when the user manually picks an entry the origin didn't auto-match:679 * save the origin's domains onto the entry (additional URLs) so it is680 * suggested automatically next time — Android counterpart of the browser's681 * "Remember this site" toast.682 */683@Composable684private fun RememberSitePrompt(685    entryTitle: String,686    domains: List<String>,687    onRemember: () -> Unit,688    onFillOnce: () -> Unit,689    onBack: () -> Unit,690) {691    val accent = LocalPeachAccent.current692    Text(693        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",694        color = peachColors.T0,695        fontFamily = PeachType.Body,696        fontSize = 14.sp,697    )698    Text(699        text = "It will be suggested automatically the next time you fill here.",700        color = peachColors.T2,701        fontFamily = PeachType.Mono,702        fontSize = 10.sp,703        modifier = Modifier.padding(top = 4.dp),704    )705    Row(706        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),707        horizontalArrangement = Arrangement.spacedBy(18.dp),708        verticalAlignment = Alignment.CenterVertically,709    ) {710        Text(711            text = "Remember & fill",712            color = accent,713            fontFamily = PeachType.Mono,714            fontSize = 13.sp,715            modifier = Modifier716                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))717                .clickable(onClick = onRemember)718                .padding(horizontal = 12.dp, vertical = 8.dp),719        )720        Text(721            text = "Just fill",722            color = peachColors.T1,723            fontFamily = PeachType.Mono,724            fontSize = 13.sp,725            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),726        )727        Text(728            text = "Back",729            color = peachColors.T2,730            fontFamily = PeachType.Mono,731            fontSize = 13.sp,732            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),733        )734    }735}736737/**738 * A4 identity helper: signup forms with no saved matches list the vault's739 * derived identities (deduplicated usernames/emails) with type filter chips —740 * Android counterpart of the browser's inline identity selector.741 */742@Composable743private fun IdentitySection(744    identities: List<SavedIdentity>,745    onPick: (SavedIdentity) -> Unit,746) {747    val accent = LocalPeachAccent.current748    var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }749    val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }750751    Text(752        text = "use a saved identity",753        color = peachColors.T2,754        fontFamily = PeachType.Mono,755        fontSize = 9.sp,756        modifier = Modifier.padding(bottom = 8.dp),757    )758    Row(759        horizontalArrangement = Arrangement.spacedBy(8.dp),760        modifier = Modifier.padding(bottom = 10.dp),761    ) {762        for (filterMode in IdentityFilterMode.values()) {763            val active = filterMode == mode764            Text(765                text = filterMode.label,766                color = if (active) peachColors.S1 else peachColors.T2,767                fontFamily = PeachType.Mono,768                fontSize = 10.sp,769                modifier = Modifier770                    .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))771                    .clickable { mode = filterMode }772                    .padding(horizontal = 10.dp, vertical = 5.dp),773            )774        }775    }776    LazyColumn {777        items(visible, key = { it.type.name + ":" + it.value }) { identity ->778            IdentityRow(identity, onClick = { onPick(identity) })779        }780    }781}782783@Composable784private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {785    Column(786        modifier = Modifier787            .fillMaxWidth()788            .clickable(onClick = onClick)789            .padding(vertical = 8.dp),790    ) {791        Text(792            text = identity.value,793            color = peachColors.T0,794            fontFamily = PeachType.Body,795            fontSize = 13.sp,796            maxLines = 1,797            overflow = TextOverflow.Ellipsis,798        )799        val meta = listOfNotNull(800            identity.exampleDomain,801            if (identity.type == IdentityType.EMAIL) "email" else "username",802        ).joinToString(" \u00b7 ")803        Text(804            text = meta,805            color = peachColors.T2,806            fontFamily = PeachType.Mono,807            fontSize = 9.sp,808            maxLines = 1,809            overflow = TextOverflow.Ellipsis,810        )811    }812}813814@Composable815private fun UnlockBody(816    error: String?,817    busy: Boolean,818    canBiometric: Boolean,819    onBiometric: () -> Unit,820    onUnlock: (String) -> Unit,821) {822    val accent = LocalPeachAccent.current823    var password by remember { mutableStateOf("") }824825    Text(826        text = "Unlock to fill",827        color = peachColors.T0,828        fontFamily = PeachType.Body,829        fontSize = 15.sp,830        modifier = Modifier.padding(bottom = 10.dp),831    )832    BasicTextField(833        value = password,834        onValueChange = { password = it },835        singleLine = true,836        enabled = !busy,837        textStyle = TextStyle(838            color = peachColors.T0,839            fontFamily = PeachType.Mono,840            fontSize = 16.sp,841        ),842        cursorBrush = SolidColor(accent),843        visualTransformation = PasswordVisualTransformation(),844        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),845        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),846        modifier = Modifier847            .fillMaxWidth()848            .background(peachColors.S2, RoundedCornerShape(8.dp))849            .padding(horizontal = 12.dp, vertical = 12.dp),850    )851    if (error != null) {852        Text(853            text = error,854            color = peachColors.Breach,855            fontFamily = PeachType.Body,856            fontSize = 12.sp,857            modifier = Modifier.padding(top = 8.dp),858        )859    }860    Row(861        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),862        horizontalArrangement = Arrangement.spacedBy(20.dp),863        verticalAlignment = Alignment.CenterVertically,864    ) {865        if (canBiometric) {866            Text(867                text = "Use biometrics",868                color = accent,869                fontFamily = PeachType.Mono,870                fontSize = 13.sp,871                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),872            )873        }874        Text(875            text = if (busy) "Unlocking…" else "Unlock",876            color = if (password.isEmpty() || busy) peachColors.T2 else accent,877            fontFamily = PeachType.Mono,878            fontSize = 13.sp,879            modifier = Modifier880                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }881                .padding(vertical = 8.dp),882        )883    }884}885886@Composable887private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {888    Row(889        modifier = Modifier890            .fillMaxWidth()891            .clickable(onClick = onClick)892            .padding(vertical = 9.dp),893        verticalAlignment = Alignment.CenterVertically,894    ) {895        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)896        Column(modifier = Modifier.padding(start = 12.dp)) {897            Text(898                text = entry.title,899                color = peachColors.T0,900                fontFamily = PeachType.Body,901                fontSize = 15.sp,902                maxLines = 1,903                overflow = TextOverflow.Ellipsis,904            )905            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""906            if (sub.isNotBlank()) {907                Text(908                    text = sub,909                    color = peachColors.T2,910                    fontFamily = PeachType.Mono,911                    fontSize = 11.sp,912                    maxLines = 1,913                    overflow = TextOverflow.Ellipsis,914                )915            }916        }917    }918}919920/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */921private const val SEARCH_DEBOUNCE_MS = 200L