Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c646372
Add fill assist rules network data
aj-rosado May 29, 2026
f57a7d0
reverted unwanted changes on AuthRepositoryTest
aj-rosado May 29, 2026
8b6ce8b
Add fill assist data layer
aj-rosado May 29, 2026
09692af
wire fill-assist manager into vault sync and app startup
aj-rosado May 29, 2026
00b2479
chained code on FillAssistManager
aj-rosado May 29, 2026
61db0cd
Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-…
aj-rosado May 29, 2026
b718f56
Merge branch 'PM-37255/fill-assist-data-layer' into PM-37255/fill-ass…
aj-rosado May 29, 2026
f66485f
Removed nulls and sets on non nullable fields by schema definition. R…
aj-rosado Jun 8, 2026
0b39ad2
following autofill assist forms schema
aj-rosado Jun 8, 2026
b25404e
removed unnecessary null set
aj-rosado Jun 9, 2026
31a9cf7
Added FillAssist to BaseUrlInterceptors
aj-rosado Jun 9, 2026
bbdc2f1
Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-…
aj-rosado Jun 9, 2026
ad2aada
updating environmentDiskSource.fillAssistUrl when serverConfigStateFl…
aj-rosado Jun 10, 2026
9c5d53c
Merge branch 'PM-37255/fill-assist-data-layer' into PM-37255/fill-ass…
aj-rosado Jun 10, 2026
d6f0a47
Merge branch 'main' into PM-37255/fill-assist-network-layer
aj-rosado Jun 10, 2026
6c0bfa5
Improved code readability
aj-rosado Jun 11, 2026
8b05d40
Addressing pr comments
aj-rosado Jun 11, 2026
1db11d1
Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-…
aj-rosado Jun 12, 2026
20f9642
Merge branch 'PM-37255/fill-assist-data-layer' into PM-37255/fill-ass…
aj-rosado Jun 12, 2026
66b7f36
Add fill assist logic to Autofill
aj-rosado Jun 15, 2026
3a2fe1e
Using heuristics autofill if fill assist does not have rules for that…
aj-rosado Jun 24, 2026
1a2ea61
added verify for FillAssistManager syncIfNecessary
aj-rosado Jun 25, 2026
6814294
Merge branch 'PM-37255/fill-assist-data-layer' into PM-37255/fill-ass…
aj-rosado Jun 29, 2026
02f1319
Merge branch 'PM-37255/fill-assist-integration' into PM-37256/apply-f…
aj-rosado Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.app.Application
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
Expand All @@ -20,6 +21,9 @@ import javax.inject.Inject
class BitwardenApplication : Application() {
// Inject classes here that must be triggered on startup but are not otherwise consumed by
// other callers.
@Inject
lateinit var fillAssistManager: FillAssistManager

@Inject
lateinit var logsManager: LogsManager

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ 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
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
Expand Down Expand Up @@ -100,9 +102,13 @@ object AutofillModule {
@Provides
fun providesAutofillParser(
settingsRepository: SettingsRepository,
fillAssistManager: FillAssistManager,
featureFlagManager: FeatureFlagManager,
): AutofillParser =
AutofillParserImpl(
settingsRepository = settingsRepository,
fillAssistManager = fillAssistManager,
featureFlagManager = featureFlagManager,
)

@Singleton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ 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]*)""")

// 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].
*/
Expand Down Expand Up @@ -207,13 +212,13 @@ 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 (>>>) and space-separated CSS descendant selectors are both handled this way.
// Whitespace inside [...] is part of an attribute value and must not be treated as a separator.
val effective = when {
selector.contains(">>>") -> selector.substringAfterLast(">>>").trim()
else -> selector.split(DESCENDANT_SEPARATOR_REGEX).last().trim()
}
if (effective.trimStart().startsWith(".")) return null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -100,56 +123,44 @@ class AutofillParserImpl(
val urlBarWebsite = traversalDataList
.flatMap { it.urlBarWebsites }
.firstOrNull()
val autofillViews = traversalDataList.toAutofillViews(urlBarWebsite = urlBarWebsite)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new buildFillAssistViews is awfully similar to the existing logic for parsing the assist structure. It's hard for me to tell if it is possible but can we optimize this to parse the structure only once?


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

// Find the focused view, or fallback to the first fillable item on the screen (so
// we at least have something to hook into)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we leave in the comments in place? Autofill is confusing enough as it is, I want to make sure we leave goo notes for the next person.

        // 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()
            // The view is unfillable if there are no focused views.
            ?: return AutofillRequest.Unfillable

val focusedView = autofillViews
.firstOrNull { it.data.isFocused }
val focusedView = autofillViews.firstOrNull { it.data.isFocused }
?: autofillViews.firstOrNull()
?: return AutofillRequest.Unfillable

val packageName =
traversalDataList.buildPackageNameOrNull(assistStructure = assistStructure)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we clean this up a bit

val packageName = traversalDataList.buildPackageNameOrNull(
    assistStructure = assistStructure,
)

val uri = focusedView.buildUriOrNull(packageName = packageName)

if (focusedView == null) {
// The view is unfillable if there are no focused views.
if ((settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS).contains(uri)) {
return AutofillRequest.Unfillable
}

val packageName = traversalDataList.buildPackageNameOrNull(
val effectiveViews = resolveEffectiveViews(
assistStructure = assistStructure,
)
val uri = focusedView.buildUriOrNull(
packageName = packageName,
autofillViews = autofillViews,
uri = uri,
focusedView = focusedView,
urlBarWebsite = urlBarWebsite,
)

val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS
if (blockListedURIs.contains(uri)) {
// The view is unfillable if the URI is block listed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, let's leave the comments in for posterity.

return AutofillRequest.Unfillable
}
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>(),
)
}

Expand All @@ -166,7 +177,6 @@ class AutofillParserImpl(

// Get inline information if available
val isInlineAutofillEnabled = settingsRepository.isInlineAutofillEnabled
Timber.d("Autofill request isInlineEnabled=$isInlineAutofillEnabled -- ${fillRequest?.id}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we removing this?

val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = isInlineAutofillEnabled,
Expand All @@ -185,6 +195,46 @@ 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].
*/
private fun resolveEffectiveViews(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about making this an extension function?

    private fun List<AutofillView>.toEffectiveViews(
        assistStructure: AssistStructure,
        uri: String?,
        focusedView: AutofillView,
        urlBarWebsite: String?,
    ): List<AutofillView> {

assistStructure: AssistStructure,
autofillViews: List<AutofillView>,
uri: String?,
focusedView: AutofillView,
urlBarWebsite: String?,
): List<AutofillView> {
val hostRules = uri
?.takeUnless { it.startsWith("androidapp://") }
?.toUri()
?.host
?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just return early instead of putting this check in the chain?

        if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) {
            return autofillViews
        }

?.let { host ->
fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www."))
}
?: return autofillViews

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 {
autofillViews
}
}
}

/**
Expand All @@ -201,6 +251,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 }
return (viewsLists
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: viewsLists
.flatten()
.filter { it !is AutofillView.Unused })
.map { it.updateWebsiteIfNecessary(website = urlBarWebsite) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that this was moved out into it's own method.

What do you think of this minor update:

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

I have never found that original block of code particularly readable πŸ˜„

}

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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

/**
* 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,
)
}
}
}
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) {
"username" -> AutofillView.Login.Username(data = data)
"password", "newPassword" -> AutofillView.Login.Password(data = data)
"cardNumber" -> AutofillView.Card.Number(data = data)
"cardholderName" -> AutofillView.Card.CardholderName(data = data)
"cardExpirationDate" -> AutofillView.Card.ExpirationDate(data = data)
"cardExpirationMonth" -> AutofillView.Card.ExpirationMonth(data = data, monthValue = null)
"cardExpirationYear" -> AutofillView.Card.ExpirationYear(data = data, yearValue = null)
"cardCvv" -> AutofillView.Card.SecurityCode(data = data)
"cardType" -> AutofillView.Card.Brand(data = data, brandValue = null)
else -> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package com.x8bit.bitwarden.data.autofill.util

import android.view.ViewStructure.HtmlInfo
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules

/**
* Whether this [HtmlInfo] represents a password field.
Expand Down Expand Up @@ -93,6 +94,29 @@ fun HtmlInfo?.hints(): List<String> = this
*/
val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input"

/**
* Whether this [HtmlInfo] matches the given [SelectorClause].
*
* This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires
* instrumentation testing.
*/
internal fun HtmlInfo.matchesSelectorClause(clause: FillAssistRules.SelectorClause): Boolean {
if (clause.tag != null && clause.tag != tag) return false
val attrs = attributes
?: return clause.id == null &&
clause.name == null &&
clause.type == null &&
clause.role == null
fun hasAttr(key: String, value: String) = attrs.any { it.first == key && it.second == value }
return listOf(
clause.id to "id",
clause.name to "name",
clause.type to "type",
clause.role to "role",
)
.all { (value, key) -> value == null || hasAttr(key, value) }
}

/**
* Checks if the list of strings contains any of the specified patterns.
*/
Expand Down
Loading
Loading