mirror of
https://github.com/bitwarden/android.git
synced 2026-08-02 02:32:04 -05:00
[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:
co-authored by
claude[bot] <41898282+claude[bot]@users.noreply.github.com>
aj-rosado
parent
2a0dccac06
commit
109470e92e
@@ -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
|
||||
|
||||
+46
-8
@@ -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
|
||||
|
||||
+103
-27
@@ -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
|
||||
|
||||
+84
@@ -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
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+321
-4
@@ -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",
|
||||
|
||||
+609
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user