bcd1c9e62e50

feat: signup identity helper in the autofill picker (v2.10.9)

Parity queue #4 (A4), porting the browser's identity-first signup surface (getIdentities + content/identity-helper.ts). - Identities.kt (pure): deriveIdentities dedupes usernames/emails across active login entries with usage tallies (email-shaped usernames count as emails; dedicated email field tallied; emails first, useCount order); filterIdentities ports filterSavedIdentities (Both/Emails/Usernames + substring over value/domain/type) - Picker: origins with no saved matches list derived identities with filter chips instead of an empty state; a tap fills ONLY the identity field (identityDatasetFor: email->emailId fallback usernameId, username-> usernameId fallback emailId) - Two-step wizard deliberately not ported: Android fills complete the autofill session, so the second field fills on the next picker open - tests: IdentitiesTest (8); full suite 891, only the 5 known pre-existing failures. Identity UI needs on-device QA.

time
2026-08-02 23:30 UTC · 2 months ago
machine
koh2
parent
ba1ac2895e25
5 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.81+2.10.9

view file

app/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 }

view file

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")

view file

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+}

view file

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+}

view file