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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt index b991982dd9a..4f46148ebc3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt @@ -1,33 +1,51 @@ package com.x8bit.bitwarden.data.autofill.model /** - * Autofill hints used to determine what data an input field is associated with. + * Autofill hints used to determine what data an input field is associated with, grouped by the + * [AutofillView] partition they belong to. */ -enum class AutofillHint { - CARD_CARDHOLDER, - CARD_EXPIRATION_DATE, - CARD_EXPIRATION_MONTH, - CARD_EXPIRATION_YEAR, - CARD_NUMBER, - CARD_SECURITY_CODE, - CARD_BRAND, - PASSWORD, - USERNAME, - IDENTITY_PERSON_NAME_FULL, - IDENTITY_PERSON_NAME_PREFIX, - IDENTITY_PERSON_NAME_GIVEN, - IDENTITY_PERSON_NAME_MIDDLE, - IDENTITY_PERSON_NAME_FAMILY, - IDENTITY_POSTAL_ADDRESS_FULL, - IDENTITY_ADDRESS_STREET, - IDENTITY_ADDRESS_LOCALITY, - IDENTITY_ADDRESS_REGION, - IDENTITY_ADDRESS_COUNTRY, - IDENTITY_POSTAL_CODE, - IDENTITY_PHONE_FULL, - IDENTITY_COMPANY, - IDENTITY_EMAIL, - IDENTITY_SSN, - IDENTITY_PASSPORT_NUMBER, - IDENTITY_LICENSE_NUMBER, +sealed interface AutofillHint { + /** + * Hints for the [AutofillView.Card] partition. + */ + enum class Card : AutofillHint { + BRAND, + CARDHOLDER, + EXPIRATION_DATE, + EXPIRATION_MONTH, + EXPIRATION_YEAR, + NUMBER, + SECURITY_CODE, + } + + /** + * Hints for the [AutofillView.Login] partition. + */ + enum class Login : AutofillHint { + PASSWORD, + USERNAME, + } + + /** + * Hints for the [AutofillView.Identity] partition. + */ + enum class Identity : AutofillHint { + ADDRESS_COUNTRY, + ADDRESS_LOCALITY, + ADDRESS_REGION, + ADDRESS_STREET, + COMPANY, + EMAIL, + LICENSE_NUMBER, + PASSPORT_NUMBER, + PERSON_NAME_FAMILY, + PERSON_NAME_FULL, + PERSON_NAME_GIVEN, + PERSON_NAME_MIDDLE, + PERSON_NAME_PREFIX, + POSTAL_ADDRESS_FULL, + POSTAL_CODE, + PHONE_FULL, + SSN, + } } 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 1617f62929f..f858fa3685a 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 @@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount +import com.x8bit.bitwarden.data.autofill.util.isEmailField +import com.x8bit.bitwarden.data.autofill.util.isPhoneField import com.x8bit.bitwarden.data.autofill.util.toAutofillView import com.x8bit.bitwarden.data.autofill.util.website import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager @@ -118,16 +120,27 @@ class AutofillParserImpl( fillRequest: FillRequest?, ): AutofillRequest { Timber.d("Parsing AssistStructure -- ${fillRequest?.id}") + // Identity classification/fulfillment ship together: until this flag is on, every node + // must classify exactly as it did before identity heuristics existed, so behaviors like + // updateForMissingUsernameFields's Unused-only promotion keep working unchanged. + val isIdentityAutofillEnabled = featureFlagManager.getFeatureFlag(FlagKey.IdentityAutofill) // Parse the `assistStructure` into internal models. - val traversalDataList = assistStructure.traverse() + val traversalDataList = assistStructure.traverse( + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + ) val urlBarWebsite = traversalDataList .flatMap { it.urlBarWebsites } .firstOrNull() // Heuristic views: the focused node's candidates with unfillable (Unused) fields removed, - // falling back to all fillable views when nothing has focus. + // falling back to all fillable views when nothing has focus. Identity is also excluded + // here for now -- Identity partition construction lands in Phase D, so until then a field + // classified as Identity must keep falling through exactly as it would have as Unused + // (e.g. resolving to a sibling Login/Card field on the same form, or its Unused-only + // promotion in updateForMissingUsernameFields), not become the focused view and force this + // request to Unfillable. val autofillViews = traversalDataList .selectCandidateAutofillViews(urlBarWebsite = urlBarWebsite) { - it !is AutofillView.Unused + it !is AutofillView.Unused && it !is AutofillView.Identity } val isFillAssistEnabled = featureFlagManager @@ -187,8 +200,7 @@ class AutofillParserImpl( } is AutofillView.Identity -> { - // Identity partition construction is wired up in a later phase; treating it as - // unfillable for now is a no-op since nothing yet classifies a view as Identity. + // Identity partition construction lands in Phase D. Unfillable until then. return AutofillRequest.Unfillable } @@ -254,8 +266,7 @@ class AutofillParserImpl( rule.category in LOGIN_FILL_ASSIST_CATEGORIES || rule.category in CARD_FILL_ASSIST_CATEGORIES } - // Fill-assist category coverage for Identity is added in a later phase; this is a - // no-op today since nothing yet classifies a view as Identity. + // Identity fill-assist categories land in a later phase. is AutofillView.Identity -> false } } @@ -278,13 +289,18 @@ class AutofillParserImpl( /** * Traverse the [AssistStructure] and convert it into a list of [ViewNodeTraversalData]s. */ -private fun AssistStructure.traverse(): List = +private fun AssistStructure.traverse( + isIdentityAutofillEnabled: Boolean, +): List = (0 until windowNodeCount) .map { getWindowNodeAt(it) } .mapNotNull { windowNode -> windowNode .rootViewNode - ?.traverse(parentWebsite = null) + ?.traverse( + parentWebsite = null, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + ) ?.updateForMissingPasswordFields() ?.updateForMissingUsernameFields() } @@ -389,8 +405,10 @@ private fun ViewNodeTraversalData.copyAndMapAutofillViews( * Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the * data into [ViewNodeTraversalData]. */ +@Suppress("CyclomaticComplexMethod", "LongMethod") private fun AssistStructure.ViewNode.traverse( parentWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): ViewNodeTraversalData { // Set up mutable lists for collecting valid AutofillViews and ignorable view ids. val mutableAutofillViewList: MutableList = mutableListOf() @@ -410,12 +428,32 @@ private fun AssistStructure.ViewNode.traverse( // Try converting this `ViewNode` into an `AutofillView`. If a valid instance is returned, add // it to the list. Otherwise, ignore the `AutofillId` associated with this `ViewNode`. - toAutofillView(parentWebsite = parentWebsite) + toAutofillView( + parentWebsite = parentWebsite, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + ) ?.also { view -> if (view !is AutofillView.Unused) { claimedAutofillIds.add(view.data.autofillId) } mutableAutofillViewList.add(view) + + if (isIdentityAutofillEnabled) { + // An email-hinted or email-heuristic field is offered as both a Login candidate + // (above) and an Identity candidate, since the two partitions aren't mutually + // exclusive for this field. Reuses the same (container-redirect-corrected) data as + // the primary view rather than re-deriving it. + if (view is AutofillView.Login.Username && this.isEmailField) { + mutableAutofillViewList.add(AutofillView.Identity.Email(data = view.data)) + } + + // Some phone hints (e.g. "mobilephone") also match the username heuristic's + // "phone" term and resolve to Login.Username above, so they need the same + // dual-classification as email. + if (view is AutofillView.Login.Username && this.isPhoneField) { + mutableAutofillViewList.add(AutofillView.Identity.PhoneFull(data = view.data)) + } + } } ?: autofillId?.run(mutableIgnoreAutofillIdList::add) @@ -423,7 +461,10 @@ private fun AssistStructure.ViewNode.traverse( for (i in 0 until childCount) { // Extract the traversal data from each child view node and add it to the lists. getChildAt(i) - .traverse(parentWebsite = website) + .traverse( + parentWebsite = website, + isIdentityAutofillEnabled = isIdentityAutofillEnabled, + ) .let { viewNodeTraversalData -> viewNodeTraversalData.autofillViews // filter out existing AutofillIds to avoid duplicates diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensions.kt new file mode 100644 index 00000000000..b41df033841 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensions.kt @@ -0,0 +1,172 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import com.x8bit.bitwarden.data.autofill.model.AutofillHint +import com.x8bit.bitwarden.data.autofill.model.AutofillView + +/** + * Builds an [AutofillView.Card] for the given card-related [autofillHint]. + */ +internal fun AssistStructure.ViewNode.buildCardView( + autofillOptions: List, + autofillViewData: AutofillView.Data, + autofillHint: AutofillHint.Card, +): AutofillView.Card = when (autofillHint) { + AutofillHint.Card.EXPIRATION_MONTH -> { + val monthValue = this + .autofillValue + ?.extractMonthValue( + autofillOptions = autofillOptions, + ) + + AutofillView.Card.ExpirationMonth( + data = autofillViewData, + monthValue = monthValue, + ) + } + + AutofillHint.Card.EXPIRATION_YEAR -> { + val yearValue = this + .autofillValue + ?.extractYearValue( + autofillOptions = autofillOptions, + ) + + AutofillView.Card.ExpirationYear( + data = autofillViewData, + yearValue = yearValue, + ) + } + + AutofillHint.Card.EXPIRATION_DATE -> { + AutofillView.Card.ExpirationDate( + data = autofillViewData, + ) + } + + AutofillHint.Card.NUMBER -> { + AutofillView.Card.Number( + data = autofillViewData, + ) + } + + AutofillHint.Card.SECURITY_CODE -> { + AutofillView.Card.SecurityCode( + data = autofillViewData, + ) + } + + AutofillHint.Card.CARDHOLDER -> { + AutofillView.Card.CardholderName( + data = autofillViewData, + ) + } + + AutofillHint.Card.BRAND -> { + val brandValue = this.autofillValue + ?.extractCardBrandValue( + autofillOptions = autofillOptions, + ) + AutofillView.Card.Brand( + data = autofillViewData, + brandValue = brandValue, + ) + } +} + +/** + * Builds an [AutofillView.Login] for the given login-related [autofillHint]. + */ +internal fun buildLoginView( + autofillViewData: AutofillView.Data, + autofillHint: AutofillHint.Login, +): AutofillView.Login = when (autofillHint) { + AutofillHint.Login.PASSWORD -> { + AutofillView.Login.Password( + data = autofillViewData, + ) + } + + AutofillHint.Login.USERNAME -> { + AutofillView.Login.Username( + data = autofillViewData, + ) + } +} + +/** + * Builds an [AutofillView.Identity] for the given identity-related [autofillHint]. + */ +internal fun buildIdentityView( + autofillViewData: AutofillView.Data, + autofillHint: AutofillHint.Identity, +): AutofillView.Identity = when (autofillHint) { + AutofillHint.Identity.PERSON_NAME_FULL -> { + AutofillView.Identity.PersonNameFull(data = autofillViewData) + } + + AutofillHint.Identity.PERSON_NAME_PREFIX -> { + AutofillView.Identity.PersonNamePrefix(data = autofillViewData) + } + + AutofillHint.Identity.PERSON_NAME_GIVEN -> { + AutofillView.Identity.PersonNameGiven(data = autofillViewData) + } + + AutofillHint.Identity.PERSON_NAME_MIDDLE -> { + AutofillView.Identity.PersonNameMiddle(data = autofillViewData) + } + + AutofillHint.Identity.PERSON_NAME_FAMILY -> { + AutofillView.Identity.PersonNameFamily(data = autofillViewData) + } + + AutofillHint.Identity.POSTAL_ADDRESS_FULL -> { + AutofillView.Identity.PostalAddressFull(data = autofillViewData) + } + + AutofillHint.Identity.ADDRESS_STREET -> { + AutofillView.Identity.AddressStreet(data = autofillViewData) + } + + AutofillHint.Identity.ADDRESS_LOCALITY -> { + AutofillView.Identity.AddressLocality(data = autofillViewData) + } + + AutofillHint.Identity.ADDRESS_REGION -> { + AutofillView.Identity.AddressRegion(data = autofillViewData) + } + + AutofillHint.Identity.ADDRESS_COUNTRY -> { + AutofillView.Identity.AddressCountry(data = autofillViewData) + } + + AutofillHint.Identity.POSTAL_CODE -> { + AutofillView.Identity.PostalCode(data = autofillViewData) + } + + AutofillHint.Identity.PHONE_FULL -> { + AutofillView.Identity.PhoneFull(data = autofillViewData) + } + + AutofillHint.Identity.COMPANY -> { + AutofillView.Identity.Company(data = autofillViewData) + } + + AutofillHint.Identity.EMAIL -> { + // Produced by AutofillParserImpl's traverse(), not by this dispatch. + AutofillView.Identity.Email(data = autofillViewData) + } + + AutofillHint.Identity.SSN -> { + AutofillView.Identity.Ssn(data = autofillViewData) + } + + AutofillHint.Identity.PASSPORT_NUMBER -> { + AutofillView.Identity.PassportNumber(data = autofillViewData) + } + + AutofillHint.Identity.LICENSE_NUMBER -> { + AutofillView.Identity.LicenseNumber(data = autofillViewData) + } +} 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 dc6ac24297a..8cd748d018a 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 @@ -65,6 +65,108 @@ fun HtmlInfo?.isCardSecurityCodeField(): Boolean = isInputField && fun HtmlInfo?.isCardBrandField(): Boolean = isInputField && hints().containsAnyTerms(SUPPORTED_RAW_CARD_BRAND_HINTS) +/** + * Whether this [HtmlInfo] represents an email field. + */ +fun HtmlInfo?.isEmailField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_EMAIL_HINTS) + +/** + * Whether this [HtmlInfo] represents a full person name field. + */ +fun HtmlInfo?.isPersonNameFullField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FULL_HINTS) + +/** + * Whether this [HtmlInfo] represents a person name prefix field. + */ +fun HtmlInfo?.isPersonNamePrefixField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_PREFIX_HINTS) + +/** + * Whether this [HtmlInfo] represents a given (first) name field. + */ +fun HtmlInfo?.isPersonNameGivenField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_GIVEN_HINTS) + +/** + * Whether this [HtmlInfo] represents a middle name field. + */ +fun HtmlInfo?.isPersonNameMiddleField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_MIDDLE_HINTS) + +/** + * Whether this [HtmlInfo] represents a family (last) name field. + */ +fun HtmlInfo?.isPersonNameFamilyField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FAMILY_HINTS) + +/** + * Whether this [HtmlInfo] represents a full postal address field. + */ +fun HtmlInfo?.isPostalAddressFullField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_POSTAL_ADDRESS_FULL_HINTS) + +/** + * Whether this [HtmlInfo] represents a street address field. + */ +fun HtmlInfo?.isAddressStreetField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_ADDRESS_STREET_HINTS) + +/** + * Whether this [HtmlInfo] represents a locality (city) field. + */ +fun HtmlInfo?.isAddressLocalityField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_ADDRESS_LOCALITY_HINTS) + +/** + * Whether this [HtmlInfo] represents a region (state/province) field. + */ +fun HtmlInfo?.isAddressRegionField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_ADDRESS_REGION_HINTS) + +/** + * Whether this [HtmlInfo] represents a country field. + */ +fun HtmlInfo?.isAddressCountryField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_ADDRESS_COUNTRY_HINTS) + +/** + * Whether this [HtmlInfo] represents a postal code field. + */ +fun HtmlInfo?.isPostalCodeField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_POSTAL_CODE_HINTS) + +/** + * Whether this [HtmlInfo] represents a phone number field. + */ +fun HtmlInfo?.isPhoneField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) + +/** + * Whether this [HtmlInfo] represents a company field. + */ +fun HtmlInfo?.isCompanyField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_COMPANY_HINTS) + +/** + * Whether this [HtmlInfo] represents a social security number field. + */ +fun HtmlInfo?.isSsnField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_SSN_HINTS) + +/** + * Whether this [HtmlInfo] represents a passport number field. + */ +fun HtmlInfo?.isPassportNumberField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PASSPORT_HINTS) + +/** + * Whether this [HtmlInfo] represents a license number field. + */ +fun HtmlInfo?.isLicenseNumberField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_LICENSE_HINTS) + /** * Attributes that can be used as hints to determine the type of data the associated node expects. * @@ -158,6 +260,9 @@ private fun List.containsAnyTerms(terms: List): Boolean = /** * The supported attribute keys whose value can represent an autofill hint. + * + * Matched as substrings of the attribute name, so `autofill` and `hint` also admit the + * browser-supplied `*-autofill-hints` attributes. */ private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List = listOf( "name", @@ -165,4 +270,5 @@ private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List = listOf( "type", "hint", "autofill", + "autocomplete", ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 9edbb2d7004..7ec24a0edae 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -5,6 +5,7 @@ import android.view.View import android.view.autofill.AutofillId import android.widget.EditText import androidx.annotation.VisibleForTesting +import androidx.autofill.HintConstants import com.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.data.autofill.model.AutofillHint import com.x8bit.bitwarden.data.autofill.model.AutofillView @@ -15,7 +16,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillView private const val DEFAULT_SCHEME: String = "https" /** - * The supported autofill Android View hints. + * The supported autofill Android View hints that predate identity autofill. */ private val SUPPORTED_VIEW_HINTS: List = listOf( View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, @@ -28,6 +29,26 @@ private val SUPPORTED_VIEW_HINTS: List = listOf( View.AUTOFILL_HINT_USERNAME, ) +/** + * The supported autofill Android View hints that are only meaningful for identity autofill. + * Consulted only when identity autofill is enabled -- + * see [AssistStructure.ViewNode.toAutofillView]. + */ +private val SUPPORTED_IDENTITY_VIEW_HINTS: List = listOf( + HintConstants.AUTOFILL_HINT_PERSON_NAME, + HintConstants.AUTOFILL_HINT_PERSON_NAME_PREFIX, + HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN, + HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE, + HintConstants.AUTOFILL_HINT_PERSON_NAME_FAMILY, + View.AUTOFILL_HINT_POSTAL_ADDRESS, + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS, + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY, + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_REGION, + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY, + View.AUTOFILL_HINT_POSTAL_CODE, + View.AUTOFILL_HINT_PHONE, +) + /** * Whether this [AssistStructure.ViewNode] represents an input field. */ @@ -52,9 +73,10 @@ private val AssistStructure.ViewNode.isInputField: Boolean */ fun AssistStructure.ViewNode.toAutofillView( parentWebsite: String?, + isIdentityAutofillEnabled: Boolean, ): AutofillView? { val nonNullAutofillId = this.autofillId ?: return null - val hint = this.supportedAutofillHint + val hint = this.supportedAutofillHint(isIdentityAutofillEnabled = isIdentityAutofillEnabled) val isInput = this.isInputField if (hint == null && !isInput) return null @@ -124,158 +146,138 @@ internal fun AssistStructure.ViewNode.toAutofillViewData( ) /** - * The first supported autofill hint for this view node, or null if none are found. - */ -private val AssistStructure.ViewNode.supportedAutofillHint: AutofillHint? - get() = firstSupportedAutofillHintOrNull() - ?: when { - this.isUsernameField -> AutofillHint.USERNAME - this.isPasswordField -> AutofillHint.PASSWORD - this.isCardExpirationMonthField -> AutofillHint.CARD_EXPIRATION_MONTH - this.isCardExpirationYearField -> AutofillHint.CARD_EXPIRATION_YEAR - this.isCardExpirationDateField -> AutofillHint.CARD_EXPIRATION_DATE - this.isCardNumberField -> AutofillHint.CARD_NUMBER - this.isCardSecurityCodeField -> AutofillHint.CARD_SECURITY_CODE - this.isCardholderNameField -> AutofillHint.CARD_CARDHOLDER - this.isCardBrandField -> AutofillHint.CARD_BRAND - else -> null - } + * The first supported autofill hint for this view node, or null if none are found. Identity + * classification is gated on [isIdentityAutofillEnabled] so that, until identity fulfillment + * ships, a node classifies exactly as it did before identity heuristics existed -- e.g. falling + * through to [isUsernameField] rather than being claimed by an identity heuristic. + */ +@Suppress("CyclomaticComplexMethod") +private fun AssistStructure.ViewNode.supportedAutofillHint( + isIdentityAutofillEnabled: Boolean, +): AutofillHint? = firstSupportedAutofillHintOrNull( + isIdentityAutofillEnabled = isIdentityAutofillEnabled, +) + ?: when { + this.isUsernameField -> AutofillHint.Login.USERNAME + this.isPasswordField -> AutofillHint.Login.PASSWORD + this.isCardExpirationMonthField -> AutofillHint.Card.EXPIRATION_MONTH + this.isCardExpirationYearField -> AutofillHint.Card.EXPIRATION_YEAR + this.isCardExpirationDateField -> AutofillHint.Card.EXPIRATION_DATE + this.isCardNumberField -> AutofillHint.Card.NUMBER + this.isCardSecurityCodeField -> AutofillHint.Card.SECURITY_CODE + this.isCardholderNameField -> AutofillHint.Card.CARDHOLDER + this.isCardBrandField -> AutofillHint.Card.BRAND + // Identity heuristics only below here; anything else would stop matching when the flag + // is off. Add non-identity heuristics above this branch. + !isIdentityAutofillEnabled -> null + this.isPersonNameFullField -> AutofillHint.Identity.PERSON_NAME_FULL + this.isPersonNamePrefixField -> AutofillHint.Identity.PERSON_NAME_PREFIX + this.isPersonNameGivenField -> AutofillHint.Identity.PERSON_NAME_GIVEN + this.isPersonNameMiddleField -> AutofillHint.Identity.PERSON_NAME_MIDDLE + this.isPersonNameFamilyField -> AutofillHint.Identity.PERSON_NAME_FAMILY + this.isPostalAddressFullField -> AutofillHint.Identity.POSTAL_ADDRESS_FULL + this.isAddressStreetField -> AutofillHint.Identity.ADDRESS_STREET + this.isAddressLocalityField -> AutofillHint.Identity.ADDRESS_LOCALITY + this.isAddressRegionField -> AutofillHint.Identity.ADDRESS_REGION + this.isAddressCountryField -> AutofillHint.Identity.ADDRESS_COUNTRY + this.isPostalCodeField -> AutofillHint.Identity.POSTAL_CODE + this.isPhoneField -> AutofillHint.Identity.PHONE_FULL + this.isCompanyField -> AutofillHint.Identity.COMPANY + this.isSsnField -> AutofillHint.Identity.SSN + this.isPassportNumberField -> AutofillHint.Identity.PASSPORT_NUMBER + this.isLicenseNumberField -> AutofillHint.Identity.LICENSE_NUMBER + else -> null + } /** * Get the first supported autofill hint from the view node's autofillHints, or null if none are - * found. + * found. [SUPPORTED_IDENTITY_VIEW_HINTS] is only consulted when [isIdentityAutofillEnabled] is + * true. */ -private fun AssistStructure.ViewNode.firstSupportedAutofillHintOrNull(): AutofillHint? = - autofillHints - ?.firstOrNull { SUPPORTED_VIEW_HINTS.contains(it) } +private fun AssistStructure.ViewNode.firstSupportedAutofillHintOrNull( + isIdentityAutofillEnabled: Boolean, +): AutofillHint? { + val supportedHints = if (isIdentityAutofillEnabled) { + SUPPORTED_VIEW_HINTS + SUPPORTED_IDENTITY_VIEW_HINTS + } else { + SUPPORTED_VIEW_HINTS + } + return autofillHints + ?.firstOrNull { supportedHints.contains(it) } ?.toBitwardenAutofillHintOrNull() +} private fun String.toBitwardenAutofillHintOrNull(): AutofillHint? = when (this) { - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> AutofillHint.CARD_EXPIRATION_MONTH - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> AutofillHint.CARD_EXPIRATION_YEAR - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> AutofillHint.CARD_EXPIRATION_DATE - View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> AutofillHint.CARD_NUMBER - View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> AutofillHint.CARD_SECURITY_CODE - View.AUTOFILL_HINT_PASSWORD -> AutofillHint.PASSWORD + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> AutofillHint.Card.EXPIRATION_MONTH + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> AutofillHint.Card.EXPIRATION_YEAR + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> AutofillHint.Card.EXPIRATION_DATE + View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> AutofillHint.Card.NUMBER + View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> AutofillHint.Card.SECURITY_CODE + View.AUTOFILL_HINT_PASSWORD -> AutofillHint.Login.PASSWORD View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_USERNAME, - -> AutofillHint.USERNAME + -> AutofillHint.Login.USERNAME + + HintConstants.AUTOFILL_HINT_PERSON_NAME -> AutofillHint.Identity.PERSON_NAME_FULL + HintConstants.AUTOFILL_HINT_PERSON_NAME_PREFIX -> AutofillHint.Identity.PERSON_NAME_PREFIX + HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN -> AutofillHint.Identity.PERSON_NAME_GIVEN + HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE -> AutofillHint.Identity.PERSON_NAME_MIDDLE + HintConstants.AUTOFILL_HINT_PERSON_NAME_FAMILY -> AutofillHint.Identity.PERSON_NAME_FAMILY + View.AUTOFILL_HINT_POSTAL_ADDRESS -> AutofillHint.Identity.POSTAL_ADDRESS_FULL + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS -> { + AutofillHint.Identity.ADDRESS_STREET + } + + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY -> { + AutofillHint.Identity.ADDRESS_LOCALITY + } + + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_REGION -> AutofillHint.Identity.ADDRESS_REGION + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY -> { + AutofillHint.Identity.ADDRESS_COUNTRY + } + + View.AUTOFILL_HINT_POSTAL_CODE -> AutofillHint.Identity.POSTAL_CODE + View.AUTOFILL_HINT_PHONE -> AutofillHint.Identity.PHONE_FULL else -> null } /** - * Attempt to convert this [AssistStructure.ViewNode] and [autofillViewData] into an [AutofillView]. + * Attempt to convert this [AssistStructure.ViewNode] and [autofillViewData] into an [AutofillView], + * dispatching to [buildCardView], [buildLoginView] or [buildIdentityView] by [autofillHint] + * category. */ -@Suppress("LongMethod") private fun AssistStructure.ViewNode.buildAutofillView( autofillOptions: List, autofillViewData: AutofillView.Data, autofillHint: AutofillHint?, ): AutofillView = when (autofillHint) { - AutofillHint.CARD_EXPIRATION_MONTH -> { - val monthValue = this - .autofillValue - ?.extractMonthValue( - autofillOptions = autofillOptions, - ) - - AutofillView.Card.ExpirationMonth( - data = autofillViewData, - monthValue = monthValue, + is AutofillHint.Card -> { + buildCardView( + autofillOptions = autofillOptions, + autofillViewData = autofillViewData, + autofillHint = autofillHint, ) } - AutofillHint.CARD_EXPIRATION_YEAR -> { - val yearValue = this - .autofillValue - ?.extractYearValue( - autofillOptions = autofillOptions, - ) - - AutofillView.Card.ExpirationYear( - data = autofillViewData, - yearValue = yearValue, + is AutofillHint.Login -> { + buildLoginView( + autofillViewData = autofillViewData, + autofillHint = autofillHint, ) } - AutofillHint.CARD_EXPIRATION_DATE -> { - AutofillView.Card.ExpirationDate( - data = autofillViewData, - ) - } - - AutofillHint.CARD_NUMBER -> { - AutofillView.Card.Number( - data = autofillViewData, - ) - } - - AutofillHint.CARD_SECURITY_CODE -> { - AutofillView.Card.SecurityCode( - data = autofillViewData, - ) - } - - AutofillHint.CARD_CARDHOLDER -> { - AutofillView.Card.CardholderName( - data = autofillViewData, - ) - } - - AutofillHint.PASSWORD -> { - AutofillView.Login.Password( - data = autofillViewData, - ) - } - - AutofillHint.USERNAME -> { - AutofillView.Login.Username( - data = autofillViewData, - ) - } - - AutofillHint.CARD_BRAND -> { - val brandValue = this.autofillValue - ?.extractCardBrandValue( - autofillOptions = autofillOptions, - ) - AutofillView.Card.Brand( - data = autofillViewData, - brandValue = brandValue, + is AutofillHint.Identity -> { + buildIdentityView( + autofillViewData = autofillViewData, + autofillHint = autofillHint, ) } null -> { - AutofillView.Unused( - data = autofillViewData, - ) - } - - // Identity hint detection/dispatch is wired up in a later phase; treating these as Unused for - // now is a no-op since nothing yet produces an IDENTITY_* hint. - AutofillHint.IDENTITY_PERSON_NAME_FULL, - AutofillHint.IDENTITY_PERSON_NAME_PREFIX, - AutofillHint.IDENTITY_PERSON_NAME_GIVEN, - AutofillHint.IDENTITY_PERSON_NAME_MIDDLE, - AutofillHint.IDENTITY_PERSON_NAME_FAMILY, - AutofillHint.IDENTITY_POSTAL_ADDRESS_FULL, - AutofillHint.IDENTITY_ADDRESS_STREET, - AutofillHint.IDENTITY_ADDRESS_LOCALITY, - AutofillHint.IDENTITY_ADDRESS_REGION, - AutofillHint.IDENTITY_ADDRESS_COUNTRY, - AutofillHint.IDENTITY_POSTAL_CODE, - AutofillHint.IDENTITY_PHONE_FULL, - AutofillHint.IDENTITY_COMPANY, - AutofillHint.IDENTITY_EMAIL, - AutofillHint.IDENTITY_SSN, - AutofillHint.IDENTITY_PASSPORT_NUMBER, - AutofillHint.IDENTITY_LICENSE_NUMBER, - -> { - AutofillView.Unused( - data = autofillViewData, - ) + AutofillView.Unused(data = autofillViewData) } } @@ -385,6 +387,230 @@ internal val AssistStructure.ViewNode.isCardBrandField: Boolean ?.containsAnyTerms(SUPPORTED_RAW_CARD_BRAND_HINTS) == true || htmlInfo.isCardBrandField() +/** + * Check whether this [AssistStructure.ViewNode] represents an email field. Kept separate from + * [isUsernameField] so an email-hinted or email-heuristic field can be independently offered as + * an identity candidate alongside the existing username classification. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isEmailField: Boolean + get() = autofillHints?.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS) == true || + idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_EMAIL_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_EMAIL_HINTS) == true || + htmlInfo.isEmailField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a full person name field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPersonNameFullField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FULL_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FULL_HINTS) == true || + htmlInfo.isPersonNameFullField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a person name prefix field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPersonNamePrefixField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_PREFIX_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_PREFIX_HINTS) == true || + htmlInfo.isPersonNamePrefixField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a given (first) name field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPersonNameGivenField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_GIVEN_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_GIVEN_HINTS) == true || + htmlInfo.isPersonNameGivenField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a middle name field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPersonNameMiddleField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_MIDDLE_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_MIDDLE_HINTS) == true || + htmlInfo.isPersonNameMiddleField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a family (last) name field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPersonNameFamilyField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FAMILY_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PERSON_NAME_FAMILY_HINTS) == true || + htmlInfo.isPersonNameFamilyField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a full postal address field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPostalAddressFullField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_POSTAL_ADDRESS_FULL_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_POSTAL_ADDRESS_FULL_HINTS) == true || + htmlInfo.isPostalAddressFullField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a street address field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isAddressStreetField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_STREET_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_STREET_HINTS) == true || + htmlInfo.isAddressStreetField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a locality (city) field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isAddressLocalityField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_LOCALITY_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_LOCALITY_HINTS) == true || + htmlInfo.isAddressLocalityField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a region (state/province) field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isAddressRegionField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_REGION_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_REGION_HINTS) == true || + htmlInfo.isAddressRegionField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a country field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isAddressCountryField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_COUNTRY_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_ADDRESS_COUNTRY_HINTS) == true || + htmlInfo.isAddressCountryField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a postal code field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPostalCodeField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_POSTAL_CODE_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_POSTAL_CODE_HINTS) == true || + htmlInfo.isPostalCodeField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a phone number field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPhoneField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) == true || + htmlInfo.isPhoneField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a company field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isCompanyField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_COMPANY_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_COMPANY_HINTS) == true || + htmlInfo.isCompanyField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a social security number field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isSsnField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_SSN_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_SSN_HINTS) == true || + htmlInfo.isSsnField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a passport number field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPassportNumberField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PASSPORT_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_PASSPORT_HINTS) == true || + htmlInfo.isPassportNumberField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a license number field. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isLicenseNumberField: Boolean + get() = idEntry + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_LICENSE_HINTS) == true || + hint + ?.toLowerCaseAndStripNonAlpha() + ?.containsAnyTerms(SUPPORTED_RAW_LICENSE_HINTS) == true || + htmlInfo.isLicenseNumberField() + /** * Check whether this [AssistStructure.ViewNode] contains any ignored hint terms. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt index 483fa8ded43..7f93758d487 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt @@ -147,3 +147,153 @@ val SUPPORTED_RAW_CARD_BRAND_HINTS: List = listOf( "creditcardbrand", "ccbrand", ) + +/** + * The supported email autofill hints. Kept separate from [SUPPORTED_RAW_USERNAME_HINTS] (which + * also matches "email") so identity email detection can be checked independently of the username + * heuristic — an email field is offered as both a [SUPPORTED_RAW_USERNAME_HINTS]-driven Login + * candidate and an identity candidate, since the two partitions aren't mutually exclusive here. + */ +val SUPPORTED_RAW_EMAIL_HINTS: List = listOf( + "email", +) + +/** + * The supported full person name autofill hints. + */ +val SUPPORTED_RAW_PERSON_NAME_FULL_HINTS: List = listOf( + "fullname", +) + +/** + * The supported person name prefix (e.g. "Mr.", "Dr.") autofill hints. + */ +val SUPPORTED_RAW_PERSON_NAME_PREFIX_HINTS: List = listOf( + "honorificprefix", + "nameprefix", +) + +/** + * The supported given (first) name autofill hints. + */ +val SUPPORTED_RAW_PERSON_NAME_GIVEN_HINTS: List = listOf( + "firstname", + "givenname", +) + +/** + * The supported middle name autofill hints. + */ +val SUPPORTED_RAW_PERSON_NAME_MIDDLE_HINTS: List = listOf( + "middlename", +) + +/** + * The supported family (last) name autofill hints. + */ +val SUPPORTED_RAW_PERSON_NAME_FAMILY_HINTS: List = listOf( + "lastname", + "familyname", + "surname", +) + +/** + * The supported full postal address autofill hints. + */ +val SUPPORTED_RAW_POSTAL_ADDRESS_FULL_HINTS: List = listOf( + "mailingaddress", + "shippingaddress", + "billingaddress", + "fulladdress", +) + +/** + * The supported street address autofill hints. + */ +val SUPPORTED_RAW_ADDRESS_STREET_HINTS: List = listOf( + "streetaddress", + "addressline", +) + +/** + * The supported locality (city) autofill hints. + */ +val SUPPORTED_RAW_ADDRESS_LOCALITY_HINTS: List = listOf( + "locality", + "city", + "town", +) + +/** + * The supported region (state/province) autofill hints. + */ +val SUPPORTED_RAW_ADDRESS_REGION_HINTS: List = listOf( + "addressregion", + "province", + "state", +) + +/** + * The supported country autofill hints. + */ +val SUPPORTED_RAW_ADDRESS_COUNTRY_HINTS: List = listOf( + "country", +) + +/** + * The supported postal code autofill hints. + */ +val SUPPORTED_RAW_POSTAL_CODE_HINTS: List = listOf( + "postalcode", + "zipcode", + "zip", +) + +/** + * The supported phone number autofill hints. + */ +val SUPPORTED_RAW_PHONE_HINTS: List = listOf( + "phonenumber", + "telephone", + "mobilephone", + "mobile", +) + +/** + * The supported company autofill hints. + */ +val SUPPORTED_RAW_COMPANY_HINTS: List = listOf( + "company", + "organization", + "employer", +) + +/** + * The supported social security number autofill hints. Narrower than most other identity hints + * since there is no official Android or HTML signal for this field — false positives here expose + * a more sensitive field than a false positive on, say, a company name. + */ +val SUPPORTED_RAW_SSN_HINTS: List = listOf( + "ssn", + "socialsecuritynumber", + "socialsecurity", +) + +/** + * The supported passport number autofill hints. See [SUPPORTED_RAW_SSN_HINTS] for why this list + * is kept narrow. + */ +val SUPPORTED_RAW_PASSPORT_HINTS: List = listOf( + "passportnumber", + "passport", +) + +/** + * The supported license number autofill hints. See [SUPPORTED_RAW_SSN_HINTS] for why this list + * is kept narrow. + */ +val SUPPORTED_RAW_LICENSE_HINTS: List = listOf( + "licensenumber", + "driverslicense", + "driverlicense", +) 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 f1ee29df1f7..e31da831e6e 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 @@ -53,6 +53,7 @@ class AutofillParserTests { every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null + every { this@mockk.hint } returns null } private val loginAutofillHint = View.AUTOFILL_HINT_USERNAME private val loginAutofillId: AutofillId = mockk() @@ -63,6 +64,7 @@ class AutofillParserTests { every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null + every { this@mockk.hint } returns null } private val cardWindowNode: AssistStructure.WindowNode = mockk { every { this@mockk.rootViewNode } returns cardViewNode @@ -85,6 +87,7 @@ class AutofillParserTests { } private val fillAssistManager: FillAssistManager = mockk() private val mutableFillAssistFlagFlow = MutableStateFlow(false) + private val mutableIdentityAutofillFlagFlow = MutableStateFlow(false) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlag(FlagKey.FillAssistTargetingRules) @@ -94,6 +97,11 @@ class AutofillParserTests { every { getFeatureFlagFlow(FlagKey.FillAssistTargetingRules) } returns mutableFillAssistFlagFlow + every { + getFeatureFlag(FlagKey.IdentityAutofill) + } answers { + mutableIdentityAutofillFlagFlow.value + } } private var mockIsInlineAutofillEnabled = true @@ -265,7 +273,12 @@ class AutofillParserTests { website = null, ), ) - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView val autofillPartition = AutofillPartition.Login( views = listOf( loginAutofillView.copy(data = loginAutofillView.data.copy(website = website)), @@ -313,7 +326,12 @@ class AutofillParserTests { every { this@mockk.childCount } returns 0 every { this@mockk.idPackage } returns null every { this@mockk.isFocused } returns false - every { this@mockk.toAutofillView(parentWebsite = any()) } returns null + every { + this@mockk.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns null every { this@mockk.website } returns null } // `invalidChildViewNode` simulates the OS assigning a node's idPackage to "android", which @@ -326,7 +344,12 @@ class AutofillParserTests { every { this@mockk.childCount } returns 0 every { this@mockk.idPackage } returns ID_PACKAGE_ANDROID every { this@mockk.isFocused } returns false - every { this@mockk.toAutofillView(parentWebsite = any()) } returns null + every { + this@mockk.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns null every { this@mockk.website } returns null } val parentAutofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR @@ -347,7 +370,12 @@ class AutofillParserTests { every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint) every { this@mockk.autofillId } returns parentAutofillId every { this@mockk.idPackage } returns null - every { this@mockk.toAutofillView(parentWebsite = any()) } returns parentAutofillView + every { + this@mockk.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns parentAutofillView every { this@mockk.childCount } returns 2 every { this@mockk.getChildAt(0) } returns childViewNode every { this@mockk.getChildAt(1) } returns invalidChildViewNode @@ -433,8 +461,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -496,8 +534,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -552,7 +600,12 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns unusedAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns unusedAutofillView // Test val actual = parser.parse( @@ -654,12 +707,23 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { rootViewNode.toAutofillView(parentWebsite = any()) } returns null every { - hiddenUserNameViewNode.toAutofillView(parentWebsite = any()) + rootViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns null + every { + hiddenUserNameViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) } returns unusedAutofillView every { - passwordViewNode.toAutofillView(parentWebsite = any()) + passwordViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) } returns loginPasswordAutofillView // Test @@ -722,8 +786,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -786,8 +860,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -858,8 +942,18 @@ class AutofillParserTests { website = URI, ), ) - every { urlBarViewNode.toAutofillView(parentWebsite = any()) } returns unusedFocusedView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + urlBarViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns unusedFocusedView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -915,8 +1009,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -979,8 +1083,18 @@ class AutofillParserTests { partition = autofillPartition, uri = URI, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse( @@ -1035,8 +1149,18 @@ class AutofillParserTests { "blockListedUri.com", "blockListedAgainUri.com", ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView every { settingsRepository.blockedAutofillUris } returns remoteBlockList // A function for asserting that a block listed URI results in an unfillable request. @@ -1108,7 +1232,12 @@ class AutofillParserTests { ), yearValue = null, ) - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns cardAutofillView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1165,7 +1294,12 @@ class AutofillParserTests { website = FILL_ASSIST_URI, ), ) - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginAutofillView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1238,7 +1372,12 @@ class AutofillParserTests { } returns fillAssistLoginData every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns heuristicLoginView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns heuristicLoginView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1311,7 +1450,12 @@ class AutofillParserTests { } returns fillAssistCardData every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode - every { cardViewNode.toAutofillView(parentWebsite = any()) } returns heuristicCardView + every { + cardViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns heuristicCardView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1373,7 +1517,12 @@ class AutofillParserTests { every { any().matchesSelectorClause(any()) } returns true every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns unusedLoginView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns unusedLoginView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1443,7 +1592,12 @@ class AutofillParserTests { // matchesSelectorClause defaults to false per setup -- fill-assist matches nothing. every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns unusedLoginView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns unusedLoginView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1471,7 +1625,12 @@ class AutofillParserTests { ) every { assistStructure.windowNodeCount } returns 1 every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode - every { loginViewNode.toAutofillView(parentWebsite = any()) } returns unusedLoginView + every { + loginViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns unusedLoginView // Test val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) @@ -1513,8 +1672,18 @@ class AutofillParserTests { website = URI, ), ) - every { parentViewNode.toAutofillView(parentWebsite = any()) } returns sharedAutofillView - every { childViewNode.toAutofillView(parentWebsite = any()) } returns sharedAutofillView + every { + parentViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns sharedAutofillView + every { + childViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns sharedAutofillView val expected = AutofillRequest.Fillable( ignoreAutofillIds = emptyList(), @@ -1547,6 +1716,252 @@ class AutofillParserTests { } } + @Suppress("MaxLineLength") + @Test + fun `parse should choose AutofillPartition Login when an Identity view is focused but a Login view is fillable elsewhere`() { + // Setup: a focused field heuristics classify as Identity (e.g. "First name" on a signup + // form) sits in one window, while a fillable, unfocused Login.Username field exists in + // another window on the same screen. Before Phase D's Identity partition exists, a + // focused Identity view must not force the whole request to Unfillable when a fillable + // Login/Card partition exists elsewhere -- it should be excluded from candidates exactly + // like Unused, falling through to the other fillable view. + val identityAutofillId: AutofillId = mockk() + 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.idPackage } returns ID_PACKAGE + every { this@mockk.website } returns null + } + val identityWindowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns identityViewNode + } + every { assistStructure.windowNodeCount } returns 2 + every { assistStructure.getWindowNodeAt(0) } returns identityWindowNode + every { assistStructure.getWindowNodeAt(1) } returns loginWindowNode + val identityAutofillView = AutofillView.Identity.PersonNameGiven( + data = AutofillView.Data( + autofillId = identityAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = null, + ), + ) + val loginAutofillView = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = URI, + ), + ) + every { + identityViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns identityAutofillView + every { + loginViewNode.toAutofillView(parentWebsite = any(), isIdentityAutofillEnabled = any()) + } returns loginAutofillView + + // 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(loginAutofillView)), + uri = URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should promote a phone-hinted field to Login Username via updateForMissingUsernameFields when IdentityAutofill is disabled`() { + // Setup: a node that heuristics would classify as Identity PhoneFull once identity + // detection is active, sitting directly above a password field. Before identity + // detection existed, this same node fell through to Unused and was promoted to + // Login.Username via updateForMissingUsernameFields. With IdentityAutofill disabled + // (the default), toAutofillView must still resolve it to Unused, so that promotion + // continues to work exactly as it did before identity heuristics existed. + val (rootViewNode, phoneHintedViewNode, passwordViewNode, passwordAutofillId) = + setupPhoneHintedFieldAbovePassword() + val windowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns rootViewNode + } + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns windowNode + val unusedPhoneView = AutofillView.Unused( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = URI, + ), + ) + val loginPasswordAutofillView = AutofillView.Login.Password( + data = AutofillView.Data( + autofillId = passwordAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = URI, + ), + ) + every { + phoneHintedViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = false, + ) + } returns unusedPhoneView + every { + passwordViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginPasswordAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: promoted to Login.Username, so both fields are present in the partition. + val loginUsernameAutofillView = AutofillView.Login.Username(data = unusedPhoneView.data) + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = listOf(rootViewNode.autofillId!!), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login( + views = listOf(loginUsernameAutofillView, loginPasswordAutofillView), + ), + uri = URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should not promote a phone-hinted field to Login Username when IdentityAutofill is enabled and the field resolves to Identity PhoneFull`() { + // Setup: same shape as the disabled case above, but IdentityAutofill is enabled, so + // toAutofillView resolves the field to Identity.PhoneFull instead of Unused before + // updateForMissingUsernameFields ever runs. The promotion is skipped, and the Login + // partition ends up missing its username field -- the regression this fix is guarding. + mutableIdentityAutofillFlagFlow.value = true + val (rootViewNode, phoneHintedViewNode, passwordViewNode, passwordAutofillId) = + setupPhoneHintedFieldAbovePassword() + val windowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns rootViewNode + } + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns windowNode + val identityPhoneView = AutofillView.Identity.PhoneFull( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = URI, + ), + ) + val loginPasswordAutofillView = AutofillView.Login.Password( + data = AutofillView.Data( + autofillId = passwordAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = URI, + ), + ) + every { + phoneHintedViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = true, + ) + } returns identityPhoneView + every { + passwordViewNode.toAutofillView( + parentWebsite = any(), + isIdentityAutofillEnabled = any(), + ) + } returns loginPasswordAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: no promotion -- the Login partition only contains the password field. + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = listOf(rootViewNode.autofillId!!), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login(views = listOf(loginPasswordAutofillView)), + uri = URI, + ) + assertEquals(expected, actual) + } + + /** + * Sets up a root node with two children -- a phone-hinted field and a password field + * directly below it -- for testing [updateForMissingUsernameFields]'s Unused-only promotion + * against a field that heuristics may classify as Identity.PhoneFull. + */ + private fun setupPhoneHintedFieldAbovePassword(): PhoneAbovePasswordNodes { + val phoneHintedViewNode: AssistStructure.ViewNode = mockk { + every { this@mockk.autofillHints } returns emptyArray() + every { this@mockk.autofillId } returns loginAutofillId + every { this@mockk.childCount } returns 0 + every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.website } returns WEBSITE + } + val passwordAutofillId = mockk() + val passwordViewNode: AssistStructure.ViewNode = mockk { + every { this@mockk.autofillHints } returns emptyArray() + every { this@mockk.autofillId } returns passwordAutofillId + every { this@mockk.childCount } returns 0 + every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.website } returns WEBSITE + } + val rootAutofillId = mockk() + val rootViewNode: AssistStructure.ViewNode = mockk { + every { this@mockk.autofillHints } returns emptyArray() + every { this@mockk.autofillId } returns rootAutofillId + every { this@mockk.childCount } returns 2 + every { this@mockk.idPackage } returns ID_PACKAGE + every { this@mockk.website } returns WEBSITE + every { this@mockk.getChildAt(0) } returns phoneHintedViewNode + every { this@mockk.getChildAt(1) } returns passwordViewNode + every { + this@mockk.toAutofillView(parentWebsite = any(), isIdentityAutofillEnabled = any()) + } returns null + } + return PhoneAbovePasswordNodes( + rootViewNode = rootViewNode, + phoneHintedViewNode = phoneHintedViewNode, + passwordViewNode = passwordViewNode, + passwordAutofillId = passwordAutofillId, + ) + } + /** * Setup [assistStructure] to return window nodes with each [AutofillView] type (card and login) * so we can test how different window node configurations produce different partitions. @@ -1558,6 +1973,18 @@ class AutofillParserTests { } } +/** + * The nodes built by `setupPhoneHintedFieldAbovePassword`, returned so each test can stub + * [AssistStructure.ViewNode.toAutofillView] differently for the phone-hinted node depending on + * whether IdentityAutofill is enabled. + */ +private data class PhoneAbovePasswordNodes( + val rootViewNode: AssistStructure.ViewNode, + val phoneHintedViewNode: AssistStructure.ViewNode, + val passwordViewNode: AssistStructure.ViewNode, + val passwordAutofillId: AutofillId, +) + private const val FILL_ASSIST_URI: String = "https://example.com" private val BLOCK_LISTED_URIS: List = listOf( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensionsTest.kt new file mode 100644 index 00000000000..7f8ba32f052 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewBuilderExtensionsTest.kt @@ -0,0 +1,120 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import android.view.View +import android.view.autofill.AutofillId +import com.x8bit.bitwarden.data.autofill.model.AutofillHint +import com.x8bit.bitwarden.data.autofill.model.AutofillView +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AutofillViewBuilderExtensionsTest { + + private val viewNode: AssistStructure.ViewNode = mockk { + every { autofillValue } returns null + } + + @Test + fun `buildCardView should map every card hint to its Card view`() { + val data = autofillViewData() + val expectedByHint = mapOf( + AutofillHint.Card.BRAND to AutofillView.Card.Brand(data = data, brandValue = null), + AutofillHint.Card.CARDHOLDER to AutofillView.Card.CardholderName(data = data), + AutofillHint.Card.EXPIRATION_DATE to AutofillView.Card.ExpirationDate(data = data), + AutofillHint.Card.EXPIRATION_MONTH to + AutofillView.Card.ExpirationMonth(data = data, monthValue = null), + AutofillHint.Card.EXPIRATION_YEAR to + AutofillView.Card.ExpirationYear(data = data, yearValue = null), + AutofillHint.Card.NUMBER to AutofillView.Card.Number(data = data), + AutofillHint.Card.SECURITY_CODE to AutofillView.Card.SecurityCode(data = data), + ) + + assertEquals(AutofillHint.Card.entries.toSet(), expectedByHint.keys) + expectedByHint.forEach { (hint, expected) -> + val actual = viewNode.buildCardView( + autofillOptions = emptyList(), + autofillViewData = data, + autofillHint = hint, + ) + + assertEquals(expected, actual, "$hint mapped to the wrong view") + } + } + + @Test + fun `buildLoginView should map every login hint to its Login view`() { + val data = autofillViewData() + val expectedByHint = mapOf( + AutofillHint.Login.PASSWORD to AutofillView.Login.Password(data = data), + AutofillHint.Login.USERNAME to AutofillView.Login.Username(data = data), + ) + + assertEquals(AutofillHint.Login.entries.toSet(), expectedByHint.keys) + expectedByHint.forEach { (hint, expected) -> + val actual = buildLoginView( + autofillViewData = data, + autofillHint = hint, + ) + + assertEquals(expected, actual, "$hint mapped to the wrong view") + } + } + + @Test + fun `buildIdentityView should map every identity hint to its Identity view`() { + val data = autofillViewData() + val expectedByHint = mapOf( + AutofillHint.Identity.ADDRESS_COUNTRY to + AutofillView.Identity.AddressCountry(data = data), + AutofillHint.Identity.ADDRESS_LOCALITY to + AutofillView.Identity.AddressLocality(data = data), + AutofillHint.Identity.ADDRESS_REGION to + AutofillView.Identity.AddressRegion(data = data), + AutofillHint.Identity.ADDRESS_STREET to + AutofillView.Identity.AddressStreet(data = data), + AutofillHint.Identity.COMPANY to AutofillView.Identity.Company(data = data), + AutofillHint.Identity.EMAIL to AutofillView.Identity.Email(data = data), + AutofillHint.Identity.LICENSE_NUMBER to + AutofillView.Identity.LicenseNumber(data = data), + AutofillHint.Identity.PASSPORT_NUMBER to + AutofillView.Identity.PassportNumber(data = data), + AutofillHint.Identity.PERSON_NAME_FAMILY to + AutofillView.Identity.PersonNameFamily(data = data), + AutofillHint.Identity.PERSON_NAME_FULL to + AutofillView.Identity.PersonNameFull(data = data), + AutofillHint.Identity.PERSON_NAME_GIVEN to + AutofillView.Identity.PersonNameGiven(data = data), + AutofillHint.Identity.PERSON_NAME_MIDDLE to + AutofillView.Identity.PersonNameMiddle(data = data), + AutofillHint.Identity.PERSON_NAME_PREFIX to + AutofillView.Identity.PersonNamePrefix(data = data), + AutofillHint.Identity.POSTAL_ADDRESS_FULL to + AutofillView.Identity.PostalAddressFull(data = data), + AutofillHint.Identity.POSTAL_CODE to AutofillView.Identity.PostalCode(data = data), + AutofillHint.Identity.PHONE_FULL to AutofillView.Identity.PhoneFull(data = data), + AutofillHint.Identity.SSN to AutofillView.Identity.Ssn(data = data), + ) + + assertEquals(AutofillHint.Identity.entries.toSet(), expectedByHint.keys) + expectedByHint.forEach { (hint, expected) -> + val actual = buildIdentityView( + autofillViewData = data, + autofillHint = hint, + ) + + assertEquals(expected, actual, "$hint mapped to the wrong view") + } + } + + private fun autofillViewData(): AutofillView.Data = AutofillView.Data( + autofillId = mockk(), + autofillOptions = emptyList(), + autofillType = View.AUTOFILL_TYPE_TEXT, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = null, + ) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt index b4b325d6268..535b2de6f49 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -5,6 +5,7 @@ import android.view.View import android.view.ViewStructure.HtmlInfo import android.view.autofill.AutofillId import android.view.autofill.AutofillValue +import androidx.autofill.HintConstants import com.x8bit.bitwarden.data.autofill.model.AutofillView import io.mockk.every import io.mockk.mockk @@ -99,7 +100,10 @@ class ViewNodeExtensionsTest { every { viewNode.autofillHints } returns arrayOf(autofillHint) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -127,7 +131,10 @@ class ViewNodeExtensionsTest { } returns monthValue // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -143,7 +150,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_MONTH_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -158,7 +168,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARD_EXP_MONTH_HINTS.forEach { idEntry -> every { viewNode.idEntry } returns idEntry - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for idEntry: $idEntry") } @@ -173,7 +186,10 @@ class ViewNodeExtensionsTest { ) SUPPORTED_RAW_CARD_EXP_MONTH_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } } @@ -188,7 +204,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.autofillHints } returns arrayOf(autofillHint) - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -203,7 +222,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARD_EXP_YEAR_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -219,7 +241,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_YEAR_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -233,7 +258,10 @@ class ViewNodeExtensionsTest { every { viewNode.autofillHints } returns arrayOf(autofillHint) every { mockHtmlInfo.isInputField } returns true - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -247,7 +275,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARD_EXP_DATE_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -262,7 +293,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_DATE_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -277,7 +311,10 @@ class ViewNodeExtensionsTest { every { viewNode.autofillHints } returns arrayOf(autofillHint) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -292,7 +329,11 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARD_NUMBER_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + // Flag off pins the identity gate below the card heuristics. + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -306,7 +347,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_NUMBER_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -321,7 +365,10 @@ class ViewNodeExtensionsTest { every { viewNode.autofillHints } returns arrayOf(autofillHint) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -336,7 +383,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -350,7 +400,10 @@ class ViewNodeExtensionsTest { data = autofillViewData, ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -363,7 +416,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARDHOLDER_NAME_HINTS.forEach { idEntry -> every { viewNode.idEntry } returns idEntry - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for idEntry: $idEntry") } @@ -378,7 +434,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_CARDHOLDER_NAME_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -392,7 +451,10 @@ class ViewNodeExtensionsTest { data = autofillViewData, ) every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARDHOLDER_NAME_HINTS - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -405,7 +467,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.autofillHints } returns arrayOf(autofillHint) - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -419,7 +484,10 @@ class ViewNodeExtensionsTest { SUPPORTED_RAW_PASSWORD_HINTS.forEach { hint -> every { viewNode.hint } returns hint - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual, "Failed for hint: $hint") } @@ -434,7 +502,10 @@ class ViewNodeExtensionsTest { ) every { viewNode.htmlInfo.isPasswordField() } returns true - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -453,7 +524,10 @@ class ViewNodeExtensionsTest { every { any().isUsernameInputType } returns true // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -472,7 +546,10 @@ class ViewNodeExtensionsTest { every { any().isUsernameInputType } returns true // Test - val actual = viewNode.toAutofillView(parentWebsite = website) + val actual = viewNode.toAutofillView( + parentWebsite = website, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -491,7 +568,11 @@ class ViewNodeExtensionsTest { every { any().isUsernameInputType } returns true // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + // Flag off pins the identity gate below the username heuristic. + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) // Verify assertEquals(expected, actual) @@ -510,7 +591,10 @@ class ViewNodeExtensionsTest { every { any().isUsernameInputType } returns true // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -525,7 +609,10 @@ class ViewNodeExtensionsTest { every { viewNode.htmlInfo.isInputField } returns false // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertNull(actual) @@ -539,7 +626,10 @@ class ViewNodeExtensionsTest { data = autofillViewData, ) - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) assertEquals(expected, actual) } @@ -556,7 +646,10 @@ class ViewNodeExtensionsTest { every { viewNode.autofillHints } returns arrayOf(autofillHintOne, autofillHintTwo) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -1263,7 +1356,10 @@ class ViewNodeExtensionsTest { ) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -1302,7 +1398,10 @@ class ViewNodeExtensionsTest { ) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) @@ -1330,12 +1429,575 @@ class ViewNodeExtensionsTest { ) // Test - val actual = viewNode.toAutofillView(parentWebsite = null) + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) // Verify assertEquals(expected, actual) } + //region Identity: official autofillHints dispatch (toAutofillView) + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PersonNameFull when autofillHints match`() { + every { viewNode.autofillHints } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PersonNameFull(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PersonNamePrefix when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME_PREFIX) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PersonNamePrefix(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PersonNameGiven when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PersonNameGiven(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PersonNameMiddle when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PersonNameMiddle(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PersonNameFamily when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME_FAMILY) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PersonNameFamily(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity PostalAddressFull when autofillHints match`() { + every { viewNode.autofillHints } returns arrayOf(View.AUTOFILL_HINT_POSTAL_ADDRESS) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.PostalAddressFull(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity AddressStreet when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.AddressStreet(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity AddressLocality when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.AddressLocality(data = autofillViewData), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity AddressRegion when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_REGION) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals(AutofillView.Identity.AddressRegion(data = autofillViewData), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Identity AddressCountry when autofillHints match`() { + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals( + AutofillView.Identity.AddressCountry(data = autofillViewData), + actual, + ) + } + + @Test + fun `toAutofillView should return AutofillView Identity PostalCode when autofillHints match`() { + every { viewNode.autofillHints } returns arrayOf(View.AUTOFILL_HINT_POSTAL_CODE) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals(AutofillView.Identity.PostalCode(data = autofillViewData), actual) + } + + @Test + fun `toAutofillView should return AutofillView Identity PhoneFull when autofillHints match`() { + every { viewNode.autofillHints } returns arrayOf(View.AUTOFILL_HINT_PHONE) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals(AutofillView.Identity.PhoneFull(data = autofillViewData), actual) + } + + //endregion Identity: official autofillHints dispatch (toAutofillView) + + @Test + fun `toAutofillView should map every identity heuristic to its Identity view`() { + // Each idEntry uses a different term from the isXxxField tests below. + val expectedByIdEntry = mapOf( + "fullname" to AutofillView.Identity.PersonNameFull(data = autofillViewData), + "nameprefix" to AutofillView.Identity.PersonNamePrefix(data = autofillViewData), + "givenname" to AutofillView.Identity.PersonNameGiven(data = autofillViewData), + "middlename" to AutofillView.Identity.PersonNameMiddle(data = autofillViewData), + "surname" to AutofillView.Identity.PersonNameFamily(data = autofillViewData), + "mailingaddress" to AutofillView.Identity.PostalAddressFull(data = autofillViewData), + "addressline" to AutofillView.Identity.AddressStreet(data = autofillViewData), + "town" to AutofillView.Identity.AddressLocality(data = autofillViewData), + "province" to AutofillView.Identity.AddressRegion(data = autofillViewData), + "country" to AutofillView.Identity.AddressCountry(data = autofillViewData), + "postalcode" to AutofillView.Identity.PostalCode(data = autofillViewData), + "mobile" to AutofillView.Identity.PhoneFull(data = autofillViewData), + "company" to AutofillView.Identity.Company(data = autofillViewData), + "socialsecurity" to AutofillView.Identity.Ssn(data = autofillViewData), + "passport" to AutofillView.Identity.PassportNumber(data = autofillViewData), + "licensenumber" to AutofillView.Identity.LicenseNumber(data = autofillViewData), + ) + + expectedByIdEntry.forEach { (idEntry, expected) -> + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns idEntry + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = true, + ) + + assertEquals(expected, actual, "idEntry \"$idEntry\" mapped to the wrong view") + } + } + + //region Identity: flag-off gate (isIdentityAutofillEnabled = false) + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Unused when autofillHints contain an identity hint and IdentityAutofill is disabled`() { + setupUnsupportedInputFieldViewNode() + every { + viewNode.autofillHints + } returns arrayOf(HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(AutofillView.Unused(data = autofillViewData), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Unused when hint is First name and IdentityAutofill is disabled`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.hint } returns "First name" + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(AutofillView.Unused(data = autofillViewData), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Unused when hint is Mobile number and IdentityAutofill is disabled`() { + // "Mobile number" only matches the phone identity heuristic (SUPPORTED_RAW_PHONE_HINTS), + // not isUsernameField's raw hint list, so with the flag off this must fall through to + // Unused -- not become the focused view via Identity.PhoneFull -- so + // updateForMissingUsernameFields can still promote it, per the regression this gate + // guards against. + setupUnsupportedInputFieldViewNode() + every { viewNode.hint } returns "Mobile number" + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(AutofillView.Unused(data = autofillViewData), actual) + } + + //endregion Identity: flag-off gate (isIdentityAutofillEnabled = false) + + //region Identity: heuristic idEntry fallback + all-null guard (isXxxField) + + @Test + fun `isPersonNameFullField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "fullname" + + assertTrue(viewNode.isPersonNameFullField) + } + + @Test + fun `isPersonNameFullField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPersonNameFullField) + } + + @Test + fun `isPersonNamePrefixField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "honorificPrefix" + + assertTrue(viewNode.isPersonNamePrefixField) + } + + @Test + fun `isPersonNamePrefixField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPersonNamePrefixField) + } + + @Test + fun `isPersonNameGivenField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "first_name" + + assertTrue(viewNode.isPersonNameGivenField) + } + + @Test + fun `isPersonNameGivenField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPersonNameGivenField) + } + + @Test + fun `isPersonNameMiddleField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "middle_name" + + assertTrue(viewNode.isPersonNameMiddleField) + } + + @Test + fun `isPersonNameMiddleField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPersonNameMiddleField) + } + + @Test + fun `isPersonNameFamilyField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "last_name" + + assertTrue(viewNode.isPersonNameFamilyField) + } + + @Test + fun `isPersonNameFamilyField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPersonNameFamilyField) + } + + @Test + fun `isPostalAddressFullField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "shipping_address" + + assertTrue(viewNode.isPostalAddressFullField) + } + + @Test + fun `isPostalAddressFullField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPostalAddressFullField) + } + + @Test + fun `isAddressStreetField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "street_address" + + assertTrue(viewNode.isAddressStreetField) + } + + @Test + fun `isAddressStreetField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isAddressStreetField) + } + + @Test + fun `isAddressLocalityField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "city" + + assertTrue(viewNode.isAddressLocalityField) + } + + @Test + fun `isAddressLocalityField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isAddressLocalityField) + } + + @Test + fun `isAddressRegionField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "state" + + assertTrue(viewNode.isAddressRegionField) + } + + @Test + fun `isAddressRegionField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isAddressRegionField) + } + + @Test + fun `isAddressCountryField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "country" + + assertTrue(viewNode.isAddressCountryField) + } + + @Test + fun `isAddressCountryField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isAddressCountryField) + } + + @Test + fun `isPostalCodeField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "zip_code" + + assertTrue(viewNode.isPostalCodeField) + } + + @Test + fun `isPostalCodeField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPostalCodeField) + } + + @Test + fun `isPhoneField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "telephone" + + assertTrue(viewNode.isPhoneField) + } + + @Test + fun `isPhoneField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPhoneField) + } + + @Test + fun `isCompanyField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "organization" + + assertTrue(viewNode.isCompanyField) + } + + @Test + fun `isCompanyField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isCompanyField) + } + + @Test + fun `isSsnField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "ssn" + + assertTrue(viewNode.isSsnField) + } + + @Test + fun `isSsnField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isSsnField) + } + + @Test + fun `isPassportNumberField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "passport_number" + + assertTrue(viewNode.isPassportNumberField) + } + + @Test + fun `isPassportNumberField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isPassportNumberField) + } + + @Test + fun `isLicenseNumberField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "drivers_license" + + assertTrue(viewNode.isLicenseNumberField) + } + + @Test + fun `isLicenseNumberField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isLicenseNumberField) + } + + //endregion Identity: heuristic idEntry fallback + all-null guard (isXxxField) + + //region Identity: isEmailField (dual-classification trigger) + + @Test + fun `isEmailField returns true when autofillHints match`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.autofillHints } returns arrayOf(View.AUTOFILL_HINT_EMAIL_ADDRESS) + + assertTrue(viewNode.isEmailField) + } + + @Test + fun `isEmailField returns true when idEntry is supported`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.idEntry } returns "email" + + assertTrue(viewNode.isEmailField) + } + + @Test + fun `isEmailField returns false when idEntry, hint, and htmlInfo are all null`() { + setupUnsupportedInputFieldViewNode() + + assertFalse(viewNode.isEmailField) + } + + //endregion Identity: isEmailField (dual-classification trigger) + /** * Set up [viewNode] to be an input field but not supported. */