mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:19 -05:00
[PM-41292] feat: Add heuristic detection for identity autofill fields (#7233)
This commit is contained in:
1 parent
0945093ce9
commit
d23d1d1706
10 files changed
+2150
-228
No files matched your search
+1
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
+52
-11
@@ -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
|
||||
|
||||
+172
@@ -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",
|
||||
)
|
||||
+348
-122
@@ -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",
|
||||
)
|
||||
+460
-33
@@ -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(
|
||||
|
||||
+120
@@ -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,
|
||||
)
|
||||
}
|
||||
+695
-33
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user