43e6d3efc508

add a vault-wide search field, a plus button that pre-fills from the host form, and the navigation hand-off to EntryEdit

time
2026-07-25 23:56 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
autofill picker search and create
parent
c0512b43fe9f
10 files app/src/main/java/com/peachpasswords/MainActivity.kt+19 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+212 −20app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+32 −3app/src/main/java/com/peachpasswords/autofill/AutofillNavRepository.kt+43 −0app/src/main/java/com/peachpasswords/autofill/AutofillNavViewModel.kt+15 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+28 −1app/src/main/java/com/peachpasswords/ui/PeachNav.kt+21 −3app/src/main/java/com/peachpasswords/ui/vault/EntryEditViewModel.kt+13 −1app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+83 −0app/src/test/java/com/peachpasswords/ui/NavigationFlowTest.kt+17 −4
app/src/main/java/com/peachpasswords/MainActivity.kt+19 −0
@@ -25,6 +25,8 @@2525 import androidx.fragment.app.FragmentActivity2626 import dagger.hilt.android.AndroidEntryPoint2727 import javax.inject.Inject28+import com.peachpasswords.autofill.AutofillIntents29+import com.peachpasswords.autofill.AutofillNavRepository2830 import com.peachpasswords.ui.PeachAppNavHost2931 import com.peachpasswords.ui.components.QaFlags3032 import com.peachpasswords.ui.onboarding.ONBOARD_KEY@@ -44,6 +46,7 @@4446     @Inject lateinit var vault: VaultRepository4547     @Inject lateinit var lockTimer: VaultLockTimer4648     @Inject lateinit var updates: UpdateRepository49+    @Inject lateinit var autofillNav: AutofillNavRepository4750 4851     // Every touch/key event resets the idle auto-lock countdown (CF-1).4952     override fun onUserInteraction() {@@ -58,6 +61,7 @@5861         super.onNewIntent(intent)5962         setIntent(intent)6063         if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()64+        forwardNewEntryRequest(intent)6165         if (BuildConfig.DEBUG) {6266             QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)6367         }@@ -69,6 +73,7 @@6973 7074         // Cold launch from the update notification (the warm case is onNewIntent).7175         if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()76+        forwardNewEntryRequest(intent)7277 7378         // Match the splash/window fill to the resolved theme's --bg so launch7479         // never flashes the wrong colour before Compose draws. Resolved from the@@ -141,6 +146,20 @@141146 142147 /** Layout width a phone gives us; anything past this is "extra" to spend. */143148 private val BASE_WIDTH = 411.dp149+150+/**151+ * Pick up the "+ Create new entry" launcher from the autofill picker and stash152+ * the prefill payload in [autofillNav]. The NavHost observes that flow and153+ * navigates to the new-entry editor, mirroring the UpdateRepository pattern.154+ */155+private fun MainActivity.forwardNewEntryRequest(intent: Intent) {156+    if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return157+    autofillNav.requestNewEntry(158+        url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),159+        username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),160+        email = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL),161+    )162+}144163 145164 /** Cap on the up-scale — past this, big text stops reading as deliberate. */146165 private const val MAX_SCALE = 1.35f

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+212 −20
@@ -38,13 +38,13 @@3838 import androidx.compose.ui.text.input.PasswordVisualTransformation3939 import androidx.compose.ui.Alignment4040 import androidx.compose.ui.Modifier41-import androidx.compose.ui.graphics.Color4241 import androidx.compose.ui.text.font.FontStyle4342 import androidx.compose.ui.text.style.TextOverflow4443 import androidx.compose.ui.unit.dp4544 import androidx.compose.ui.unit.sp4645 import androidx.fragment.app.FragmentActivity4746 import androidx.lifecycle.lifecycleScope47+import com.peachpasswords.MainActivity4848 import com.peachpasswords.shared.extractDomain4949 import com.peachpasswords.ui.lock.BiometricKeyStore5050 import com.peachpasswords.ui.lock.Biometrics@@ -59,6 +59,7 @@5959 import com.peachpasswords.vault.VaultEntry6060 import com.peachpasswords.vault.VaultRepository6161 import dagger.hilt.android.AndroidEntryPoint62+import kotlinx.coroutines.delay6263 import kotlinx.coroutines.launch6364 import javax.inject.Inject6465 @@ -69,13 +70,19 @@6970  * [android.service.autofill.Dataset] to the framework via7071  * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].7172  *72- * Unlocking happens HERE, in the sheet. It used to send the user to73- * MainActivity and return RESULT_CANCELED, which produced a loop with no exit:74- * the framework treats a cancelled auth as "not filled" and re-offers the same75- * prompt, so the user unlocked the app, came back to a field that still asked76- * them to unlock, and had no way to tell that anything had happened. Autofill77- * is a modal interruption of someone else's app — leaving it to go somewhere78- * else is the one thing it must not do.73+ * Inline-mirror of the browser's selector: always-on search field, vault-wide74+ * match against title/username/email/url, and a "+ Create new entry" affordance75+ * in the header. The affordance cancels this activity and launches [MainActivity]76+ * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had77+ * typed on the host page.78+ *79+ * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity80+ * and return RESULT_CANCELED, which produced a loop with no exit: the framework81+ * treats a cancelled auth as "not filled" and re-offers the same prompt, so the82+ * user unlocked the app, came back to a field that still asked them to unlock,83+ * and had no way to tell that anything had happened. Autofill is a modal84+ * interruption of someone else's app — leaving it to go somewhere else is the85+ * one thing it must not do.7986  */8087 @AndroidEntryPoint8188 class AutofillEntryPickerActivity : FragmentActivity() {@@ -88,6 +95,8 @@8895 8996         val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)9097         val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)98+        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)99+        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)91100         val parsed = ParsedStructure(92101             usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),93102             passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),@@ -105,9 +114,16 @@105114                 var error by remember { mutableStateOf<String?>(null) }106115                 var busy by remember { mutableStateOf(false) }107116 108-                val entries = remember(unlocked) {109-                    if (unlocked) autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS) else emptyList()117+                // The picker filters across the entire vault when the user types118+                // a search, so the unlocked entry list is the universe of choices —119+                // not just the matched ones. The header still surfaces the matched120+                // count separately so users know what the search is broadening.121+                val allEntries = remember(unlocked) {122+                    if (unlocked) vault.getEntries() else emptyList()110123                 }124+                val matchedEntries = remember(unlocked, allEntries) {125+                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()126+                }111127 112128                 // Offer biometrics immediately — it is the whole point of113129                 // unlocking here rather than in the app, and a prompt the user@@ -129,8 +145,10 @@129145                 ) {130146                     PickerBody(131147                         origin = parsed.origin,148+                        typedIdentity = typedUsername ?: typedEmail,132149                         unlocked = unlocked,133-                        entries = entries,150+                        matchedEntries = matchedEntries,151+                        allEntries = allEntries,134152                         error = error,135153                         busy = busy,136154                         canBiometric = canBiometric(),@@ -148,6 +166,7 @@148166                             }149167                         },150168                         onPick = { returnDataset(parsed, it) },169+                        onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },151170                     )152171                 }153172             }@@ -183,6 +202,25 @@183202         finish()184203     }185204 205+    /**206+     * Cancel the picker and fire MainActivity on the new-entry path. The picker207+     * returns CANCELED on purpose — the user is choosing to leave the autofill208+     * prompt, not to fill it — and the launcher extra carries the host's typed209+     * identity and origin so the new-entry editor opens pre-filled.210+     */211+    private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {212+        val typedIdentity = typedUsername ?: typedEmail213+        val intent = Intent(this, MainActivity::class.java).apply {214+            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)215+            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)216+            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)217+            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)218+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)219+        }220+        startActivity(intent)221+        cancel()222+    }223+186224     private fun cancel() {187225         setResult(RESULT_CANCELED)188226         finish()@@ -211,6 +249,18 @@211249     else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)212250 }213251 252+/**253+ * Case-insensitive substring match across the four user-facing identity columns254+ * the browser uses (title, username, email, url). Exposed at file scope so the255+ * picker can call it directly and the test suite can exercise the same code.256+ */257+internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {258+    if (query.isEmpty()) return true259+    val needle = query.lowercase()260+    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)261+        .any { it.lowercase().contains(needle) }262+}263+214264 @Composable215265 private fun AccentDragHandle() {216266     val accent = LocalPeachAccent.current@@ -229,14 +279,17 @@229279 @Composable230280 private fun PickerBody(231281     origin: String?,282+    typedIdentity: String?,232283     unlocked: Boolean,233-    entries: List<VaultEntry>,284+    matchedEntries: List<VaultEntry>,285+    allEntries: List<VaultEntry>,234286     error: String?,235287     busy: Boolean,236288     canBiometric: Boolean,237289     onBiometric: () -> Unit,238290     onUnlock: (String) -> Unit,239291     onPick: (VaultEntry) -> Unit,292+    onCreateNew: () -> Unit,240293 ) {241294     val accent = LocalPeachAccent.current242295     Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {@@ -257,18 +310,154 @@257310 258311         if (!unlocked) {259312             UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)260-        } else if (entries.isEmpty()) {313+        } else {314+            // Always-on search field; debounced 200ms so a fast typer doesn't315+            // thrash the filter. Vault-wide (not matched-only) so the user can316+            // reach any entry from here.317+            var rawQuery by remember { mutableStateOf("") }318+            var debouncedQuery by remember { mutableStateOf("") }319+            LaunchedEffect(rawQuery) {320+                delay(SEARCH_DEBOUNCE_MS)321+                debouncedQuery = rawQuery322+            }323+            SearchField(324+                value = rawQuery,325+                onValueChange = { rawQuery = it },326+                placeholder = if (matchedEntries.isNotEmpty())327+                    "search for another entry…" else "search vault…",328+            )329+330+            // No-results-vs-empty-case is a single observable: an empty331+            // entry list with no active query means the vault really had332+            // no matches for this origin; a non-empty query producing an333+            // empty list means the user typed something the vault can't find.334+            // Either way, the picker still offers "+ Create new entry" so the335+            // user can pivot to a save instead of being stranded.336+            val visibleEntries = if (debouncedQuery.isBlank()) {337+                matchedEntries338+            } else {339+                allEntries.filter { matchesPickerSearch(it, debouncedQuery) }340+            }341+            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()342+343+            if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {344+                Text(345+                    text = "No saved entries match this site.",346+                    color = peachColors.T1,347+                    fontFamily = PeachType.Body,348+                    fontSize = 13.sp,349+                    modifier = Modifier.padding(vertical = 6.dp),350+                )351+            } else if (showSearchEmpty) {352+                Text(353+                    text = "No entries match \"${debouncedQuery.trim()}\".",354+                    color = peachColors.T1,355+                    fontFamily = PeachType.Body,356+                    fontSize = 13.sp,357+                    modifier = Modifier.padding(vertical = 6.dp),358+                )359+            }360+361+            if (visibleEntries.isNotEmpty()) {362+                LazyColumn {363+                    items(visibleEntries, key = { it.id }) { entry ->364+                        PickerRow(entry, onClick = { onPick(entry) })365+                    }366+                }367+            }368+369+            // Header affordance, not buried at the end of the list — mirrors370+            // the inline selector's "+" so the user is never trapped in the371+            // search list when they want to make a new entry.372+            CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)373+        }374+    }375+}376+377+@Composable378+private fun SearchField(379+    value: String,380+    onValueChange: (String) -> Unit,381+    placeholder: String,382+) {383+    val accent = LocalPeachAccent.current384+    BasicTextField(385+        value = value,386+        onValueChange = onValueChange,387+        singleLine = true,388+        textStyle = TextStyle(389+            color = peachColors.T0,390+            fontFamily = PeachType.Mono,391+            fontSize = 14.sp,392+        ),393+        cursorBrush = SolidColor(accent),394+        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),395+        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),396+        modifier = Modifier397+            .fillMaxWidth()398+            .background(peachColors.S2, RoundedCornerShape(8.dp))399+            .padding(horizontal = 12.dp, vertical = 10.dp),400+        decorationBox = { inner ->401+            Box(modifier = Modifier.fillMaxWidth()) {402+                if (value.isEmpty()) {403+                    Text(404+                        text = placeholder,405+                        color = peachColors.T2,406+                        fontFamily = PeachType.Mono,407+                        fontSize = 12.sp,408+                    )409+                }410+                inner()411+            }412+        },413+    )414+}415+416+@Composable417+private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {418+    val accent = LocalPeachAccent.current419+    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"420+        else "Create new entry"421+    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }422+    Row(423+        modifier = Modifier424+            .fillMaxWidth()425+            .clickable(onClick = onClick)426+            .padding(vertical = 10.dp),427+        verticalAlignment = Alignment.CenterVertically,428+    ) {429+        Box(430+            modifier = Modifier431+                .size(34.dp)432+                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),433+            contentAlignment = Alignment.Center,434+        ) {261435             Text(262-                text = "No saved entries match this site.",263-                color = peachColors.T1,436+                text = "+",437+                color = accent,438+                fontFamily = PeachType.Display,439+                fontStyle = FontStyle.Italic,440+                fontSize = 18.sp,441+            )442+        }443+        Column(modifier = Modifier.padding(start = 12.dp)) {444+            Text(445+                text = title,446+                color = accent,264447                 fontFamily = PeachType.Body,265448                 fontSize = 14.sp,449+                maxLines = 1,450+                overflow = TextOverflow.Ellipsis,266451             )267-        } else {268-            LazyColumn {269-                items(entries, key = { it.id }) { entry ->270-                    PickerRow(entry, onClick = { onPick(entry) })271-                }452+            if (subtitle != null) {453+                Text(454+                    text = "prefill: $subtitle",455+                    color = peachColors.T2,456+                    fontFamily = PeachType.Mono,457+                    fontSize = 10.sp,458+                    maxLines = 1,459+                    overflow = TextOverflow.Ellipsis,460+                )272461             }273462         }274463     }@@ -379,3 +568,6 @@379568         }380569     }381570 }571+572+/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */573+private const val SEARCH_DEBOUNCE_MS = 200L\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+32 −3
@@ -8,8 +8,10 @@88 99 /**1010  * PendingIntents the autofill surfaces hand to the framework: a no-op11- * attribution target the inline chip requires, and the authentication12- * IntentSender that opens the branded [AutofillEntryPickerActivity].11+ * attribution target the inline chip requires, the authentication12+ * IntentSender that opens the branded [AutofillEntryPickerActivity], and the13+ * "open new entry" launcher that the picker fires when the user taps the14+ * "+ Create new entry" affordance.1315  */1416 object AutofillIntents {1517     const val EXTRA_IDENTITY_ID = "peach.autofill.identityId"@@ -18,6 +20,23 @@1820     const val EXTRA_ORIGIN = "peach.autofill.origin"1921     const val EXTRA_IS_WEB_ORIGIN = "peach.autofill.isWebOrigin"2022 23+    /** Host-typed username captured by the autofill framework — used to prefill the new-entry editor. */24+    const val EXTRA_TYPED_USERNAME = "peach.autofill.typedUsername"25+    /** Host-typed email captured by the autofill framework — used to prefill the new-entry editor. */26+    const val EXTRA_TYPED_EMAIL = "peach.autofill.typedEmail"27+28+    /**29+     * Sent by [AutofillEntryPickerActivity] to launch MainActivity on the "+ Create30+     * new entry" path. The picker cancels itself and starts MainActivity with the31+     * origin + typed identity so the user can save a brand-new entry pre-filled with32+     * what they had typed on the host page. Carries the same key/value shape as the33+     * update notification's [com.peachpasswords.updates.EXTRA_OPEN_UPDATE].34+     */35+    const val EXTRA_OPEN_NEW_ENTRY = "peach.autofill.openNewEntry"36+    const val EXTRA_NEW_ENTRY_URL = "peach.autofill.newEntryUrl"37+    const val EXTRA_NEW_ENTRY_USERNAME = "peach.autofill.newEntryUsername"38+    const val EXTRA_NEW_ENTRY_EMAIL = "peach.autofill.newEntryEmail"39+2140     private const val REQ_CHIP = 0xC0DE2241     private const val REQ_PICKER = 0xBEEF2342 @@ -35,6 +54,16 @@3554             putExtra(EXTRA_OTP_ID, parsed.otpId)3655             putExtra(EXTRA_ORIGIN, parsed.origin)3756             putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)57+            // Mirror the browser's preUsername/preEmail capture so the "+ Create58+            // new entry" path can hand the typed identity to the new-entry editor.59+            // Empty/blank values are dropped — the picker reads the typed username60+            // from the identity field the host anchored on.61+            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {62+                putExtra(EXTRA_TYPED_USERNAME, it)63+            }64+            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {65+                putExtra(EXTRA_TYPED_EMAIL, it)66+            }3867         }3968         return PendingIntent.getActivity(context, REQ_PICKER, intent, mutableFlags()).intentSender4069     }@@ -46,4 +75,4 @@4675     private fun mutableFlags(): Int =4776         PendingIntent.FLAG_UPDATE_CURRENT or4877             if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 049-}78+}\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/autofill/AutofillNavRepository.kt+43 −0
@@ -0,0 +1,43 @@1+package com.peachpasswords.autofill2+3+import kotlinx.coroutines.flow.MutableStateFlow4+import kotlinx.coroutines.flow.StateFlow5+import kotlinx.coroutines.flow.asStateFlow6+import javax.inject.Inject7+import javax.inject.Singleton8+9+/**10+ * Carries a one-shot "open the new-entry editor" request from the autofill picker11+ * to the nav host. Mirrors [com.peachpasswords.updates.UpdateRepository]'s shape12+ * so the host reads a [StateFlow] and calls a `consume` after navigating.13+ *14+ * Held at app scope so MainActivity's [android.app.Activity.onNewIntent] can set15+ * the request before the NavHost subscribes (cold launches hit the same flow via16+ * the picker→MainActivity launch path).17+ */18+@Singleton19+class AutofillNavRepository @Inject constructor() {20+21+    private val _newEntryRequest = MutableStateFlow<NewEntryRequest?>(null)22+    val newEntryRequest: StateFlow<NewEntryRequest?> = _newEntryRequest.asStateFlow()23+24+    fun requestNewEntry(url: String?, username: String?, email: String?) {25+        _newEntryRequest.value = NewEntryRequest(26+            url = url?.takeIf { it.isNotBlank() },27+            username = username?.takeIf { it.isNotBlank() },28+            email = email?.takeIf { it.isNotBlank() },29+        )30+    }31+32+    fun consumeNewEntryRequest() { _newEntryRequest.value = null }33+}34+35+/**36+ * Prefill payload for the new-entry editor. Every field is optional — the37+ * minimum contract is [url], mirroring the inline picker's create form.38+ */39+data class NewEntryRequest(40+    val url: String? = null,41+    val username: String? = null,42+    val email: String? = null,43+)\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/autofill/AutofillNavViewModel.kt+15 −0
@@ -0,0 +1,15 @@1+package com.peachpasswords.autofill2+3+import androidx.lifecycle.ViewModel4+import dagger.hilt.android.lifecycle.HiltViewModel5+import javax.inject.Inject6+7+/**8+ * Thin Compose-friendly wrapper around [AutofillNavRepository] so the NavHost9+ * can grab the singleton via `hiltViewModel()` without reaching for an10+ * EntryPoint accessor. The repository itself stays the source of truth.11+ */12+@HiltViewModel13+class AutofillNavViewModel @Inject constructor(14+    val repo: AutofillNavRepository,15+) : ViewModel()\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+28 −1
@@ -21,6 +21,8 @@2121 import androidx.navigation.compose.composable2222 import androidx.navigation.compose.rememberNavController2323 import androidx.navigation.navArgument24+import com.peachpasswords.autofill.AutofillNavRepository25+import com.peachpasswords.autofill.AutofillNavViewModel2426 import com.peachpasswords.ui.codex.CodexGenerateScreen2527 import com.peachpasswords.ui.codex.CodexRestoreScreen2628 import com.peachpasswords.ui.components.CopyFeedbackOverlay@@ -49,6 +51,7 @@4951     navController: NavHostController = rememberNavController(),5052     startDestination: PeachNav = PeachNav.Lock,5153     isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,54+    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,5255 ) {5356     val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }5457     val back: () -> Unit = { navController.popBackStack() }@@ -69,6 +72,25 @@6972             navController.navigate(PeachNav.Update.route) { launchSingleTop = true }7073         }7174     }75+76+    // "+ Create new entry" deep link from the autofill picker. Holds the request77+    // until the vault is open (the user must be unlocked to save a new entry).78+    // Mirrors UpdateRepository's request/consume shape so MainActivity and the79+    // nav host stay loosely coupled.80+    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()81+    LaunchedEffect(newEntryRequest, unlocked) {82+        val req = newEntryRequest ?: return@LaunchedEffect83+        if (!unlocked) return@LaunchedEffect84+        autofillNav.consumeNewEntryRequest()85+        navController.navigate(86+            PeachNav.EntryEdit.of(87+                id = null,88+                prefillUrl = req.url,89+                prefillUsername = req.username,90+                prefillEmail = req.email,91+            ),92+        ) { launchSingleTop = true }93+    }7294     val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()7395     // Held until the vault is open: the changelog is harmless, but shoving it in7496     // front of the lock screen reads as the app having lost its place.@@ -205,7 +227,12 @@205227         }206228         composable(207229             route = PeachNav.EntryEdit.ROUTE,208-            arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true }),230+            arguments = listOf(231+                navArgument("id") { type = NavType.StringType; nullable = true },232+                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },233+                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },234+                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },235+            ),209236         ) { backStack ->210237             val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }211238             if (editId == null) {

view file

app/src/main/java/com/peachpasswords/ui/PeachNav.kt+21 −3
@@ -28,10 +28,28 @@2828         }2929     }3030 31-    data class EntryEdit(val id: String?) : PeachNav("edit?id=${id ?: ""}") {31+    data class EntryEdit(32+        val id: String?,33+        val prefillUrl: String? = null,34+        val prefillUsername: String? = null,35+        val prefillEmail: String? = null,36+    ) : PeachNav(37+        "edit?id=${id ?: ""}" +38+            "&prefillUrl=${prefillUrl ?: ""}" +39+            "&prefillUsername=${prefillUsername ?: ""}" +40+            "&prefillEmail=${prefillEmail ?: ""}",41+    ) {3242         companion object {33-            const val ROUTE = "edit?id={id}"34-            fun of(id: String?) = "edit?id=${id ?: ""}"43+            const val ROUTE = "edit?id={id}&prefillUrl={prefillUrl}&prefillUsername={prefillUsername}&prefillEmail={prefillEmail}"44+            fun of(45+                id: String?,46+                prefillUrl: String? = null,47+                prefillUsername: String? = null,48+                prefillEmail: String? = null,49+            ) = "edit?id=${id ?: ""}" +50+                "&prefillUrl=${prefillUrl ?: ""}" +51+                "&prefillUsername=${prefillUsername ?: ""}" +52+                "&prefillEmail=${prefillEmail ?: ""}"3553         }3654     }3755 

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryEditViewModel.kt+13 −1
@@ -62,6 +62,13 @@6262     val isNew: Boolean = id == null6363     private val existing: VaultEntry? = id?.let { vault.getEntry(it) }6464 65+    // Prefill values arrive on the EntryEdit route when the user follows the66+    // autofill picker's "+ Create new entry" path. Only apply to fresh entries67+    // — editing an existing entry must not be silently overwritten.68+    private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }69+    private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }70+    private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }71+6572     private val _form = MutableStateFlow(6673         existing?.let {6774             EditForm(@@ -85,7 +92,12 @@8592                 showTotp = !it.totp.isNullOrBlank(),8693                 showNotes = !it.notes.isNullOrBlank(),8794             )88-        } ?: EditForm(password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH)),95+        } ?: EditForm(96+            password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),97+            url = prefillUrl.orEmpty(),98+            username = prefillUsername.orEmpty(),99+            email = prefillEmail.orEmpty(),100+        ),89101     )90102     val form: StateFlow<EditForm> = _form.asStateFlow()91103 

view file

app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+83 −0
@@ -0,0 +1,83 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.vault.VaultEntry4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertFalse6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+class AutofillPickerSearchTest {10+11+    private fun entry(12+        title: String,13+        username: String? = null,14+        email: String? = null,15+        url: String? = null,16+    ) = VaultEntry(17+        id = title,18+        title = title,19+        username = username,20+        email = email,21+        url = url,22+        createdAt = 0,23+        modifiedAt = 0,24+    )25+26+    @Test fun empty_query_matches_every_entry() {27+        val gh = entry("GitHub", username = "june", url = "https://github.com")28+        val fb = entry("Fastmail", email = "[email protected]")29+        assertTrue(matchesPickerSearch(gh, ""))30+        assertTrue(matchesPickerSearch(fb, ""))31+    }32+33+    @Test fun whitespace_only_query_does_not_match_arbitrary_text() {34+        // The function itself is exact-empty-or-substring; the picker uses35+        // `isBlank()` upstream to decide between matched vs vault-wide search.36+        // A whitespace-only query therefore should not claim a match unless37+        // the entry literally contains whitespace in one of its fields.38+        val gh = entry("GitHub", username = "june")39+        assertFalse(matchesPickerSearch(gh, "   "))40+    }41+42+    @Test fun matches_title_case_insensitively() {43+        val gh = entry("GitHub", username = "june")44+        assertTrue(matchesPickerSearch(gh, "github"))45+        assertTrue(matchesPickerSearch(gh, "GITHUB"))46+        assertTrue(matchesPickerSearch(gh, "git"))47+    }48+49+    @Test fun matches_username_and_email() {50+        val u = entry("Acme", username = "june.doe")51+        val e = entry("Acme 2", email = "[email protected]")52+        assertTrue(matchesPickerSearch(u, "doe"))53+        assertTrue(matchesPickerSearch(e, "fastmail"))54+    }55+56+    @Test fun matches_url_substring() {57+        val gh = entry("GitHub", url = "https://github.com/june")58+        assertTrue(matchesPickerSearch(gh, "github"))59+    }60+61+    @Test fun rejects_when_no_field_matches() {62+        val gh = entry("GitHub", username = "june", url = "https://github.com")63+        assertFalse(matchesPickerSearch(gh, "fastmail"))64+        assertFalse(matchesPickerSearch(gh, "x"))65+    }66+67+    @Test fun null_fields_are_skipped_not_crashed() {68+        val bare = entry("Bare Title")69+        // title still matches, nothing else is required70+        assertTrue(matchesPickerSearch(bare, "bare"))71+        assertFalse(matchesPickerSearch(bare, "nope"))72+    }73+74+    @Test fun pickerEntries_filter_pipeline_drops_unmatched_and_keeps_order() {75+        val entries = listOf(76+            entry("GitHub", username = "june", url = "https://github.com"),77+            entry("Fastmail", email = "[email protected]"),78+            entry("Peach", url = "https://peachpasswords.com"),79+        )80+        val filtered = entries.filter { matchesPickerSearch(it, "github") }81+        assertEquals(listOf("GitHub"), filtered.map { it.title })82+    }83+}\ No newline at end of file

view file

app/src/test/java/com/peachpasswords/ui/NavigationFlowTest.kt+17 −4
@@ -31,10 +31,23 @@3131     }3232 3333     @Test fun entry_edit_route_handles_null_and_value() {34-        assertEquals("edit?id=", PeachNav.EntryEdit(null).route)35-        assertEquals("edit?id=x", PeachNav.EntryEdit("x").route)36-        assertEquals("edit?id=", PeachNav.EntryEdit.of(null))37-        assertEquals("edit?id=x", PeachNav.EntryEdit.of("x"))34+        assertEquals("edit?id=&prefillUrl=&prefillUsername=&prefillEmail=", PeachNav.EntryEdit(null).route)35+        assertEquals("edit?id=x&prefillUrl=&prefillUsername=&prefillEmail=", PeachNav.EntryEdit("x").route)36+        assertEquals("edit?id=&prefillUrl=&prefillUsername=&prefillEmail=", PeachNav.EntryEdit.of(null))37+        assertEquals("edit?id=x&prefillUrl=&prefillUsername=&prefillEmail=", PeachNav.EntryEdit.of("x"))38+    }39+40+    @Test fun entry_edit_route_carries_prefill_payload() {41+        val route = PeachNav.EntryEdit.of(42+            id = null,43+            prefillUrl = "github.com",44+            prefillUsername = "june",45+            prefillEmail = "[email protected]",46+        )47+        assertEquals(48+            "edit?id=&prefillUrl=github.com&prefillUsername=june&[email protected]",49+            route,50+        )3851     }3952 4053     @Test fun static_routes_are_stable() {

view file