[PM-37255] feat: Integrate fill-assist targeting rules into autofill parser (#7066)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: aj-rosado <aj-rosado@users.noreply.github.com>
This commit is contained in:
aj-rosado
2026-07-17 15:38:33 +00:00
committed by GitHub
co-authored by claude[bot] <41898282+claude[bot]@users.noreply.github.com> aj-rosado
parent 2a0dccac06
commit 109470e92e
11 changed files with 1287 additions and 41 deletions
@@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogMa
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
@@ -27,6 +28,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
@@ -100,9 +102,13 @@ object AutofillModule {
@Provides
fun providesAutofillParser(
settingsRepository: SettingsRepository,
fillAssistManager: FillAssistManager,
featureFlagManager: FeatureFlagManager,
): AutofillParser =
AutofillParserImpl(
settingsRepository = settingsRepository,
fillAssistManager = fillAssistManager,
featureFlagManager = featureFlagManager,
)
@Singleton
@@ -44,6 +44,16 @@ private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""")
// Extracts the leading tag name from a selector (e.g. "input", "select", "form").
private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""")
// Matches a CSS class qualifier (e.g. ".hidden" in "input.hidden") outside any [...] attribute
// brackets, so a literal "." inside an attribute value (e.g. [title='Enter your email.']) isn't
// mistaken for one.
private val CLASS_QUALIFIER_REGEX = Regex("""\.[a-zA-Z][\w-]*(?![^\[]*])""")
// Splits on whitespace that is outside [...] attribute brackets, so that descendant selectors
// like "div#container input#field" are split correctly while attribute values containing spaces
// (e.g. [placeholder='Email address']) are preserved intact.
private val DESCENDANT_SEPARATOR_REGEX = Regex("""\s+(?![^\[]*])""")
/**
* Primary implementation of [FillAssistManager].
*/
@@ -213,14 +223,14 @@ private fun parseCompositeSelectorArray(element: JsonElement): List<SelectorClau
}
internal fun parseSingleSelector(selector: String): SelectorClause? {
// For shadow DOM / iframe selectors (>>>), extract the last segment — the actual target
// element. Android's autofill framework may expose these elements via htmlInfo when they
// are reachable (e.g. open shadow roots), so we parse their attributes for matching.
val effective = if (selector.contains(">>>")) {
selector.substringAfterLast(">>>").trim()
} else {
selector
}
// For descendant selectors, only the last segment describes the target element — earlier
// parts describe ancestors that are not represented as view nodes by the autofill framework.
// Shadow DOM (>>>) boundaries are stripped first since they aren't view-node-represented
// either, then the remainder is still split on descendant whitespace — the segment after a
// shadow boundary can itself contain further ancestor segments (e.g. "host >>> form input").
// Whitespace inside [...] is part of an attribute value and must not be treated as a separator.
val afterShadowBoundary = selector.substringAfterLast(">>>").trim()
val effective = afterShadowBoundary.split(DESCENDANT_SEPARATOR_REGEX).last().trim()
if (effective.trimStart().startsWith(".")) return null
val tag = TAG_REGEX.find(effective)?.groupValues?.get(1)
@@ -230,6 +240,8 @@ internal fun parseSingleSelector(selector: String): SelectorClause? {
var type: String? = null
var role: String? = null
var hasUnsupportedAttribute = false
// For e.g. "[type='password']": groupValues[0]="[type='password']", [1]="type", [2]="password".
ATTRIBUTE_REGEX.findAll(effective).forEach { match ->
val attrName = match.groupValues[1]
@@ -239,6 +251,9 @@ internal fun parseSingleSelector(selector: String): SelectorClause? {
NAME -> name = attrValue
TYPE -> type = attrValue
ROLE -> role = attrValue
// Attributes we can't represent as a SelectorClause constraint (e.g. autocomplete,
// placeholder) are tracked so we know not to fall back to a tag-only match below.
else -> hasUnsupportedAttribute = true
}
}
@@ -247,7 +262,30 @@ internal fun parseSingleSelector(selector: String): SelectorClause? {
id = ID_SHORTHAND_REGEX.find(effective)?.groupValues?.get(1)
}
// A residual class qualifier (e.g. "input.hidden") is just as unrepresentable as an
// unsupported attribute — it must not be allowed to fall back to a tag-only match either.
val hasClassQualifier = CLASS_QUALIFIER_REGEX.containsMatchIn(effective)
// If the selector's only constraint is an attribute or class we can't represent, dropping it
// here would otherwise leave a tag-only clause that matches every element with that tag.
val noAttributeConstraints = hasNoAttributeConstraints(
id = id,
name = name,
type = type,
role = role,
)
if ((hasUnsupportedAttribute || hasClassQualifier) && noAttributeConstraints) {
return null
}
return SelectorClause(tag = tag, id = id, name = name, type = type, role = role)
}
private fun hasNoAttributeConstraints(
id: String?,
name: String?,
type: String?,
role: String?,
): Boolean = id == null && name == null && type == null && role == null
// endregion
@@ -3,17 +3,22 @@ package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
import android.service.autofill.FillRequest
import android.view.autofill.AutofillId
import androidx.core.net.toUri
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildFillAssistViews
import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull
import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs
import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount
import com.x8bit.bitwarden.data.autofill.util.toAutofillView
import com.x8bit.bitwarden.data.autofill.util.website
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import timber.log.Timber
@@ -50,12 +55,30 @@ private val URL_BARS: Map<String, String> = mapOf(
"com.brave.browser_nightly" to "url_bar",
)
/**
* A list of categories from Fill Assist that are used for [AutofillView.Login]
*/
private val LOGIN_FILL_ASSIST_CATEGORIES: List<String> = listOf(
"account-login",
"account-creation",
"account-update",
)
/**
* A list of categories from Fill Assist that are used for [AutofillView.Card]
*/
private val CARD_FILL_ASSIST_CATEGORIES: List<String> = listOf(
"payment-card",
)
/**
* The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data
* from the OS into domain models.
*/
class AutofillParserImpl(
private val settingsRepository: SettingsRepository,
private val fillAssistManager: FillAssistManager,
private val featureFlagManager: FeatureFlagManager,
) : AutofillParser {
override fun parse(
autofillAppInfo: AutofillAppInfo,
@@ -100,56 +123,49 @@ class AutofillParserImpl(
val urlBarWebsite = traversalDataList
.flatMap { it.urlBarWebsites }
.firstOrNull()
// Take only the autofill views from the node that currently has focus.
// Then remove all the fields that cannot be filled with data.
// We fallback to taking all the fillable views if nothing has focus.
val autofillViewsList = traversalDataList.map { it.autofillViews }
val autofillViews = (autofillViewsList
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: autofillViewsList
.flatten()
.filter { it !is AutofillView.Unused })
.map { it.updateWebsiteIfNecessary(website = urlBarWebsite) }
val autofillViews = traversalDataList.toAutofillViews(urlBarWebsite = urlBarWebsite)
// Find the focused view, or fallback to the first fillable item on the screen (so
// we at least have something to hook into)
val focusedView = autofillViews
.firstOrNull { it.data.isFocused }
?: autofillViews.firstOrNull()
if (focusedView == null) {
// The view is unfillable if there are no focused views.
return AutofillRequest.Unfillable
}
?: return AutofillRequest.Unfillable
val packageName = traversalDataList.buildPackageNameOrNull(
assistStructure = assistStructure,
)
val uri = focusedView.buildUriOrNull(
packageName = packageName,
)
val uri = focusedView.buildUriOrNull(packageName = packageName)
val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS
if (blockListedURIs.contains(uri)) {
// The view is unfillable if the URI is block listed.
// The view is unfillable if the URI is block listed.
if ((settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS).contains(uri)) {
return AutofillRequest.Unfillable
}
val effectiveViews = autofillViews.toEffectiveViews(
assistStructure = assistStructure,
uri = uri,
focusedView = focusedView,
urlBarWebsite = urlBarWebsite,
)
val effectiveFocusedView = effectiveViews
.firstOrNull { it.data.isFocused }
?: effectiveViews.firstOrNull()
?: return AutofillRequest.Unfillable
// Choose the first focused partition of data for fulfillment.
val partition = when (focusedView) {
val partition = when (effectiveFocusedView) {
is AutofillView.Card -> {
AutofillPartition.Card(
views = autofillViews.filterIsInstance<AutofillView.Card>(),
views = effectiveViews.filterIsInstance<AutofillView.Card>(),
)
}
is AutofillView.Login -> {
AutofillPartition.Login(
views = autofillViews.filterIsInstance<AutofillView.Login>(),
views = effectiveViews.filterIsInstance<AutofillView.Login>(),
)
}
@@ -185,6 +201,45 @@ class AutofillParserImpl(
uri = uri,
)
}
/**
* Returns the effective [AutofillView] list for filling. Applies fill-assist targeting rules
* when the feature flag is enabled and the host rules cover the current partition type;
* otherwise returns the heuristic autofillViews [this].
*/
private fun List<AutofillView>.toEffectiveViews(
assistStructure: AssistStructure,
uri: String?,
focusedView: AutofillView,
urlBarWebsite: String?,
): List<AutofillView> {
val hostRules = uri
?.takeUnless { it.startsWith("androidapp://") }
?.toUri()
?.host
?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) }
?.let { host ->
fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www."))
}
?: return this
val coversCurrentPartition = hostRules.any { rule ->
when (focusedView) {
is AutofillView.Card -> rule.category in CARD_FILL_ASSIST_CATEGORIES
is AutofillView.Login -> rule.category in LOGIN_FILL_ASSIST_CATEGORIES
is AutofillView.Unused -> false
}
}
return if (coversCurrentPartition) {
assistStructure.buildFillAssistViews(
hostRules = hostRules,
urlBarWebsite = urlBarWebsite,
)
} else {
this
}
}
}
/**
@@ -201,6 +256,27 @@ private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
?.updateForMissingUsernameFields()
}
/**
* Assembles the [AutofillView] list from this [ViewNodeTraversalData] list.
* Take only the autofill views from the node that currently has focus.
* Then remove all the fields that cannot be filled with data.
* We fall back to taking all the fillable views if nothing has focus.
*/
private fun List<ViewNodeTraversalData>.toAutofillViews(
urlBarWebsite: String?,
): List<AutofillView> {
val viewsLists = map { it.autofillViews }
val autofillViewLists = viewsLists
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: viewsLists
.flatten()
.filter { it !is AutofillView.Unused }
return autofillViewLists.map { it.updateWebsiteIfNecessary(website = urlBarWebsite) }
}
/**
* This helper function updates the [ViewNodeTraversalData] if necessary for missing password
* fields that were marked invalid because they contained a specific `hint` or `idEntry`. If the
@@ -0,0 +1,84 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
private const val FIELD_KEY_USERNAME = "username"
private const val FIELD_KEY_PASSWORD = "password"
private const val FIELD_KEY_NEW_PASSWORD = "newPassword"
private const val FIELD_KEY_CARD_NUMBER = "cardNumber"
private const val FIELD_KEY_CARDHOLDER_NAME = "cardholderName"
private const val FIELD_KEY_CARD_EXPIRATION_DATE = "cardExpirationDate"
private const val FIELD_KEY_CARD_EXPIRATION_MONTH = "cardExpirationMonth"
private const val FIELD_KEY_CARD_EXPIRATION_YEAR = "cardExpirationYear"
private const val FIELD_KEY_CARD_CVV = "cardCvv"
private const val FIELD_KEY_CARD_TYPE = "cardType"
/**
* Traverses the [AssistStructure] and returns a list of [AutofillView]s classified by the
* provided [hostRules]. Only view nodes whose [android.view.ViewStructure.HtmlInfo] attributes
* match a [FillAssistRules.SelectorClause] are included; unmatched nodes are omitted (no
* heuristic fallback).
*/
internal fun AssistStructure.buildFillAssistViews(
hostRules: List<FillAssistRules.HostRule>,
urlBarWebsite: String?,
): List<AutofillView> =
(0 until windowNodeCount)
.mapNotNull { getWindowNodeAt(it).rootViewNode }
.flatMap { it.traverseForFillAssist(hostRules = hostRules, parentWebsite = urlBarWebsite) }
private fun AssistStructure.ViewNode.traverseForFillAssist(
hostRules: List<FillAssistRules.HostRule>,
parentWebsite: String?,
): List<AutofillView> {
val website = this.website ?: parentWebsite
val ownView = autofillId?.let { id ->
hostRules
.flatMap { it.fields.entries }
.filter { (_, alternatives) ->
alternatives.any {
htmlInfo?.matchesSelectorClause(it) ?: false
}
}
.takeIf { it.isNotEmpty() }
?.let { matchingEntries ->
val data = toAutofillViewData(autofillId = id, website = website)
matchingEntries.firstNotNullOfOrNull { (key, _) ->
key.toAutofillViewForFieldKey(
data = data,
)
}
}
}
val childViews = (0 until childCount)
.flatMap { index ->
getChildAt(index).traverseForFillAssist(
hostRules = hostRules,
parentWebsite = website,
)
}
return listOfNotNull(ownView) + childViews
}
private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = when (this) {
FIELD_KEY_USERNAME -> AutofillView.Login.Username(data = data)
FIELD_KEY_PASSWORD, FIELD_KEY_NEW_PASSWORD -> AutofillView.Login.Password(data = data)
FIELD_KEY_CARD_NUMBER -> AutofillView.Card.Number(data = data)
FIELD_KEY_CARDHOLDER_NAME -> AutofillView.Card.CardholderName(data = data)
FIELD_KEY_CARD_EXPIRATION_DATE -> AutofillView.Card.ExpirationDate(data = data)
FIELD_KEY_CARD_EXPIRATION_MONTH -> AutofillView.Card.ExpirationMonth(
data = data,
monthValue = null,
)
FIELD_KEY_CARD_EXPIRATION_YEAR -> AutofillView.Card.ExpirationYear(
data = data,
yearValue = null,
)
FIELD_KEY_CARD_CVV -> AutofillView.Card.SecurityCode(data = data)
FIELD_KEY_CARD_TYPE -> AutofillView.Card.Brand(data = data, brandValue = null)
else -> null
}
@@ -2,7 +2,14 @@
package com.x8bit.bitwarden.data.autofill.util
import android.util.Pair
import android.view.ViewStructure.HtmlInfo
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
private const val HTML_ATTR_ID = "id"
private const val HTML_ATTR_NAME = "name"
private const val HTML_ATTR_TYPE = "type"
private const val HTML_ATTR_ROLE = "role"
/**
* Whether this [HtmlInfo] represents a password field.
@@ -93,6 +100,46 @@ fun HtmlInfo?.hints(): List<String> = this
*/
val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input"
/**
* Whether this [HtmlInfo] matches the given [FillAssistRules.SelectorClause].
*
* This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires
* instrumentation testing.
*/
internal fun HtmlInfo.matchesSelectorClause(clause: FillAssistRules.SelectorClause): Boolean {
// A clause with no usable constraint must not match every node with the same tag.
if (clause.isUnconstrained) return false
if (clause.tag != null && clause.tag != tag) return false
val attrs = attributes ?: return clause.hasNoAttributeConstraints
return matchesAttr(attrs, clause.id, HTML_ATTR_ID) &&
matchesAttr(attrs, clause.name, HTML_ATTR_NAME) &&
matchesAttr(attrs, clause.type, HTML_ATTR_TYPE) &&
matchesAttr(attrs, clause.role, HTML_ATTR_ROLE)
}
/**
* Whether this [FillAssistRules.SelectorClause] has no tag or attribute constraint, and would
* therefore vacuously match every node if not explicitly rejected.
*/
private val FillAssistRules.SelectorClause.isUnconstrained: Boolean
get() = tag == null && hasNoAttributeConstraints
/**
* Whether this [FillAssistRules.SelectorClause] has no `id`/`name`/`type`/`role` constraint.
*/
private val FillAssistRules.SelectorClause.hasNoAttributeConstraints: Boolean
get() = id == null && name == null && type == null && role == null
/**
* Whether [value] is unconstrained, or [attrs] contains an attribute named [key] with [value].
*/
private fun matchesAttr(
attrs: List<Pair<String, String>>,
value: String?,
key: String,
): Boolean = value == null || attrs.any { it.first == key && it.second == value }
/**
* Checks if the list of strings contains any of the specified patterns.
*/
@@ -106,6 +106,23 @@ private fun AssistStructure.ViewNode.findFirstAutofillableChild(): AutofillableC
return null
}
/**
* Builds an [AutofillView.Data] for this [AssistStructure.ViewNode] using the given [autofillId]
* and [website].
*/
internal fun AssistStructure.ViewNode.toAutofillViewData(
autofillId: AutofillId,
website: String?,
): AutofillView.Data = AutofillView.Data(
autofillId = autofillId,
autofillOptions = autofillOptions?.map { it.toString() }.orEmpty(),
autofillType = autofillType,
isFocused = isFocused,
textValue = autofillValue?.extractTextValue(),
hasPasswordTerms = hasPasswordTerms(),
website = website,
)
/**
* The first supported autofill hint for this view node, or null if none are found.
*/
@@ -15,12 +15,12 @@ import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.error.SecurityStampMismatchException
@@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.manager.KdfManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
@@ -44,7 +45,6 @@ import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -386,6 +386,20 @@ class FillAssistManagerTest {
assertNull(parseSingleSelector(".loginForm"))
}
@Test
fun `parseSingleSelector splits on descendant whitespace remaining after a shadow boundary`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = "email",
type = null,
role = null,
),
parseSingleSelector("custom-element >>> div.wrapper input[name='email']"),
)
}
@Test
fun `parseSingleSelector handles select element`() {
assertEquals(
@@ -400,6 +414,44 @@ class FillAssistManagerTest {
)
}
@Test
fun `parseSingleSelector returns null when only constraint is an unsupported attribute`() {
assertNull(parseSingleSelector("input[autocomplete='current-password']"))
}
@Test
fun `parseSingleSelector ignores an unsupported attribute when a supported one is present`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = "password",
type = null,
role = null,
),
parseSingleSelector("input[name='password'][autocomplete='current-password']"),
)
}
@Test
fun `parseSingleSelector returns null when only constraint is a class qualifier`() {
assertNull(parseSingleSelector("input.hidden"))
}
@Test
fun `parseSingleSelector ignores a class qualifier when a supported attribute is present`() {
assertEquals(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = "password",
type = null,
role = null,
),
parseSingleSelector("input.hidden[name='password']"),
)
}
// endregion
}
@@ -1,28 +1,38 @@
package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
import android.net.Uri
import android.net.Uri.parse
import android.service.autofill.FillContext
import android.service.autofill.FillRequest
import android.view.View
import android.view.ViewStructure.HtmlInfo
import android.view.autofill.AutofillId
import android.widget.inline.InlinePresentationSpec
import com.bitwarden.core.data.manager.model.FlagKey
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull
import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs
import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount
import com.x8bit.bitwarden.data.autofill.util.matchesSelectorClause
import com.x8bit.bitwarden.data.autofill.util.toAutofillView
import com.x8bit.bitwarden.data.autofill.util.toAutofillViewData
import com.x8bit.bitwarden.data.autofill.util.website
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@@ -40,6 +50,7 @@ class AutofillParserTests {
every { this@mockk.autofillHints } returns arrayOf(cardAutofillHint)
every { this@mockk.autofillId } returns cardAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.htmlInfo } returns mockk(relaxed = true)
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.idEntry } returns null
}
@@ -49,6 +60,7 @@ class AutofillParserTests {
every { this@mockk.autofillHints } returns arrayOf(loginAutofillHint)
every { this@mockk.autofillId } returns loginAutofillId
every { this@mockk.childCount } returns 0
every { this@mockk.htmlInfo } returns mockk(relaxed = true)
every { this@mockk.idPackage } returns ID_PACKAGE
every { this@mockk.idEntry } returns null
}
@@ -70,13 +82,42 @@ class AutofillParserTests {
every { isInlineAutofillEnabled } answers { mockIsInlineAutofillEnabled }
every { blockedAutofillUris } returns emptyList()
}
private val fillAssistManager: FillAssistManager = mockk()
private val mutableFillAssistFlagFlow = MutableStateFlow(false)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlag(FlagKey.FillAssistTargetingRules)
} answers {
mutableFillAssistFlagFlow.value
}
every {
getFeatureFlagFlow(FlagKey.FillAssistTargetingRules)
} returns mutableFillAssistFlagFlow
}
private var mockIsInlineAutofillEnabled = true
private var mockIsFillAssistEnabled = false
@BeforeEach
fun setup() {
mockIsFillAssistEnabled = false
// toAutofillView, website, and toAutofillViewData all compile into the same
// ViewNodeExtensionsKt class, so one mockkStatic call covers all three.
mockkStatic(AssistStructure.ViewNode::toAutofillView)
mockkStatic(AssistStructure.ViewNode::website)
// Default stub for toAutofillViewData (same mocked class — no separate mockkStatic needed).
every {
any<AssistStructure.ViewNode>().toAutofillViewData(autofillId = any(), website = any())
} answers {
AutofillView.Data(
autofillId = firstArg(),
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = false,
textValue = null,
hasPasswordTerms = false,
website = secondArg(),
)
}
mockkStatic(
FillRequest::getMaxInlineSuggestionsCount,
FillRequest::getInlinePresentationSpecs,
@@ -127,13 +168,23 @@ class AutofillParserTests {
every { any<AutofillView>().buildUriOrNull(PACKAGE_NAME) } returns URI
parser = AutofillParserImpl(
settingsRepository = settingsRepository,
fillAssistManager = fillAssistManager,
featureFlagManager = featureFlagManager,
)
mockkStatic(Uri::parse)
every { parse(any()) } returns mockk {
every { host } returns FILL_ASSIST_URI
}
mockkStatic(HtmlInfo::matchesSelectorClause)
every { any<HtmlInfo>().matchesSelectorClause(any()) } returns false
}
@AfterEach
fun teardown() {
unmockkStatic(AssistStructure.ViewNode::toAutofillView)
unmockkStatic(AssistStructure.ViewNode::website)
unmockkStatic(Uri::parse)
unmockkStatic(HtmlInfo::matchesSelectorClause)
unmockkStatic(
FillRequest::getMaxInlineSuggestionsCount,
FillRequest::getInlinePresentationSpecs,
@@ -545,10 +596,9 @@ class AutofillParserTests {
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.childCount } returns 2
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
}
@@ -947,6 +997,271 @@ class AutofillParserTests {
}
}
@Suppress("MaxLineLength")
@Test
fun `parse should fall back to heuristics when fill-assist rules exist but only cover login and a card view is focused`() {
// Setup: fill-assist enabled with login-only rules, but a card view is focused.
mutableFillAssistFlagFlow.value = true
mockIsFillAssistEnabled = true
every {
any<AutofillView>().buildUriOrNull(PACKAGE_NAME)
} returns FILL_ASSIST_URI
every { fillAssistManager.getFillAssistRules() } returns FillAssistRules(
hostRules = mapOf(
FILL_ASSIST_URI to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode
val cardAutofillView = AutofillView.Card.ExpirationYear(
data = AutofillView.Data(
autofillId = cardAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = FILL_ASSIST_URI,
),
yearValue = null,
)
every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView
// Test
val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest)
// Verify: heuristic card view used
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = AutofillPartition.Card(views = listOf(cardAutofillView)),
uri = FILL_ASSIST_URI,
)
assertEquals(expected, actual)
}
@Suppress("MaxLineLength")
@Test
fun `parse should fall back to heuristics when fill-assist rules exist but only cover payment-card and a login view is focused`() {
// Setup: fill-assist enabled with card-only rules, but a login view is focused.
mutableFillAssistFlagFlow.value = true
mockIsFillAssistEnabled = true
every { any<AutofillView>().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI
every { fillAssistManager.getFillAssistRules() } returns FillAssistRules(
hostRules = mapOf(
FILL_ASSIST_URI to listOf(
FillAssistRules.HostRule(
category = "payment-card",
fields = mapOf(
"cardNumber" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "card-number",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode
val loginAutofillView = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = FILL_ASSIST_URI,
),
)
every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView
// Test
val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest)
// Verify: heuristic login view used
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = AutofillPartition.Login(views = listOf(loginAutofillView)),
uri = FILL_ASSIST_URI,
)
assertEquals(expected, actual)
}
@Test
fun `parse should use fill-assist views when rules cover login and a login view is focused`() {
// Setup: fill-assist with login rules, login view focused.
// The heuristic and fill-assist paths produce views with DIFFERENT autofillIds so the
// assertion proves which path was actually taken. If heuristics are used the partition
// contains loginAutofillId; if fill-assist is used it contains fillAssistAutofillId.
mutableFillAssistFlagFlow.value = true
mockIsFillAssistEnabled = true
every { any<AutofillView>().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI
every { fillAssistManager.getFillAssistRules() } returns FillAssistRules(
hostRules = mapOf(
FILL_ASSIST_URI to listOf(
FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
val heuristicLoginView = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = FILL_ASSIST_URI,
),
)
val fillAssistAutofillId: AutofillId = mockk()
val fillAssistLoginData = AutofillView.Data(
autofillId = fillAssistAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = WEBSITE,
)
every { any<HtmlInfo>().matchesSelectorClause(any()) } returns true
every {
loginViewNode.toAutofillViewData(autofillId = loginAutofillId, website = WEBSITE)
} returns fillAssistLoginData
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode
every { loginViewNode.toAutofillView(parentWebsite = any()) } returns heuristicLoginView
// Test
val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest)
// Verify: fill-assist views used — partition contains fillAssistAutofillId.
// Heuristics would have produced loginAutofillId
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = AutofillPartition.Login(
views = listOf(AutofillView.Login.Username(data = fillAssistLoginData)),
),
uri = FILL_ASSIST_URI,
)
assertEquals(expected, actual)
}
@Test
fun `parse should use fill-assist when rules cover payment-card and a card view is focused`() {
mutableFillAssistFlagFlow.value = true
mockIsFillAssistEnabled = true
every { any<AutofillView>().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI
every { fillAssistManager.getFillAssistRules() } returns FillAssistRules(
hostRules = mapOf(
FILL_ASSIST_URI to listOf(
FillAssistRules.HostRule(
category = "payment-card",
fields = mapOf(
"cardNumber" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "card-number",
name = null,
type = null,
role = null,
),
),
),
),
),
),
)
val heuristicCardView = AutofillView.Card.ExpirationYear(
data = AutofillView.Data(
autofillId = cardAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = FILL_ASSIST_URI,
),
yearValue = null,
)
val fillAssistAutofillId: AutofillId = mockk()
val fillAssistCardData = AutofillView.Data(
autofillId = fillAssistAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = true,
textValue = null,
hasPasswordTerms = false,
website = WEBSITE,
)
every { any<HtmlInfo>().matchesSelectorClause(any()) } returns true
every {
cardViewNode.toAutofillViewData(autofillId = cardAutofillId, website = WEBSITE)
} returns fillAssistCardData
every { assistStructure.windowNodeCount } returns 1
every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode
every { cardViewNode.toAutofillView(parentWebsite = any()) } returns heuristicCardView
// Test
val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest)
// Verify: fill-assist views used — partition contains fillAssistAutofillId.
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = AutofillPartition.Card(
views = listOf(AutofillView.Card.Number(data = fillAssistCardData)),
),
uri = FILL_ASSIST_URI,
)
assertEquals(expected, actual)
}
@Suppress("MaxLineLength")
@Test
fun `parse should deduplicate when a child node returns an AutofillView with the same autofillId already claimed by a non-Unused parent view`() {
@@ -1025,6 +1340,8 @@ class AutofillParserTests {
}
}
private const val FILL_ASSIST_URI: String = "https://example.com"
private val BLOCK_LISTED_URIS: List<String> = listOf(
"androidapp://android",
"androidapp://com.android.settings",
@@ -0,0 +1,609 @@
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 com.x8bit.bitwarden.data.autofill.model.FillAssistRules
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.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class FillAssistViewNodeExtensionsTest {
private val autofillId: AutofillId = mockk()
@BeforeEach
fun setup() {
mockkStatic(AssistStructure.ViewNode::toAutofillViewData)
mockkStatic(HtmlInfo::matchesSelectorClause)
}
@AfterEach
fun teardown() {
unmockkStatic(AssistStructure.ViewNode::toAutofillViewData)
unmockkStatic(HtmlInfo::matchesSelectorClause)
}
@Test
fun `buildFillAssistViews should return empty list when there are no window nodes`() {
val assistStructure: AssistStructure = mockk {
every { windowNodeCount } returns 0
}
val actual = assistStructure.buildFillAssistViews(
hostRules = emptyList(),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Test
fun `buildFillAssistViews should exclude node with null htmlInfo`() {
val viewNode = createViewNode(htmlInfo = null)
val assistStructure = createAssistStructure(viewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should return Login Username when htmlInfo matches username clause`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should return Login Password when htmlInfo matches password clause`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"password" to listOf(selectorClause(tag = "input", id = "pass")),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Password(data = data)), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should return Login Password when htmlInfo matches newPassword clause`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-registration",
fields = mapOf(
"newPassword" to listOf(selectorClause(tag = "input", id = "new-pass")),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Password(data = data)), actual)
}
@Test
fun `buildFillAssistViews should map all card field keys to correct AutofillView subtypes`() {
val cardFieldExpectations: List<Pair<String, AutofillView>> = listOf(
"cardNumber" to AutofillView.Card.Number(data = autofillData()),
"cardholderName" to AutofillView.Card.CardholderName(data = autofillData()),
"cardExpirationDate" to AutofillView.Card.ExpirationDate(data = autofillData()),
"cardExpirationMonth" to AutofillView.Card.ExpirationMonth(
data = autofillData(),
monthValue = null,
),
"cardExpirationYear" to AutofillView.Card.ExpirationYear(
data = autofillData(),
yearValue = null,
),
"cardCvv" to AutofillView.Card.SecurityCode(data = autofillData()),
"cardType" to AutofillView.Card.Brand(data = autofillData(), brandValue = null),
)
cardFieldExpectations.forEach { (fieldKey, expectedView) ->
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every {
viewNode.toAutofillViewData(autofillId = autofillId, website = null)
} returns data
val hostRule = FillAssistRules.HostRule(
category = "payment-card",
fields = mapOf(
fieldKey to listOf(selectorClause(tag = "input", id = "field-$fieldKey")),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(
listOf(expectedView),
actual,
"Failed for field key: $fieldKey",
)
}
}
@Test
fun `buildFillAssistViews should exclude node whose matched field key is unknown`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
every {
viewNode.toAutofillViewData(autofillId = autofillId, website = null)
} returns autofillData()
val hostRule = FillAssistRules.HostRule(
category = "unknown",
fields = mapOf(
"unknownFieldKey" to listOf(selectorClause(tag = "input", id = "mystery")),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should pick first mapped key when multiple keys match the same node`() {
// Two field keys ("email" - unknown - and "username") both match the same node. The
// implementation iterates in insertion order and selects the first key whose mapping is
// non-null. Since "email" is unknown, "username" wins; this also demonstrates that an
// earlier known key wins over a later one.
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = linkedMapOf(
"email" to listOf(selectorClause(tag = "input", id = "shared")),
"username" to listOf(selectorClause(tag = "input", id = "shared")),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should traverse child nodes recursively`() {
val childHtmlInfo = createHtmlInfo()
val childViewNode = createViewNode(htmlInfo = childHtmlInfo)
val childData = autofillData()
every {
childViewNode.toAutofillViewData(autofillId = autofillId, website = null)
} returns childData
val rootHtmlInfo = createHtmlInfo(matches = false)
val rootViewNode = createViewNode(
htmlInfo = rootHtmlInfo,
children = listOf(childViewNode),
)
val assistStructure = createAssistStructure(rootViewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = childData)), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should propagate urlBarWebsite as website when node has no website`() {
val urlBarWebsite = "https://example.com"
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo, website = null)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData(website = urlBarWebsite)
every {
viewNode.toAutofillViewData(autofillId = autofillId, website = urlBarWebsite)
} returns data
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = urlBarWebsite,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should prefer node's own website over urlBarWebsite`() {
val nodeWebsite = "https://node.example.com"
val urlBarWebsite = "https://urlbar.example.com"
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo, website = nodeWebsite)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData(website = nodeWebsite)
every {
viewNode.toAutofillViewData(autofillId = autofillId, website = nodeWebsite)
} returns data
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = urlBarWebsite,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should exclude node with no autofillId`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo, autofillId = null)
val assistStructure = createAssistStructure(viewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Test
fun `buildFillAssistViews should not match when htmlInfo tag differs from clause tag`() {
val htmlInfo = createHtmlInfo(matches = false)
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should match when htmlInfo has no attributes and all clause attrs are null`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = null,
name = null,
type = null,
role = null,
),
),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should not match when htmlInfo has no attributes but clause requires id`() {
val htmlInfo = createHtmlInfo(matches = false)
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Test
fun `buildFillAssistViews should match when htmlInfo has correct id attribute`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should not match when htmlInfo id attribute is wrong`() {
val htmlInfo = createHtmlInfo(matches = false)
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should require all non-null clause attributes to match (AND logic)`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = "username",
type = "text",
role = "textbox",
),
),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFillAssistViews should not match when one of multiple required attributes is wrong`() {
val htmlInfo = createHtmlInfo(matches = false)
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = "input",
id = "user",
name = "username",
type = "text",
role = "textbox",
),
),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(emptyList<AutofillView>(), actual)
}
@Test
fun `buildFillAssistViews should skip null clause attributes (not required)`() {
// Clause only requires tag + id; node has extra attributes which should be ignored.
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should match when clause has null tag (tag check skipped)`() {
val htmlInfo = createHtmlInfo()
val viewNode = createViewNode(htmlInfo = htmlInfo)
val assistStructure = createAssistStructure(viewNode)
val data = autofillData()
every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data
val hostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(
FillAssistRules.SelectorClause(
tag = null,
id = "user",
name = null,
type = null,
role = null,
),
),
),
)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(hostRule),
urlBarWebsite = null,
)
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
}
@Test
fun `buildFillAssistViews should return views from all children when only some match`() {
val matchingChildHtmlInfo = createHtmlInfo()
val matchingChild = createViewNode(htmlInfo = matchingChildHtmlInfo)
val matchingChildData = autofillData()
every {
matchingChild.toAutofillViewData(autofillId = autofillId, website = null)
} returns matchingChildData
val nonMatchingChildHtmlInfo = createHtmlInfo(matches = false)
val nonMatchingChild = createViewNode(htmlInfo = nonMatchingChildHtmlInfo)
val rootHtmlInfo = createHtmlInfo(matches = false)
val rootViewNode = createViewNode(
htmlInfo = rootHtmlInfo,
children = listOf(matchingChild, nonMatchingChild),
)
val assistStructure = createAssistStructure(rootViewNode)
val actual = assistStructure.buildFillAssistViews(
hostRules = listOf(usernameHostRule(tag = "input", id = "user")),
urlBarWebsite = null,
)
assertEquals(
listOf(
AutofillView.Login.Username(
data = matchingChildData,
),
),
actual,
)
// Sanity check that traversal visited multiple children.
assertTrue(actual.size == 1)
}
private fun autofillData(website: String? = null): AutofillView.Data = AutofillView.Data(
autofillId = autofillId,
autofillOptions = emptyList(),
autofillType = View.AUTOFILL_TYPE_TEXT,
isFocused = false,
textValue = null,
hasPasswordTerms = false,
website = website,
)
private fun usernameHostRule(
tag: String?,
id: String?,
): FillAssistRules.HostRule = FillAssistRules.HostRule(
category = "account-login",
fields = mapOf(
"username" to listOf(selectorClause(tag = tag, id = id)),
),
)
private fun selectorClause(
tag: String? = null,
id: String? = null,
name: String? = null,
type: String? = null,
role: String? = null,
): FillAssistRules.SelectorClause = FillAssistRules.SelectorClause(
tag = tag,
id = id,
name = name,
type = type,
role = role,
)
private fun createHtmlInfo(matches: Boolean = true): HtmlInfo = mockk<HtmlInfo>().also {
every { it.matchesSelectorClause(any()) } returns matches
}
private fun createViewNode(
htmlInfo: HtmlInfo?,
autofillId: AutofillId? = this.autofillId,
website: String? = null,
children: List<AssistStructure.ViewNode> = emptyList(),
): AssistStructure.ViewNode = mockk {
every { this@mockk.htmlInfo } returns htmlInfo
every { this@mockk.autofillId } returns autofillId
every { this@mockk.website } returns website
every { this@mockk.childCount } returns children.size
children.forEachIndexed { index, child ->
every { this@mockk.getChildAt(index) } returns child
}
}
private fun createAssistStructure(
rootViewNode: AssistStructure.ViewNode,
): AssistStructure {
val windowNode: AssistStructure.WindowNode = mockk {
every { this@mockk.rootViewNode } returns rootViewNode
}
return mockk {
every { windowNodeCount } returns 1
every { getWindowNodeAt(0) } returns windowNode
}
}
}