From 96513d74c33fea8aa06308786b8007b871241a69 Mon Sep 17 00:00:00 2001 From: Lucas Kivi <125697099+lucas-livefront@users.noreply.github.com> Date: Wed, 24 Jan 2024 22:38:16 -0600 Subject: [PATCH] Perform deeper login data parsing (#758) --- .../data/autofill/util/HtmlInfoExtensions.kt | 31 ++ .../data/autofill/util/IntExtensions.kt | 31 ++ .../data/autofill/util/StringExtensions.kt | 18 ++ .../data/autofill/util/ViewNodeExtensions.kt | 137 +++++++-- .../autofill/util/HtmlInfoExtensionsTest.kt | 38 +++ .../data/autofill/util/IntExtensionsTest.kt | 94 ++++++ .../autofill/util/StringExtensionsTest.kt | 64 ++++ .../autofill/util/ViewNodeExtensionsTest.kt | 282 ++++++++++++++++-- 8 files changed, 639 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/IntExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/util/StringExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt new file mode 100644 index 000000000..4d378da9d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.view.ViewStructure.HtmlInfo +import com.x8bit.bitwarden.data.platform.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 + +/** + * Whether this [HtmlInfo] represents an input field. + */ +val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input" diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt new file mode 100644 index 000000000..d5d2de926 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.text.InputType + +/** + * Whether this [Int] is a password [InputType]. + */ +val Int.isPasswordInputType: Boolean + get() { + // The legacy xamarin app mentions that multi-line input types are coming through with + // TYPE_TEXT_VARIATION_PASSWORD flags. We have no other context to this. + val isMultiline = this.hasFlag(InputType.TYPE_TEXT_VARIATION_PASSWORD) && + this.hasFlag(InputType.TYPE_TEXT_FLAG_MULTI_LINE) + + val isPasswordInputType = this.hasFlag(InputType.TYPE_TEXT_VARIATION_PASSWORD) || + this.hasFlag(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) || + this.hasFlag(InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) + + return !isMultiline && isPasswordInputType + } + +/** + * Whether this [Int] is a username [InputType]. + */ +val Int.isUsernameInputType: Boolean + get() = this.hasFlag(InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS) + +/** + * Whether this [Int] contains [flag]. + */ +private fun Int.hasFlag(flag: Int): Boolean = (this and flag) == flag diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt new file mode 100644 index 000000000..6b74153a4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/StringExtensions.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.autofill.util + +/** + * Check whether this string contains any of these [terms]. + * + * @param terms The terms that should be searched for in this [String]. + * @param ignoreCase Whether the comparison should be sensitive to casing. + */ +fun String.containsAnyTerms( + terms: List, + ignoreCase: Boolean = true, +): Boolean = + terms.any { + this.contains( + other = it, + ignoreCase = ignoreCase, + ) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 09adad211..b049b7ac9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -4,17 +4,72 @@ import android.app.assist.AssistStructure import android.view.View import com.x8bit.bitwarden.data.autofill.model.AutofillView +/** + * The class name of the android edit text field. + */ +private const val ANDROID_EDIT_TEXT_CLASS_NAME: String = "android.widget.EditText" + +/** + * 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_NUMBER, + View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, + View.AUTOFILL_HINT_EMAIL_ADDRESS, + View.AUTOFILL_HINT_PASSWORD, + View.AUTOFILL_HINT_USERNAME, +) + +/** + * Whether this [AssistStructure.ViewNode] represents an input field. + */ +private val AssistStructure.ViewNode.isInputField: Boolean + get() = className == ANDROID_EDIT_TEXT_CLASS_NAME || htmlInfo.isInputField + /** * Attempt to convert this [AssistStructure.ViewNode] into an [AutofillView]. If the view node * doesn't contain a valid autofillId, it isn't an a view setup for autofill, so we return null. If - * it is has an autofillHint that we do not support, we also return null. + * it doesn't have a supported hint and isn't an input field, we also return null. */ -fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId - // We only care about nodes with a valid `AutofillId`. - ?.let { nonNullAutofillId -> - autofillHints - ?.firstOrNull { SUPPORTED_HINTS.contains(it) } - ?.let { supportedHint -> +fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = + this + .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) { val autofillViewData = AutofillView.Data( autofillId = nonNullAutofillId, idPackage = idPackage, @@ -24,51 +79,51 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? = autofillId ) buildAutofillView( autofillViewData = autofillViewData, - hint = supportedHint, + supportedHint = supportedHint, ) + } else { + null } - } + } /** - * Convert [autofillViewData] into an [AutofillView] if the [hint] is supported. + * Attempt to convert this [AssistStructure.ViewNode] and [autofillViewData] into an [AutofillView]. */ -private fun buildAutofillView( +private fun AssistStructure.ViewNode.buildAutofillView( autofillViewData: AutofillView.Data, - hint: String, -): AutofillView? = when (hint) { - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { + supportedHint: String?, +): AutofillView? = when { + supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { AutofillView.Card.ExpirationMonth( data = autofillViewData, ) } - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> { + supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR -> { AutofillView.Card.ExpirationYear( data = autofillViewData, ) } - View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> { + supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER -> { AutofillView.Card.Number( data = autofillViewData, ) } - View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> { + supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> { AutofillView.Card.SecurityCode( data = autofillViewData, ) } - View.AUTOFILL_HINT_PASSWORD -> { + this.isPasswordField(supportedHint) -> { AutofillView.Login.Password( data = autofillViewData, ) } - View.AUTOFILL_HINT_EMAIL_ADDRESS, - View.AUTOFILL_HINT_USERNAME, - -> { + this.isUsernameField(supportedHint) -> { AutofillView.Login.Username( data = autofillViewData, ) @@ -78,14 +133,34 @@ private fun buildAutofillView( } /** - * All of the supported autofill hints for the app. + * Check whether this [AssistStructure.ViewNode] represents a password field. */ -private val SUPPORTED_HINTS: List = listOf( - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, - View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, - View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, - View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, - View.AUTOFILL_HINT_EMAIL_ADDRESS, - View.AUTOFILL_HINT_PASSWORD, - View.AUTOFILL_HINT_USERNAME, -) +@Suppress("ReturnCount") +fun AssistStructure.ViewNode.isPasswordField( + supportedHint: String?, +): Boolean { + if (supportedHint == View.AUTOFILL_HINT_PASSWORD) return true + + if (this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true) 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() +} + +/** + * 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 || + idEntry?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true || + hint?.containsAnyTerms(SUPPORTED_RAW_USERNAME_HINTS) == true diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt new file mode 100644 index 000000000..d4bc4013c --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensionsTest.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.view.ViewStructure.HtmlInfo +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +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/java/com/x8bit/bitwarden/data/autofill/util/IntExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/IntExtensionsTest.kt new file mode 100644 index 000000000..ccc228d8e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/IntExtensionsTest.kt @@ -0,0 +1,94 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.text.InputType +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class IntExtensionsTest { + @Test + fun `isPasswordInputType returns false when is multiline`() { + // Setup + val int = InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_TEXT_FLAG_MULTI_LINE + + // Test + val actual = int.isPasswordInputType + + // Verify + assertFalse(actual) + } + + @Test + fun `isPasswordInputType returns false when not multiline and not password`() { + // Setup + val int = InputType.TYPE_CLASS_PHONE + + // Test + val actual = int.isPasswordInputType + + // Verify + assertFalse(actual) + } + + @Test + fun `isPasswordInputType returns true when not multiline and TYPE_TEXT_VARIATION_PASSWORD`() { + // Setup + val int = InputType.TYPE_TEXT_VARIATION_PASSWORD + + // Test + val actual = int.isPasswordInputType + + // Verify + assertTrue(actual) + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordInputType returns true when not multiline and TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`() { + // Setup + val int = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + + // Test + val actual = int.isPasswordInputType + + // Verify + assertTrue(actual) + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordInputType returns true when not multiline and TYPE_TEXT_VARIATION_WEB_PASSWORD`() { + // Setup + val int = InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD + + // Test + val actual = int.isPasswordInputType + + // Verify + assertTrue(actual) + } + + @Test + fun `isUsernameInputType returns false when not TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS`() { + // Setup + val int = InputType.TYPE_CLASS_PHONE + + // Test + val actual = int.isUsernameInputType + + // Verify + assertFalse(actual) + } + + @Test + fun `isUsernameInputType returns true when TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS`() { + // Setup + val int = InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS + + // Test + val actual = int.isUsernameInputType + + // Verify + assertTrue(actual) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/StringExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/StringExtensionsTest.kt new file mode 100644 index 000000000..dd5d82f63 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/StringExtensionsTest.kt @@ -0,0 +1,64 @@ +package com.x8bit.bitwarden.data.autofill.util + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StringExtensionsTest { + @Test + fun `containsAnyTerms returns true when string contains a term`() { + // Setup + val terms = listOf( + "bike", + "bicycle", + ) + val string = "I want to ride my bicycle" + + // Test + val actual = string.containsAnyTerms( + terms = terms, + ignoreCase = false, + ) + + // Verify + assertTrue(actual) + } + + @Test + fun `containsAnyTerms returns false when string doesn't contain a term`() { + // Setup + val terms = listOf( + "bike", + "bicycle", + ) + val string = "I want to ride my tricycle" + + // Test + val actual = string.containsAnyTerms( + terms = terms, + ignoreCase = false, + ) + + // Verify + assertFalse(actual) + } + + @Test + fun `containsAnyTerms returns true when string contains a term while ignoring case`() { + // Setup + val terms = listOf( + "bike", + "bicycle", + ) + val string = "I want to ride my BICYCLE" + + // Test + val actual = string.containsAnyTerms( + terms = terms, + ignoreCase = true, + ) + + // Verify + assertTrue(actual) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt index 3ed649dae..1e54d3f92 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -2,12 +2,19 @@ package com.x8bit.bitwarden.data.autofill.util import android.app.assist.AssistStructure import android.view.View +import android.view.ViewStructure.HtmlInfo import android.view.autofill.AutofillId import com.x8bit.bitwarden.data.autofill.model.AutofillView import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class ViewNodeExtensionsTest { @@ -20,15 +27,33 @@ class ViewNodeExtensionsTest { webDomain = WEB_DOMAIN, webScheme = WEB_SCHEME, ) + private val viewNode: AssistStructure.ViewNode = mockk { every { this@mockk.autofillId } returns expectedAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.inputType } returns 1 every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.isFocused } returns expectedIsFocused every { this@mockk.webDomain } returns WEB_DOMAIN every { this@mockk.webScheme } returns WEB_SCHEME } + @BeforeEach + fun setup() { + mockkStatic(HtmlInfo::isInputField) + mockkStatic(HtmlInfo::isPasswordField) + mockkStatic(Int::isPasswordInputType) + mockkStatic(Int::isUsernameInputType) + } + + @AfterEach + fun teardown() { + unmockkStatic(HtmlInfo::isInputField) + unmockkStatic(HtmlInfo::isPasswordField) + unmockkStatic(Int::isPasswordInputType) + unmockkStatic(Int::isUsernameInputType) + } + @Test fun `toAutofillView should return AutofillView Card ExpirationMonth when hint matches`() { // Setup @@ -94,23 +119,7 @@ class ViewNodeExtensionsTest { } @Test - fun `toAutofillView should return AutofillView Login Username when hint is EMAIL`() { - // Setup - val autofillHint = View.AUTOFILL_HINT_EMAIL_ADDRESS - val expected = AutofillView.Login.Username( - 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`() { + fun `toAutofillView should return AutofillView Login Password when isPasswordField`() { // Setup val autofillHint = View.AUTOFILL_HINT_PASSWORD val expected = AutofillView.Login.Password( @@ -125,14 +134,17 @@ class ViewNodeExtensionsTest { assertEquals(expected, actual) } + @Suppress("MaxLineLength") @Test - fun `toAutofillView should return AutofillView Login Username when hint is USERNAME`() { + fun `toAutofillView should return AutofillView Login Username when is android text field and is isUsernameField`() { // Setup - val autofillHint = View.AUTOFILL_HINT_USERNAME val expected = AutofillView.Login.Username( data = autofillViewData, ) - every { viewNode.autofillHints } returns arrayOf(autofillHint) + setupUnsupportedInputFieldViewNode() + every { viewNode.className } returns ANDROID_EDIT_TEXT_CLASS_NAME + every { any().isPasswordInputType } returns false + every { any().isUsernameInputType } returns true // Test val actual = viewNode.toAutofillView() @@ -142,10 +154,25 @@ class ViewNodeExtensionsTest { } @Test - fun `toAutofillView should return null when hint is not supported`() { + fun `toAutofillView should return null when hint is not supported and isn't an inputField`() { // Setup val autofillHint = "Shenanigans" every { viewNode.autofillHints } returns arrayOf(autofillHint) + every { viewNode.className } returns null + every { viewNode.htmlInfo.isInputField } returns false + + // Test + val actual = viewNode.toAutofillView() + + // Verify + assertNull(actual) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillView should return null when hint is not supported, is an inputField, and isn't a username or password`() { + // Setup + setupUnsupportedInputFieldViewNode() // Test val actual = viewNode.toAutofillView() @@ -171,9 +198,214 @@ class ViewNodeExtensionsTest { assertEquals(expected, actual) } - companion object { - private const val ID_PACKAGE: String = "ID_PACKAGE" - private const val WEB_DOMAIN: String = "WEB_DOMAIN" - private const val WEB_SCHEME: String = "WEB_SCHEME" + @Test + fun `isPasswordField returns true when supportedHint is AUTOFILL_HINT_PASSWORD`() { + // Setup + val supportedHint = View.AUTOFILL_HINT_PASSWORD + + // Test + val actual = viewNode.isPasswordField( + supportedHint = supportedHint, + ) + + // Verify + assertTrue(actual) + } + + @Test + fun `isPasswordField returns true when supportedHint is null and hint is supported`() { + SUPPORTED_RAW_PASSWORD_HINTS + .forEach { hint -> + // Setup + every { viewNode.hint } returns hint + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertTrue(actual) + } + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordField returns true when hints aren't supported, isPasswordInputType, isValidField, and isn't username`() { + // Setup + setupUnsupportedInputFieldViewNode() + every { any().isPasswordInputType } returns true + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertTrue(actual) + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordField returns true when hints aren't supported, not isPasswordInputType, and htmlInfo isPasswordField is true`() { + // Setup + setupUnsupportedInputFieldViewNode() + every { viewNode.htmlInfo.isPasswordField() } returns true + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertTrue(actual) + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordField returns false when hints aren't supported, isPasswordInputType, not validInputField, and htmlInfo isPasswordField is false`() { + // Setup testing the hint + setupUnsupportedInputFieldViewNode() + every { any().isPasswordInputType } returns true + + IGNORED_RAW_HINTS.forEach { hint -> + // Setup + every { viewNode.hint } returns hint + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertFalse(actual) + } + + // Setup testing the idEntry + every { viewNode.hint } returns null + IGNORED_RAW_HINTS.forEach { hint -> + // Setup + every { viewNode.idEntry } returns hint + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertFalse(actual) + } + } + + @Suppress("MaxLineLength") + @Test + fun `isPasswordField returns false when hints aren't supported, isPasswordInputType, validInputField, isUsernameField, and htmlInfo isPasswordField is false`() { + // Setup + setupUnsupportedInputFieldViewNode() + every { any().isPasswordInputType } returns true + every { viewNode.hint } returns SUPPORTED_RAW_USERNAME_HINTS.first() + + // Test + val actual = viewNode.isPasswordField( + supportedHint = null, + ) + + // Verify + assertFalse(actual) + } + + @Test + fun `isUsernameField returns true whe supportedHint is AUTOFILL_HINT_USERNAME`() { + // Setup + val supportedHint = View.AUTOFILL_HINT_USERNAME + + // 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, + ) + + // Verify + assertTrue(actual) + } + + @Test + fun `isUsernameField returns true when supportedHint is null and raw hint is supported`() { + // Setup testing the hints + every { viewNode.idEntry } returns null + SUPPORTED_RAW_USERNAME_HINTS.forEach { hint -> + // Setup + every { viewNode.hint } returns hint + + // Test + val actual = viewNode.isUsernameField( + supportedHint = null, + ) + + // Verify + assertTrue(actual) + } + + // Setup testing the idEntries + every { viewNode.hint } returns null + SUPPORTED_RAW_USERNAME_HINTS.forEach { hint -> + // Setup + every { viewNode.idEntry } returns hint + + // Test + val actual = viewNode.isUsernameField( + supportedHint = null, + ) + + // Verify + assertTrue(actual) + } + } + + /** + * Set up [viewNode] to be an input field but not supported. + */ + private fun setupUnsupportedInputFieldViewNode() { + every { viewNode.hint } returns null + every { viewNode.htmlInfo.isPasswordField() } returns false + every { viewNode.htmlInfo.isInputField } returns true + every { viewNode.idEntry } returns null + every { viewNode.autofillHints } returns emptyArray() + every { viewNode.className } returns null + every { any().isPasswordInputType } returns false + every { any().isUsernameInputType } returns false } } + +private const val ANDROID_EDIT_TEXT_CLASS_NAME: String = "android.widget.EditText" +private const val ID_PACKAGE: String = "ID_PACKAGE" +private const val WEB_DOMAIN: String = "WEB_DOMAIN" +private const val WEB_SCHEME: String = "WEB_SCHEME" +private val IGNORED_RAW_HINTS: List = listOf( + "search", + "find", + "recipient", + "edit", +) +private val SUPPORTED_RAW_PASSWORD_HINTS: List = listOf( + "password", + "pswd", +) +private val SUPPORTED_RAW_USERNAME_HINTS: List = listOf( + "email", + "phone", + "username", +)