ba1ac2895e25
feat: remember-this-site — learn manual autofill picks (v2.10.8)
- time
- 2026-08-02 23:20 UTC · 2 months ago
- machine
- koh2
- parent
- 431bdd9b44f6
↓ peach-android-ba1ac289.tar.gzatlas at this save
files changed5 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 −0app/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 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 }app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+1 −0
@@ -111,3 +111,4 @@111111 assertEquals(null, authenticatedPackageName(null))112112 }113113 }114+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+}