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..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 @@ -177,12 +177,15 @@ class AutofillParserImpl( uri = uri, focusedView = focusedView, urlBarWebsite = urlBarWebsite, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, ) } else { 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. @@ -248,6 +251,7 @@ class AutofillParserImpl( uri: String?, focusedView: AutofillView, urlBarWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): List { val hostRules = uri ?.takeUnless { it.startsWith("androidapp://") } @@ -275,6 +279,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 @@ -523,44 +528,20 @@ 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.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.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)) } 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..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,27 +16,49 @@ 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 = "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_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" /** * 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 ownView = autofillId?.let { id -> + val ownViews = autofillId?.let { id -> hostRules .flatMap { it.fields.entries } .filter { (_, alternatives) -> @@ -48,45 +70,108 @@ 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, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + )?.let { key to it } + } + // Prefer Username: it has no format gate, while Login.Email rejects non-email + // 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 + + // 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)) + } + } } - // 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() } - } + }.orEmpty() val childViews = (0 until childCount) .flatMap { index -> getChildAt(index).traverseForFillAssist( hostRules = hostRules, parentWebsite = website, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, ) } - 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, + isIdentityAutofillEnabled: Boolean, +): AutofillView? = + toLoginViewForFieldKey(data = data) + ?: toCardViewForFieldKey(data = data) + ?: if (isIdentityAutofillEnabled) toIdentityViewForFieldKey(data = data) else null - FIELD_KEY_CARD_EXPIRATION_YEAR -> AutofillView.Card.ExpirationYear( - data = data, - yearValue = null, - ) +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_CVV -> AutofillView.Card.SecurityCode(data = data) - FIELD_KEY_CARD_TYPE -> AutofillView.Card.Brand(data = data, brandValue = null) - else -> 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_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_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_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/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", ) 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..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 @@ -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), + "fullName" 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`() { 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..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 @@ -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,13 +74,51 @@ 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) } + @Suppress("MaxLineLength") + @Test + 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) + 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 = true, + ) + + assertEquals( + listOf( + AutofillView.Login.Email(data = data), + AutofillView.Identity.Email(data = data), + ), + actual, + ) + } + + @Suppress("MaxLineLength") @Test - fun `buildFillAssistViews should return Login Email when htmlInfo matches email 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) @@ -100,13 +140,15 @@ class FillAssistViewNodeExtensionsTest { 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 when htmlInfo matches phone clause`() { + 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) @@ -128,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") @@ -152,6 +201,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) @@ -176,6 +226,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) @@ -218,6 +269,57 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + listOf(expectedView), + actual, + "Failed for field key: $fieldKey", + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should map all identity field keys to correct AutofillView subtypes`() { + val identityFieldExpectations: List> = listOf( + "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()), + "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()), + ) + + 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, + isIdentityAutofillEnabled = true, ) assertEquals( @@ -228,6 +330,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( + "fullName" 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() @@ -247,6 +375,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -258,7 +387,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) @@ -276,16 +405,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) @@ -303,9 +440,53 @@ 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, + ) + } + + @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( + "fullName" 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 @@ -328,6 +509,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) @@ -348,6 +530,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) @@ -368,6 +551,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) @@ -382,6 +566,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -396,6 +581,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -428,6 +614,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -443,6 +630,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -459,6 +647,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) @@ -473,6 +662,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -505,6 +695,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -535,6 +726,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(emptyList(), actual) @@ -552,6 +744,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) @@ -583,6 +776,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(hostRule), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) @@ -611,6 +805,7 @@ class FillAssistViewNodeExtensionsTest { val actual = assistStructure.buildFillAssistViews( hostRules = listOf(usernameHostRule(tag = "input", id = "user")), urlBarWebsite = null, + isIdentityAutofillEnabled = true, ) assertEquals(