From db03c7d703198b8795497a4842906559ffbcf339 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:47:53 -0400 Subject: [PATCH] Refactor Autofill Hint Logic and Add Card Autofill Support (#5640) --- .../autofill/builder/FilledDataBuilderImpl.kt | 54 ++- .../data/autofill/model/AutofillHint.kt | 15 + .../data/autofill/model/AutofillView.kt | 14 + .../data/autofill/util/HtmlInfoExtensions.kt | 124 ++++-- .../data/autofill/util/StringExtensions.kt | 10 + .../data/autofill/util/ViewNodeExtensions.kt | 207 ++++++--- .../data/autofill/util/ViewStructureUtils.kt | 137 ++++++ .../itemlisting/VaultItemListingViewModel.kt | 61 ++- .../autofill/builder/FilledDataBuilderTest.kt | 108 ++++- .../autofill/util/HtmlInfoExtensionsTest.kt | 6 - .../autofill/util/ViewNodeExtensionsTest.kt | 399 ++++++++++++++---- ui/src/main/res/values/strings.xml | 1 + 12 files changed, 935 insertions(+), 201 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt index 278cfffd34..e8335861c3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -116,15 +116,13 @@ class FilledDataBuilderImpl( ): FilledPartition { val filledItems = autofillViews .mapNotNull { autofillView -> - val value = when (autofillView) { - is AutofillView.Card.ExpirationMonth -> autofillCipher.expirationMonth - is AutofillView.Card.ExpirationYear -> autofillCipher.expirationYear - is AutofillView.Card.Number -> autofillCipher.number - is AutofillView.Card.SecurityCode -> autofillCipher.code - } - autofillView.buildFilledItemOrNull( - value = value, - ) + autofillCipher + .getAutofillValueOrNull(autofillView) + ?.let { value -> + autofillView.buildFilledItemOrNull( + value = value, + ) + } } return FilledPartition( @@ -162,6 +160,44 @@ class FilledDataBuilderImpl( } } +/** + * Get the autofill value for the given [autofillView], or null if no value is available. + */ +private fun AutofillCipher.Card.getAutofillValueOrNull(autofillView: AutofillView.Card): String? = + when (autofillView) { + is AutofillView.Card.CardholderName -> { + cardholderName.takeIf { it.isNotEmpty() } + } + + is AutofillView.Card.ExpirationMonth -> { + expirationMonth.takeIf { it.isNotEmpty() } + } + + is AutofillView.Card.ExpirationYear -> { + expirationYear.takeIf { it.isNotEmpty() } + } + + is AutofillView.Card.Number -> { + number + .filter { it.isDigit() } + .takeIf { it.isNotEmpty() } + } + + is AutofillView.Card.SecurityCode -> { + code + .filter { it.isDigit() } + .takeIf { it.isNotEmpty() } + } + + is AutofillView.Card.ExpirationDate -> { + if (expirationMonth.isNotBlank() && expirationYear.isNotBlank()) { + expirationMonth.padStart(2, '0') + expirationYear.takeLast(2) + } else { + null + } + } + } + /** * Get the item at the [index]. If that fails, return the last item in the list. If that also fails, * return null. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt new file mode 100644 index 0000000000..36ee8050ae --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillHint.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.autofill.model + +/** + * Autofill hints used to determine what data an input field is associated with. + */ +enum class AutofillHint { + CARD_CARDHOLDER, + CARD_EXPIRATION_DATE, + CARD_EXPIRATION_MONTH, + CARD_EXPIRATION_YEAR, + CARD_NUMBER, + CARD_SECURITY_CODE, + PASSWORD, + USERNAME, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt index a032276035..f5842f9d11 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt @@ -54,6 +54,20 @@ sealed class AutofillView { override val data: Data, ) : Card() + /** + * The expiration date [AutofillView] for the [Card] data partition. + */ + data class ExpirationDate( + override val data: Data, + ) : Card() + + /** + * The cardholder name [AutofillView] for the [Card] data partition. + */ + data class CardholderName( + override val data: Data, + ) : Card() + /** * The number [AutofillView] for the [Card] data partition. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt index 7ff01ebfdc..5950b81505 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt @@ -1,53 +1,111 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.data.autofill.util import android.view.ViewStructure.HtmlInfo -import com.bitwarden.annotation.OmitFromCoverage /** * Whether this [HtmlInfo] represents a password field. - * - * This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires - * instrumentation testing. */ -@OmitFromCoverage -fun HtmlInfo?.isPasswordField(): Boolean = - this - ?.let { htmlInfo -> - if (htmlInfo.isInputField) { - htmlInfo - .attributes - ?.any { - it.first == "type" && it.second == "password" - } - } else { - false - } - } - ?: false +fun HtmlInfo?.isPasswordField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) /** * Whether this [HtmlInfo] represents a username field. + */ +fun HtmlInfo?.isUsernameField(): Boolean = isInputField && + hints().containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) + +/** + * Whether this [HtmlInfo] represents a cardholder name field. + */ +fun HtmlInfo?.isCardholderNameField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS) + +/** + * Whether this [HtmlInfo] represents a card number field. + */ +fun HtmlInfo?.isCardNumberField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS) + +/** + * Whether this [HtmlInfo] represents a card expiration month field. + */ +fun HtmlInfo?.isCardExpirationMonthField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS) + +/** + * Whether this [HtmlInfo] represents a card expiration year field. + */ +fun HtmlInfo?.isCardExpirationYearField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS) + +/** + * Whether this [HtmlInfo] represents a card expiration date field. + */ +fun HtmlInfo?.isCardExpirationDateField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS) + +/** + * Whether this [HtmlInfo] represents a card security code field. + */ +fun HtmlInfo?.isCardSecurityCodeField(): Boolean = isInputField && + hints().containsAnyPatterns(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS) + +/** + * Attributes that can be used as hints to determine the type of data the associated node expects. * * This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires * instrumentation testing. + * + * @see IGNORED_RAW_HINTS + * @see SUPPORTED_HTML_ATTRIBUTE_HINTS */ -@OmitFromCoverage -fun HtmlInfo?.isUsernameField(): Boolean = - this - ?.let { htmlInfo -> - if (htmlInfo.isInputField) { - htmlInfo - .attributes - ?.any { - it.first == "type" && it.second == "email" - } - } else { - false +fun HtmlInfo?.hints(): List = this + ?.let { htmlInfo -> + htmlInfo + .attributes + // Filter out attributes with null values or values that match ignored raw hints + ?.filter { attribute -> + attribute.second != null && + !attribute.second.containsAnyTerms(IGNORED_RAW_HINTS) } - } - ?: false + // Filter attributes that match supported HTML attribute hints + ?.filter { attribute -> + attribute.first.containsAnyTerms( + terms = SUPPORTED_HTML_ATTRIBUTE_HINTS, + ignoreCase = true, + ) + } + .orEmpty() + .mapNotNull { it.second } + } + .orEmpty() /** * Whether this [HtmlInfo] represents an input field. */ val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input" + +/** + * Checks if the list of strings contains any of the specified patterns. + */ +private fun List.containsAnyPatterns(patterns: List): Boolean = this + .any { string -> patterns.any { pattern -> string.matches(pattern) } } + +/** + * Checks if the list of strings contains any of the specified terms. + */ +private fun List.containsAnyTerms(terms: List): Boolean = + this.any { string -> string.containsAnyTerms(terms) } + +/** + * The supported attribute keys whose value can represent an autofill hint. + */ +private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List = listOf( + "name", + "label", + "type", + "hint", + "autofill", +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt index 6b74153a47..fb5057e0fa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt @@ -16,3 +16,13 @@ fun String.containsAnyTerms( ignoreCase = ignoreCase, ) } + +/** + * Check whether this string matches any of these [expressions]. + */ +fun String.matchesAnyExpressions( + expressions: List, +): Boolean = + expressions.any { + this.matches(regex = it) + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 8053ebbc19..4527bb2c51 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -3,7 +3,9 @@ package com.x8bit.bitwarden.data.autofill.util import android.app.assist.AssistStructure import android.view.View import android.widget.EditText +import androidx.annotation.VisibleForTesting import com.bitwarden.ui.platform.base.util.orNullIfBlank +import com.x8bit.bitwarden.data.autofill.model.AutofillHint import com.x8bit.bitwarden.data.autofill.model.AutofillView /** @@ -11,39 +13,13 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillView */ private const val DEFAULT_SCHEME: String = "https" -/** - * The set of raw autofill hints that should be ignored. - */ -private val IGNORED_RAW_HINTS: List = listOf( - "search", - "find", - "recipient", - "edit", -) - -/** - * The supported password autofill hints. - */ -private val SUPPORTED_RAW_PASSWORD_HINTS: List = listOf( - "password", - "pswd", -) - -/** - * The supported raw autofill hints. - */ -private val SUPPORTED_RAW_USERNAME_HINTS: List = listOf( - "email", - "phone", - "username", -) - /** * The supported autofill Android View hints. */ private val SUPPORTED_VIEW_HINTS: List = listOf( View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, View.AUTOFILL_HINT_EMAIL_ADDRESS, @@ -60,7 +36,7 @@ private val AssistStructure.ViewNode.isInputField: Boolean ?.let { try { Class.forName(it) - } catch (e: ClassNotFoundException) { + } catch (_: ClassNotFoundException) { null } } @@ -78,11 +54,7 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = .autofillId // We only care about nodes with a valid `AutofillId`. ?.let { nonNullAutofillId -> - val supportedHint = this - .autofillHints - ?.firstOrNull { SUPPORTED_VIEW_HINTS.contains(it) } - - if (supportedHint != null || this.isInputField) { + if (supportedAutofillHint != null || this.isInputField) { val autofillOptions = this .autofillOptions .orEmpty() @@ -99,22 +71,63 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = buildAutofillView( autofillOptions = autofillOptions, autofillViewData = autofillViewData, - supportedHint = supportedHint, + autofillHint = supportedAutofillHint, ) } else { null } } +/** + * The first supported autofill hint for this view node, or null if none are found. + */ +private val AssistStructure.ViewNode.supportedAutofillHint: AutofillHint? + get() = firstSupportedAutofillHintOrNull() + ?: when { + this.isUsernameField -> AutofillHint.USERNAME + this.isPasswordField -> AutofillHint.PASSWORD + this.isCardExpirationMonthField -> AutofillHint.CARD_EXPIRATION_MONTH + this.isCardExpirationYearField -> AutofillHint.CARD_EXPIRATION_YEAR + this.isCardExpirationDateField -> AutofillHint.CARD_EXPIRATION_DATE + this.isCardNumberField -> AutofillHint.CARD_NUMBER + this.isCardSecurityCodeField -> AutofillHint.CARD_SECURITY_CODE + this.isCardholderNameField -> AutofillHint.CARD_CARDHOLDER + else -> null + } + +/** + * Get the first supported autofill hint from the view node's autofillHints, or null if none are + * found. + */ +private fun AssistStructure.ViewNode.firstSupportedAutofillHintOrNull(): AutofillHint? = + autofillHints + ?.firstOrNull { SUPPORTED_VIEW_HINTS.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_EMAIL_ADDRESS, + View.AUTOFILL_HINT_USERNAME, + -> AutofillHint.USERNAME + + else -> null + } + /** * Attempt to convert this [AssistStructure.ViewNode] and [autofillViewData] into an [AutofillView]. */ private fun AssistStructure.ViewNode.buildAutofillView( autofillOptions: List, autofillViewData: AutofillView.Data, - supportedHint: String?, -): AutofillView = when { - supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { + autofillHint: AutofillHint?, +): AutofillView = when (autofillHint) { + AutofillHint.CARD_EXPIRATION_MONTH -> { val monthValue = this .autofillValue ?.extractMonthValue( @@ -127,31 +140,43 @@ private fun AssistStructure.ViewNode.buildAutofillView( ) } - supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> { + AutofillHint.CARD_EXPIRATION_YEAR -> { AutofillView.Card.ExpirationYear( data = autofillViewData, ) } - supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> { + AutofillHint.CARD_EXPIRATION_DATE -> { + AutofillView.Card.ExpirationDate( + data = autofillViewData, + ) + } + + AutofillHint.CARD_NUMBER -> { AutofillView.Card.Number( data = autofillViewData, ) } - supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> { + AutofillHint.CARD_SECURITY_CODE -> { AutofillView.Card.SecurityCode( data = autofillViewData, ) } - this.isPasswordField(supportedHint) -> { + AutofillHint.CARD_CARDHOLDER -> { + AutofillView.Card.CardholderName( + data = autofillViewData, + ) + } + + AutofillHint.PASSWORD -> { AutofillView.Login.Password( data = autofillViewData, ) } - this.isUsernameField(supportedHint) -> { + AutofillHint.USERNAME -> { AutofillView.Login.Username( data = autofillViewData, ) @@ -167,41 +192,97 @@ private fun AssistStructure.ViewNode.buildAutofillView( /** * Check whether this [AssistStructure.ViewNode] represents a password field. */ -fun AssistStructure.ViewNode.isPasswordField( - supportedHint: String?, -): Boolean { - if (supportedHint == View.AUTOFILL_HINT_PASSWORD) return true +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isPasswordField: Boolean + get() { + val isUsernameField = this.isUsernameField + if ( + this.inputType.isPasswordInputType && + !this.containsIgnoredHintTerms() && + !isUsernameField + ) { + return true + } - val isInvalidField = this.idEntry?.containsAnyTerms(IGNORED_RAW_HINTS) == true || - this.hint?.containsAnyTerms(IGNORED_RAW_HINTS) == true - val isUsernameField = this.isUsernameField(supportedHint) - if (this.inputType.isPasswordInputType && !isInvalidField && !isUsernameField) return true - - return this - .htmlInfo - .isPasswordField() -} + return hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true || + htmlInfo.isPasswordField() + } /** * Check whether this [AssistStructure.ViewNode] includes any password specific terms. */ -fun AssistStructure.ViewNode.hasPasswordTerms(): Boolean = +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun AssistStructure.ViewNode.hasPasswordTerms(): Boolean = this.idEntry?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true || - this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true + this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true || + this.htmlInfo.hints().any { it.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) } /** * Check whether this [AssistStructure.ViewNode] represents a username field. */ -fun AssistStructure.ViewNode.isUsernameField( - supportedHint: String?, -): Boolean = - supportedHint == View.AUTOFILL_HINT_USERNAME || - supportedHint == View.AUTOFILL_HINT_EMAIL_ADDRESS || - inputType.isUsernameInputType || +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal val AssistStructure.ViewNode.isUsernameField: Boolean + get() = inputType.isUsernameInputType || idEntry?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true || hint?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true || htmlInfo.isUsernameField() +/** + * Check whether this [AssistStructure.ViewNode] represents a card expiration month field. + */ +private val AssistStructure.ViewNode.isCardExpirationMonthField: Boolean + get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS) == true || + htmlInfo.isCardExpirationMonthField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a card expiration year field. + */ +private val AssistStructure.ViewNode.isCardExpirationYearField: Boolean + get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS) == true || + htmlInfo.isCardExpirationYearField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a card expiration date field. + */ +private val AssistStructure.ViewNode.isCardExpirationDateField: Boolean + get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS) == true || + htmlInfo.isCardExpirationDateField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a card number field based. + */ +private val AssistStructure.ViewNode.isCardNumberField: Boolean + get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS) == true || + htmlInfo.isCardNumberField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a card security code field based. + */ +private val AssistStructure.ViewNode.isCardSecurityCodeField: Boolean + get() = + idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS) == true || + htmlInfo.isCardSecurityCodeField() + +/** + * Check whether this [AssistStructure.ViewNode] represents a cardholder name field based. + */ +private val AssistStructure.ViewNode.isCardholderNameField: Boolean + get() = idEntry?.matchesAnyExpressions(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS) == true || + hint?.matchesAnyExpressions(SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS) == true || + htmlInfo.isCardholderNameField() + +/** + * Check whether this [AssistStructure.ViewNode] contains any ignored hint terms. + */ +private fun AssistStructure.ViewNode.containsIgnoredHintTerms(): Boolean = + this.idEntry?.containsAnyTerms(IGNORED_RAW_HINTS) == true || + this.hint?.containsAnyTerms(IGNORED_RAW_HINTS) == true || + this.htmlInfo.hints().any { it.containsAnyTerms(IGNORED_RAW_HINTS) } /** * The website that this [AssistStructure.ViewNode] is a part of representing. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt new file mode 100644 index 0000000000..f3c0f6838d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewStructureUtils.kt @@ -0,0 +1,137 @@ +package com.x8bit.bitwarden.data.autofill.util + +/** + * The set of raw autofill hints that should be ignored. + */ +val IGNORED_RAW_HINTS: List = listOf( + "search", + "find", + "recipient", + "edit", +) + +/** + * The supported password autofill hints. + */ +val SUPPORTED_RAW_PASSWORD_HINTS: List = listOf( + "password", + "pswd", +) + +/** + * The supported raw autofill hints. + */ +val SUPPORTED_RAW_USERNAME_HINTS: List = listOf( + "email", + "phone", + "username", +) + +/** + * Matches common patterns for cardholder name hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - `(?:cc|card)[\\s_-](?:name|cardholder).*`: Matches "cc" or "card" followed by a space, + * underscore, or hyphen, then "name" or "cardholder", and finally any characters. This covers + * variations like "cc name", "card_cardholder", "credit-card-name something else". + * - `|`: OR operator, allowing for an alternative pattern. + * - `name[\\s_-]on[\\s_-]card`: Matches "name" followed by a space, underscore, or hyphen, then + * "on", another space, underscore, or hyphen, and finally "card". This covers phrases like "name on + * card" or "name_on_card". + * - `\b`: Word boundary to ensure we match whole words. + */ +val SUPPORTED_RAW_CARDHOLDER_NAME_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:cc|card)[\\s_-](?:name|cardholder).*\\b".toRegex(), + "\\b(?i)name[\\s_-]on[\\s_-]card\\b".toRegex(), +) + +/** + * Matches common patterns for card number hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - `(?:cc|card)`: Matches "cc" or "card". + * - `[\\s_-]number`: Matches "number" preceded by a space, underscore, or hyphen. + * - `\b`: Word boundary to ensure we match whole words. + */ +val SUPPORTED_RAW_CARD_NUMBER_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:cc|card)[\\s_-]number\\b".toRegex(), +) + +/** + * Matches common patterns for card expiration month hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - `(?:cc|card)`: Matches "cc" or "card". + * - `[\\s_-]exp[\\s_-]month`: Matches "exp" followed by a space, underscore, or hyphen, then + * "month". + * - `\b`: Word boundary to ensure we match whole words. + * + * Examples: + * - "credit card exp month" + * - "cc_exp_month" + * - "card-exp-month" + */ +val SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]month\\b" + .toRegex(), +) + +/** + * Matches common patterns for card expiration year hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - `(?:cc|card)`: Matches "cc" or "card". + * - `[\\s_-]exp[\\s_-]year`: Matches "exp" followed by a space, underscore, or hyphen, then "year". + * - `\b`: Word boundary to ensure we match whole words. + * + * Similar to [SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS], but for "year" instead of "month". + * @see SUPPORTED_RAW_CARD_EXP_MONTH_HINT_PATTERNS + */ +val SUPPORTED_RAW_CARD_EXP_YEAR_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]year\\b" + .toRegex(), +) + +/** + * Matches common patterns for card expiration date hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - `(?:cc|card)`: Matches "cc" or "card". + * - `[\\s_-]exp[\\s_-]date`: Matches "exp" followed by a space, underscore, or hyphen, then "date". + * - `.*`: Matches any characters following "date" (e.g., "MM/YY", "month/year"). + * - `\b`: Word boundary to ensure we match whole words. + * + * Examples: + * - "credit card exp date" + * - "cc_exp_date_mm_yy" + * - "card-exp-date month/year" + */ +val SUPPORTED_RAW_CARD_EXP_DATE_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:(cc|card)[\\s_-])?(?:exp|expiration|expiry)[\\s_-]date\\b" + .toRegex(), +) + +/** + * Matches common patterns for card security code hints. + * - `\b(?i)`: Case-insensitive word boundary to ensure we match whole words. + * - `(?:credit[\\s_-])?`: Optionally matches "credit" followed by a space, underscore, or hyphen. + * - The first pattern `(?:cc|card[\\s_-])(cvc|cvv)\b`: + * - `(?:cc|card[\\s_-])`: Matches "cc" or "card" followed by a space, underscore, or hyphen. + * - `(cvc|cvv)\b`: Matches "cvc" or "cvv" followed by a word boundary. + * - The second pattern `(?:cc|card)(?:[\\s_-]verification)?([\\s_-]code)\b`: + * - `(?:cc|card)`: Matches "cc" or "card". + * - `(?:[\\s_-]verification)?`: Optionally matches "verification" preceded by a space, + * underscore, or hyphen. + * - `([\\s_-]code)\b`: Matches "code" preceded by a space, underscore, or hyphen, and + * followed by a word boundary. + * + * Examples: + * - "credit card cvc" + * - "cc_verification_code" + * - "card-code" + */ +val SUPPORTED_RAW_CARD_SECURITY_CODE_HINT_PATTERNS: List = listOf( + "\\b(?i)(?:credit[\\s_-])?(?:cc|card[\\s_-])?(cvc|cvv)2?\\b".toRegex(), + "\\b(?i)(?:credit[\\s_-])?(?:cc|card)(?:[\\s_-](?:verification|security))?([\\s_-]code)\\b" + .toRegex(), +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 5fe5e0292f..711a2805e7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -26,6 +26,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat +import com.bitwarden.vault.CipherListViewType import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView @@ -2476,19 +2477,35 @@ class VaultItemListingViewModel @Inject constructor( * Takes the given vault data and filters it for autofill if necessary. */ private suspend fun DataState.filterForAutofillIfNecessary(): DataState { - val matchUri = state - .autofillSelectionData - ?.uri - ?: return this - return this.map { vaultData -> - vaultData.copy( - decryptCipherListResult = vaultData.decryptCipherListResult.copy( - successes = cipherMatchingManager.filterCiphersForMatches( - cipherListViews = vaultData.decryptCipherListResult.successes, - matchUri = matchUri, - ), - ), - ) + val autofillSelectionData = state.autofillSelectionData ?: return this + return when (autofillSelectionData.type) { + AutofillSelectionData.Type.CARD -> { + this.map { vaultData -> + vaultData.copy( + decryptCipherListResult = vaultData.decryptCipherListResult.copy( + successes = vaultData.decryptCipherListResult.successes + .filter { it.type is CipherListViewType.Card }, + ), + ) + } + } + + AutofillSelectionData.Type.LOGIN -> { + val matchUri = state + .autofillSelectionData + ?.uri + ?: return this + this.map { vaultData -> + vaultData.copy( + decryptCipherListResult = vaultData.decryptCipherListResult.copy( + successes = cipherMatchingManager.filterCiphersForMatches( + cipherListViews = vaultData.decryptCipherListResult.successes, + matchUri = matchUri, + ), + ), + ) + } + } } } @@ -2626,9 +2643,21 @@ data class VaultItemListingState( */ val appBarTitle: Text get() = autofillSelectionData - ?.uri - ?.toHostOrPathOrNull() - ?.let { BitwardenString.items_for_uri.asText(it) } + ?.let { data -> + data.uri + ?.toHostOrPathOrNull() + ?.let { + when (data.type) { + AutofillSelectionData.Type.CARD -> { + BitwardenString.select_a_card_for_x.asText(it) + } + + AutofillSelectionData.Type.LOGIN -> { + BitwardenString.items_for_uri.asText(it) + } + } + } + } ?: createCredentialRequest ?.relyingPartyIdOrNull ?.let { BitwardenString.items_for_uri.asText(it) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index 5db368eb15..d73e2ed9b5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -177,11 +177,13 @@ class FilledDataBuilderTest { runTest { // Setup val code = "123" - val expirationMonth = "January" + val expirationMonth = "01" val expirationYear = "1999" val number = "1234567890" + val expirationDate = "0199" + val cardholderName = "John" val autofillCipher = AutofillCipher.Card( - cardholderName = "John", + cardholderName = cardholderName, cipherId = null, code = code, expirationMonth = expirationMonth, @@ -194,6 +196,8 @@ class FilledDataBuilderTest { val filledItemExpirationMonth: FilledItem = mockk() val filledItemExpirationYear: FilledItem = mockk() val filledItemNumber: FilledItem = mockk() + val filledItemCardholderName: FilledItem = mockk() + val filledItemExpirationDate: FilledItem = mockk() val autofillViewCode: AutofillView.Card.SecurityCode = mockk { every { buildFilledItemOrNull(code) } returns filledItemCode } @@ -209,6 +213,12 @@ class FilledDataBuilderTest { val autofillViewNumberTwo: AutofillView.Card.Number = mockk { every { buildFilledItemOrNull(number) } returns null } + val autofillViewCardholderName: AutofillView.Card.CardholderName = mockk { + every { buildFilledItemOrNull(cardholderName) } returns filledItemCardholderName + } + val autofillViewExpirationDate: AutofillView.Card.ExpirationDate = mockk { + every { buildFilledItemOrNull(expirationDate) } returns filledItemExpirationDate + } val autofillPartition = AutofillPartition.Card( views = listOf( autofillViewCode, @@ -216,6 +226,8 @@ class FilledDataBuilderTest { autofillViewExpirationYear, autofillViewNumberOne, autofillViewNumberTwo, + autofillViewCardholderName, + autofillViewExpirationDate, ), ) val ignoreAutofillIds: List = mockk() @@ -234,6 +246,8 @@ class FilledDataBuilderTest { filledItemExpirationMonth, filledItemExpirationYear, filledItemNumber, + filledItemCardholderName, + filledItemExpirationDate, ), inlinePresentationSpec = null, ) @@ -265,6 +279,96 @@ class FilledDataBuilderTest { autofillViewExpirationYear.buildFilledItemOrNull(expirationYear) autofillViewNumberOne.buildFilledItemOrNull(number) autofillViewNumberTwo.buildFilledItemOrNull(number) + autofillViewCardholderName.buildFilledItemOrNull(cardholderName) + autofillViewExpirationDate.buildFilledItemOrNull(expirationDate) + } + } + + @Suppress("MaxLineLength") + @Test + fun `build should skip empty AutofillValues and return empty data when Card data is blank`() = + runTest { + // Setup + val code = "" + val expirationMonth = "" + val expirationYear = "" + val number = "" + val autofillCipher = AutofillCipher.Card( + cardholderName = "", + cipherId = null, + code = code, + expirationMonth = expirationMonth, + expirationYear = expirationYear, + name = "", + number = number, + subtitle = "", + ) + val filledItemCode: FilledItem = mockk() + val filledItemExpirationMonth: FilledItem = mockk() + val filledItemExpirationYear: FilledItem = mockk() + val filledItemNumber: FilledItem = mockk() + val autofillViewCode: AutofillView.Card.SecurityCode = mockk() + val autofillViewExpirationMonth: AutofillView.Card.ExpirationMonth = mockk() + val autofillViewExpirationYear: AutofillView.Card.ExpirationYear = mockk() + val autofillViewNumberOne: AutofillView.Card.Number = mockk() + val autofillViewNumberTwo: AutofillView.Card.Number = mockk() + val autofillViewCardholderName: AutofillView.Card.CardholderName = mockk() + val autofillViewExpirationDate: AutofillView.Card.ExpirationDate = mockk() + val autofillPartition = AutofillPartition.Card( + views = listOf( + autofillViewCode, + autofillViewExpirationMonth, + autofillViewExpirationYear, + autofillViewNumberOne, + autofillViewNumberTwo, + autofillViewCardholderName, + autofillViewExpirationDate, + ), + ) + val ignoreAutofillIds: List = mockk() + val autofillRequest = AutofillRequest.Fillable( + ignoreAutofillIds = ignoreAutofillIds, + inlinePresentationSpecs = emptyList(), + maxInlineSuggestionsCount = 0, + packageName = null, + partition = autofillPartition, + uri = URI, + ) + val filledPartition = FilledPartition( + autofillCipher = autofillCipher, + filledItems = emptyList(), + inlinePresentationSpec = null, + ) + val expected = FilledData( + filledPartitions = listOf( + filledPartition, + ), + ignoreAutofillIds = ignoreAutofillIds, + originalPartition = autofillPartition, + uri = URI, + vaultItemInlinePresentationSpec = null, + isVaultLocked = false, + ) + coEvery { + autofillCipherProvider.getCardAutofillCiphers() + } returns listOf(autofillCipher) + + // Test + val actual = filledDataBuilder.build( + autofillRequest = autofillRequest, + ) + + // Verify + assertEquals(expected, actual) + coVerify(exactly = 1) { + autofillCipherProvider.getCardAutofillCiphers() + } + coVerify(exactly = 0) { + autofillViewCode.buildFilledItemOrNull(code) + autofillViewExpirationMonth.buildFilledItemOrNull(expirationMonth) + autofillViewExpirationYear.buildFilledItemOrNull(expirationYear) + autofillViewNumberOne.buildFilledItemOrNull(number) + autofillViewNumberTwo.buildFilledItemOrNull(number) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt index d4bc4013cf..fba1819de3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt @@ -10,29 +10,23 @@ import org.junit.jupiter.api.Test class HtmlInfoExtensionsTest { @Test fun `isInputField should return true when tag is 'input'`() { - // Setup val htmlInfo: HtmlInfo = mockk { every { tag } returns "input" } - // Test val actual = htmlInfo.isInputField - // Verify assertTrue(actual) } @Test fun `isInputField should return false when tag is not 'input'`() { - // Setup val htmlInfo: HtmlInfo = mockk { every { tag } returns "not input" } - // Test val actual = htmlInfo.isInputField - // Verify assertFalse(actual) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt index 3fb8bc464a..a41c53e773 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -30,17 +30,21 @@ class ViewNodeExtensionsTest { hasPasswordTerms = false, ) private val testAutofillValue: AutofillValue = mockk() + private val mockHtmlInfo: HtmlInfo = mockk { + every { attributes } returns emptyList() + } private val viewNode: AssistStructure.ViewNode = mockk { - every { this@mockk.autofillId } returns expectedAutofillId - every { this@mockk.idEntry } returns null - every { this@mockk.hint } returns null - every { this@mockk.autofillOptions } returns AUTOFILL_OPTIONS_ARRAY - every { this@mockk.autofillType } returns AUTOFILL_TYPE - every { this@mockk.autofillValue } returns testAutofillValue - every { this@mockk.childCount } returns 0 - every { this@mockk.inputType } returns 1 - every { this@mockk.isFocused } returns expectedIsFocused + every { autofillId } returns expectedAutofillId + every { idEntry } returns null + every { hint } returns null + every { autofillOptions } returns AUTOFILL_OPTIONS_ARRAY + every { autofillType } returns AUTOFILL_TYPE + every { autofillValue } returns testAutofillValue + every { childCount } returns 0 + every { inputType } returns 1 + every { isFocused } returns expectedIsFocused + every { htmlInfo } returns mockHtmlInfo } @BeforeEach @@ -69,8 +73,9 @@ class ViewNodeExtensionsTest { unmockkStatic(AutofillValue::extractTextValue) } + @Suppress("MaxLineLength") @Test - fun `toAutofillView should return AutofillView Card ExpirationMonth when hint matches`() { + fun `toAutofillView should return AutofillView Card ExpirationMonth when autofillHints match`() { // Setup val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH val expected = AutofillView.Card.ExpirationMonth( @@ -88,7 +93,7 @@ class ViewNodeExtensionsTest { @Suppress("MaxLineLength") @Test - fun `toAutofillView should return AutofillView Card ExpirationMonth with empty options when hint matches and options are null`() { + fun `toAutofillView should return AutofillView Card ExpirationMonth with empty options when autofillHints match and options are null`() { // Setup val autofillViewData = autofillViewData.copy( autofillOptions = emptyList(), @@ -114,24 +119,139 @@ class ViewNodeExtensionsTest { assertEquals(expected, actual) } + @Suppress("MaxLineLength") @Test - fun `toAutofillView should return AutofillView Card ExpirationYear when hint matches`() { - // Setup + fun `toAutofillView should return AutofillView Card ExpirationMonth when html info isCardExpirationMonthField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationMonth( + data = autofillViewData, + monthValue = MONTH_VALUE, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_MONTH_HINTS + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card ExpirationMonth when idEntry matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationMonth( + data = autofillViewData, + monthValue = MONTH_VALUE, + ) + SUPPORTED_RAW_CARD_EXP_MONTH_HINTS.forEach { idEntry -> + every { viewNode.idEntry } returns idEntry + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for idEntry: $idEntry") + } + } + + @Test + fun `toAutofillView should return AutofillView Card ExpirationMonth when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationMonth( + data = autofillViewData, + monthValue = MONTH_VALUE, + ) + SUPPORTED_RAW_CARD_EXP_MONTH_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + val actual = viewNode.toAutofillView() + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Card ExpirationYear when autofillHints match`() { val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR val expected = AutofillView.Card.ExpirationYear( data = autofillViewData, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) - // Test val actual = viewNode.toAutofillView() - // Verify assertEquals(expected, actual) } @Test - fun `toAutofillView should return AutofillView Card Number when hint matches`() { + fun `toAutofillView should return AutofillView Card ExpirationYear when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationYear( + data = autofillViewData, + ) + SUPPORTED_RAW_CARD_EXP_YEAR_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Card ExpirationYear when html info isCardExpirationYearField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationYear( + data = autofillViewData, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_YEAR_HINTS + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card ExpirationDate when autofillHints match`() { + val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE + val expected = AutofillView.Card.ExpirationDate( + data = autofillViewData, + ) + every { viewNode.autofillHints } returns arrayOf(autofillHint) + every { mockHtmlInfo.isInputField } returns true + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card ExpirationDate when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationDate( + data = autofillViewData, + ) + SUPPORTED_RAW_CARD_EXP_DATE_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Card ExpirationDate when html info isCardExpirationDateField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.ExpirationDate( + data = autofillViewData, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_EXP_DATE_HINTS + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card Number when autofillHints match`() { // Setup val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_NUMBER val expected = AutofillView.Card.Number( @@ -147,7 +267,35 @@ class ViewNodeExtensionsTest { } @Test - fun `toAutofillView should return AutofillView Card SecurityCode when hint matches`() { + fun `toAutofillView should return AutofillView Card Number when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.Number( + data = autofillViewData, + ) + SUPPORTED_RAW_CARD_NUMBER_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Test + fun `toAutofillView should return AutofillView Card Number when html info isCardNumberField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.Number( + data = autofillViewData, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_NUMBER_HINTS + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card SecurityCode when autofillHints match`() { // Setup val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE val expected = AutofillView.Card.SecurityCode( @@ -163,18 +311,114 @@ class ViewNodeExtensionsTest { } @Test - fun `toAutofillView should return AutofillView Login Password when isPasswordField`() { - // Setup + fun `toAutofillView should return AutofillView Card SecurityCode when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.SecurityCode( + data = autofillViewData, + ) + SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Card SecurityCode when html info isCardSecurityCodeField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.SecurityCode( + data = autofillViewData, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS + val actual = viewNode.toAutofillView() + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Card CardholderName when idEntry matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.CardholderName( + data = autofillViewData, + ) + SUPPORTED_RAW_CARDHOLDER_NAME_HINTS.forEach { idEntry -> + every { viewNode.idEntry } returns idEntry + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for idEntry: $idEntry") + } + } + + @Test + fun `toAutofillView should return AutofillView Card CardholderName when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.CardholderName( + data = autofillViewData, + ) + SUPPORTED_RAW_CARDHOLDER_NAME_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Card CardholderName when html info isCardholderNameField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Card.CardholderName( + data = autofillViewData, + ) + every { viewNode.htmlInfo.hints() } returns SUPPORTED_RAW_CARDHOLDER_NAME_HINTS + val actual = viewNode.toAutofillView() + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Login Password when autofillHints match`() { val autofillHint = View.AUTOFILL_HINT_PASSWORD val expected = AutofillView.Login.Password( data = autofillViewData, ) every { viewNode.autofillHints } returns arrayOf(autofillHint) - // Test val actual = viewNode.toAutofillView() - // Verify + assertEquals(expected, actual) + } + + @Test + fun `toAutofillView should return AutofillView Login Password when hint matches`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Login.Password( + data = autofillViewData.copy(hasPasswordTerms = true), + ) + SUPPORTED_RAW_PASSWORD_HINTS.forEach { hint -> + every { viewNode.hint } returns hint + + val actual = viewNode.toAutofillView() + + assertEquals(expected, actual, "Failed for hint: $hint") + } + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return AutofillView Login Password when html info isPasswordField`() { + setupUnsupportedInputFieldViewNode() + val expected = AutofillView.Login.Password( + data = autofillViewData, + ) + every { viewNode.htmlInfo.isPasswordField() } returns true + + val actual = viewNode.toAutofillView() + assertEquals(expected, actual) } @@ -234,16 +478,13 @@ class ViewNodeExtensionsTest { @Suppress("MaxLineLength") @Test fun `toAutofillView should return only unused field when hint is not supported, is an inputField, and isn't a username or password`() { - // Setup setupUnsupportedInputFieldViewNode() val expected = AutofillView.Unused( data = autofillViewData, ) - // Test val actual = viewNode.toAutofillView() - // Verify assertEquals(expected, actual) } @@ -265,14 +506,13 @@ class ViewNodeExtensionsTest { } @Test - fun `isPasswordField returns true when supportedHint is AUTOFILL_HINT_PASSWORD`() { + fun `isPasswordField returns true when html hints contains a supported password hint`() { // Setup - val supportedHint = View.AUTOFILL_HINT_PASSWORD + every { mockHtmlInfo.isInputField } returns true + every { mockHtmlInfo.hints() } returns listOf("password") // Test - val actual = viewNode.isPasswordField( - supportedHint = supportedHint, - ) + val actual = viewNode.isPasswordField // Verify assertTrue(actual) @@ -286,9 +526,7 @@ class ViewNodeExtensionsTest { every { any().isPasswordInputType } returns true // Test - val actual = viewNode.isPasswordField( - supportedHint = null, - ) + val actual = viewNode.isPasswordField // Verify assertTrue(actual) @@ -302,9 +540,7 @@ class ViewNodeExtensionsTest { every { viewNode.htmlInfo.isPasswordField() } returns true // Test - val actual = viewNode.isPasswordField( - supportedHint = null, - ) + val actual = viewNode.isPasswordField // Verify assertTrue(actual) @@ -322,9 +558,7 @@ class ViewNodeExtensionsTest { every { viewNode.hint } returns hint // Test - val actual = viewNode.isPasswordField( - supportedHint = null, - ) + val actual = viewNode.isPasswordField // Verify assertFalse(actual) @@ -337,9 +571,7 @@ class ViewNodeExtensionsTest { every { viewNode.idEntry } returns hint // Test - val actual = viewNode.isPasswordField( - supportedHint = null, - ) + val actual = viewNode.isPasswordField // Verify assertFalse(actual) @@ -355,37 +587,20 @@ class ViewNodeExtensionsTest { every { viewNode.hint } returns SUPPORTED_RAW_USERNAME_HINTS.first() // Test - val actual = viewNode.isPasswordField( - supportedHint = null, - ) + val actual = viewNode.isPasswordField // Verify assertFalse(actual) } @Test - fun `isUsernameField returns true when supportedHint is AUTOFILL_HINT_USERNAME`() { + fun `isUsernameField returns true when html hints contains a supported username hint`() { // Setup - val supportedHint = View.AUTOFILL_HINT_USERNAME + every { mockHtmlInfo.isInputField } returns true + every { mockHtmlInfo.hints() } returns SUPPORTED_RAW_USERNAME_HINTS // Test - val actual = viewNode.isUsernameField( - supportedHint = supportedHint, - ) - - // Verify - assertTrue(actual) - } - - @Test - fun `isUsernameField returns true when supportedHint is AUTOFILL_HINT_EMAIL_ADDRESS`() { - // Setup - val supportedHint = View.AUTOFILL_HINT_EMAIL_ADDRESS - - // Test - val actual = viewNode.isUsernameField( - supportedHint = supportedHint, - ) + val actual = viewNode.isUsernameField // Verify assertTrue(actual) @@ -400,9 +615,7 @@ class ViewNodeExtensionsTest { every { viewNode.hint } returns hint // Test - val actual = viewNode.isUsernameField( - supportedHint = null, - ) + val actual = viewNode.isUsernameField // Verify assertTrue(actual) @@ -415,9 +628,7 @@ class ViewNodeExtensionsTest { every { viewNode.idEntry } returns hint // Test - val actual = viewNode.isUsernameField( - supportedHint = null, - ) + val actual = viewNode.isUsernameField // Verify assertTrue(actual) @@ -431,9 +642,7 @@ class ViewNodeExtensionsTest { every { viewNode.htmlInfo.isUsernameField() } returns true // Test - val actual = viewNode.isUsernameField( - supportedHint = null, - ) + val actual = viewNode.isUsernameField // Verify assertTrue(actual) @@ -570,6 +779,7 @@ class ViewNodeExtensionsTest { every { viewNode.className } returns null every { any().isPasswordInputType } returns false every { any().isUsernameInputType } returns false + every { viewNode.htmlInfo.hints() } returns emptyList() } } @@ -599,5 +809,50 @@ private val SUPPORTED_RAW_USERNAME_HINTS: List = listOf( "phone", "username", ) +private val SUPPORTED_RAW_CARD_EXP_MONTH_HINTS: List = listOf( + "exp_month", + "expiration_month", + "cc_exp_month", + "card_exp_month", +) +private val SUPPORTED_RAW_CARD_EXP_YEAR_HINTS: List = listOf( + "exp_year", + "expiration_year", + "cc_exp_year", + "card_exp_year", +) +private val SUPPORTED_RAW_CARD_NUMBER_HINTS: List = listOf( + "cc_number", + "card_number", + "credit_card_number", +) +private val SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS: List = listOf( + "cc_security_code", + "card_security_code", + "credit_card_security_code", + "cc_verification_code", + "card_verification_code", + "credit_card_verification_code", + "cvv", + "cvc", + "cvv2", + "cvc2", +) +private val SUPPORTED_RAW_CARD_EXP_DATE_HINTS: List = listOf( + "exp_date", + "expiration_date", + "expiry_date", + "cc_exp_date", + "card_exp_date", +) +private val SUPPORTED_RAW_CARDHOLDER_NAME_HINTS: List = listOf( + "cc_name", + "cc_cardholder", + "card_name", + "card_cardholder", + "credit_card_name", + "credit_card_cardholder", + "name_on_card", +) private const val MONTH_VALUE: String = "MONTH_VALUE" private const val TEXT_VALUE: String = "TEXT_VALUE" diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 63ac0571a5..d751ac1f22 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1069,4 +1069,5 @@ Do you want to switch to this account? Local items are collapsed, click to expand. Items are expanded, click to collapse. Items are collapsed, click to expand. + Select a card for %s