bcd1c9e62e50
feat: signup identity helper in the autofill picker (v2.10.9)
- time
- 2026-08-02 23:30 UTC · 2 months ago
- machine
- koh2
- parent
- ba1ac2895e25
↓ peach-android-bcd1c9e6.tar.gzatlas at this save
files changed5 files
VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+107 −1app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+21 −0app/src/main/java/com/peachpasswords/autofill/Identities.kt+99 −0app/src/test/java/com/peachpasswords/autofill/IdentitiesTest.kt+124 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+107 −1
@@ -2,6 +2,7 @@22 33 import android.content.Intent44 import android.os.Bundle5+import android.service.autofill.Dataset56 import android.view.autofill.AutofillId67 import android.view.autofill.AutofillManager78 import androidx.activity.compose.setContent@@ -150,6 +151,12 @@150151 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()151152 }152153 154+ // A4: signup forms with no saved matches offer derived identities155+ // (deduplicated usernames/emails across the vault).156+ val identities = remember(unlocked, allEntries, matchedEntries) {157+ if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()158+ }159+153160 // Manual pick of an entry the origin didn't auto-match: offer to154161 // remember the origin on the entry before filling.155162 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }@@ -207,6 +214,16 @@207214 pickNow(entry)208215 },209216 onRememberDismiss = { rememberOffer = null },217+ identities = identities,218+ onPickIdentity = { identity ->219+ val epoch = unlockedEpoch220+ if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()221+ else finishWithDataset(222+ AutofillResponseBuilder.identityDatasetFor(223+ this@AutofillEntryPickerActivity, parsed, identity,224+ ),225+ )226+ },210227 error = error,211228 busy = busy,212229 canBiometric = canBiometric(),@@ -304,6 +321,10 @@304321 } else {305322 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)306323 }324+ finishWithDataset(dataset)325+ }326+327+ private fun finishWithDataset(dataset: Dataset?) {307328 if (dataset == null) { cancel(); return }308329 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)309330 setResult(RESULT_OK, reply)@@ -428,6 +449,8 @@428449 onRemember: (VaultEntry, List<String>) -> Unit,429450 onFillOnce: (VaultEntry) -> Unit,430451 onRememberDismiss: () -> Unit,452+ identities: List<SavedIdentity>,453+ onPickIdentity: (SavedIdentity) -> Unit,431454 error: String?,432455 busy: Boolean,433456 canBiometric: Boolean,@@ -481,6 +504,8 @@481504 },482505 )483506 507+ val showIdentities =508+ matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()484509 val visibleEntries = when {485510 debouncedQuery.isBlank() -> matchedEntries486511 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }@@ -488,7 +513,7 @@488513 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489514 490515 val emptyMessage = when {491- matchedEntries.isEmpty() && debouncedQuery.isBlank() ->516+ matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->492517 "No saved entries match this site."493518 showSearchEmpty ->494519 "No entries match \"${debouncedQuery.trim()}\"."@@ -504,6 +529,10 @@504529 )505530 }506531 532+ if (showIdentities) {533+ IdentitySection(identities = identities, onPick = onPickIdentity)534+ }535+507536 if (visibleEntries.isNotEmpty()) {508537 LazyColumn {509538 items(visibleEntries, key = { it.id }) { entry ->@@ -668,6 +697,83 @@668697 fontFamily = PeachType.Mono,669698 fontSize = 13.sp,670699 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),700+ )701+ }702+}703+704+/**705+ * A4 identity helper: signup forms with no saved matches list the vault's706+ * derived identities (deduplicated usernames/emails) with type filter chips —707+ * Android counterpart of the browser's inline identity selector.708+ */709+@Composable710+private fun IdentitySection(711+ identities: List<SavedIdentity>,712+ onPick: (SavedIdentity) -> Unit,713+) {714+ val accent = LocalPeachAccent.current715+ var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }716+ val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }717+718+ Text(719+ text = "use a saved identity",720+ color = peachColors.T2,721+ fontFamily = PeachType.Mono,722+ fontSize = 9.sp,723+ modifier = Modifier.padding(bottom = 8.dp),724+ )725+ Row(726+ horizontalArrangement = Arrangement.spacedBy(8.dp),727+ modifier = Modifier.padding(bottom = 10.dp),728+ ) {729+ for (filterMode in IdentityFilterMode.values()) {730+ val active = filterMode == mode731+ Text(732+ text = filterMode.label,733+ color = if (active) peachColors.S1 else peachColors.T2,734+ fontFamily = PeachType.Mono,735+ fontSize = 10.sp,736+ modifier = Modifier737+ .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))738+ .clickable { mode = filterMode }739+ .padding(horizontal = 10.dp, vertical = 5.dp),740+ )741+ }742+ }743+ LazyColumn {744+ items(visible, key = { it.type.name + ":" + it.value }) { identity ->745+ IdentityRow(identity, onClick = { onPick(identity) })746+ }747+ }748+}749+750+@Composable751+private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {752+ Column(753+ modifier = Modifier754+ .fillMaxWidth()755+ .clickable(onClick = onClick)756+ .padding(vertical = 8.dp),757+ ) {758+ Text(759+ text = identity.value,760+ color = peachColors.T0,761+ fontFamily = PeachType.Body,762+ fontSize = 13.sp,763+ maxLines = 1,764+ overflow = TextOverflow.Ellipsis,765+ )766+ val meta = listOfNotNull(767+ identity.exampleDomain,768+ if (identity.type == IdentityType.EMAIL) "email" else "username",769+ ).joinToString(" \u00b7 ")770+ Text(771+ text = meta,772+ color = peachColors.T2,773+ fontFamily = PeachType.Mono,774+ fontSize = 9.sp,775+ maxLines = 1,776+ overflow = TextOverflow.Ellipsis,671777 )672778 }673779 }app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+21 −0
@@ -186,6 +186,27 @@186186 .build()187187 }188188 189+ /**190+ * A4 identity helper: a dataset filling ONLY the signup identity field.191+ * Email identities prefer the email field, usernames the username field;192+ * each falls back to the other when its preferred field is absent.193+ */194+ fun identityDatasetFor(195+ context: Context,196+ parsed: ParsedStructure,197+ identity: SavedIdentity,198+ ): Dataset? {199+ val targetId = when (identity.type) {200+ IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId201+ IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId202+ } ?: return null203+ val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"204+ val menu = simpleRow(context, identity.value, subtitle)205+ return Dataset.Builder()206+ .setValue(targetId, AutofillValue.forText(identity.value), menu)207+ .build()208+ }209+189210 // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but190211 // reading the slice to build an InlinePresentation is the documented usage.191212 @SuppressLint("RestrictedApi")app/src/main/java/com/peachpasswords/autofill/Identities.kt+99 −0
@@ -0,0 +1,99 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.shared.extractDomain4+import com.peachpasswords.vault.VaultEntry5+6+/**7+ * Signup identities for the autofill picker (parity gap A4). Ports the8+ * browser's `getIdentities()` (background/vault.ts) and `identity-helper.ts`:9+ * identities are DERIVED from active login entries — deduplicated10+ * usernames/emails with usage tallies — not separate vault objects.11+ */12+13+enum class IdentityType { EMAIL, USERNAME }14+15+enum class IdentityFilterMode(val label: String) {16+ BOTH("Both"),17+ EMAIL("Emails"),18+ USERNAME("Usernames"),19+}20+21+data class SavedIdentity(22+ val value: String,23+ val type: IdentityType,24+ val lastUsed: Long,25+ val useCount: Int,26+ val exampleDomain: String? = null,27+)28+29+private val EMAIL_RE = Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")30+31+private class Tally(var lastUsed: Long, var count: Int, var domain: String)32+33+/** Dedupe active login entries' usernames/emails into usage-ordered identities. */34+internal fun deriveIdentities(entries: List<VaultEntry>): List<SavedIdentity> {35+ val active = entries.filter { it.deletedAt == null && it.type == "login" }36+ val emailMap = HashMap<String, Tally>()37+ val usernameMap = HashMap<String, Tally>()38+39+ fun tally(map: HashMap<String, Tally>, value: String, modifiedAt: Long, domain: String) {40+ val existing = map[value]41+ if (existing == null) {42+ map[value] = Tally(modifiedAt, 1, domain)43+ } else {44+ existing.count++45+ if (modifiedAt > existing.lastUsed) {46+ existing.lastUsed = modifiedAt47+ existing.domain = domain48+ }49+ }50+ }51+52+ for (entry in active) {53+ val domain = entry.url?.let(::extractDomain) ?: ""54+ entry.username?.trim()?.takeIf { it.isNotEmpty() }?.let { value ->55+ tally(if (EMAIL_RE.matches(value)) emailMap else usernameMap, value, entry.modifiedAt, domain)56+ }57+ // Count the dedicated email field too — entries created via the58+ // new-entry editor store email here rather than in username.59+ entry.email?.trim()?.takeIf { it.isNotEmpty() }?.let { value ->60+ tally(emailMap, value, entry.modifiedAt, domain)61+ }62+ }63+64+ fun toIdentities(map: HashMap<String, Tally>, type: IdentityType) =65+ map.entries66+ .map { (value, t) ->67+ SavedIdentity(value, type, t.lastUsed, t.count, t.domain.takeIf { it.isNotEmpty() })68+ }69+ .sortedByDescending { it.useCount }70+71+ return toIdentities(emailMap, IdentityType.EMAIL) + toIdentities(usernameMap, IdentityType.USERNAME)72+}73+74+/** Browser `filterSavedIdentities`: type filter, usage order, then substring. */75+internal fun filterIdentities(76+ identities: List<SavedIdentity>,77+ query: String,78+ mode: IdentityFilterMode,79+): List<SavedIdentity> {80+ val typeFiltered = when (mode) {81+ IdentityFilterMode.EMAIL -> identities.filter { it.type == IdentityType.EMAIL }82+ IdentityFilterMode.USERNAME -> identities.filter { it.type == IdentityType.USERNAME }83+ IdentityFilterMode.BOTH -> identities84+ }85+ val sorted = typeFiltered.sortedWith(86+ compareByDescending<SavedIdentity> { it.lastUsed }87+ .thenByDescending { it.useCount }88+ .thenBy { it.value }89+ .thenBy { it.type.name },90+ )91+ val trimmed = query.trim().lowercase()92+ if (trimmed.isEmpty()) return sorted93+ return sorted.filter { identity ->94+ listOf(identity.value, identity.exampleDomain ?: "", identity.type.name.lowercase())95+ .joinToString(" ")96+ .lowercase()97+ .contains(trimmed)98+ }99+}app/src/test/java/com/peachpasswords/autofill/IdentitiesTest.kt+124 −0
@@ -0,0 +1,124 @@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 IdentitiesTest {9+ private fun login(10+ id: String,11+ username: String? = null,12+ email: String? = null,13+ url: String? = null,14+ modifiedAt: Long = 100,15+ deleted: Boolean = false,16+ type: String? = "login",17+ ) = VaultEntry(18+ id = id,19+ title = id,20+ username = username,21+ email = email,22+ url = url,23+ createdAt = 0,24+ modifiedAt = modifiedAt,25+ deletedAt = if (deleted) 1L else null,26+ type = type,27+ )28+29+ @Test fun usernames_and_emails_are_derived_from_login_entries() {30+ val identities = deriveIdentities(31+ listOf(32+ login("a", username = "june", url = "https://github.com"),33+ login("b", email = "[email protected]", url = "https://fastmail.com"),34+ ),35+ )36+ assertEquals(37+ listOf(38+ SavedIdentity("[email protected]", IdentityType.EMAIL, 100, 1, "fastmail.com"),39+ SavedIdentity("june", IdentityType.USERNAME, 100, 1, "github.com"),40+ ),41+ identities,42+ )43+ }44+45+ @Test fun username_shaped_like_an_email_counts_as_email() {46+ val identities = deriveIdentities(listOf(login("a", username = "[email protected]")))47+ assertEquals(listOf(SavedIdentity("[email protected]", IdentityType.EMAIL, 100, 1, null)), identities)48+ }49+50+ @Test fun same_email_across_entries_appears_once_with_use_count() {51+ val identities = deriveIdentities(52+ listOf(53+ login("a", email = "[email protected]", url = "https://one.com", modifiedAt = 10),54+ login("b", username = "[email protected]", url = "https://two.com", modifiedAt = 30),55+ login("c", email = "[email protected]", url = "https://three.com", modifiedAt = 20),56+ ),57+ )58+ val identity = identities.single()59+ assertEquals("[email protected]", identity.value)60+ assertEquals(3, identity.useCount)61+ assertEquals(30L, identity.lastUsed)62+ assertEquals("two.com", identity.exampleDomain)63+ }64+65+ @Test fun emails_come_first_and_sort_by_use_count() {66+ val identities = deriveIdentities(67+ listOf(68+ login("a", username = "u1"),69+ login("b", username = "u2"),70+ login("c", email = "[email protected]"),71+ login("d", email = "[email protected]"),72+ login("e", email = "[email protected]"),73+ ),74+ )75+ assertEquals(76+ listOf("[email protected]", "[email protected]", "u1", "u2"),77+ identities.map { it.value },78+ )79+ }80+81+ @Test fun deleted_and_non_login_entries_are_ignored() {82+ val identities = deriveIdentities(83+ listOf(84+ login("a", username = "gone", deleted = true),85+ login("b", username = "cardholder", type = "card"),86+ login("c", username = "note-taker", type = "note"),87+ ),88+ )89+ assertTrue(identities.isEmpty())90+ }91+92+ @Test fun filter_modes_restrict_the_type() {93+ val identities = listOf(94+ SavedIdentity("[email protected]", IdentityType.EMAIL, 2, 1),95+ SavedIdentity("june", IdentityType.USERNAME, 1, 1),96+ )97+ assertEquals(listOf("[email protected]"), filterIdentities(identities, "", IdentityFilterMode.EMAIL).map { it.value })98+ assertEquals(listOf("june"), filterIdentities(identities, "", IdentityFilterMode.USERNAME).map { it.value })99+ assertEquals(2, filterIdentities(identities, "", IdentityFilterMode.BOTH).size)100+ }101+102+ @Test fun filter_orders_by_last_used_then_use_count() {103+ val identities = listOf(104+ SavedIdentity("[email protected]", IdentityType.EMAIL, lastUsed = 1, useCount = 9),105+ SavedIdentity("[email protected]", IdentityType.EMAIL, lastUsed = 5, useCount = 1),106+ SavedIdentity("[email protected]", IdentityType.EMAIL, lastUsed = 3, useCount = 2),107+ )108+ assertEquals(109+ listOf("[email protected]", "[email protected]", "[email protected]"),110+ filterIdentities(identities, "", IdentityFilterMode.BOTH).map { it.value },111+ )112+ }113+114+ @Test fun filter_query_matches_value_domain_or_type() {115+ val identities = listOf(116+ SavedIdentity("[email protected]", IdentityType.EMAIL, 2, 1, "fastmail.com"),117+ SavedIdentity("june", IdentityType.USERNAME, 1, 1, "github.com"),118+ )119+ assertEquals(listOf("[email protected]"), filterIdentities(identities, "fastmail", IdentityFilterMode.BOTH).map { it.value })120+ assertEquals(listOf("june"), filterIdentities(identities, "github", IdentityFilterMode.BOTH).map { it.value })121+ assertEquals(listOf("[email protected]"), filterIdentities(identities, "email", IdentityFilterMode.BOTH).map { it.value })122+ assertTrue(filterIdentities(identities, "zzz", IdentityFilterMode.BOTH).isEmpty())123+ }124+}