-
Notifications
You must be signed in to change notification settings - Fork 1.1k
[PM-41294] feat: Build and fill identity autofill partitions through a multi-partition pipeline #7369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: PM-41293/identity-autofill-fill-assist-field-mapping
Are you sure you want to change the base?
[PM-41294] feat: Build and fill identity autofill partitions through a multi-partition pipeline #7369
Changes from all commits
f15821e
1b55328
d972e7a
ce091c6
7bcecc9
73d3e9e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,45 +59,10 @@ class FilledDataBuilderImpl( | |
| } | ||
| ?.also { inlineSuggestionsAdded += 1 } | ||
|
|
||
| val filledPartitions = when (autofillRequest.partition) { | ||
| is AutofillPartition.Card -> { | ||
| autofillCipherProvider | ||
| .getCardAutofillCiphers() | ||
| .map { autofillCipher -> | ||
| fillCardPartition( | ||
| autofillCipher = autofillCipher, | ||
| autofillViews = autofillRequest.partition.views, | ||
| inlinePresentationSpec = getCipherInlinePresentationOrNull(), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| is AutofillPartition.Login -> { | ||
| autofillRequest | ||
| .uri | ||
| ?.let { nonNullUri -> | ||
| autofillCipherProvider | ||
| .getLoginAutofillCiphers( | ||
| uri = nonNullUri, | ||
| ) | ||
| .map { autofillCipher -> | ||
| fillLoginPartition( | ||
| autofillCipher = autofillCipher, | ||
| autofillViews = autofillRequest.partition.views, | ||
| inlinePresentationSpec = getCipherInlinePresentationOrNull(), | ||
| packageName = autofillRequest.packageName, | ||
| ) | ||
| } | ||
| } | ||
| .orEmpty() | ||
| } | ||
|
|
||
| is AutofillPartition.Identity -> { | ||
| // Filling an identity partition is wired up in a later phase; this is a no-op | ||
| // today since an identity partition is never constructed yet. | ||
| emptyList() | ||
| } | ||
| } | ||
| val filledPartitions = buildFilledPartitions( | ||
| autofillRequest = autofillRequest, | ||
| getCipherInlinePresentationOrNull = ::getCipherInlinePresentationOrNull, | ||
| ) | ||
|
|
||
| // Use getOrLastOrNull so if the list has run dry take the last spec. | ||
| val vaultItemInlinePresentationSpec = autofillRequest | ||
|
|
@@ -116,6 +81,59 @@ class FilledDataBuilderImpl( | |
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Build the [FilledPartition]s for the [autofillRequest]'s partition by fetching the matching | ||
| * ciphers and fulfilling the partition's views with each cipher's data. | ||
| */ | ||
| private suspend fun buildFilledPartitions( | ||
| autofillRequest: AutofillRequest.Fillable, | ||
| getCipherInlinePresentationOrNull: () -> InlinePresentationSpec?, | ||
| ): List<FilledPartition> = when (autofillRequest.partition) { | ||
| is AutofillPartition.Card -> { | ||
| autofillCipherProvider | ||
| .getCardAutofillCiphers() | ||
| .map { autofillCipher -> | ||
| fillCardPartition( | ||
| autofillCipher = autofillCipher, | ||
| autofillViews = autofillRequest.partition.views, | ||
| inlinePresentationSpec = getCipherInlinePresentationOrNull(), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| is AutofillPartition.Login -> { | ||
| autofillRequest | ||
| .uri | ||
| ?.let { nonNullUri -> | ||
| autofillCipherProvider | ||
| .getLoginAutofillCiphers( | ||
| uri = nonNullUri, | ||
| ) | ||
| .map { autofillCipher -> | ||
| fillLoginPartition( | ||
| autofillCipher = autofillCipher, | ||
| autofillViews = autofillRequest.partition.views, | ||
| inlinePresentationSpec = getCipherInlinePresentationOrNull(), | ||
| packageName = autofillRequest.packageName, | ||
| ) | ||
| } | ||
| } | ||
| .orEmpty() | ||
| } | ||
|
|
||
| is AutofillPartition.Identity -> { | ||
| autofillCipherProvider | ||
| .getIdentityAutofillCiphers() | ||
| .map { autofillCipher -> | ||
| fillIdentityPartition( | ||
| autofillCipher = autofillCipher, | ||
| autofillViews = autofillRequest.partition.views, | ||
| inlinePresentationSpec = getCipherInlinePresentationOrNull(), | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Construct a [FilledPartition] by fulfilling the card [autofillViews] with data from the | ||
| * card [autofillCipher]. | ||
|
|
@@ -181,6 +199,33 @@ class FilledDataBuilderImpl( | |
| inlinePresentationSpec = inlinePresentationSpec, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Construct a [FilledPartition] by fulfilling the identity [autofillViews] with data from the | ||
| * identity [autofillCipher]. | ||
| */ | ||
| private fun fillIdentityPartition( | ||
| autofillCipher: AutofillCipher.Identity, | ||
| autofillViews: List<AutofillView.Identity>, | ||
| inlinePresentationSpec: InlinePresentationSpec?, | ||
| ): FilledPartition { | ||
| val filledItems = autofillViews | ||
| .mapNotNull { autofillView -> | ||
| autofillCipher | ||
| .getAutofillValueOrNull(autofillView) | ||
| ?.let { value -> | ||
| autofillView.buildFilledItemOrNull( | ||
| value = value, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| return FilledPartition( | ||
| autofillCipher = autofillCipher, | ||
| filledItems = filledItems, | ||
| inlinePresentationSpec = inlinePresentationSpec, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -225,6 +270,34 @@ private fun AutofillCipher.Card.getAutofillValueOrNull(autofillView: AutofillVie | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get the autofill value for the given [autofillView], or null if no value is available. | ||
| */ | ||
| @Suppress("CyclomaticComplexMethod") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this needed? |
||
| private fun AutofillCipher.Identity.getAutofillValueOrNull( | ||
| autofillView: AutofillView.Identity, | ||
| ): String? = | ||
| when (autofillView) { | ||
| is AutofillView.Identity.PersonNameFull -> fullName | ||
| is AutofillView.Identity.PersonNamePrefix -> title | ||
| is AutofillView.Identity.PersonNameGiven -> firstName | ||
| is AutofillView.Identity.PersonNameMiddle -> middleName | ||
| is AutofillView.Identity.PersonNameFamily -> lastName | ||
| is AutofillView.Identity.PostalAddressFull -> fullAddress | ||
| is AutofillView.Identity.AddressStreet -> address1 | ||
| is AutofillView.Identity.AddressLocality -> city | ||
| is AutofillView.Identity.AddressRegion -> state | ||
| is AutofillView.Identity.AddressCountry -> country | ||
| is AutofillView.Identity.PostalCode -> postalCode | ||
| is AutofillView.Identity.PhoneFull -> phone | ||
| is AutofillView.Identity.Company -> company | ||
| is AutofillView.Identity.Email -> email | ||
| is AutofillView.Identity.Ssn -> ssn | ||
| is AutofillView.Identity.PassportNumber -> passportNumber | ||
| is AutofillView.Identity.LicenseNumber -> licenseNumber | ||
| } | ||
| ?.takeIf { it.isNotEmpty() } | ||
|
|
||
| /** | ||
| * Get the item at the [index]. If that fails, return the last item in the list. If that also fails, | ||
| * return null. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,8 +62,6 @@ private val URL_BARS: Map<String, String> = mapOf( | |
| */ | ||
| private val LOGIN_FILL_ASSIST_CATEGORIES: List<String> = listOf( | ||
| "account-login", | ||
| "account-creation", | ||
| "account-update", | ||
| ) | ||
|
|
||
| /** | ||
|
|
@@ -73,6 +71,17 @@ private val CARD_FILL_ASSIST_CATEGORIES: List<String> = listOf( | |
| "payment-card", | ||
| ) | ||
|
|
||
| /** | ||
| * A list of categories from Fill Assist that are used for [AutofillView.Identity]. | ||
| * | ||
| * Account creation and account update flows are where identity fields (name, address, phone, | ||
| * etc.) are curated alongside credential fields. | ||
| */ | ||
| private val IDENTITY_FILL_ASSIST_CATEGORIES: List<String> = listOf( | ||
| "account-creation", | ||
| "account-update", | ||
| ) | ||
|
|
||
| /** | ||
| * The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data | ||
| * from the OS into domain models. | ||
|
|
@@ -132,15 +141,10 @@ class AutofillParserImpl( | |
| .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. 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. | ||
| // falling back to all fillable views when nothing has focus. | ||
| val autofillViews = traversalDataList | ||
| .selectCandidateAutofillViews(urlBarWebsite = urlBarWebsite) { | ||
| it !is AutofillView.Unused && it !is AutofillView.Identity | ||
| it !is AutofillView.Unused | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π |
||
| } | ||
|
|
||
| val isFillAssistEnabled = featureFlagManager | ||
|
|
@@ -183,9 +187,7 @@ class AutofillParserImpl( | |
| autofillViews | ||
| } | ||
|
|
||
| val effectiveFocusedView = effectiveViews | ||
| .filterNot { it is AutofillView.Identity } | ||
| .firstFocusedOrNull() | ||
| val effectiveFocusedView = effectiveViews.effectiveFocusedViewOrNull() | ||
| ?: return AutofillRequest.Unfillable | ||
|
|
||
| // Choose the first focused partition of data for fulfillment. | ||
|
|
@@ -203,8 +205,12 @@ class AutofillParserImpl( | |
| } | ||
|
|
||
| is AutofillView.Identity -> { | ||
| // Identity partition construction lands in Phase D. Unfillable until then. | ||
| return AutofillRequest.Unfillable | ||
| // Gated behind FlagKey.IdentityAutofill until the feature is ready for | ||
| // production; disabled matches this partition's pre-feature behavior. | ||
| if (!isIdentityAutofillEnabled) return AutofillRequest.Unfillable | ||
| AutofillPartition.Identity( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Details and fix
Execution path once the flag is on and an Identity partition is built:
When the vault is locked, Suggested fix: add an
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @claude this will be addressed in a future branch. Until the feature is fully ready feature flag won't be active
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude finished @aj-rosado's task in 1m 4s ββ View job ResponseUnderstood β thanks for the context. Deferring the two flag-on findings to a follow-up branch makes sense given
One item the flag doesn't coverThe private val SUPPORTED_HTML_ATTRIBUTE_HINTS: List<String> = listOf(
"name",
"label",
"type",
"hint",
"autofill",
)
It may still be the right call β if some
If it is intentional and scoped as-is, feel free to resolve β just wanted to make sure it wasn't swept in under the flag-off assumption alongside the other two. |
||
| views = effectiveViews.filterIsInstance<AutofillView.Identity>(), | ||
| ) | ||
| } | ||
|
|
||
| is AutofillView.Unused -> { | ||
|
|
@@ -262,16 +268,26 @@ class AutofillParserImpl( | |
| } | ||
| ?: return this | ||
|
|
||
| // Identity categories were Login categories before identity autofill, so with the flag | ||
| // off they must stay Login's to keep fill-assist coverage unchanged on those hosts. | ||
| val loginCategories = if (isIdentityAutofillEnabled) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe rename this to |
||
| LOGIN_FILL_ASSIST_CATEGORIES | ||
| } else { | ||
| LOGIN_FILL_ASSIST_CATEGORIES + IDENTITY_FILL_ASSIST_CATEGORIES | ||
| } | ||
|
Comment on lines
+273
to
+277
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Details and fixWith Those categories carry Login field keys: Suggested fix: keep the identity categories additive for the Login branch in both flag states ( Note the new tests cover the flag-off case for this shape (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @claude this will be addressed in a future branch. Until the feature is fully ready feature flag won't be active
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude finished @aj-rosado's task in 1m 19s ββ View job Understood β deferring the vault-item dataset routingThanks @aj-rosado, that disposition makes sense for that finding. The same reasoning covers my second comment (fill-assist categories on Still open: the
|
||
|
|
||
| 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.Login -> rule.category in loginCategories | ||
| is AutofillView.Identity -> rule.category in IDENTITY_FILL_ASSIST_CATEGORIES | ||
| is AutofillView.Unused -> { | ||
| rule.category in LOGIN_FILL_ASSIST_CATEGORIES || | ||
| rule.category in CARD_FILL_ASSIST_CATEGORIES | ||
| val identityCoversRule = isIdentityAutofillEnabled && | ||
| rule.category in IDENTITY_FILL_ASSIST_CATEGORIES | ||
| rule.category in loginCategories || | ||
| rule.category in CARD_FILL_ASSIST_CATEGORIES || | ||
| identityCoversRule | ||
| } | ||
| // Identity fill-assist categories land in a later phase. | ||
| is AutofillView.Identity -> false | ||
| } | ||
| } | ||
| if (!coversCurrentPartition) return this | ||
|
|
@@ -337,6 +353,18 @@ private fun List<ViewNodeTraversalData>.selectCandidateAutofillViews( | |
| private fun List<AutofillView>.firstFocusedOrNull(): AutofillView? = | ||
| firstOrNull { it.data.isFocused } ?: firstOrNull() | ||
|
|
||
| /** | ||
| * Returns the [AutofillView] that should win partition selection: a focused Login/Card view | ||
| * first, then any focused view (so a real Identity partition can still be built when Identity is | ||
| * the only classification available), then any Login/Card view, then any view at all. | ||
| */ | ||
| private fun List<AutofillView>.effectiveFocusedViewOrNull(): AutofillView? { | ||
| val focusedNonIdentity = firstOrNull { it.data.isFocused && it !is AutofillView.Identity } | ||
| val focusedAny = firstOrNull { it.data.isFocused } | ||
| val nonIdentity = firstOrNull { it !is AutofillView.Identity } | ||
| return focusedNonIdentity ?: focusedAny ?: nonIdentity ?: firstOrNull() | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
|
|
@@ -452,9 +480,8 @@ private fun AssistStructure.ViewNode.traverse( | |
| 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. | ||
| // A phone-hinted or phone-heuristic field resolves to Login.Username above (see | ||
| // supportedAutofillHint), so it needs the same dual-classification as email. | ||
| if (view is AutofillView.Login.Username && this.isPhoneField) { | ||
| mutableAutofillViewList.add(AutofillView.Identity.PhoneFull(data = view.data)) | ||
| } | ||
|
|
@@ -471,17 +498,29 @@ private fun AssistStructure.ViewNode.traverse( | |
| isIdentityAutofillEnabled = isIdentityAutofillEnabled, | ||
| ) | ||
| .let { viewNodeTraversalData -> | ||
| // Ids already claimed by an ancestor's own view (e.g. a container-redirect | ||
| // target) before this child's results are considered. A primary always precedes | ||
| // its Identity dual-classification sibling in this same child's results, so the | ||
| // sibling's id is still fresh here and passes the check below. | ||
| val idsClaimedByAncestor = claimedAutofillIds.toSet() | ||
| // Flatten child views into this node, keeping the first view seen for each autofill | ||
| // id and dropping later duplicates (e.g. a container-redirect leftover). | ||
| viewNodeTraversalData.autofillViews | ||
| // filter out existing AutofillIds to avoid duplicates | ||
| .filter { view -> | ||
| val id = view.data.autofillId | ||
| if (id in claimedAutofillIds) { | ||
| false | ||
| } else if (view !is AutofillView.Unused) { | ||
| claimedAutofillIds.add(id) | ||
| true | ||
| } else { | ||
| true | ||
| when (view) { | ||
| // Never claims an id, so a real view for that id can still be kept. | ||
| is AutofillView.Unused -> id !in claimedAutofillIds | ||
| // Kept if the id is fresh, i.e. it's a dual-classification sibling of | ||
| // a primary view from this same child. Dropped if the id was already | ||
| // claimed by an ancestor's own view -- that means this is a stale | ||
| // container-redirect leftover, not an intentional sibling. | ||
| is AutofillView.Identity -> { | ||
| (id !in idsClaimedByAncestor) | ||
| .also { keep -> if (keep) claimedAutofillIds.add(id) } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we just do this: if (id !in idsClaimedByAncestor) {
claimedAutofillIds.add(id)
}That may be more straight forward to read. |
||
| } | ||
| // Kept only the first time its id is seen (add returns false if known). | ||
| else -> claimedAutofillIds.add(id) | ||
| } | ||
| } | ||
| .forEach(mutableAutofillViewList::add) | ||
|
|
@@ -554,7 +593,6 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie | |
| is AutofillView.Identity.PostalAddressFull -> { | ||
| this.copy(data = this.data.copy(website = site)) | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is supposed to be here, right? |
||
| is AutofillView.Identity.PostalCode -> this.copy(data = this.data.copy(website = site)) | ||
| is AutofillView.Identity.Ssn -> this.copy(data = this.data.copy(website = site)) | ||
| is AutofillView.Unused -> this.copy(data = this.data.copy(website = site)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -181,7 +181,7 @@ private fun AssistStructure.ViewNode.supportedAutofillHint( | |
| this.isAddressRegionField -> AutofillHint.Identity.ADDRESS_REGION | ||
| this.isAddressCountryField -> AutofillHint.Identity.ADDRESS_COUNTRY | ||
| this.isPostalCodeField -> AutofillHint.Identity.POSTAL_CODE | ||
| this.isPhoneField -> AutofillHint.Identity.PHONE_FULL | ||
| this.isPhoneField -> AutofillHint.Login.USERNAME | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this mean that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It will autofill phone numbers, just not display the identity when pressing on a phone number |
||
| this.isCompanyField -> AutofillHint.Identity.COMPANY | ||
| this.isSsnField -> AutofillHint.Identity.SSN | ||
| this.isPassportNumberField -> AutofillHint.Identity.PASSPORT_NUMBER | ||
|
|
@@ -239,7 +239,7 @@ private fun String.toBitwardenAutofillHintOrNull(): AutofillHint? = | |
| } | ||
|
|
||
| View.AUTOFILL_HINT_POSTAL_CODE -> AutofillHint.Identity.POSTAL_CODE | ||
| View.AUTOFILL_HINT_PHONE -> AutofillHint.Identity.PHONE_FULL | ||
| View.AUTOFILL_HINT_PHONE -> AutofillHint.Login.USERNAME | ||
|
|
||
| else -> null | ||
| } | ||
|
|
@@ -551,9 +551,10 @@ internal val AssistStructure.ViewNode.isPostalCodeField: Boolean | |
| */ | ||
| @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) | ||
| internal val AssistStructure.ViewNode.isPhoneField: Boolean | ||
| get() = idEntry | ||
| ?.toLowerCaseAndStripNonAlpha() | ||
| ?.containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) == true || | ||
| get() = autofillHints?.contains(View.AUTOFILL_HINT_PHONE) == true || | ||
| idEntry | ||
| ?.toLowerCaseAndStripNonAlpha() | ||
| ?.containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) == true || | ||
| hint | ||
| ?.toLowerCaseAndStripNonAlpha() | ||
| ?.containsAnyTerms(SUPPORTED_RAW_PHONE_HINTS) == true || | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π