Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
142309d
Address PR comments
aj-rosado Jul 3, 2026
1d3381f
cache fill assist rules
aj-rosado Jul 6, 2026
f677f80
Fixing fillAssistManager import
aj-rosado Jul 6, 2026
2219e6f
Restore FillAssistManager import and fix import ordering
github-actions[bot] Jul 6, 2026
adfb12b
reverted cachedRules
aj-rosado Jul 6, 2026
643be12
Revert "Restore FillAssistManager import and fix import ordering"
aj-rosado Jul 6, 2026
3b1c44e
Merge branch 'main' into PM-37256/apply-fill-assist-rules
aj-rosado Jul 7, 2026
87342f9
Address PR comments
aj-rosado Jul 7, 2026
17a85f6
Fix silent attribute collision in matchesSelectorClause when clause v…
aj-rosado Jul 7, 2026
5facbf9
Merge branch 'main' into PM-37256/apply-fill-assist-rules
aj-rosado Jul 15, 2026
12c2e6d
addressed PR comments
aj-rosado Jul 16, 2026
a83a4e5
Prevent fill-assist selectors from silently over- or under-matching
aj-rosado Jul 16, 2026
ba465ec
Avoid residual class qualifier to over match when fill assisting
aj-rosado Jul 16, 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 @@ -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 @@ -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].
*/
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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
}
}

Expand All @@ -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)
Comment thread
aj-rosado marked this conversation as resolved.
Comment thread
aj-rosado marked this conversation as resolved.
}

private fun hasNoAttributeConstraints(
id: String?,
name: String?,
type: String?,
role: String?,
): Boolean = id == null && name == null && type == null && role == null

// endregion
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",
)
Comment thread
aj-rosado marked this conversation as resolved.

/**
* 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,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)

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is possible but as It adds complexity to the traverse, not sure if it a real improvement.
I have created a draft PR with a possible solution for this.
#7144


// 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 }
?: 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.
Comment thread
david-livefront marked this conversation as resolved.
// 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>(),
)
}

Expand Down Expand Up @@ -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) }

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
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

https://github.com/bitwarden/android/pull/7075/changes#diff-e1c5c53907c2162defc188d108bdeb90c4792990fbd3ea388b62e75731c2da6cL215

Next PR in chain will remove that. Are you ok if we keep it here for now to avoid more conflicts when merging?

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 is it being removed in that PR?

@aj-rosado aj-rosado Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved the validation to outside this method

val effectiveViews = if (isFillAssistEnabled) {
            resolveEffectiveViews(
                assistStructure = assistStructure,
                autofillViews = autofillViews,
                uri = uri,
                focusedView = focusedView,
                urlBarWebsite = urlBarWebsite,
            )
        } else {
            autofillViews
        }

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 DEBT: getFillAssistRules() is invoked on the autofill parse hot path (once per parse() β€” i.e. per field focus / fill request). Its implementation re-reads the SharedPreferences string and fully re-deserializes the entire rules blob every call:

// FillAssistDiskSourceImpl
override fun getFillAssistRules(serverUrl: String): FillAssistRules? =
    getString(FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl))
        ?.let { json.decodeFromStringOrNull(it) }

Since the cached blob can contain rules for many hosts, this JSON parse repeats on every autofill interaction with no in-memory memoization. Consider caching the deserialized FillAssistRules in memory (invalidated on sync) so the parse path avoids repeated full deserialization. Gated behind the feature flag, so not urgent β€” but worth addressing before broad rollout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@claude updated

@claude claude Bot Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude finished @aj-rosado's task in 1m 20s β€”β€” View job


Verifying the caching update

  • Reviewed the cache fill assist rules commit (1d3381f)
  • Confirmed the hot-path re-deserialization is resolved
  • Checked test coverage

βœ… Resolved. The DEBT concern is addressed. getFillAssistRules() now memoizes the deserialized FillAssistRules in the cachedRules field and short-circuits before touching disk (FillAssistManagerImpl.kt:146), so the full JSON blob is no longer re-parsed on every parse() call. Invalidation is wired correctly:

  • reset on server-config change (init block, :78)
  • reset after a sync stores new rules (:137)

Test coverage is solid β€” the three new cases cover the cache hit (disk read exactly = 1), re-read after server-config change, and returning the latest rules after a sync.

One carry-over note: this newly-added cachedRules var is written from three contexts (init/unconfined, sync/io, and getFillAssistRules on the binder thread) without @Volatile. That's the same publication concern already tracked in the sibling ♻️ DEBT thread on this field β€” marking @Volatile there would also cover this cache. No action needed here beyond that existing thread.

}
?: 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
}
}
}

/**
Expand All @@ -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
Expand Down
Loading
Loading