Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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 @@ -94,7 +94,7 @@ class FilledDataBuilderImpl(

is AutofillPartition.Identity -> {
// Filling an identity partition is wired up in a later phase; this is a no-op
// today since nothing yet classifies a view as Identity.
// today since an identity partition is never constructed yet.
emptyList()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,51 @@
package com.x8bit.bitwarden.data.autofill.model

/**
* Autofill hints used to determine what data an input field is associated with.
* Autofill hints used to determine what data an input field is associated with, grouped by the
* [AutofillView] partition they belong to.
*/
enum class AutofillHint {
CARD_CARDHOLDER,
CARD_EXPIRATION_DATE,
CARD_EXPIRATION_MONTH,
CARD_EXPIRATION_YEAR,
CARD_NUMBER,
CARD_SECURITY_CODE,
CARD_BRAND,
PASSWORD,
USERNAME,
IDENTITY_PERSON_NAME_FULL,
IDENTITY_PERSON_NAME_PREFIX,
IDENTITY_PERSON_NAME_GIVEN,
IDENTITY_PERSON_NAME_MIDDLE,
IDENTITY_PERSON_NAME_FAMILY,
IDENTITY_POSTAL_ADDRESS_FULL,
IDENTITY_ADDRESS_STREET,
IDENTITY_ADDRESS_LOCALITY,
IDENTITY_ADDRESS_REGION,
IDENTITY_ADDRESS_COUNTRY,
IDENTITY_POSTAL_CODE,
IDENTITY_PHONE_FULL,
IDENTITY_COMPANY,
IDENTITY_EMAIL,
IDENTITY_SSN,
IDENTITY_PASSPORT_NUMBER,
IDENTITY_LICENSE_NUMBER,
sealed interface AutofillHint {
/**
* Hints for the [AutofillView.Card] partition.
*/
enum class Card : AutofillHint {
BRAND,
CARDHOLDER,
EXPIRATION_DATE,
EXPIRATION_MONTH,
EXPIRATION_YEAR,
NUMBER,
SECURITY_CODE,
}

/**
* Hints for the [AutofillView.Login] partition.
*/
enum class Login : AutofillHint {

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.

This is nice

PASSWORD,
USERNAME,
}

/**
* Hints for the [AutofillView.Identity] partition.
*/
enum class Identity : AutofillHint {
ADDRESS_COUNTRY,
ADDRESS_LOCALITY,
ADDRESS_REGION,
ADDRESS_STREET,
COMPANY,
EMAIL,
LICENSE_NUMBER,
PASSPORT_NUMBER,
PERSON_NAME_FAMILY,
PERSON_NAME_FULL,
PERSON_NAME_GIVEN,
PERSON_NAME_MIDDLE,
PERSON_NAME_PREFIX,
POSTAL_ADDRESS_FULL,
POSTAL_CODE,
PHONE_FULL,
SSN,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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.isEmailField
import com.x8bit.bitwarden.data.autofill.util.isPhoneField
import com.x8bit.bitwarden.data.autofill.util.toAutofillView
import com.x8bit.bitwarden.data.autofill.util.website
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
Expand Down Expand Up @@ -118,16 +120,27 @@ class AutofillParserImpl(
fillRequest: FillRequest?,
): AutofillRequest {
Timber.d("Parsing AssistStructure -- ${fillRequest?.id}")
// Identity classification/fulfillment ship together: until this flag is on, every node
// must classify exactly as it did before identity heuristics existed, so behaviors like
// updateForMissingUsernameFields's Unused-only promotion keep working unchanged.
val isIdentityAutofillEnabled = featureFlagManager.getFeatureFlag(FlagKey.IdentityAutofill)
// Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse()
val traversalDataList = assistStructure.traverse(
isIdentityAutofillEnabled = isIdentityAutofillEnabled,
)
val urlBarWebsite = traversalDataList
.flatMap { it.urlBarWebsites }
.firstOrNull()
// Heuristic views: the focused node's candidates with unfillable (Unused) fields removed,
// falling back to all fillable views when nothing has focus.
// falling back to all fillable views when nothing has focus. Identity is also excluded
// here for now -- Identity partition construction lands in Phase D, so until then a field
// classified as Identity must keep falling through exactly as it would have as Unused
// (e.g. resolving to a sibling Login/Card field on the same form, or its Unused-only
// promotion in updateForMissingUsernameFields), not become the focused view and force this
// request to Unfillable.
val autofillViews = traversalDataList
.selectCandidateAutofillViews(urlBarWebsite = urlBarWebsite) {
it !is AutofillView.Unused
it !is AutofillView.Unused && it !is AutofillView.Identity
}

val isFillAssistEnabled = featureFlagManager
Expand Down Expand Up @@ -187,8 +200,7 @@ class AutofillParserImpl(
}

is AutofillView.Identity -> {
// Identity partition construction is wired up in a later phase; treating it as
// unfillable for now is a no-op since nothing yet classifies a view as Identity.
// Identity partition construction lands in Phase D. Unfillable until then.

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.

❌ CRITICAL: Classifying identity fields today (with no Identity partition) turns requests that used to be fillable into Unfillable, removing autofill suggestions from registration/checkout/phone-login screens.

Details and fix

Before this PR an identity-looking field (e.g. idEntry/hint of "First name", "City", "Mobile number") resolved to AutofillView.Unused. autofillViews filters Unused out, so firstFocusedOrNull() fell through to ?: firstOrNull() and returned a Login/Card view β€” the request stayed Fillable.

Now those fields resolve to AutofillView.Identity.*, survive the it !is AutofillView.Unused filter, and become the focused view β€” so this branch returns Unfillable.

Traceable regressions:

  1. Signup form [First name][Email][Password]. User taps First name β†’ focused view is Identity.PersonNameGiven β†’ Unfillable. Previously the user got the login suggestion.
  2. Login screen whose identifier field is hint = "Mobile number". isUsernameField doesn't match ("mobile" isn't in SUPPORTED_RAW_USERNAME_HINTS), so it is now Identity.PhoneFull instead of Unused. That also means updateForMissingUsernameFields() can no longer promote it to Login.Username, so login autofill is lost on that screen entirely.

This contradicts the PR description's "behavior-neutral in production until a later stacked phase".

Suggested fix β€” keep Identity out of the heuristic candidate set until Phase D lands, e.g. in selectCandidateAutofillViews:

val autofillViews = traversalDataList
    .selectCandidateAutofillViews(urlBarWebsite = urlBarWebsite) {
        it !is AutofillView.Unused && it !is AutofillView.Identity
    }

or gate the new identity heuristics behind a feature flag so classification and fulfillment ship together.

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 fixed

@claude claude Bot Aug 5, 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 3m 31s β€”β€” View job


Re-checked against be0bed8

  • Gather PR context / find the new commit
  • Re-verify the dual-classification dedup finding (this thread)
  • Re-check the Unfillable finding
  • Report back

The push landed β€” be0bed8 Gating Identity autofill behind feature flag. Here's what it does and doesn't cover.

βœ… Fixed: the Unfillable regression (the other thread)

Two layers, both correct:

  1. FlagKey.IdentityAutofill is read in parseInternal and threaded through traverse() β†’ toAutofillView(). With the flag off, supportedAutofillHint() short-circuits at !isIdentityAutofillEnabled -> null (ViewNodeExtensions.kt:170) after the card checks and SUPPORTED_IDENTITY_VIEW_HINTS is excluded from firstSupportedAutofillHintOrNull β€” so every node classifies exactly as it did on the base branch.
  2. Belt-and-braces: it !is AutofillView.Unused && it !is AutofillView.Identity in selectCandidateAutofillViews (AutofillParserImpl.kt:141), so even with the flag on a focused identity field falls through instead of forcing Unfillable.

Both of my traced cases are now covered by tests β€” the signup-form fall-through and the updateForMissingUsernameFields promotion of a phone-hinted login field (AutofillParserTests.kt:1721, :1791, :1860). Good call codifying the flag-on variant too; it documents that phone-login username promotion is still lost once the flag flips, which is the right thing for Phase D to pick up.

⚠️ Not fixed: this thread's finding (dedup filter)

AutofillParserImpl.kt:470-482 is still byte-for-byte the base-branch filter β€” no AutofillView.Identity case:

.filter { view ->
    val id = view.data.autofillId
    if (id in claimedAutofillIds) {
        false
    } else if (view !is AutofillView.Unused) {
        claimedAutofillIds.add(id)
        true
    } else {
        true
    }
}

So the sibling added at :442-459 is still dropped by the parent node whenever the classified field isn't a window root β€” which it never is in a real structure. AutofillParserTests.kt has no case asserting a dual-classified sibling survives traversal.

Severity is much lower than when I first flagged it, though, and it's reasonable to leave: with the flag off no sibling is created at all, and with the flag on the sibling is dropped by this filter and excluded from candidates by the new it !is AutofillView.Identity guard. Either way it's currently unreachable code rather than a user-visible defect. The practical consequence is just that the dual-classification block is dead and untested until Phase D β€” at which point the filter has to change or whole-identity fill will skip the email/phone field.

Your call: fix it now (small diff + one parser test), or note it on the Phase D ticket. Fix this β†’ β€” or say the word and I'll push it here.

One thing the flag doesn't cover

"autocomplete" was added to SUPPORTED_HTML_ATTRIBUTE_HINTS (HtmlInfoExtensions.kt:276), and that list feeds hints() for all checks β€” including the pre-existing isUsernameField/isPasswordField/card ones. That change is live with the flag off, so "classifies exactly as before" holds for native nodes but not for web nodes.

I checked the collision surface and it looks like a clean win rather than a risk: cc-number, cc-exp-month, cc-name, email, new-password now match (all correctly), while one-time-code, tel, off and nickname match nothing. Worth a conscious decision rather than an accident, since it's the one part of Phase C that changes production behavior today.
β€’ branch PM-41292/identity-autofill-heuristic-field-detection

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.

@claude This is fixed now.

@claude claude Bot Aug 27, 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 @david-livefront's task in 1m 39s β€”β€” View job


Verified 036ba24 β€” the gate ordering is pinned

  • Gather PR context and identify the new commits
  • Re-verify the open items against the branch
  • Report back

Confirmed. 036ba24 Swapped test to flag off flips exactly the two heuristic tests that make the ordering load-bearing, and I traced that each one now actually fails if the gate moves.

The invariant under test: !isIdentityAutofillEnabled -> null sits below the username/password/card branches in supportedAutofillHint (ViewNodeExtensions.kt:169-171). If it were hoisted to the top of the when, hint becomes null, isInputField is still true so there's no early return, and buildAutofillView falls into null -> AutofillView.Unused (ViewNodeExtensions.kt:279-281).

Test Flag Expected If the gate moved above
…Card Number when hint matches (ViewNodeExtensionsTest.kt:329-340) false Card.Number Unused β‡’ ❌ fails
…Login Username when is EditText and isUsernameField (:568-578) false Login.Username Unused β‡’ ❌ fails

Both are heuristic-path tests (setupUnsupportedInputFieldViewNode(), no autofillHints), so they run through the when rather than short-circuiting in firstSupportedAutofillHintOrNull β€” that's the part that matters. The inline comments you added on each (// Flag off pins the identity gate below the card/username heuristics.) are what stops someone "tidying" them back to true later, which was the real risk.

Coverage note, not a request: the remaining non-identity heuristics (password, the other six card branches) are still only exercised with the flag on. That's fine β€” the hoist failure mode is all-or-nothing, so two red tests is enough signal.

While I was in the file, the other items from this round check out too:

  • Exhaustive whens β€” buildCardView / buildLoginView / buildIdentityView (AutofillViewBuilderExtensions.kt:13, :79, :98) each when over an enum with no else, so AutofillHint (AutofillHint.kt:7-50) being a sealed interface of three enums makes the compiler enforce them. Adding a hint constant now breaks the build at the dispatcher rather than silently falling through.
  • The ordering note β€” present at ViewNodeExtensions.kt:169-170: "Identity heuristics only below here; anything else would stop matching when the flag is off. Add non-identity heuristics above this branch."

Nothing open from my side. The only carry-forward is housekeeping: the PR description still claims "the container-redirect dedup filter in traversal is updated to special-case AutofillView.Identity", which isn't in the diff β€” worth correcting so Phase D doesn't inherit the assumption, since that filter will need the change before whole-identity fill works.

(Static analysis only β€” ./gradlew isn't permitted in this environment, so CI remains the source of truth for these two tests passing.)
β€’ branch PM-41292/identity-autofill-heuristic-field-detection

return AutofillRequest.Unfillable
}

Expand Down Expand Up @@ -254,8 +266,7 @@ class AutofillParserImpl(
rule.category in LOGIN_FILL_ASSIST_CATEGORIES ||
rule.category in CARD_FILL_ASSIST_CATEGORIES
}
// Fill-assist category coverage for Identity is added in a later phase; this is a
// no-op today since nothing yet classifies a view as Identity.
// Identity fill-assist categories land in a later phase.
is AutofillView.Identity -> false
}
}
Expand All @@ -278,13 +289,18 @@ class AutofillParserImpl(
/**
* Traverse the [AssistStructure] and convert it into a list of [ViewNodeTraversalData]s.
*/
private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
private fun AssistStructure.traverse(
isIdentityAutofillEnabled: Boolean,
): List<ViewNodeTraversalData> =
(0 until windowNodeCount)
.map { getWindowNodeAt(it) }
.mapNotNull { windowNode ->
windowNode
.rootViewNode
?.traverse(parentWebsite = null)
?.traverse(
parentWebsite = null,
isIdentityAutofillEnabled = isIdentityAutofillEnabled,
)
?.updateForMissingPasswordFields()
?.updateForMissingUsernameFields()
}
Expand Down Expand Up @@ -389,8 +405,10 @@ private fun ViewNodeTraversalData.copyAndMapAutofillViews(
* Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the
* data into [ViewNodeTraversalData].
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun AssistStructure.ViewNode.traverse(
parentWebsite: String?,
isIdentityAutofillEnabled: Boolean,
): ViewNodeTraversalData {
// Set up mutable lists for collecting valid AutofillViews and ignorable view ids.
val mutableAutofillViewList: MutableList<AutofillView> = mutableListOf()
Expand All @@ -410,20 +428,43 @@ private fun AssistStructure.ViewNode.traverse(

// Try converting this `ViewNode` into an `AutofillView`. If a valid instance is returned, add
// it to the list. Otherwise, ignore the `AutofillId` associated with this `ViewNode`.
toAutofillView(parentWebsite = parentWebsite)
toAutofillView(
parentWebsite = parentWebsite,
isIdentityAutofillEnabled = isIdentityAutofillEnabled,
)
?.also { view ->
if (view !is AutofillView.Unused) {
claimedAutofillIds.add(view.data.autofillId)
}
mutableAutofillViewList.add(view)

if (isIdentityAutofillEnabled) {
// An email-hinted or email-heuristic field is offered as both a Login candidate
// (above) and an Identity candidate, since the two partitions aren't mutually
// exclusive for this field. Reuses the same (container-redirect-corrected) data as
// the primary view rather than re-deriving it.
if (view is AutofillView.Login.Username && this.isEmailField) {
mutableAutofillViewList.add(AutofillView.Identity.Email(data = view.data))
}

// Some phone hints (e.g. "mobilephone") also match the username heuristic's
// "phone" term and resolve to Login.Username above, so they need the same
// dual-classification as email.
if (view is AutofillView.Login.Username && this.isPhoneField) {
mutableAutofillViewList.add(AutofillView.Identity.PhoneFull(data = view.data))
}
}
}
?: autofillId?.run(mutableIgnoreAutofillIdList::add)

// Recursively traverse all of this view node's children.
for (i in 0 until childCount) {
// Extract the traversal data from each child view node and add it to the lists.
getChildAt(i)
.traverse(parentWebsite = website)
.traverse(
parentWebsite = website,
isIdentityAutofillEnabled = isIdentityAutofillEnabled,
)
.let { viewNodeTraversalData ->
viewNodeTraversalData.autofillViews
// filter out existing AutofillIds to avoid duplicates
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.x8bit.bitwarden.data.autofill.util

import android.app.assist.AssistStructure
import com.x8bit.bitwarden.data.autofill.model.AutofillHint
import com.x8bit.bitwarden.data.autofill.model.AutofillView

/**
* Builds an [AutofillView.Card] for the given card-related [autofillHint].
*/
internal fun AssistStructure.ViewNode.buildCardView(
autofillOptions: List<String>,
autofillViewData: AutofillView.Data,
autofillHint: AutofillHint.Card,
): AutofillView.Card = when (autofillHint) {
AutofillHint.Card.EXPIRATION_MONTH -> {
val monthValue = this
.autofillValue
?.extractMonthValue(
autofillOptions = autofillOptions,
)

AutofillView.Card.ExpirationMonth(
data = autofillViewData,
monthValue = monthValue,
)
}

AutofillHint.Card.EXPIRATION_YEAR -> {
val yearValue = this
.autofillValue
?.extractYearValue(
autofillOptions = autofillOptions,
)

AutofillView.Card.ExpirationYear(
data = autofillViewData,
yearValue = yearValue,
)
}

AutofillHint.Card.EXPIRATION_DATE -> {
AutofillView.Card.ExpirationDate(
data = autofillViewData,
)
}

AutofillHint.Card.NUMBER -> {
AutofillView.Card.Number(
data = autofillViewData,
)
}

AutofillHint.Card.SECURITY_CODE -> {
AutofillView.Card.SecurityCode(
data = autofillViewData,
)
}

AutofillHint.Card.CARDHOLDER -> {
AutofillView.Card.CardholderName(
data = autofillViewData,
)
}

AutofillHint.Card.BRAND -> {
val brandValue = this.autofillValue
?.extractCardBrandValue(
autofillOptions = autofillOptions,
)
AutofillView.Card.Brand(
data = autofillViewData,
brandValue = brandValue,
)
}
}

/**
* Builds an [AutofillView.Login] for the given login-related [autofillHint].
*/
internal fun buildLoginView(
autofillViewData: AutofillView.Data,
autofillHint: AutofillHint.Login,
): AutofillView.Login = when (autofillHint) {
AutofillHint.Login.PASSWORD -> {
AutofillView.Login.Password(
data = autofillViewData,
)
}

AutofillHint.Login.USERNAME -> {
AutofillView.Login.Username(
data = autofillViewData,
)
}
}

/**
* Builds an [AutofillView.Identity] for the given identity-related [autofillHint].
*/
internal fun buildIdentityView(
autofillViewData: AutofillView.Data,
autofillHint: AutofillHint.Identity,
): AutofillView.Identity = when (autofillHint) {
AutofillHint.Identity.PERSON_NAME_FULL -> {
AutofillView.Identity.PersonNameFull(data = autofillViewData)
}

AutofillHint.Identity.PERSON_NAME_PREFIX -> {
AutofillView.Identity.PersonNamePrefix(data = autofillViewData)
}

AutofillHint.Identity.PERSON_NAME_GIVEN -> {
AutofillView.Identity.PersonNameGiven(data = autofillViewData)
}

AutofillHint.Identity.PERSON_NAME_MIDDLE -> {
AutofillView.Identity.PersonNameMiddle(data = autofillViewData)
}

AutofillHint.Identity.PERSON_NAME_FAMILY -> {
AutofillView.Identity.PersonNameFamily(data = autofillViewData)
}

AutofillHint.Identity.POSTAL_ADDRESS_FULL -> {
AutofillView.Identity.PostalAddressFull(data = autofillViewData)
}

AutofillHint.Identity.ADDRESS_STREET -> {
AutofillView.Identity.AddressStreet(data = autofillViewData)
}

AutofillHint.Identity.ADDRESS_LOCALITY -> {
AutofillView.Identity.AddressLocality(data = autofillViewData)
}

AutofillHint.Identity.ADDRESS_REGION -> {
AutofillView.Identity.AddressRegion(data = autofillViewData)
}

AutofillHint.Identity.ADDRESS_COUNTRY -> {
AutofillView.Identity.AddressCountry(data = autofillViewData)
}

AutofillHint.Identity.POSTAL_CODE -> {
AutofillView.Identity.PostalCode(data = autofillViewData)
}

AutofillHint.Identity.PHONE_FULL -> {
AutofillView.Identity.PhoneFull(data = autofillViewData)
}

AutofillHint.Identity.COMPANY -> {
AutofillView.Identity.Company(data = autofillViewData)
}

AutofillHint.Identity.EMAIL -> {
// Produced by AutofillParserImpl's traverse(), not by this dispatch.
AutofillView.Identity.Email(data = autofillViewData)
}

AutofillHint.Identity.SSN -> {
AutofillView.Identity.Ssn(data = autofillViewData)
}

AutofillHint.Identity.PASSPORT_NUMBER -> {
AutofillView.Identity.PassportNumber(data = autofillViewData)
}

AutofillHint.Identity.LICENSE_NUMBER -> {
AutofillView.Identity.LicenseNumber(data = autofillViewData)
}
}
Loading
Loading