Perform deeper login data parsing (#758)

This commit is contained in:
Lucas Kivi 2024-01-24 22:38:16 -06:00 committed by Álison Fernandes
parent 54802db0b3
commit 96513d74c3
8 changed files with 639 additions and 56 deletions

View file

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

View file

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

View file

@ -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<String>,
ignoreCase: Boolean = true,
): Boolean =
terms.any {
this.contains(
other = it,
ignoreCase = ignoreCase,
)
}

View file

@ -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<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_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<String> = 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

View file

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

View file

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

View file

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

View file

@ -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<Int>().isPasswordInputType } returns false
every { any<Int>().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<Int>().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<Int>().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<Int>().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<Int>().isPasswordInputType } returns false
every { any<Int>().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<String> = listOf(
"search",
"find",
"recipient",
"edit",
)
private val SUPPORTED_RAW_PASSWORD_HINTS: List<String> = listOf(
"password",
"pswd",
)
private val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"email",
"phone",
"username",
)