From f926f4c45d636454a52f9f8a413d0cce101e8378 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 23 Jul 2026 17:41:24 +0100 Subject: [PATCH 1/8] Add heuristic detection for identity autofill fields --- .../com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt index 8cd748d018a..6eb60de1a23 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt @@ -270,5 +270,4 @@ private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List = listOf( "type", "hint", "autofill", - "autocomplete", ) From 89edb292b5d0bac2c0a53f96e4db50222369b07d Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 23 Jul 2026 11:36:25 +0100 Subject: [PATCH 2/8] Add model and data layer for Identity Autofill --- .../autofill/builder/FilledDataBuilderImpl.kt | 2 +- .../autofill/parser/AutofillParserImpl.kt | 60 ++++--------------- 2 files changed, 13 insertions(+), 49 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt index da12e597298..e7a23226d32 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -94,7 +94,7 @@ class FilledDataBuilderImpl( is AutofillPartition.Identity -> { // Filling an identity partition is wired up in a later phase; this is a no-op - // today since an identity partition is never constructed yet. + // today since nothing yet classifies a view as Identity. emptyList() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index f858fa3685a..eaa874c5c11 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -523,57 +523,21 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie is AutofillView.Login.Email -> this.copy(data = this.data.copy(website = site)) is AutofillView.Login.Password -> this.copy(data = this.data.copy(website = site)) is AutofillView.Login.Username -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.AddressCountry -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.AddressLocality -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.AddressRegion -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.AddressStreet -> { - this.copy(data = this.data.copy(website = site)) - } - + is AutofillView.Identity.AddressCountry -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.AddressLocality -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.AddressRegion -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.AddressStreet -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.Company -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.Email -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.LicenseNumber -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PassportNumber -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PersonNameFamily -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PersonNameFull -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PersonNameGiven -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PersonNameMiddle -> { - this.copy(data = this.data.copy(website = site)) - } - - is AutofillView.Identity.PersonNamePrefix -> { - this.copy(data = this.data.copy(website = site)) - } - + is AutofillView.Identity.LicenseNumber -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PassportNumber -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameFamily -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameFull -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameGiven -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameMiddle -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNamePrefix -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.PhoneFull -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.PostalAddressFull -> { - this.copy(data = this.data.copy(website = site)) - } - + is AutofillView.Identity.PostalAddressFull -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.PostalCode -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.Ssn -> this.copy(data = this.data.copy(website = site)) is AutofillView.Unused -> this.copy(data = this.data.copy(website = site)) From e018259f558dce7c4d1357ecb4de936465018040 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 23 Jul 2026 17:41:24 +0100 Subject: [PATCH 3/8] Add heuristic detection for identity autofill fields --- .../bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt index e7a23226d32..da12e597298 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -94,7 +94,7 @@ class FilledDataBuilderImpl( is AutofillPartition.Identity -> { // Filling an identity partition is wired up in a later phase; this is a no-op - // today since nothing yet classifies a view as Identity. + // today since an identity partition is never constructed yet. emptyList() } } From 1ff6d4f461b8f1f8b47f7633f91a54a689a36212 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 23 Jul 2026 22:05:03 +0100 Subject: [PATCH 4/8] Added identity data to FillAssistViewNodeExtensions --- .../util/FillAssistViewNodeExtensions.kt | 111 ++++++++++++++---- .../util/FillAssistViewNodeExtensionsTest.kt | 62 +++++++++- 2 files changed, 147 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt index 13d2fe6c4f0..c1ab7cb2ffe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -16,6 +16,22 @@ private const val FIELD_KEY_CARD_EXPIRATION_MONTH = "cardExpirationMonth" private const val FIELD_KEY_CARD_EXPIRATION_YEAR = "cardExpirationYear" private const val FIELD_KEY_CARD_CVV = "cardCvv" private const val FIELD_KEY_CARD_TYPE = "cardType" +private const val FIELD_KEY_PERSON_NAME_FULL = "personNameFull" +private const val FIELD_KEY_PERSON_NAME_PREFIX = "personNamePrefix" +private const val FIELD_KEY_PERSON_NAME_GIVEN = "personNameGiven" +private const val FIELD_KEY_PERSON_NAME_MIDDLE = "personNameMiddle" +private const val FIELD_KEY_PERSON_NAME_FAMILY = "personNameFamily" +private const val FIELD_KEY_POSTAL_ADDRESS_FULL = "postalAddressFull" +private const val FIELD_KEY_ADDRESS_STREET = "addressStreet" +private const val FIELD_KEY_ADDRESS_LOCALITY = "addressLocality" +private const val FIELD_KEY_ADDRESS_REGION = "addressRegion" +private const val FIELD_KEY_ADDRESS_COUNTRY = "addressCountry" +private const val FIELD_KEY_POSTAL_CODE = "postalCode" +private const val FIELD_KEY_PHONE_FULL = "phoneFull" +private const val FIELD_KEY_COMPANY = "company" +private const val FIELD_KEY_SSN = "ssn" +private const val FIELD_KEY_PASSPORT_NUMBER = "passportNumber" +private const val FIELD_KEY_LICENSE_NUMBER = "licenseNumber" /** * Traverses the [AssistStructure] and returns a list of [AutofillView]s classified by the @@ -36,7 +52,7 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( parentWebsite: String?, ): List { val website = this.website ?: parentWebsite - val ownView = autofillId?.let { id -> + val ownViews = autofillId?.let { id -> hostRules .flatMap { it.fields.entries } .filter { (_, alternatives) -> @@ -48,17 +64,27 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( ?.let { matchingEntries -> val data = toAutofillViewData(autofillId = id, website = website) val candidateViews = matchingEntries.mapNotNull { (key, _) -> - key.toAutofillViewForFieldKey(data = data) + key.toAutofillViewForFieldKey(data = data)?.let { key to it } } // A single field can legitimately match both the "email" and "phone"/"username" // keys (e.g. a combined phone-or-email login field). Login.Username has no format // gate and fills any stored value, while Login.Email rejects non-email values via // isValidEmail(). Preferring Username when both match avoids rejecting a phone // number credential on a field that would have accepted it. - candidateViews.firstOrNull { it is AutofillView.Login.Username } + candidateViews.firstOrNull { (_, view) -> view is AutofillView.Login.Username } ?: candidateViews.firstOrNull() } - } + ?.let { (key, view) -> + // An email field key is offered as both a Login candidate (primary) and an + // Identity candidate, mirroring the same dual-classification used by heuristic + // detection, since the two partitions aren't mutually exclusive for this field. + if (key == FIELD_KEY_EMAIL) { + listOf(view, AutofillView.Identity.Email(data = view.data)) + } else { + listOf(view) + } + } + }.orEmpty() val childViews = (0 until childCount) .flatMap { index -> getChildAt(index).traverseForFillAssist( @@ -66,27 +92,64 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( parentWebsite = website, ) } - return listOfNotNull(ownView) + childViews + return ownViews + childViews } -private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = when (this) { - FIELD_KEY_USERNAME, FIELD_KEY_PHONE -> AutofillView.Login.Username(data = data) - FIELD_KEY_EMAIL -> AutofillView.Login.Email(data = data) - FIELD_KEY_PASSWORD, FIELD_KEY_NEW_PASSWORD -> AutofillView.Login.Password(data = data) - FIELD_KEY_CARD_NUMBER -> AutofillView.Card.Number(data = data) - FIELD_KEY_CARDHOLDER_NAME -> AutofillView.Card.CardholderName(data = data) - FIELD_KEY_CARD_EXPIRATION_DATE -> AutofillView.Card.ExpirationDate(data = data) - FIELD_KEY_CARD_EXPIRATION_MONTH -> AutofillView.Card.ExpirationMonth( - data = data, - monthValue = null, - ) +/** + * Maps this field key to the [AutofillView] it represents, or null if this key is unrecognized. + * Delegates to a type-specific mapper ([toLoginViewForFieldKey], [toCardViewForFieldKey], + * [toIdentityViewForFieldKey]) grouped by the category the field key belongs to. + */ +private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = + toLoginViewForFieldKey(data = data) + ?: toCardViewForFieldKey(data = data) + ?: toIdentityViewForFieldKey(data = data) + +private fun String.toLoginViewForFieldKey(data: AutofillView.Data): AutofillView.Login? = + when (this) { + FIELD_KEY_USERNAME, FIELD_KEY_PHONE -> AutofillView.Login.Username(data = data) + FIELD_KEY_EMAIL -> AutofillView.Login.Email(data = data) + FIELD_KEY_PASSWORD, FIELD_KEY_NEW_PASSWORD -> AutofillView.Login.Password(data = data) + else -> null + } - FIELD_KEY_CARD_EXPIRATION_YEAR -> AutofillView.Card.ExpirationYear( - data = data, - yearValue = null, - ) +private fun String.toCardViewForFieldKey(data: AutofillView.Data): AutofillView.Card? = + when (this) { + FIELD_KEY_CARD_NUMBER -> AutofillView.Card.Number(data = data) + FIELD_KEY_CARDHOLDER_NAME -> AutofillView.Card.CardholderName(data = data) + FIELD_KEY_CARD_EXPIRATION_DATE -> AutofillView.Card.ExpirationDate(data = data) + FIELD_KEY_CARD_EXPIRATION_MONTH -> AutofillView.Card.ExpirationMonth( + data = data, + monthValue = null, + ) - FIELD_KEY_CARD_CVV -> AutofillView.Card.SecurityCode(data = data) - FIELD_KEY_CARD_TYPE -> AutofillView.Card.Brand(data = data, brandValue = null) - else -> null -} + FIELD_KEY_CARD_EXPIRATION_YEAR -> AutofillView.Card.ExpirationYear( + data = data, + yearValue = null, + ) + + FIELD_KEY_CARD_CVV -> AutofillView.Card.SecurityCode(data = data) + FIELD_KEY_CARD_TYPE -> AutofillView.Card.Brand(data = data, brandValue = null) + else -> null + } + +private fun String.toIdentityViewForFieldKey(data: AutofillView.Data): AutofillView.Identity? = + when (this) { + FIELD_KEY_PERSON_NAME_FULL -> AutofillView.Identity.PersonNameFull(data = data) + FIELD_KEY_PERSON_NAME_PREFIX -> AutofillView.Identity.PersonNamePrefix(data = data) + FIELD_KEY_PERSON_NAME_GIVEN -> AutofillView.Identity.PersonNameGiven(data = data) + FIELD_KEY_PERSON_NAME_MIDDLE -> AutofillView.Identity.PersonNameMiddle(data = data) + FIELD_KEY_PERSON_NAME_FAMILY -> AutofillView.Identity.PersonNameFamily(data = data) + FIELD_KEY_POSTAL_ADDRESS_FULL -> AutofillView.Identity.PostalAddressFull(data = data) + FIELD_KEY_ADDRESS_STREET -> AutofillView.Identity.AddressStreet(data = data) + FIELD_KEY_ADDRESS_LOCALITY -> AutofillView.Identity.AddressLocality(data = data) + FIELD_KEY_ADDRESS_REGION -> AutofillView.Identity.AddressRegion(data = data) + FIELD_KEY_ADDRESS_COUNTRY -> AutofillView.Identity.AddressCountry(data = data) + FIELD_KEY_POSTAL_CODE -> AutofillView.Identity.PostalCode(data = data) + FIELD_KEY_PHONE_FULL -> AutofillView.Identity.PhoneFull(data = data) + FIELD_KEY_COMPANY -> AutofillView.Identity.Company(data = data) + FIELD_KEY_SSN -> AutofillView.Identity.Ssn(data = data) + FIELD_KEY_PASSPORT_NUMBER -> AutofillView.Identity.PassportNumber(data = data) + FIELD_KEY_LICENSE_NUMBER -> AutofillView.Identity.LicenseNumber(data = data) + else -> null + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt index fda0040a5ee..4f36f53750a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -77,8 +77,9 @@ class FillAssistViewNodeExtensionsTest { assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) } + @Suppress("MaxLineLength") @Test - fun `buildFillAssistViews should return Login Email when htmlInfo matches email clause`() { + fun `buildFillAssistViews should return Login Email and Identity Email when htmlInfo matches email clause`() { val htmlInfo = createHtmlInfo() val viewNode = createViewNode(htmlInfo = htmlInfo) val assistStructure = createAssistStructure(viewNode) @@ -102,7 +103,13 @@ class FillAssistViewNodeExtensionsTest { urlBarWebsite = null, ) - assertEquals(listOf(AutofillView.Login.Email(data = data)), actual) + assertEquals( + listOf( + AutofillView.Login.Email(data = data), + AutofillView.Identity.Email(data = data), + ), + actual, + ) } @Test @@ -228,6 +235,57 @@ class FillAssistViewNodeExtensionsTest { } } + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should map all identity field keys to correct AutofillView subtypes`() { + val identityFieldExpectations: List> = listOf( + "personNameFull" to AutofillView.Identity.PersonNameFull(data = autofillData()), + "personNamePrefix" to AutofillView.Identity.PersonNamePrefix(data = autofillData()), + "personNameGiven" to AutofillView.Identity.PersonNameGiven(data = autofillData()), + "personNameMiddle" to AutofillView.Identity.PersonNameMiddle(data = autofillData()), + "personNameFamily" to AutofillView.Identity.PersonNameFamily(data = autofillData()), + "postalAddressFull" to AutofillView.Identity.PostalAddressFull(data = autofillData()), + "addressStreet" to AutofillView.Identity.AddressStreet(data = autofillData()), + "addressLocality" to AutofillView.Identity.AddressLocality(data = autofillData()), + "addressRegion" to AutofillView.Identity.AddressRegion(data = autofillData()), + "addressCountry" to AutofillView.Identity.AddressCountry(data = autofillData()), + "postalCode" to AutofillView.Identity.PostalCode(data = autofillData()), + "phoneFull" to AutofillView.Identity.PhoneFull(data = autofillData()), + "company" to AutofillView.Identity.Company(data = autofillData()), + "ssn" to AutofillView.Identity.Ssn(data = autofillData()), + "passportNumber" to AutofillView.Identity.PassportNumber(data = autofillData()), + "licenseNumber" to AutofillView.Identity.LicenseNumber(data = autofillData()), + ) + + identityFieldExpectations.forEach { (fieldKey, expectedView) -> + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns data + + val hostRule = FillAssistRules.HostRule( + category = "identity-form", + fields = mapOf( + fieldKey to listOf(selectorClause(tag = "input", id = "field-$fieldKey")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals( + listOf(expectedView), + actual, + "Failed for field key: $fieldKey", + ) + } + } + @Test fun `buildFillAssistViews should exclude node whose matched field key is unknown`() { val htmlInfo = createHtmlInfo() From 993488be00dceabc8b54fb0bb106fe1b56b2276b Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 1 Sep 2026 17:54:36 +0100 Subject: [PATCH 5/8] Improved identity gating --- .../autofill/parser/AutofillParserImpl.kt | 23 +++- .../util/FillAssistViewNodeExtensions.kt | 64 ++++++---- .../util/FillAssistViewNodeExtensionsTest.kt | 115 +++++++++++++++++- 3 files changed, 171 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index eaa874c5c11..33c56861e3a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -177,6 +177,7 @@ class AutofillParserImpl( uri = uri, focusedView = focusedView, urlBarWebsite = urlBarWebsite, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, ) } else { autofillViews @@ -248,6 +249,7 @@ class AutofillParserImpl( uri: String?, focusedView: AutofillView, urlBarWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): List { val hostRules = uri ?.takeUnless { it.startsWith("androidapp://") } @@ -275,6 +277,7 @@ class AutofillParserImpl( val fillAssistViews = assistStructure.buildFillAssistViews( hostRules = hostRules, urlBarWebsite = urlBarWebsite, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, ) // Fill-assist is authoritative for a partition its rules cover (guarded by // coversCurrentPartition above), so its views are used even when empty: for Login/Card @@ -531,13 +534,25 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie is AutofillView.Identity.Email -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.LicenseNumber -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.PassportNumber -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.PersonNameFamily -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameFamily -> { + this.copy(data = this.data.copy(website = site)) + } + is AutofillView.Identity.PersonNameFull -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.PersonNameGiven -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.PersonNameMiddle -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.PersonNamePrefix -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PersonNameMiddle -> { + this.copy(data = this.data.copy(website = site)) + } + + is AutofillView.Identity.PersonNamePrefix -> { + this.copy(data = this.data.copy(website = site)) + } + is AutofillView.Identity.PhoneFull -> this.copy(data = this.data.copy(website = site)) - is AutofillView.Identity.PostalAddressFull -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Identity.PostalAddressFull -> { + this.copy(data = this.data.copy(website = site)) + } + is AutofillView.Identity.PostalCode -> this.copy(data = this.data.copy(website = site)) is AutofillView.Identity.Ssn -> this.copy(data = this.data.copy(website = site)) is AutofillView.Unused -> this.copy(data = this.data.copy(website = site)) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt index c1ab7cb2ffe..971234e914a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -37,19 +37,27 @@ private const val FIELD_KEY_LICENSE_NUMBER = "licenseNumber" * Traverses the [AssistStructure] and returns a list of [AutofillView]s classified by the * provided [hostRules]. Only view nodes whose [android.view.ViewStructure.HtmlInfo] attributes * match a [FillAssistRules.SelectorClause] are included; unmatched nodes are omitted (no - * heuristic fallback). + * heuristic fallback). All identity classification is gated behind [isIdentityAutofillEnabled]. */ internal fun AssistStructure.buildFillAssistViews( hostRules: List, urlBarWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): List = (0 until windowNodeCount) .mapNotNull { getWindowNodeAt(it).rootViewNode } - .flatMap { it.traverseForFillAssist(hostRules = hostRules, parentWebsite = urlBarWebsite) } + .flatMap { + it.traverseForFillAssist( + hostRules = hostRules, + parentWebsite = urlBarWebsite, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + ) + } private fun AssistStructure.ViewNode.traverseForFillAssist( hostRules: List, parentWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): List { val website = this.website ?: parentWebsite val ownViews = autofillId?.let { id -> @@ -64,24 +72,34 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( ?.let { matchingEntries -> val data = toAutofillViewData(autofillId = id, website = website) val candidateViews = matchingEntries.mapNotNull { (key, _) -> - key.toAutofillViewForFieldKey(data = data)?.let { key to it } + key.toAutofillViewForFieldKey( + data = data, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + )?.let { key to it } } - // A single field can legitimately match both the "email" and "phone"/"username" - // keys (e.g. a combined phone-or-email login field). Login.Username has no format - // gate and fills any stored value, while Login.Email rejects non-email values via - // isValidEmail(). Preferring Username when both match avoids rejecting a phone - // number credential on a field that would have accepted it. - candidateViews.firstOrNull { (_, view) -> view is AutofillView.Login.Username } - ?: candidateViews.firstOrNull() - } - ?.let { (key, view) -> - // An email field key is offered as both a Login candidate (primary) and an - // Identity candidate, mirroring the same dual-classification used by heuristic - // detection, since the two partitions aren't mutually exclusive for this field. - if (key == FIELD_KEY_EMAIL) { - listOf(view, AutofillView.Identity.Email(data = view.data)) - } else { - listOf(view) + // Prefer Username: it has no format gate, while Login.Email rejects non-email + // values via isValidEmail(). + val view = candidateViews + .firstOrNull { (_, view) -> view is AutofillView.Login.Username } + ?.second + ?: candidateViews.firstOrNull()?.second + ?: return@let null + + // Dual-classify off the full matched-key set, not just the winning key, so a + // field matched under both "email" and "phone" gets both Identity views. + val isLoginIdentifierView = view is AutofillView.Login.Username || + view is AutofillView.Login.Email + buildList { + add(view) + if (isIdentityAutofillEnabled && isLoginIdentifierView) { + val matchedKeys = candidateViews.mapTo(mutableSetOf()) { it.first } + if (FIELD_KEY_EMAIL in matchedKeys) { + add(AutofillView.Identity.Email(data = view.data)) + } + if (FIELD_KEY_PHONE in matchedKeys) { + add(AutofillView.Identity.PhoneFull(data = view.data)) + } + } } } }.orEmpty() @@ -90,6 +108,7 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( getChildAt(index).traverseForFillAssist( hostRules = hostRules, parentWebsite = website, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, ) } return ownViews + childViews @@ -100,10 +119,13 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( * Delegates to a type-specific mapper ([toLoginViewForFieldKey], [toCardViewForFieldKey], * [toIdentityViewForFieldKey]) grouped by the category the field key belongs to. */ -private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = +private fun String.toAutofillViewForFieldKey( + data: AutofillView.Data, + isIdentityAutofillEnabled: Boolean, +): AutofillView? = toLoginViewForFieldKey(data = data) ?: toCardViewForFieldKey(data = data) - ?: toIdentityViewForFieldKey(data = data) + ?: if (isIdentityAutofillEnabled) toIdentityViewForFieldKey(data = data) else null private fun String.toLoginViewForFieldKey(data: AutofillView.Data): AutofillView.Login? = when (this) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt index 4f36f53750a..862a6331e8c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -42,6 +42,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = emptyList(), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -55,6 +56,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -72,6 +74,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -101,6 +104,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals( @@ -112,8 +116,39 @@ class FillAssistViewNodeExtensionsTest { ) } + @Suppress("MaxLineLength") @Test - fun `buildFillAssistViews should return Login Username when htmlInfo matches phone clause`() { + fun `buildFillAssistViews should return only Login Email when htmlInfo matches email clause and IdentityAutofill is disabled`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { + viewNode.toAutofillViewData( + autofillId = autofillId, + website = null, + ) + } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "email" to listOf(selectorClause(tag = "input", id = "email")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(listOf(AutofillView.Login.Email(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Username and Identity PhoneFull when htmlInfo matches phone clause`() { val htmlInfo = createHtmlInfo() val viewNode = createViewNode(htmlInfo = htmlInfo) val assistStructure = createAssistStructure(viewNode) @@ -135,9 +170,16 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) - assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + assertEquals( + listOf( + AutofillView.Login.Username(data = data), + AutofillView.Identity.PhoneFull(data = data), + ), + actual, + ) } @Suppress("MaxLineLength") @@ -159,6 +201,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) @@ -183,6 +226,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) @@ -225,6 +269,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals( @@ -276,6 +321,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals( @@ -286,6 +332,32 @@ class FillAssistViewNodeExtensionsTest { } } + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should exclude identity field key when IdentityAutofill is disabled`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns autofillData() + + val hostRule = FillAssistRules.HostRule( + category = "identity-form", + fields = mapOf( + "personNameFull" to listOf(selectorClause(tag = "input", id = "name")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(emptyList(), actual) + } + @Test fun `buildFillAssistViews should exclude node whose matched field key is unknown`() { val htmlInfo = createHtmlInfo() @@ -305,6 +377,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -316,7 +389,7 @@ class FillAssistViewNodeExtensionsTest { // Two field keys ("email" and "username") both match the same node, e.g. a combined // phone-or-email login field. Login.Username has no format gate and fills any stored // value, while Login.Email rejects non-email values, so Username is preferred even - // though "email" is listed first. + // though "email" is listed first. The "email" key is still dual-classified as Identity. val htmlInfo = createHtmlInfo() val viewNode = createViewNode(htmlInfo = htmlInfo) val assistStructure = createAssistStructure(viewNode) @@ -334,16 +407,24 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) - assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + assertEquals( + listOf( + AutofillView.Login.Username(data = data), + AutofillView.Identity.Email(data = data), + ), + actual, + ) } @Suppress("MaxLineLength") @Test fun `buildFillAssistViews should prefer Login Username over Login Email when phone and email keys match the same node`() { // Mirrors a real fill-assist rule where a single combined phone-or-email field is - // declared under both the "email" and "phone" keys. + // declared under both the "email" and "phone" keys. Both keys are dual-classified as + // Identity, regardless of which key won the Username-preference tie-break above. val htmlInfo = createHtmlInfo() val viewNode = createViewNode(htmlInfo = htmlInfo) val assistStructure = createAssistStructure(viewNode) @@ -361,9 +442,17 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) - assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + assertEquals( + listOf( + AutofillView.Login.Username(data = data), + AutofillView.Identity.Email(data = data), + AutofillView.Identity.PhoneFull(data = data), + ), + actual, + ) } @Test @@ -386,6 +475,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = childData)), actual) @@ -406,6 +496,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = urlBarWebsite, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -426,6 +517,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = urlBarWebsite, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -440,6 +532,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -454,6 +547,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -486,6 +580,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -501,6 +596,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -517,6 +613,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -531,6 +628,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -563,6 +661,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -593,6 +692,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -610,6 +710,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -641,6 +742,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -669,6 +771,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals( From 3679b6693ff4b2e1037edb67e1093480f496e2b0 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Wed, 2 Sep 2026 15:24:50 +0100 Subject: [PATCH 6/8] Filter out autofill identity --- .../autofill/parser/AutofillParserImpl.kt | 4 +- .../autofill/parser/AutofillParserTests.kt | 108 ++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index 33c56861e3a..aa857474820 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -183,7 +183,9 @@ class AutofillParserImpl( autofillViews } - val effectiveFocusedView = effectiveViews.firstFocusedOrNull() + val effectiveFocusedView = effectiveViews + .filterNot { it is AutofillView.Identity } + .firstFocusedOrNull() ?: return AutofillRequest.Unfillable // Choose the first focused partition of data for fulfillment. diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index e31da831e6e..e7a6e344b06 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -1786,6 +1786,114 @@ class AutofillParserTests { assertEquals(expected, actual) } + @Suppress("MaxLineLength") + @Test + fun `parse should choose AutofillPartition Login when fill-assist resolves the focused view to Identity but a Login view is fillable elsewhere`() { + // A host rule pools an identity field with a login field under one category, so + // fill-assist reclassifies the focused Unused node as Identity while a fillable Login + // view exists elsewhere -- this must not force Unfillable. + mutableFillAssistFlagFlow.value = true + mutableIdentityAutofillFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + + val usernameSelectorClause = FillAssistRules.SelectorClause( + tag = "input", id = "user", name = null, type = null, role = null, + ) + val nameSelectorClause = FillAssistRules.SelectorClause( + tag = "input", id = "fname", name = null, type = null, role = null, + ) + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "account-creation", + fields = mapOf( + "username" to listOf(usernameSelectorClause), + "personNameFull" to listOf(nameSelectorClause), + ), + ), + ), + ), + ) + + val identityAutofillId: AutofillId = mockk() + val identityHtmlInfo: HtmlInfo = mockk(relaxed = true) + val identityViewNode: AssistStructure.ViewNode = mockk { + every { this@mockk.autofillHints } returns emptyArray() + every { this@mockk.autofillId } returns identityAutofillId + every { this@mockk.childCount } returns 0 + every { this@mockk.htmlInfo } returns identityHtmlInfo + every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.website } returns null + } + val identityWindowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns identityViewNode + } + every { identityHtmlInfo.matchesSelectorClause(nameSelectorClause) } returns true + every { loginViewNode.htmlInfo!!.matchesSelectorClause(usernameSelectorClause) } returns true + + val unusedIdentityView = AutofillView.Unused( + data = AutofillView.Data( + autofillId = identityAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = null, + ), + ) + val unusedLoginView = AutofillView.Unused( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = null, + ), + ) + every { assistStructure.windowNodeCount } returns 2 + every { assistStructure.getWindowNodeAt(0) } returns identityWindowNode + every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode + every { + identityViewNode.toAutofillView(parentWebsite = any(), isIdentityAutofillEnabled = any()) + } returns unusedIdentityView + every { + loginViewNode.toAutofillView(parentWebsite = any(), isIdentityAutofillEnabled = any()) + } returns unusedLoginView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: falls through to the fillable Login view instead of becoming Unfillable. + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login( + views = listOf( + AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = WEBSITE, + ), + ), + ), + ), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + @Suppress("MaxLineLength") @Test fun `parse should promote a phone-hinted field to Login Username via updateForMissingUsernameFields when IdentityAutofill is disabled`() { From 21df4fe238c130d103b2fe7ff220243bd111dce8 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Wed, 2 Sep 2026 16:06:38 +0100 Subject: [PATCH 7/8] Prioritize login/card classifications over identity --- .../util/FillAssistViewNodeExtensions.kt | 6 +++- .../util/FillAssistViewNodeExtensionsTest.kt | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt index 971234e914a..1903ef2cf15 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -78,10 +78,14 @@ private fun AssistStructure.ViewNode.traverseForFillAssist( )?.let { key to it } } // Prefer Username: it has no format gate, while Login.Email rejects non-email - // values via isValidEmail(). + // values via isValidEmail(). Prefer any non-Identity view next, since Identity + // partitions are unbuilt and Login/Card must remain authoritative while the + // flag is on. val view = candidateViews .firstOrNull { (_, view) -> view is AutofillView.Login.Username } ?.second + ?: candidateViews.firstOrNull { (_, view) -> view !is AutofillView.Identity } + ?.second ?: candidateViews.firstOrNull()?.second ?: return@let null diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt index 862a6331e8c..ad5433cf45f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -455,6 +455,42 @@ class FillAssistViewNodeExtensionsTest { ) } + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should prefer Login Email over Identity PersonNameFull when identity key is listed first and matches the same node`() { + // Identity has no built partition yet, so it must never outrank a Login/Card + // classification for the same node, even when the Identity key is listed first. + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val autoFillData = autofillData() + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns autoFillData + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = linkedMapOf( + "personNameFull" to listOf(selectorClause(tag = "input", id = "shared")), + "email" to listOf(selectorClause(tag = "input", id = "shared")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + listOf( + AutofillView.Login.Email(data = autoFillData), + AutofillView.Identity.Email(data = autoFillData), + ), + actual, + ) + } + @Test fun `buildFillAssistViews should traverse child nodes recursively`() { val childHtmlInfo = createHtmlInfo() From 9d4563d8c34221b61b77f8648fc98a2a57529eb8 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 8 Sep 2026 12:52:01 +0100 Subject: [PATCH 8/8] rename fill assist values to properly match map-the-web --- .../util/FillAssistViewNodeExtensions.kt | 24 +++++++---------- .../autofill/parser/AutofillParserTests.kt | 2 +- .../util/FillAssistViewNodeExtensionsTest.kt | 26 +++++++++---------- 3 files changed, 23 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt index 1903ef2cf15..cde781caa2b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -16,19 +16,17 @@ private const val FIELD_KEY_CARD_EXPIRATION_MONTH = "cardExpirationMonth" private const val FIELD_KEY_CARD_EXPIRATION_YEAR = "cardExpirationYear" private const val FIELD_KEY_CARD_CVV = "cardCvv" private const val FIELD_KEY_CARD_TYPE = "cardType" -private const val FIELD_KEY_PERSON_NAME_FULL = "personNameFull" -private const val FIELD_KEY_PERSON_NAME_PREFIX = "personNamePrefix" -private const val FIELD_KEY_PERSON_NAME_GIVEN = "personNameGiven" -private const val FIELD_KEY_PERSON_NAME_MIDDLE = "personNameMiddle" -private const val FIELD_KEY_PERSON_NAME_FAMILY = "personNameFamily" -private const val FIELD_KEY_POSTAL_ADDRESS_FULL = "postalAddressFull" -private const val FIELD_KEY_ADDRESS_STREET = "addressStreet" -private const val FIELD_KEY_ADDRESS_LOCALITY = "addressLocality" -private const val FIELD_KEY_ADDRESS_REGION = "addressRegion" -private const val FIELD_KEY_ADDRESS_COUNTRY = "addressCountry" +private const val FIELD_KEY_PERSON_NAME_FULL = "fullName" +private const val FIELD_KEY_PERSON_NAME_PREFIX = "honorificPrefix" +private const val FIELD_KEY_PERSON_NAME_GIVEN = "firstName" +private const val FIELD_KEY_PERSON_NAME_MIDDLE = "middleName" +private const val FIELD_KEY_PERSON_NAME_FAMILY = "lastName" +private const val FIELD_KEY_ADDRESS_STREET = "addressLine1" +private const val FIELD_KEY_ADDRESS_LOCALITY = "addressLevel2" +private const val FIELD_KEY_ADDRESS_REGION = "addressLevel1" +private const val FIELD_KEY_ADDRESS_COUNTRY = "country" private const val FIELD_KEY_POSTAL_CODE = "postalCode" -private const val FIELD_KEY_PHONE_FULL = "phoneFull" -private const val FIELD_KEY_COMPANY = "company" +private const val FIELD_KEY_COMPANY = "organization" private const val FIELD_KEY_SSN = "ssn" private const val FIELD_KEY_PASSPORT_NUMBER = "passportNumber" private const val FIELD_KEY_LICENSE_NUMBER = "licenseNumber" @@ -166,13 +164,11 @@ private fun String.toIdentityViewForFieldKey(data: AutofillView.Data): AutofillV FIELD_KEY_PERSON_NAME_GIVEN -> AutofillView.Identity.PersonNameGiven(data = data) FIELD_KEY_PERSON_NAME_MIDDLE -> AutofillView.Identity.PersonNameMiddle(data = data) FIELD_KEY_PERSON_NAME_FAMILY -> AutofillView.Identity.PersonNameFamily(data = data) - FIELD_KEY_POSTAL_ADDRESS_FULL -> AutofillView.Identity.PostalAddressFull(data = data) FIELD_KEY_ADDRESS_STREET -> AutofillView.Identity.AddressStreet(data = data) FIELD_KEY_ADDRESS_LOCALITY -> AutofillView.Identity.AddressLocality(data = data) FIELD_KEY_ADDRESS_REGION -> AutofillView.Identity.AddressRegion(data = data) FIELD_KEY_ADDRESS_COUNTRY -> AutofillView.Identity.AddressCountry(data = data) FIELD_KEY_POSTAL_CODE -> AutofillView.Identity.PostalCode(data = data) - FIELD_KEY_PHONE_FULL -> AutofillView.Identity.PhoneFull(data = data) FIELD_KEY_COMPANY -> AutofillView.Identity.Company(data = data) FIELD_KEY_SSN -> AutofillView.Identity.Ssn(data = data) FIELD_KEY_PASSPORT_NUMBER -> AutofillView.Identity.PassportNumber(data = data) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index e7a6e344b06..832178f4ea5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -1810,7 +1810,7 @@ class AutofillParserTests { category = "account-creation", fields = mapOf( "username" to listOf(usernameSelectorClause), - "personNameFull" to listOf(nameSelectorClause), + "fullName" to listOf(nameSelectorClause), ), ), ), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt index ad5433cf45f..19b09d3314f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -284,19 +284,17 @@ class FillAssistViewNodeExtensionsTest { @Test fun `buildFillAssistViews should map all identity field keys to correct AutofillView subtypes`() { val identityFieldExpectations: List> = listOf( - "personNameFull" to AutofillView.Identity.PersonNameFull(data = autofillData()), - "personNamePrefix" to AutofillView.Identity.PersonNamePrefix(data = autofillData()), - "personNameGiven" to AutofillView.Identity.PersonNameGiven(data = autofillData()), - "personNameMiddle" to AutofillView.Identity.PersonNameMiddle(data = autofillData()), - "personNameFamily" to AutofillView.Identity.PersonNameFamily(data = autofillData()), - "postalAddressFull" to AutofillView.Identity.PostalAddressFull(data = autofillData()), - "addressStreet" to AutofillView.Identity.AddressStreet(data = autofillData()), - "addressLocality" to AutofillView.Identity.AddressLocality(data = autofillData()), - "addressRegion" to AutofillView.Identity.AddressRegion(data = autofillData()), - "addressCountry" to AutofillView.Identity.AddressCountry(data = autofillData()), + "fullName" to AutofillView.Identity.PersonNameFull(data = autofillData()), + "honorificPrefix" to AutofillView.Identity.PersonNamePrefix(data = autofillData()), + "firstName" to AutofillView.Identity.PersonNameGiven(data = autofillData()), + "middleName" to AutofillView.Identity.PersonNameMiddle(data = autofillData()), + "lastName" to AutofillView.Identity.PersonNameFamily(data = autofillData()), + "addressLine1" to AutofillView.Identity.AddressStreet(data = autofillData()), + "addressLevel2" to AutofillView.Identity.AddressLocality(data = autofillData()), + "addressLevel1" to AutofillView.Identity.AddressRegion(data = autofillData()), + "country" to AutofillView.Identity.AddressCountry(data = autofillData()), "postalCode" to AutofillView.Identity.PostalCode(data = autofillData()), - "phoneFull" to AutofillView.Identity.PhoneFull(data = autofillData()), - "company" to AutofillView.Identity.Company(data = autofillData()), + "organization" to AutofillView.Identity.Company(data = autofillData()), "ssn" to AutofillView.Identity.Ssn(data = autofillData()), "passportNumber" to AutofillView.Identity.PassportNumber(data = autofillData()), "licenseNumber" to AutofillView.Identity.LicenseNumber(data = autofillData()), @@ -345,7 +343,7 @@ class FillAssistViewNodeExtensionsTest { val hostRule = FillAssistRules.HostRule( category = "identity-form", fields = mapOf( - "personNameFull" to listOf(selectorClause(tag = "input", id = "name")), + "fullName" to listOf(selectorClause(tag = "input", id = "name")), ), ) @@ -471,7 +469,7 @@ class FillAssistViewNodeExtensionsTest { val hostRule = FillAssistRules.HostRule( category = "account-login", fields = linkedMapOf( - "personNameFull" to listOf(selectorClause(tag = "input", id = "shared")), + "fullName" to listOf(selectorClause(tag = "input", id = "shared")), "email" to listOf(selectorClause(tag = "input", id = "shared")), ), )