Add logic to find missing username fields (#3440)

This commit is contained in:
David Perez 2024-07-11 12:16:35 -05:00 committed by GitHub
parent b2300328e1
commit f6f28f6a58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 169 additions and 5 deletions

View file

@ -86,4 +86,11 @@ sealed class AutofillView {
override val data: Data, override val data: Data,
) : Login() ) : Login()
} }
/**
* A view that is an input field but does not correspond to any known autofill field.
*/
data class Unused(
override val data: Data,
) : AutofillView()
} }

View file

@ -105,6 +105,11 @@ class AutofillParserImpl(
views = autofillViews.filterIsInstance<AutofillView.Login>(), views = autofillViews.filterIsInstance<AutofillView.Login>(),
) )
} }
is AutofillView.Unused -> {
// The view is unfillable since the field is not meant to be used for autofill.
return AutofillRequest.Unfillable
}
} }
// Flatten the ignorable autofill ids. // Flatten the ignorable autofill ids.
val ignoreAutofillIds = traversalDataList val ignoreAutofillIds = traversalDataList
@ -139,7 +144,52 @@ class AutofillParserImpl(
private fun AssistStructure.traverse(): List<ViewNodeTraversalData> = private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
(0 until windowNodeCount) (0 until windowNodeCount)
.map { getWindowNodeAt(it) } .map { getWindowNodeAt(it) }
.mapNotNull { windowNode -> windowNode.rootViewNode?.traverse() } .mapNotNull { windowNode ->
windowNode
.rootViewNode
?.traverse()
?.updateForMissingUsernameFields()
}
/**
* This helper function updates the [ViewNodeTraversalData] if necessary for missing username
* fields that could have been missed. If the current `ViewNodeTraversalData` contains password
* fields but no username fields, we check to see if there are any unused fields directly above
* the password fields and we assume that those are the missing username fields.
*/
private fun ViewNodeTraversalData.updateForMissingUsernameFields(): ViewNodeTraversalData {
val passwordPositions = this.autofillViews.mapIndexedNotNull { index, autofillView ->
(autofillView as? AutofillView.Login.Password)?.let { index }
}
return if (passwordPositions.any() &&
this.autofillViews.none { it is AutofillView.Login.Username }
) {
val updatedAutofillViews = autofillViews.mapIndexed { index, autofillView ->
if (autofillView is AutofillView.Unused && passwordPositions.contains(index + 1)) {
AutofillView.Login.Username(data = autofillView.data)
} else {
autofillView
}
}
val previousUnusedIds = autofillViews
.filterIsInstance<AutofillView.Unused>()
.map { it.data.autofillId }
.toSet()
val currentUnusedIds = updatedAutofillViews
.filterIsInstance<AutofillView.Unused>()
.map { it.data.autofillId }
.toSet()
val unignoredAutofillIds = previousUnusedIds - currentUnusedIds
this.copy(
autofillViews = updatedAutofillViews,
ignoreAutofillIds = this.ignoreAutofillIds - unignoredAutofillIds,
)
} else {
// We already have username fields available or there are no password fields, so no need
// to search for them.
this
}
}
/** /**
* Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the * Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the

View file

@ -112,7 +112,7 @@ private fun AssistStructure.ViewNode.buildAutofillView(
autofillOptions: List<String>, autofillOptions: List<String>,
autofillViewData: AutofillView.Data, autofillViewData: AutofillView.Data,
supportedHint: String?, supportedHint: String?,
): AutofillView? = when { ): AutofillView = when {
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> { supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
val monthValue = this val monthValue = this
.autofillValue .autofillValue
@ -156,7 +156,11 @@ private fun AssistStructure.ViewNode.buildAutofillView(
) )
} }
else -> null else -> {
AutofillView.Unused(
data = autofillViewData,
)
}
} }
/** /**

View file

@ -28,6 +28,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class AutofillParserTests { class AutofillParserTests {
private lateinit var parser: AutofillParser private lateinit var parser: AutofillParser
@ -377,6 +378,105 @@ class AutofillParserTests {
} }
} }
@Suppress("MaxLineLength")
@Test
fun `parse should have Username and Password AutofillView when the Username field is not identifiable but directly above the Password field in the hierarchy`() {
// Setup
val hiddenUserNameViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns emptyArray()
every { this@mockk.autofillId } returns loginAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.website } returns WEBSITE
}
val passwordAutofillId = mockk<AutofillId>()
val passwordViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns emptyArray()
every { this@mockk.autofillId } returns passwordAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.website } returns WEBSITE
}
val rootAutofillId = mockk<AutofillId>()
val rootViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns emptyArray()
every { this@mockk.autofillId } returns rootAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.website } returns WEBSITE
every { this@mockk.childCount } returns 2
every { this@mockk.getChildAt(0) } returns hiddenUserNameViewNode
every { this@mockk.getChildAt(1) } returns passwordViewNode
}
val windowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns rootViewNode
}
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns windowNode
val unusedAutofillView: AutofillView.Unused = AutofillView.Unused(
data = AutofillView.Data(
autofillId = loginAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
),
)
val loginUsernameAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
),
)
val loginPasswordAutofillView: AutofillView.Login = AutofillView.Login.Password(
data = AutofillView.Data(
autofillId = passwordAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = false,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Login(
views = listOf(loginUsernameAutofillView, loginPasswordAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = listOf(rootAutofillId),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
every { rootViewNode.toAutofillView() } returns null
every { hiddenUserNameViewNode.toAutofillView() } returns unusedAutofillView
every { passwordViewNode.toAutofillView() } returns loginPasswordAutofillView
// Test
val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify
assertEquals(expected, actual)
verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@Test @Test
fun `parse should choose first focused AutofillView for partition when there are multiple`() { fun `parse should choose first focused AutofillView for partition when there are multiple`() {
// Setup // Setup

View file

@ -230,15 +230,18 @@ class ViewNodeExtensionsTest {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `toAutofillView should return null when hint is not supported, is an inputField, and isn't a username or password`() { fun `toAutofillView should return only unused field when hint is not supported, is an inputField, and isn't a username or password`() {
// Setup // Setup
setupUnsupportedInputFieldViewNode() setupUnsupportedInputFieldViewNode()
val expected = AutofillView.Unused(
data = autofillViewData,
)
// Test // Test
val actual = viewNode.toAutofillView() val actual = viewNode.toAutofillView()
// Verify // Verify
assertNull(actual) assertEquals(expected, actual)
} }
@Test @Test