[PM-41292] feat: Add heuristic detection for identity autofill fields (#7233)

This commit is contained in:
aj-rosado authored and GitHub committed 2026-08-28 09:38:00 +00:00
1 parent 0945093ce9
commit d23d1d1706
10 files changed
+2150 -228

No files matched your search

@@ -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()
}
}
@@ -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,
}
}
@@ -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<ViewNodeTraversalData> =
private fun AssistStructure.traverse(
isIdentityAutofillEnabled: Boolean,
): List<ViewNodeTraversalData> =
(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<AutofillView> = 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
@@ -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<String>,
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)
}
}
@@ -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<String>.containsAnyTerms(terms: List<String>): 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<String> = listOf(
"name",
@@ -165,4 +270,5 @@ private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List<String> = listOf(
"type",
"hint",
"autofill",
"autocomplete",
)
@@ -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<String> = listOf(
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH,
@@ -28,6 +29,26 @@ private val SUPPORTED_VIEW_HINTS: List<String> = 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<String> = 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.
* 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.
*/
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
}
@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<String>,
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.
*/
@@ -147,3 +147,153 @@ val SUPPORTED_RAW_CARD_BRAND_HINTS: List<String> = 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<String> = listOf(
"email",
)
/**
* The supported full person name autofill hints.
*/
val SUPPORTED_RAW_PERSON_NAME_FULL_HINTS: List<String> = listOf(
"fullname",
)
/**
* The supported person name prefix (e.g. "Mr.", "Dr.") autofill hints.
*/
val SUPPORTED_RAW_PERSON_NAME_PREFIX_HINTS: List<String> = listOf(
"honorificprefix",
"nameprefix",
)
/**
* The supported given (first) name autofill hints.
*/
val SUPPORTED_RAW_PERSON_NAME_GIVEN_HINTS: List<String> = listOf(
"firstname",
"givenname",
)
/**
* The supported middle name autofill hints.
*/
val SUPPORTED_RAW_PERSON_NAME_MIDDLE_HINTS: List<String> = listOf(
"middlename",
)
/**
* The supported family (last) name autofill hints.
*/
val SUPPORTED_RAW_PERSON_NAME_FAMILY_HINTS: List<String> = listOf(
"lastname",
"familyname",
"surname",
)
/**
* The supported full postal address autofill hints.
*/
val SUPPORTED_RAW_POSTAL_ADDRESS_FULL_HINTS: List<String> = listOf(
"mailingaddress",
"shippingaddress",
"billingaddress",
"fulladdress",
)
/**
* The supported street address autofill hints.
*/
val SUPPORTED_RAW_ADDRESS_STREET_HINTS: List<String> = listOf(
"streetaddress",
"addressline",
)
/**
* The supported locality (city) autofill hints.
*/
val SUPPORTED_RAW_ADDRESS_LOCALITY_HINTS: List<String> = listOf(
"locality",
"city",
"town",
)
/**
* The supported region (state/province) autofill hints.
*/
val SUPPORTED_RAW_ADDRESS_REGION_HINTS: List<String> = listOf(
"addressregion",
"province",
"state",
)
/**
* The supported country autofill hints.
*/
val SUPPORTED_RAW_ADDRESS_COUNTRY_HINTS: List<String> = listOf(
"country",
)
/**
* The supported postal code autofill hints.
*/
val SUPPORTED_RAW_POSTAL_CODE_HINTS: List<String> = listOf(
"postalcode",
"zipcode",
"zip",
)
/**
* The supported phone number autofill hints.
*/
val SUPPORTED_RAW_PHONE_HINTS: List<String> = listOf(
"phonenumber",
"telephone",
"mobilephone",
"mobile",
)
/**
* The supported company autofill hints.
*/
val SUPPORTED_RAW_COMPANY_HINTS: List<String> = 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<String> = 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<String> = 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<String> = listOf(
"licensenumber",
"driverslicense",
"driverlicense",
)
@@ -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<HtmlInfo>().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<AutofillId>()
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<AutofillId>()
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<String> = listOf(
@@ -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<AutofillId>(),
autofillOptions = emptyList(),
autofillType = View.AUTOFILL_TYPE_TEXT,
isFocused = false,
textValue = null,
hasPasswordTerms = false,
website = null,
)
}
@@ -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<Int>().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<Int>().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<Int>().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<Int>().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.
*/