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
↓ peach-android-43e6d3ef.tar.gzatlas at this save
files changed10 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 −4app/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.35fapp/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 fileapp/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 fileapp/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 fileapp/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 fileapp/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) {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 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 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 fileapp/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() {