package com.peachpasswords.autofill import android.content.Intent import android.os.Build import android.os.Bundle import android.service.autofill.Dataset import android.view.autofill.AutofillId import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.peachpasswords.BuildConfig import com.peachpasswords.MainActivity import com.peachpasswords.shared.extractDomain import com.peachpasswords.ui.lock.BiometricKeyStore import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.ui.components.FaviconInitials import com.peachpasswords.ui.components.LocalFavicons import com.peachpasswords.ui.components.QaFlags import com.peachpasswords.ui.components.SecureWindowPolicy import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.DEFAULT_ACCENT import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachTheme import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.vault.UnlockResult import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.isCard import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject /** * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach" * in the system autofill drop-down. Lists matching vault entries using the same * row vocabulary as the vault list; picking one returns a filled * [android.service.autofill.Dataset] to the framework via * [AutofillManager.EXTRA_AUTHENTICATION_RESULT]. * * Inline-mirror of the browser's selector: always-on search field, vault-wide * match against title/username/email/url, and a "+ Create new entry" affordance * in the header. The affordance cancels this activity and launches [MainActivity] * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had * typed on the host page. * * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity * and return RESULT_CANCELED, which produced a loop with no exit: the framework * treats a cancelled auth as "not filled" and re-offers the same prompt, so the * user unlocked the app, came back to a field that still asked them to unlock, * and had no way to tell that anything had happened. Autofill is a modal * interruption of someone else's app — leaving it to go somewhere else is the * one thing it must not do. */ @AndroidEntryPoint class AutofillEntryPickerActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons private var debugSecureLease = false @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // H-05: separate window from MainActivity, so it does NOT inherit the // release-wide FLAG_SECURE set there. Show vault metadata and accept the // master password — protect against screenshots / recents thumbnails. if (!BuildConfig.DEBUG) { SecureWindowPolicy.setUserSecure(window, true) } else if (!qaSecureCaptureBypass()) { // Debug builds are secure by default too. QA must opt out explicitly // on this activity's own launch intent; MainActivity's window policy // cannot protect this separate picker window. SecureWindowPolicy.acquire(window) debugSecureLease = true } val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME) val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL) // Origin trust is never read from the launch intent: a caller-supplied // trust extra could be forged or stale. Re-derive it in-process from // the raw authenticated inputs using the same authentication the // service used, so a tampered extra cannot elevate origin trust. val rawParsed = pickerParsedFromInputs( origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN), isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false), callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE), usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID), passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID), otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID), otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) { intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty() } else { @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty() }, cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID), cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID), cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID), cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID), cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID), cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID), ) val parsed = resolveAutofillOrigin(this, rawParsed) val settings = vault.settings.value val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT setContent { CompositionLocalProvider(LocalFavicons provides favicons) { PeachTheme(accent = accent, textSize = settings.textSize) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() var unlocked by remember { mutableStateOf(vault.isUnlocked.value) } var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) } var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } val allEntries = remember(unlocked) { if (unlocked) vault.getEntries() else emptyList() } val matchedEntries = remember(unlocked, allEntries) { if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList() } // A4: signup forms with no saved matches offer derived identities // (deduplicated usernames/emails across the vault). val identities = remember(unlocked, allEntries, matchedEntries) { if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList() } // Manual pick of an entry the origin didn't auto-match: offer to // remember the origin on the entry before filling. var rememberOffer by remember { mutableStateOf>?>(null) } val pickNow: (VaultEntry) -> Unit = { entry -> val epoch = unlockedEpoch if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel() else returnDataset(parsed, entry) } // Offer biometrics immediately — it is the whole point of // unlocking here rather than in the app, and a prompt the user // can dismiss straight onto the password field costs nothing. LaunchedEffect(unlocked) { if (!unlocked && canBiometric()) { tryBiometric( onUnlocked = { unlocked = true unlockedEpoch = vault.unlockedSessionEpoch() }, onFail = { }, ) } } ModalBottomSheet( onDismissRequest = { cancel() }, sheetState = sheetState, containerColor = peachColors.S1, dragHandle = { AccentDragHandle() }, ) { PickerBody( origin = parsed.origin, typedIdentity = typedUsername ?: typedEmail, unlocked = unlocked, matchedEntries = matchedEntries, allEntries = allEntries, rememberOffer = rememberOffer, onRemember = { entry, domains -> rememberOffer = null scope.launch { val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry runCatching { vault.updateEntry( current.copy( additionalUrls = (current.additionalUrls.orEmpty() + domains.map { "https://$it" }).distinct(), ), ) } pickNow(entry) } }, onFillOnce = { entry -> rememberOffer = null pickNow(entry) }, onRememberDismiss = { rememberOffer = null }, identities = identities, onPickIdentity = { identity -> val epoch = unlockedEpoch if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel() else finishWithDataset( AutofillResponseBuilder.identityDatasetFor( this@AutofillEntryPickerActivity, parsed, identity, ), ) }, error = error, busy = busy, canBiometric = canBiometric(), onBiometric = { tryBiometric( onUnlocked = { unlocked = true unlockedEpoch = vault.unlockedSessionEpoch() }, onFail = {}, ) }, onUnlock = { password -> busy = true error = null scope.launch { when (vault.unlock(password)) { is UnlockResult.Success -> { unlocked = true unlockedEpoch = vault.unlockedSessionEpoch() } UnlockResult.WrongPassword -> error = "Wrong master password." else -> error = "Couldn't unlock." } busy = false } }, onPick = { entry -> val offer = rememberOfferDomains(entry, parsed) if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer }, onCreateNew = { searchText -> launchNewEntry(parsed, typedUsername, typedEmail, searchText) }, ) } } } } } override fun onDestroy() { if (debugSecureLease) { SecureWindowPolicy.release(window) debugSecureLease = false } super.onDestroy() } private fun qaSecureCaptureBypass(): Boolean = BuildConfig.DEBUG && ( intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) || QaFlags.disableSecureCaptureForQa ) private fun canBiometric(): Boolean = Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this) private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) { val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail() Biometrics.authenticateCrypto( activity = this, title = "Unlock Peach", subtitle = "Fill your saved details", crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher), onSuccess = { result -> val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail() // M-05: unlockWithKey copies `key` into the session's derivedKey, // so the caller-owned reference is now redundant. Zero it whether // the unlock succeeds or fails so the unwrapped biometric key // doesn't outlive the auth prompt. lifecycleScope.launch { // Unseal inside the launched ownership scope. If the // lifecycle scope is cancelled before execution, no key is // allocated before a finally can protect it. var key: ByteArray? = null try { key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used) if (key == null) return@launch onFail() if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail() } finally { key?.let(com.peachpasswords.vault.VaultCrypto::zero) } } }, onFail = onFail, ) } private fun returnDataset( parsed: ParsedStructure, entry: VaultEntry, ) { val dataset = if (entry.isCard()) { AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null) } else { AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null) } finishWithDataset(dataset) } private fun finishWithDataset(dataset: Dataset?) { if (dataset == null) { cancel(); return } val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) setResult(RESULT_OK, reply) finish() } /** * Cancel the picker and fire MainActivity on the new-entry path. The picker * returns CANCELED on purpose — the user is choosing to leave the autofill * prompt, not to fill it — and the launcher extra carries the host's typed * identity and origin so the new-entry editor opens pre-filled. */ private fun launchNewEntry( parsed: ParsedStructure, typedUsername: String?, typedEmail: String?, searchText: String?, ) { val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin val intent = Intent(this, MainActivity::class.java).apply { putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true) putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin) putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername) putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } startActivity(intent) cancel() } private fun cancel() { setResult(RESULT_CANCELED) finish() } @Suppress("DEPRECATION") private fun intentAutofillId(key: String): AutofillId? = if (android.os.Build.VERSION.SDK_INT >= 33) { intent.getParcelableExtra(key, AutofillId::class.java) } else { intent.getParcelableExtra(key) } private companion object { const val MAX_ROWS = 12 } } /** * Build the initial [ParsedStructure] from raw picker intent inputs. Origin * domains and app title are resolved in-process via [resolveAutofillOrigin] from * the authenticated caller package + web domain — never from a caller extra. */ internal fun pickerParsedFromInputs( origin: String?, isWebOrigin: Boolean, callerPackage: String?, usernameId: AutofillId? = null, passwordId: AutofillId? = null, otpId: AutofillId? = null, otpDigitBoxIds: List = emptyList(), cardNumberId: AutofillId? = null, cardExpMonthId: AutofillId? = null, cardExpYearId: AutofillId? = null, cardExpDateId: AutofillId? = null, cardCvcId: AutofillId? = null, cardNameId: AutofillId? = null, ): ParsedStructure = ParsedStructure( usernameId = usernameId, passwordId = passwordId, otpId = otpId, cardNumberId = cardNumberId, cardExpMonthId = cardExpMonthId, cardExpYearId = cardExpYearId, cardExpDateId = cardExpDateId, cardCvcId = cardCvcId, cardNameId = cardNameId, otpDigitBoxIds = otpDigitBoxIds, webDomain = origin.takeIf { isWebOrigin }, packageName = if (isWebOrigin) callerPackage else origin, ) internal fun autofillPickerEntries( entries: List, parsed: ParsedStructure, limit: Int, ): List = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit) /** * Case-insensitive substring match across the four user-facing identity columns * the browser uses (title, username, email, url). Exposed at file scope so the * picker can call it directly and the test suite can exercise the same code. */ internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean { if (query.isEmpty()) return true val needle = query.lowercase() return listOfNotNull(entry.title, entry.username, entry.email, entry.url) .any { it.lowercase().contains(needle) } } @Composable private fun AccentDragHandle() { val accent = LocalPeachAccent.current Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp), horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .size(width = 32.dp, height = 4.dp) .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)), ) } } @Composable private fun PickerBody( origin: String?, typedIdentity: String?, unlocked: Boolean, matchedEntries: List, allEntries: List, rememberOffer: Pair>?, onRemember: (VaultEntry, List) -> Unit, onFillOnce: (VaultEntry) -> Unit, onRememberDismiss: () -> Unit, identities: List, onPickIdentity: (SavedIdentity) -> Unit, error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, onPick: (VaultEntry) -> Unit, onCreateNew: (String?) -> Unit, ) { val accent = LocalPeachAccent.current Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { Text( text = "peach", color = accent, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 15.sp, ) Text( text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp, modifier = Modifier.padding(top = 2.dp, bottom = 14.dp), ) if (!unlocked) { UnlockBody(error, busy, canBiometric, onBiometric, onUnlock) } else if (rememberOffer != null) { RememberSitePrompt( entryTitle = rememberOffer!!.first.title, domains = rememberOffer!!.second, onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) }, onFillOnce = { onFillOnce(rememberOffer!!.first) }, onBack = onRememberDismiss, ) } else { // The search field is a vault-wide substring search; an empty query // shows the origin's matched entries. var rawQuery by remember { mutableStateOf("") } var debouncedQuery by remember { mutableStateOf("") } LaunchedEffect(rawQuery) { delay(SEARCH_DEBOUNCE_MS) debouncedQuery = rawQuery } SearchField( value = rawQuery, onValueChange = { rawQuery = it }, placeholder = when { matchedEntries.isNotEmpty() -> "search for another entry…" else -> "search vault…" }, ) val showIdentities = matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty() val visibleEntries = when { debouncedQuery.isBlank() -> matchedEntries else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) } } val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty() val emptyMessage = when { matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities -> "No saved entries match this site." showSearchEmpty -> "No entries match \"${debouncedQuery.trim()}\"." else -> null } if (emptyMessage != null) { Text( text = emptyMessage, color = peachColors.T1, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.padding(vertical = 6.dp), ) } if (showIdentities) { IdentitySection(identities = identities, onPick = onPickIdentity) } if (visibleEntries.isNotEmpty()) { LazyColumn { items(visibleEntries, key = { it.id }) { entry -> PickerRow(entry, onClick = { onPick(entry) }) } } } val createOrigin = debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin CreateNewRow( origin = createOrigin, typedIdentity = typedIdentity, onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) }, ) } } } @Composable private fun SearchField( value: String, onValueChange: (String) -> Unit, placeholder: String, ) { val accent = LocalPeachAccent.current BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, textStyle = TextStyle( color = peachColors.T0, fontFamily = PeachType.Mono, fontSize = 14.sp, ), cursorBrush = SolidColor(accent), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }), modifier = Modifier .fillMaxWidth() .background(peachColors.S2, RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 10.dp), decorationBox = { inner -> Box(modifier = Modifier.fillMaxWidth()) { if (value.isEmpty()) { Text( text = placeholder, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp, ) } inner() } }, ) } @Composable private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) { val accent = LocalPeachAccent.current val title = if (!origin.isNullOrBlank()) "Create new entry for $origin" else "Create new entry" val subtitle = typedIdentity?.takeIf { it.isNotBlank() } Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(34.dp) .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center, ) { Text( text = "+", color = accent, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp, ) } Column(modifier = Modifier.padding(start = 12.dp)) { Text( text = title, color = accent, fontFamily = PeachType.Body, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (subtitle != null) { Text( text = "prefill: $subtitle", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } /** * Offered when the user manually picks an entry the origin didn't auto-match: * save the origin's domains onto the entry (additional URLs) so it is * suggested automatically next time — Android counterpart of the browser's * "Remember this site" toast. */ @Composable private fun RememberSitePrompt( entryTitle: String, domains: List, onRemember: () -> Unit, onFillOnce: () -> Unit, onBack: () -> Unit, ) { val accent = LocalPeachAccent.current Text( text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?", color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, ) Text( text = "It will be suggested automatically the next time you fill here.", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 4.dp), ) Row( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(18.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Remember & fill", color = accent, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)) .clickable(onClick = onRemember) .padding(horizontal = 12.dp, vertical = 8.dp), ) Text( text = "Just fill", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp), ) Text( text = "Back", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp), ) } } /** * A4 identity helper: signup forms with no saved matches list the vault's * derived identities (deduplicated usernames/emails) with type filter chips — * Android counterpart of the browser's inline identity selector. */ @Composable private fun IdentitySection( identities: List, onPick: (SavedIdentity) -> Unit, ) { val accent = LocalPeachAccent.current var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) } val visible = remember(identities, mode) { filterIdentities(identities, "", mode) } Text( text = "use a saved identity", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp, modifier = Modifier.padding(bottom = 8.dp), ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 10.dp), ) { for (filterMode in IdentityFilterMode.values()) { val active = filterMode == mode Text( text = filterMode.label, color = if (active) peachColors.S1 else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp)) .clickable { mode = filterMode } .padding(horizontal = 10.dp, vertical = 5.dp), ) } } LazyColumn { items(visible, key = { it.type.name + ":" + it.value }) { identity -> IdentityRow(identity, onClick = { onPick(identity) }) } } } @Composable private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 8.dp), ) { Text( text = identity.value, color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) val meta = listOfNotNull( identity.exampleDomain, if (identity.type == IdentityType.EMAIL) "email" else "username", ).joinToString(" \u00b7 ") Text( text = meta, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } @Composable private fun UnlockBody( error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, ) { val accent = LocalPeachAccent.current var password by remember { mutableStateOf("") } Text( text = "Unlock to fill", color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 15.sp, modifier = Modifier.padding(bottom = 10.dp), ) BasicTextField( value = password, onValueChange = { password = it }, singleLine = true, enabled = !busy, textStyle = TextStyle( color = peachColors.T0, fontFamily = PeachType.Mono, fontSize = 16.sp, ), cursorBrush = SolidColor(accent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }), modifier = Modifier .fillMaxWidth() .background(peachColors.S2, RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 12.dp), ) if (error != null) { Text( text = error, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp), ) } Row( modifier = Modifier.fillMaxWidth().padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, ) { if (canBiometric) { Text( text = "Use biometrics", color = accent, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp), ) } Text( text = if (busy) "Unlocking…" else "Unlock", color = if (password.isEmpty() || busy) peachColors.T2 else accent, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) } .padding(vertical = 8.dp), ) } } @Composable private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { FaviconInitials(source = entry.url ?: entry.title, size = 34.dp) Column(modifier = Modifier.padding(start = 12.dp)) { Text( text = entry.title, color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" if (sub.isNotBlank()) { Text( text = sub, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } /** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */ private const val SEARCH_DEBOUNCE_MS = 200L