From 908cdc233968ab00cc00554151f374303a731926 Mon Sep 17 00:00:00 2001 From: Ahmed Erabti Date: Tue, 8 Sep 2026 15:42:03 +0200 Subject: [PATCH] Recognize HTML card hints in Android autofill --- .../data/autofill/util/ViewNodeExtensions.kt | 35 ++++++-- .../autofill/util/ViewNodeExtensionsTest.kt | 87 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) 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 7ec24a0edae..98f35cc1865 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 @@ -19,6 +19,14 @@ private const val DEFAULT_SCHEME: String = "https" * The supported autofill Android View hints that predate identity autofill. */ private val SUPPORTED_VIEW_HINTS: List = listOf( + // Chromium can pass HTML autocomplete tokens directly as Android autofill hints. + "cc-exp-month", + "cc-exp-year", + "cc-exp", + "cc-number", + "cc-csc", + "cc-name", + "cc-type", View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, @@ -209,11 +217,28 @@ private fun AssistStructure.ViewNode.firstSupportedAutofillHintOrNull( 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_CREDIT_CARD_EXPIRATION_MONTH, + "cc-exp-month", + -> AutofillHint.Card.EXPIRATION_MONTH + + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, + "cc-exp-year", + -> AutofillHint.Card.EXPIRATION_YEAR + + View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, + "cc-exp", + -> AutofillHint.Card.EXPIRATION_DATE + + View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, + "cc-number", + -> AutofillHint.Card.NUMBER + + View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, + "cc-csc", + -> AutofillHint.Card.SECURITY_CODE + + "cc-name" -> AutofillHint.Card.CARDHOLDER + "cc-type" -> AutofillHint.Card.BRAND View.AUTOFILL_HINT_PASSWORD -> AutofillHint.Login.PASSWORD View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_USERNAME, 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 535b2de6f49..66153336583 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 @@ -62,6 +62,7 @@ class ViewNodeExtensionsTest { mockkStatic(Int::isUsernameInputType) mockkStatic(AutofillValue::extractMonthValue) mockkStatic(AutofillValue::extractYearValue) + mockkStatic(AutofillValue::extractCardBrandValue) mockkStatic(AutofillValue::extractTextValue) every { testAutofillValue.extractMonthValue( @@ -85,9 +86,95 @@ class ViewNodeExtensionsTest { unmockkStatic(Int::isUsernameInputType) unmockkStatic(AutofillValue::extractMonthValue) unmockkStatic(AutofillValue::extractYearValue) + unmockkStatic(AutofillValue::extractCardBrandValue) unmockkStatic(AutofillValue::extractTextValue) } + @Test + fun `HTML card autofill hints classify localized fields without heuristic hints`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.hint } returns "رقم البطاقة" + every { + testAutofillValue.extractCardBrandValue(autofillOptions = AUTOFILL_OPTIONS_LIST) + } returns null + val cases = listOf( + "cc-number" to AutofillView.Card.Number(data = autofillViewData), + "cc-exp" to AutofillView.Card.ExpirationDate(data = autofillViewData), + "cc-exp-month" to AutofillView.Card.ExpirationMonth( + data = autofillViewData, + monthValue = MONTH_VALUE, + ), + "cc-exp-year" to AutofillView.Card.ExpirationYear( + data = autofillViewData, + yearValue = YEAR_VALUE, + ), + "cc-csc" to AutofillView.Card.SecurityCode(data = autofillViewData), + "cc-name" to AutofillView.Card.CardholderName(data = autofillViewData), + "cc-type" to AutofillView.Card.Brand(data = autofillViewData, brandValue = null), + ) + + cases.forEach { (hint, expected) -> + every { viewNode.autofillHints } returns arrayOf(hint) + + listOf(false, true).forEach { identityAutofillEnabled -> + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = identityAutofillEnabled, + ) + + assertEquals(expected, actual, "Failed for HTML hint: $hint") + } + } + } + + @Test + fun `HTML security code hint takes precedence over password field heuristics`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.autofillHints } returns arrayOf("cc-csc") + every { viewNode.htmlInfo.isPasswordField() } returns true + every { viewNode.htmlInfo.hints() } returns listOf("password") + val expected = AutofillView.Card.SecurityCode( + data = autofillViewData.copy(hasPasswordTerms = true), + ) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(expected, actual) + } + + @Test + fun `unknown HTML card hint does not classify an otherwise unsupported field`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.autofillHints } returns arrayOf("cc-unknown") + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(AutofillView.Unused(data = autofillViewData), actual) + } + + @Test + fun `unsupported hints are skipped while supported hint order is preserved`() { + setupUnsupportedInputFieldViewNode() + every { viewNode.autofillHints } returns arrayOf( + "cc-unknown", + View.AUTOFILL_HINT_PASSWORD, + "cc-number", + ) + + val actual = viewNode.toAutofillView( + parentWebsite = null, + isIdentityAutofillEnabled = false, + ) + + assertEquals(AutofillView.Login.Password(data = autofillViewData), actual) + } + @Suppress("MaxLineLength") @Test fun `toAutofillView should return AutofillView Card ExpirationMonth when autofillHints match`() {