ba1ac2895e25

feat: remember-this-site — learn manual autofill picks (v2.10.8)

Queue #3 part 2. Ports the browser extension's remember-this-site flow: when the user manually picks an entry the current origin didn't auto-match, the picker offers to save the origin as a match before filling. - RememberSitePrompt in the picker sheet: Remember & fill / Just fill / Back - 'Remember' merges the origin's domains into the entry's additionalUrls (https://{domain}) so future fills auto-suggest — reuses existing domain matching, zero new matching code - rememberOfferDomains (AutofillOrigin.kt, pure): mirrors the browser trigger — offer only when the entry already carries >=1 known domain and none of them cover this origin - persistence via existing VaultRepository.updateEntry; a failed save never blocks the fill - tests: RememberOfferTest (4); full suite 883, only the 5 known pre-existing failures. Prompt UI needs on-device QA.

time
2026-08-02 23:20 UTC · 2 months ago
machine
koh2
parent
431bdd9b44f6
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+104 −8app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+17 −0app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+1 −0app/src/test/java/com/peachpasswords/autofill/RememberOfferTest.kt+36 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.71+2.10.8

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+104 −8
@@ -143,8 +143,6 @@143143                 var error by remember { mutableStateOf<String?>(null) }144144                 var busy by remember { mutableStateOf(false) }145145 146-                // Trusted origins may search the vault manually. Unknown origins stay147-                // empty until the user types a registrable target domain in the picker.148146                 val allEntries = remember(unlocked) {149147                     if (unlocked) vault.getEntries() else emptyList()150148                 }@@ -152,6 +150,15 @@152150                     if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()153151                 }154152 153+                // Manual pick of an entry the origin didn't auto-match: offer to154+                // remember the origin on the entry before filling.155+                var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }156+                val pickNow: (VaultEntry) -> Unit = { entry ->157+                    val epoch = unlockedEpoch158+                    if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()159+                    else returnDataset(parsed, entry)160+                }161+155162                 // Offer biometrics immediately — it is the whole point of156163                 // unlocking here rather than in the app, and a prompt the user157164                 // can dismiss straight onto the password field costs nothing.@@ -179,6 +186,27 @@179186                         unlocked = unlocked,180187                         matchedEntries = matchedEntries,181188                         allEntries = allEntries,189+                        rememberOffer = rememberOffer,190+                        onRemember = { entry, domains ->191+                            rememberOffer = null192+                            scope.launch {193+                                val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry194+                                runCatching {195+                                    vault.updateEntry(196+                                        current.copy(197+                                            additionalUrls = (current.additionalUrls.orEmpty() +198+                                                domains.map { "https://$it" }).distinct(),199+                                        ),200+                                    )201+                                }202+                                pickNow(entry)203+                            }204+                        },205+                        onFillOnce = { entry ->206+                            rememberOffer = null207+                            pickNow(entry)208+                        },209+                        onRememberDismiss = { rememberOffer = null },182210                         error = error,183211                         busy = busy,184212                         canBiometric = canBiometric(),@@ -207,12 +235,8 @@207235                             }208236                         },209237                         onPick = { entry ->210-                            val epoch = unlockedEpoch211-                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {212-                                cancel()213-                            } else {214-                                returnDataset(parsed, entry)215-                            }238+                            val offer = rememberOfferDomains(entry, parsed)239+                            if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer216240                         },217241                         onCreateNew = { searchText ->218242                             launchNewEntry(parsed, typedUsername, typedEmail, searchText)@@ -400,6 +424,10 @@400424     unlocked: Boolean,401425     matchedEntries: List<VaultEntry>,402426     allEntries: List<VaultEntry>,427+    rememberOffer: Pair<VaultEntry, List<String>>?,428+    onRemember: (VaultEntry, List<String>) -> Unit,429+    onFillOnce: (VaultEntry) -> Unit,430+    onRememberDismiss: () -> Unit,403431     error: String?,404432     busy: Boolean,405433     canBiometric: Boolean,@@ -427,6 +455,14 @@427455 428456         if (!unlocked) {429457             UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)458+        } else if (rememberOffer != null) {459+            RememberSitePrompt(460+                entryTitle = rememberOffer!!.first.title,461+                domains = rememberOffer!!.second,462+                onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },463+                onFillOnce = { onFillOnce(rememberOffer!!.first) },464+                onBack = onRememberDismiss,465+            )430466         } else {431467             // The search field is a vault-wide substring search; an empty query432468             // shows the origin's matched entries.@@ -573,6 +609,66 @@573609                 )574610             }575611         }612+    }613+}614+615+/**616+ * Offered when the user manually picks an entry the origin didn't auto-match:617+ * save the origin's domains onto the entry (additional URLs) so it is618+ * suggested automatically next time — Android counterpart of the browser's619+ * "Remember this site" toast.620+ */621+@Composable622+private fun RememberSitePrompt(623+    entryTitle: String,624+    domains: List<String>,625+    onRemember: () -> Unit,626+    onFillOnce: () -> Unit,627+    onBack: () -> Unit,628+) {629+    val accent = LocalPeachAccent.current630+    Text(631+        text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",632+        color = peachColors.T0,633+        fontFamily = PeachType.Body,634+        fontSize = 14.sp,635+    )636+    Text(637+        text = "It will be suggested automatically the next time you fill here.",638+        color = peachColors.T2,639+        fontFamily = PeachType.Mono,640+        fontSize = 10.sp,641+        modifier = Modifier.padding(top = 4.dp),642+    )643+    Row(644+        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),645+        horizontalArrangement = Arrangement.spacedBy(18.dp),646+        verticalAlignment = Alignment.CenterVertically,647+    ) {648+        Text(649+            text = "Remember & fill",650+            color = accent,651+            fontFamily = PeachType.Mono,652+            fontSize = 13.sp,653+            modifier = Modifier654+                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))655+                .clickable(onClick = onRemember)656+                .padding(horizontal = 12.dp, vertical = 8.dp),657+        )658+        Text(659+            text = "Just fill",660+            color = peachColors.T1,661+            fontFamily = PeachType.Mono,662+            fontSize = 13.sp,663+            modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),664+        )665+        Text(666+            text = "Back",667+            color = peachColors.T2,668+            fontFamily = PeachType.Mono,669+            fontSize = 13.sp,670+            modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),671+        )576672     }577673 }578674 

view file

app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+17 −0
@@ -5,6 +5,7 @@55 import android.content.pm.verify.domain.DomainVerificationUserState66 import android.os.Build77 import com.peachpasswords.shared.extractDomain8+import com.peachpasswords.vault.VaultEntry89 910 /**1011  * Origin resolution for autofill matching.@@ -59,4 +60,20 @@5960             .distinct()6061             .sorted()6162     }.getOrDefault(emptyList())63+}64+65+/**66+ * Domains to offer to "remember" when the user manually picks [entry] for this67+ * origin (Android counterpart of the browser's remember-this-site toast).68+ * Mirrors the browser trigger: only offer when the entry already carries at69+ * least one known domain and none of them cover this origin. Empty = no offer.70+ */71+internal fun rememberOfferDomains(entry: VaultEntry, parsed: ParsedStructure): List<String> {72+    val existing = sequenceOf(entry.url)73+        .plus(entry.additionalUrls.orEmpty().asSequence())74+        .filterNotNull()75+        .mapNotNull(::extractDomain)76+        .toSet()77+    if (existing.isEmpty() || parsed.originDomains.isEmpty()) return emptyList()78+    return parsed.originDomains.filter { it !in existing }6279 }

view file

app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+1 −0
@@ -111,3 +111,4 @@111111         assertEquals(null, authenticatedPackageName(null))112112     }113113 }114+

view file

app/src/test/java/com/peachpasswords/autofill/RememberOfferTest.kt+36 −0
@@ -0,0 +1,36 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.vault.VaultEntry4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class RememberOfferTest {9+    private fun entry(title: String, url: String?) =10+        VaultEntry(id = title, title = title, url = url, createdAt = 0, modifiedAt = 0)11+12+    @Test fun manual_pick_on_new_web_origin_offers_the_page_domain() {13+        val bank = entry("Bank", "https://bank.example.com")14+        val parsed = ParsedStructure(webDomain = "shop.other.com", originDomains = listOf("other.com"))15+        assertEquals(listOf("other.com"), rememberOfferDomains(bank, parsed))16+    }17+18+    @Test fun no_offer_when_origin_domain_already_covered() {19+        val bank = entry("Bank", "https://bank.example.com")20+            .copy(additionalUrls = listOf("https://other.com/login"))21+        val parsed = ParsedStructure(webDomain = "other.com", originDomains = listOf("other.com"))22+        assertTrue(rememberOfferDomains(bank, parsed).isEmpty())23+    }24+25+    @Test fun no_offer_for_entry_without_any_domains() {26+        val note = entry("Note", null)27+        val parsed = ParsedStructure(webDomain = "other.com", originDomains = listOf("other.com"))28+        assertTrue(rememberOfferDomains(note, parsed).isEmpty())29+    }30+31+    @Test fun no_offer_when_origin_has_no_domains() {32+        val bank = entry("Bank", "https://bank.example.com")33+        val parsed = ParsedStructure(packageName = "com.example.app")34+        assertTrue(rememberOfferDomains(bank, parsed).isEmpty())35+    }36+}

view file