package com.peachpasswords.autofill import android.content.Intent import android.os.Bundle import android.view.WindowManager 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.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 @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) { window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) } val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN) val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false) val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME) val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL) val parsed = ParsedStructure( usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID), passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID), otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID), webDomain = origin.takeIf { isWebOrigin }, packageName = origin.takeUnless { isWebOrigin }, ) 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 error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } // The picker filters across the entire vault when the user types // a search, so the unlocked entry list is the universe of choices — // not just the matched ones. The header still surfaces the matched // count separately so users know what the search is broadening. 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 }, onFail = { }, ) } } ModalBottomSheet( onDismissRequest = { cancel() }, sheetState = sheetState, containerColor = peachColors.S1, dragHandle = { AccentDragHandle() }, ) { PickerBody( origin = parsed.origin, typedIdentity = typedUsername ?: typedEmail, unlocked = unlocked, matchedEntries = matchedEntries, allEntries = allEntries, error = error, busy = busy, canBiometric = canBiometric(), onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) }, onUnlock = { password -> busy = true error = null scope.launch { when (vault.unlock(password)) { is UnlockResult.Success -> unlocked = true UnlockResult.WrongPassword -> error = "Wrong master password." else -> error = "Couldn't unlock." } busy = false } }, onPick = { returnDataset(parsed, it) }, onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) }, ) } } } } 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. val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail() lifecycleScope.launch { try { if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail() } finally { com.peachpasswords.vault.VaultCrypto.zero(key) } } }, onFail = onFail, ) } private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) { 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?) { val typedIdentity = typedUsername ?: typedEmail val intent = Intent(this, MainActivity::class.java).apply { putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true) putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin) 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 } } internal fun autofillPickerEntries( entries: List, parsed: ParsedStructure, limit: Int, ): List = when { parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit) parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit) else -> AutofillResponseBuilder.matchEntries(entries, null).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, error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, onPick: (VaultEntry) -> Unit, onCreateNew: () -> 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 { // Always-on search field; debounced 200ms so a fast typer doesn't // thrash the filter. Vault-wide (not matched-only) so the user can // reach any entry from here. 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 = if (matchedEntries.isNotEmpty()) "search for another entry…" else "search vault…", ) // No-results-vs-empty-case is a single observable: an empty // entry list with no active query means the vault really had // no matches for this origin; a non-empty query producing an // empty list means the user typed something the vault can't find. // Either way, the picker still offers "+ Create new entry" so the // user can pivot to a save instead of being stranded. val visibleEntries = if (debouncedQuery.isBlank()) { matchedEntries } else { allEntries.filter { matchesPickerSearch(it, debouncedQuery) } } val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty() if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) { Text( text = "No saved entries match this site.", color = peachColors.T1, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.padding(vertical = 6.dp), ) } else if (showSearchEmpty) { Text( text = "No entries match \"${debouncedQuery.trim()}\".", 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) }) } } } // Header affordance, not buried at the end of the list — mirrors // the inline selector's "+" so the user is never trapped in the // search list when they want to make a new entry. CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew) } } } @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