package com.peachpasswords.autofill import android.content.Intent import android.os.Bundle 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.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.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.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 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.installRelease(window) } 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), verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(), usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID), passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID), otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID), ) val parsed = authenticateAutofillOrigin(this, rawParsed) val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT setContent { PeachTheme(accent = accent) { 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) } // Trusted origins may search the vault manually. Unknown origins stay // empty until the user types a registrable target domain in the picker. val allEntries = remember(unlocked) { if (unlocked) vault.getEntries() else emptyList() } val matchedEntries = remember(unlocked, allEntries) { if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList() } // 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, originTrust = parsed.originTrust, typedIdentity = typedUsername ?: typedEmail, unlocked = unlocked, matchedEntries = matchedEntries, allEntries = allEntries, 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, confirmedTarget -> val epoch = unlockedEpoch if (epoch == null || !vault.isSessionEpochCurrent(epoch)) { cancel() } else { returnDataset(parsed, entry, confirmedTarget) } }, onCreateNew = { confirmedTarget -> launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget) }, ) } } } } 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, confirmedTarget: String?, ) { if ( parsed.originTrust == AutofillOriginTrust.UNKNOWN && AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty() ) { cancel() return } val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null) 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?, confirmedTarget: String?, ) { val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) { confirmedTarget?.let(::extractDomain) ?: return } else { 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 with * originTrust always UNKNOWN. The picker must re-derive trust in-process via * [authenticateAutofillOrigin] — a caller-supplied trust extra is never * authoritative. For web origins, the authenticated caller package is carried * separately so the re-verification can check the browser signing pin. */ internal fun pickerParsedFromInputs( origin: String?, isWebOrigin: Boolean, callerPackage: String?, verifiedDomains: List, usernameId: AutofillId? = null, passwordId: AutofillId? = null, otpId: AutofillId? = null, ): ParsedStructure = ParsedStructure( usernameId = usernameId, passwordId = passwordId, otpId = otpId, webDomain = origin.takeIf { isWebOrigin }, packageName = if (isWebOrigin) callerPackage else origin, // Always UNKNOWN here — the picker re-derives trust from these raw inputs. originTrust = AutofillOriginTrust.UNKNOWN, verifiedDomains = verifiedDomains, ) 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?, originTrust: AutofillOriginTrust, typedIdentity: String?, unlocked: Boolean, matchedEntries: List, allEntries: List, error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, onPick: (VaultEntry, String?) -> Unit, onCreateNew: (String?) -> Unit, ) { val accent = LocalPeachAccent.current val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN 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 = when { originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request" 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 (originTrust == AutofillOriginTrust.UNKNOWN) { Box( modifier = Modifier .fillMaxWidth() .padding(bottom = 14.dp) .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) .padding(12.dp), ) { Text( text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.", color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 12.sp, ) } } if (!unlocked) { UnlockBody(error, busy, canBiometric, onBiometric, onUnlock) } else { // For an unverified caller, this field is a domain confirmation boundary, // not a vault-wide search box controlled by attacker-provided origin data. var rawQuery by remember { mutableStateOf("") } var debouncedQuery by remember { mutableStateOf("") } LaunchedEffect(rawQuery) { delay(SEARCH_DEBOUNCE_MS) debouncedQuery = rawQuery } val confirmedTarget = if (untrustedOrigin) { debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) } else { null } SearchField( value = rawQuery, onValueChange = { rawQuery = it }, placeholder = when { untrustedOrigin -> "enter site domain, e.g. example.com" matchedEntries.isNotEmpty() -> "search for another entry…" else -> "search vault…" }, ) val visibleEntries = when { untrustedOrigin && confirmedTarget == null -> emptyList() untrustedOrigin -> AutofillResponseBuilder .matchEntries(allEntries, confirmedTarget) .hits debouncedQuery.isBlank() -> matchedEntries else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) } } val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty() && (!untrustedOrigin || confirmedTarget != null) val emptyMessage = when { untrustedOrigin && debouncedQuery.isBlank() -> "Type the site domain to choose a saved login." untrustedOrigin && confirmedTarget == null -> "Enter a full public domain such as example.com." untrustedOrigin && visibleEntries.isEmpty() -> "No saved entries match $confirmedTarget." matchedEntries.isEmpty() && debouncedQuery.isBlank() -> "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 (visibleEntries.isNotEmpty()) { LazyColumn { items(visibleEntries, key = { it.id }) { entry -> PickerRow(entry, onClick = { onPick(entry, confirmedTarget) }) } } } val createOrigin = if (untrustedOrigin) confirmedTarget else origin if (!untrustedOrigin || createOrigin != null) { CreateNewRow( origin = createOrigin, typedIdentity = typedIdentity, onClick = { onCreateNew(confirmedTarget) }, ) } } } } @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, ) } } } } @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