Refactor Autofill Hint Logic and Add Card Autofill Support (#5640)

This commit is contained in:
Patrick Honkonen 2025-08-07 10:47:53 -04:00 committed by GitHub
parent 6ee7f9b80f
commit db03c7d703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 935 additions and 201 deletions

View File

@ -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.

View File

@ -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,
}

View File

@ -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.
*/

View File

@ -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<String> = 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<String>.containsAnyPatterns(patterns: List<Regex>): 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<String>.containsAnyTerms(terms: List<String>): 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<String> = listOf(
"name",
"label",
"type",
"hint",
"autofill",
)

View File

@ -16,3 +16,13 @@ fun String.containsAnyTerms(
ignoreCase = ignoreCase,
)
}
/**
* Check whether this string matches any of these [expressions].
*/
fun String.matchesAnyExpressions(
expressions: List<Regex>,
): Boolean =
expressions.any {
this.matches(regex = it)
}

View File

@ -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<String> = listOf(
"search",
"find",
"recipient",
"edit",
)
/**
* The supported password autofill hints.
*/
private val SUPPORTED_RAW_PASSWORD_HINTS: List<String> = listOf(
"password",
"pswd",
)
/**
* The supported raw autofill hints.
*/
private val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"email",
"phone",
"username",
)
/**
* The supported autofill Android View hints.
*/
private val SUPPORTED_VIEW_HINTS: List<String> = 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<String>,
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.
*/

View File

@ -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<String> = listOf(
"search",
"find",
"recipient",
"edit",
)
/**
* The supported password autofill hints.
*/
val SUPPORTED_RAW_PASSWORD_HINTS: List<String> = listOf(
"password",
"pswd",
)
/**
* The supported raw autofill hints.
*/
val SUPPORTED_RAW_USERNAME_HINTS: List<String> = 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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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(),
)

View File

@ -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<VaultData>.filterForAutofillIfNecessary(): DataState<VaultData> {
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) }

View File

@ -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<AutofillId> = 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<AutofillId> = 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)
}
}

View File

@ -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)
}
}

View File

@ -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<Int>().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<Int>().isPasswordInputType } returns false
every { any<Int>().isUsernameInputType } returns false
every { viewNode.htmlInfo.hints() } returns emptyList()
}
}
@ -599,5 +809,50 @@ private val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"phone",
"username",
)
private val SUPPORTED_RAW_CARD_EXP_MONTH_HINTS: List<String> = listOf(
"exp_month",
"expiration_month",
"cc_exp_month",
"card_exp_month",
)
private val SUPPORTED_RAW_CARD_EXP_YEAR_HINTS: List<String> = listOf(
"exp_year",
"expiration_year",
"cc_exp_year",
"card_exp_year",
)
private val SUPPORTED_RAW_CARD_NUMBER_HINTS: List<String> = listOf(
"cc_number",
"card_number",
"credit_card_number",
)
private val SUPPORTED_RAW_CARD_SECURITY_CODE_HINTS: List<String> = 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<String> = listOf(
"exp_date",
"expiration_date",
"expiry_date",
"cc_exp_date",
"card_exp_date",
)
private val SUPPORTED_RAW_CARDHOLDER_NAME_HINTS: List<String> = 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"

View File

@ -1069,4 +1069,5 @@ Do you want to switch to this account?</string>
<string name="local_items_are_collapsed_click_to_expand">Local items are collapsed, click to expand.</string>
<string name="items_expanded_click_to_collapse">Items are expanded, click to collapse.</string>
<string name="items_are_collapsed_click_to_expand">Items are collapsed, click to expand.</string>
<string name="select_a_card_for_x">Select a card for %s</string>
</resources>