[Android] Register Brave as Android AutofillService for address autofill (#35567)

* [Android] Register Brave as Android AutofillService for address autofill

Re-enables the Android AutofillService that was removed in
https://github.com/brave/brave-core/pull/25759 due to crashes on
split-APK installs. Uses the SplitCompat pattern
(SplitCompatAutofillService base class) so the service works correctly
when installed from the Play Store.

When the user selects Brave as their autofill provider in Android Settings,
Brave can fill saved address profiles into third-party apps and save new
addresses from manually filled forms.

Resolves: https://github.com/brave/brave-browser/issues/41334
This commit is contained in:
Serg
2026-04-16 14:23:41 -04:00
committed by GitHub
parent 48803a3f6b
commit 90fadf6259
14 changed files with 2089 additions and 0 deletions
+2
View File
@@ -540,6 +540,7 @@ android_resources("java_resources") {
"java/brave-res/layout/brave_ads_existing_user_dialog_layout.xml",
"java/brave-res/layout/brave_ads_new_user_dialog_layout.xml",
"java/brave-res/layout/brave_ask_play_store_rating_dialog.xml",
"java/brave-res/layout/brave_autofill_service_item.xml",
"java/brave-res/layout/brave_bookmark_toolbar.xml",
"java/brave-res/layout/brave_bottom_new_tab_button.xml",
"java/brave-res/layout/brave_custom_tabs_toolbar.xml",
@@ -720,6 +721,7 @@ android_resources("java_resources") {
"java/brave-res/values/brave_styles.xml",
"java/brave-res/values/dimens.xml",
"java/brave-res/values/shimmer_attrs.xml",
"java/brave-res/xml/autofill_service_configuration.xml",
"java/brave-res/xml/background_images_preferences.xml",
"java/brave-res/xml/brave_appearance_preferences.xml",
"java/brave-res/xml/brave_download_preferences.xml",
+4
View File
@@ -10,6 +10,7 @@ import(
"//brave/android/java/org/chromium/chrome/browser/fullscreen/brave_fullscreen_java_sources.gni")
import("//brave/android/java/org/chromium/chrome/browser/playlist/sources.gni")
import("//brave/base/brave_base_shared_preferences_java.gni")
import("//brave/browser/autofill/android/java_sources.gni")
import("//brave/browser/bookmarks/android/sources.gni")
import("//brave/browser/brave_ads/android/java_sources.gni")
import("//brave/browser/hub/internal/android/java/java_sources.gni")
@@ -506,6 +507,7 @@ brave_java_sources = [
]
brave_java_sources += brave_ads_java_sources
brave_java_sources += brave_browser_autofill_service_java_sources
brave_java_sources += brave_components_embedder_support_java_sources
brave_java_sources += brave_components_signin_java_sources
brave_java_sources += brave_feed_java_sources
@@ -543,6 +545,8 @@ brave_java_base_sources = [
]
brave_java_base_module_sources = [
"//brave/android/java/org/chromium/chrome/browser/autofill/BraveAutofillService.java",
"//brave/android/java/org/chromium/chrome/browser/base/SplitCompatAutofillService.java",
"//brave/android/java/org/chromium/chrome/browser/base/SplitCompatJobIntentService.java",
"//brave/android/java/org/chromium/chrome/browser/media/ui/BraveMediaNotificationControllerServices.java",
"//brave/android/java/org/chromium/chrome/browser/playlist/hls_content/HlsService.java",
+13
View File
@@ -180,6 +180,19 @@
android:permission="android.permission.BIND_REMOTEVIEWS"
android:exported="false" />
<service
android:name="org.chromium.chrome.browser.autofill.BraveAutofillService"
android:label="@string/app_name"
android:permission="android.permission.BIND_AUTOFILL_SERVICE"
android:exported="true">
<meta-data
android:name="android.autofill"
android:resource="@xml/autofill_service_configuration" />
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
</service>
<service
android:name="org.chromium.chrome.browser.vpn.wireguard.WireguardService"
android:foregroundServiceType="dataSync"
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2026 The Brave Authors. All rights reserved.
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:layout_marginEnd="8dp"
android:src="@mipmap/app_icon" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
style="@style/LargeRegular"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:singleLine="true"
android:ellipsize="end" />
<TextView
android:id="@+id/subtitle"
style="@style/SmallRegular"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:singleLine="true"
android:ellipsize="end"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) 2026 The Brave Authors. All rights reserved.
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/. -->
<!-- Configuration metadata for BraveAutofillService, referenced from AndroidManifest.xml via
android:resource="@xml/autofill_service_configuration".
- settingsActivity: The activity Android launches when the user taps "Open" next to Brave
in Settings > Passwords & Autofill > Autofill service.
- compatibility-package: Only needed for browsers that manage autofill internally with their
own rendering engine. Regular apps (using standard WebView, EditText, etc.) get autofill
automatically from the user's selected service without any entry here.
maxLongVersionCode="10000000000" means "all versions". -->
<autofill-service
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:settingsActivity="org.chromium.chrome.browser.settings.BraveSettingsActivity"
tools:ignore="UnusedResources"> <!-- Referenced from AndroidManifest.xml -->
<compatibility-package android:name="com.brave.browser" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.brave.browser_beta" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.brave.browser_nightly" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.android.chrome" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.chrome.beta" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.chrome.dev" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.chrome.canary" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.opera.browser" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.microsoft.emmx" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="org.mozilla.firefox" android:maxLongVersionCode="10000000000"/>
<compatibility-package android:name="com.duckduckgo.mobile.android" android:maxLongVersionCode="10000000000"/>
</autofill-service>
@@ -0,0 +1,22 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.autofill;
import org.chromium.build.annotations.IdentifierNameString;
import org.chromium.build.annotations.NullMarked;
import org.chromium.chrome.browser.base.SplitCompatAutofillService;
/** See {@link BraveAutofillServiceImpl}. */
@NullMarked
public class BraveAutofillService extends SplitCompatAutofillService {
@SuppressWarnings("FieldCanBeFinal") // @IdentifierNameString requires non-final
private static @IdentifierNameString String sImplClassName =
"org.chromium.chrome.browser.autofill.BraveAutofillServiceImpl";
public BraveAutofillService() {
super(sImplClassName);
}
}
@@ -0,0 +1,90 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.base;
import android.content.Context;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.FillCallback;
import android.service.autofill.FillRequest;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveRequest;
import org.chromium.build.annotations.Initializer;
import org.chromium.build.annotations.NullMarked;
/**
* AutofillService base class which will call through to the given {@link Impl}. This class must be
* present in the base module, while the Impl can be in the chrome module.
*/
@NullMarked
public class SplitCompatAutofillService extends AutofillService {
private final String mServiceClassName;
private Impl mImpl;
public SplitCompatAutofillService(String serviceClassName) {
mServiceClassName = serviceClassName;
}
@Override
protected void attachBaseContext(Context baseContext) {
mImpl =
(Impl)
SplitCompatUtils.loadClassAndAdjustContextChrome(
baseContext, mServiceClassName);
mImpl.setService(this);
super.attachBaseContext(baseContext);
}
@Override
public void onFillRequest(
FillRequest request, CancellationSignal cancellationSignal, FillCallback callback) {
mImpl.onFillRequest(request, cancellationSignal, callback);
}
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
mImpl.onSaveRequest(request, callback);
}
@Override
public void onConnected() {
super.onConnected();
mImpl.onConnected();
}
@Override
public void onDisconnected() {
super.onDisconnected();
mImpl.onDisconnected();
}
/**
* Holds the implementation of autofill service logic. Will be called by {@link
* SplitCompatAutofillService}.
*/
public abstract static class Impl {
private SplitCompatAutofillService mService;
@Initializer
protected final void setService(SplitCompatAutofillService service) {
mService = service;
}
protected final AutofillService getService() {
return mService;
}
public abstract void onFillRequest(
FillRequest request, CancellationSignal cancellationSignal, FillCallback callback);
public abstract void onSaveRequest(SaveRequest request, SaveCallback callback);
public void onConnected() {}
public void onDisconnected() {}
}
}
+10
View File
@@ -140,6 +140,16 @@ if (is_android) {
deps = [ "//chrome/android/junit:chrome_junit_tests_helper" ]
}
robolectric_library(
"brave_junit_tests_org.chromium.chrome.browser.autofill") {
sources = [
"src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImplUnitTest.java",
"src/org/chromium/chrome/browser/autofill/BraveAutofillViewStructureParserUnitTest.java",
]
deps = [ "//chrome/android/junit:chrome_junit_tests_helper" ]
}
robolectric_library(
"brave_junit_tests_org.chromium.chrome.browser.custom_search_engines") {
sources = [ "src/org/chromium/brave/browser/custom_search_engines/CustomSearchEnginesManagerTest.java" ]
@@ -0,0 +1,218 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.autofill;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.annotation.Config;
import org.chromium.base.test.BaseRobolectricTestRunner;
/** Unit tests for pure utility methods in {@link BraveAutofillServiceImpl}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class BraveAutofillServiceImplUnitTest {
// --- splitName ---
@Test
@SmallTest
public void splitName_fullNameOnly_splitsIntoFirstAndLast() {
assertArrayEquals(
new String[] {"Jane", "Doe"},
BraveAutofillServiceImpl.splitName("Jane Doe", null, null));
}
@Test
@SmallTest
public void splitName_singleWordFullName_firstNameOnly() {
assertArrayEquals(
new String[] {"Madonna", ""},
BraveAutofillServiceImpl.splitName("Madonna", null, null));
}
@Test
@SmallTest
public void splitName_multiWordLastName_preservesRemainder() {
assertArrayEquals(
new String[] {"Anna", "Maria Van Der Berg"},
BraveAutofillServiceImpl.splitName("Anna Maria Van Der Berg", null, null));
}
@Test
@SmallTest
public void splitName_existingPartsNotOverridden() {
assertArrayEquals(
new String[] {"Jonathan", "Smithson"},
BraveAutofillServiceImpl.splitName("John Smith", "Jonathan", "Smithson"));
}
@Test
@SmallTest
public void splitName_allNull_returnsEmptyStrings() {
assertArrayEquals(
new String[] {"", ""}, BraveAutofillServiceImpl.splitName(null, null, null));
}
@Test
@SmallTest
public void splitName_emptyFullName_returnsEmptyStrings() {
assertArrayEquals(
new String[] {"", ""}, BraveAutofillServiceImpl.splitName("", null, null));
}
@Test
@SmallTest
public void splitName_fullNameWithExtraWhitespace_trims() {
assertArrayEquals(
new String[] {"Jane", "Doe"},
BraveAutofillServiceImpl.splitName(" Jane Doe ", null, null));
}
@Test
@SmallTest
public void splitName_firstNameEmpty_lastNameProvided_derivesFirstOnly() {
assertArrayEquals(
new String[] {"Jane", "Smith"},
BraveAutofillServiceImpl.splitName("Jane Doe", "", "Smith"));
}
// --- countryCodeToDisplayName ---
@Test
@SmallTest
public void countryCodeToDisplayName_us_returnsUnitedStates() {
assertEquals("United States", BraveAutofillServiceImpl.countryCodeToDisplayName("US"));
}
@Test
@SmallTest
public void countryCodeToDisplayName_gb_returnsUnitedKingdom() {
assertEquals("United Kingdom", BraveAutofillServiceImpl.countryCodeToDisplayName("GB"));
}
@Test
@SmallTest
public void countryCodeToDisplayName_de_returnsGermany() {
assertEquals("Germany", BraveAutofillServiceImpl.countryCodeToDisplayName("DE"));
}
@Test
@SmallTest
public void countryCodeToDisplayName_null_returnsEmptyString() {
assertEquals("", BraveAutofillServiceImpl.countryCodeToDisplayName(null));
}
@Test
@SmallTest
public void countryCodeToDisplayName_emptyString_returnsEmpty() {
assertEquals("", BraveAutofillServiceImpl.countryCodeToDisplayName(""));
}
@Test
@SmallTest
public void countryCodeToDisplayName_threeLetterCode_returnsUnchanged() {
assertEquals("USA", BraveAutofillServiceImpl.countryCodeToDisplayName("USA"));
}
@Test
@SmallTest
public void countryCodeToDisplayName_lowercaseCode_returnsDisplayName() {
// java.util.Locale handles lowercase country codes.
assertEquals("United States", BraveAutofillServiceImpl.countryCodeToDisplayName("us"));
}
// --- matchListOption ---
@Test
@SmallTest
public void matchListOption_exactMatch_returnsIndex() {
CharSequence[] options = {"Alabama", "California", "Texas"};
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "California"));
}
@Test
@SmallTest
public void matchListOption_exactMatchCaseInsensitive_returnsIndex() {
CharSequence[] options = {"Alabama", "California", "Texas"};
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "california"));
}
@Test
@SmallTest
public void matchListOption_optionStartsWithValue_returnsIndex() {
// Pass 2: value "CA" matches option "California".
CharSequence[] options = {"Alabama", "California", "Texas"};
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "CA"));
}
@Test
@SmallTest
public void matchListOption_valueStartsWithOption_returnsIndex() {
// Pass 3: value "California" matches option "CA".
CharSequence[] options = {"AL", "CA", "TX"};
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "California"));
}
@Test
@SmallTest
public void matchListOption_noMatch_returnsNegativeOne() {
CharSequence[] options = {"Alabama", "California", "Texas"};
assertEquals(-1, BraveAutofillServiceImpl.matchListOption(options, "New York"));
}
@Test
@SmallTest
public void matchListOption_nullOptions_returnsNegativeOne() {
assertEquals(-1, BraveAutofillServiceImpl.matchListOption(null, "California"));
}
@Test
@SmallTest
public void matchListOption_emptyOptions_returnsNegativeOne() {
assertEquals(
-1, BraveAutofillServiceImpl.matchListOption(new CharSequence[0], "California"));
}
@Test
@SmallTest
public void matchListOption_singleCharValue_skipsPass2() {
// Single character value should not match via prefix (pass 2 requires length >= 2).
CharSequence[] options = {"Cat", "Car", "Cap"};
assertEquals(-1, BraveAutofillServiceImpl.matchListOption(options, "C"));
}
@Test
@SmallTest
public void matchListOption_nullOptionElement_skipped() {
// Null elements in the options array should be safely skipped without NPE.
CharSequence[] options = {null, "California", "Texas"};
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "California"));
}
@Test
@SmallTest
public void matchListOption_exactMatchPreferredOverPrefix() {
// "CA" should match index 0 (exact) not index 1 (prefix).
CharSequence[] options = {"CA", "California"};
assertEquals(0, BraveAutofillServiceImpl.matchListOption(options, "CA"));
}
@Test
@SmallTest
public void matchListOption_singleCharOption_skipsPass3() {
// Pass 3 requires option length >= 2, so "C" is skipped.
CharSequence[] options = {"C", "CALIF"};
// "California" does not exact-match either, does not prefix-match either,
// but starts with "CALIF" (pass 3).
assertEquals(1, BraveAutofillServiceImpl.matchListOption(options, "California"));
}
}
@@ -0,0 +1,461 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.autofill;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.view.autofill.AutofillId;
import androidx.test.filters.SmallTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.annotation.Config;
import org.chromium.base.test.BaseRobolectricTestRunner;
import java.util.HashMap;
import java.util.Map;
/** Unit tests for pure methods in {@link BraveAutofillViewStructureParser}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class BraveAutofillViewStructureParserUnitTest {
private static Map<String, AutofillId> mapWithKeys(String... keys) {
Map<String, AutofillId> map = new HashMap<>();
for (int i = 0; i < keys.length; i++) {
map.put(keys[i], new AutofillId(i + 1));
}
return map;
}
// --- hasAddressFields ---
@Test
@SmallTest
public void hasAddressFields_withPostalAddress_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_POSTAL_ADDRESS)));
}
@Test
@SmallTest
public void hasAddressFields_withName_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_NAME)));
}
@Test
@SmallTest
public void hasAddressFields_withFirstName_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(BraveAutofillViewStructureParser.HINT_FIRST_NAME)));
}
@Test
@SmallTest
public void hasAddressFields_withEmail_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_EMAIL_ADDRESS)));
}
@Test
@SmallTest
public void hasAddressFields_withPhone_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_PHONE)));
}
@Test
@SmallTest
public void hasAddressFields_withPostalCode_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_POSTAL_CODE)));
}
@Test
@SmallTest
public void hasAddressFields_withCity_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(BraveAutofillViewStructureParser.HINT_CITY)));
}
@Test
@SmallTest
public void hasAddressFields_withState_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(BraveAutofillViewStructureParser.HINT_STATE)));
}
@Test
@SmallTest
public void hasAddressFields_withCountry_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(BraveAutofillViewStructureParser.HINT_COUNTRY)));
}
@Test
@SmallTest
public void hasAddressFields_withCompany_returnsTrue() {
assertTrue(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(BraveAutofillViewStructureParser.HINT_COMPANY)));
}
@Test
@SmallTest
public void hasAddressFields_emptyMap_returnsFalse() {
assertFalse(BraveAutofillViewStructureParser.hasAddressFields(new HashMap<>()));
}
@Test
@SmallTest
public void hasAddressFields_withPasswordOnly_returnsFalse() {
assertFalse(
BraveAutofillViewStructureParser.hasAddressFields(
mapWithKeys(View.AUTOFILL_HINT_PASSWORD)));
}
// --- mapAutofillHint: Android standard hints ---
@Test
@SmallTest
public void mapAutofillHint_androidNameHint_passesThrough() {
assertEquals(
View.AUTOFILL_HINT_NAME,
BraveAutofillViewStructureParser.mapAutofillHint(View.AUTOFILL_HINT_NAME));
}
@Test
@SmallTest
public void mapAutofillHint_androidEmailHint_passesThrough() {
assertEquals(
View.AUTOFILL_HINT_EMAIL_ADDRESS,
BraveAutofillViewStructureParser.mapAutofillHint(View.AUTOFILL_HINT_EMAIL_ADDRESS));
}
@Test
@SmallTest
public void mapAutofillHint_androidPhoneHint_passesThrough() {
assertEquals(
View.AUTOFILL_HINT_PHONE,
BraveAutofillViewStructureParser.mapAutofillHint(View.AUTOFILL_HINT_PHONE));
}
@Test
@SmallTest
public void mapAutofillHint_androidPasswordHint_passesThrough() {
assertEquals(
View.AUTOFILL_HINT_PASSWORD,
BraveAutofillViewStructureParser.mapAutofillHint(View.AUTOFILL_HINT_PASSWORD));
}
// --- mapAutofillHint: W3C tokens ---
@Test
@SmallTest
public void mapAutofillHint_w3cGivenName_returnsFirstName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_FIRST_NAME,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_GIVEN_NAME));
}
@Test
@SmallTest
public void mapAutofillHint_w3cFamilyName_returnsLastName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_LAST_NAME,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_FAMILY_NAME));
}
@Test
@SmallTest
public void mapAutofillHint_w3cAdditionalName_returnsMiddleName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_MIDDLE_NAME,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ADDITIONAL_NAME));
}
@Test
@SmallTest
public void mapAutofillHint_w3cStreetAddress_returnsPostalAddress() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_ADDRESS,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_STREET_ADDRESS));
}
@Test
@SmallTest
public void mapAutofillHint_w3cAddressLine1_returnsPostalAddress() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_ADDRESS,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ADDRESS_LINE1));
}
@Test
@SmallTest
public void mapAutofillHint_w3cAddressLine2_returnsAddressLine2() {
assertEquals(
BraveAutofillViewStructureParser.HINT_ADDRESS_LINE2,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ADDRESS_LINE2));
}
@Test
@SmallTest
public void mapAutofillHint_w3cPostalCode_returnsPostalCode() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_CODE,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_POSTAL_CODE));
}
@Test
@SmallTest
public void mapAutofillHint_w3cOrganization_returnsCompany() {
assertEquals(
BraveAutofillViewStructureParser.HINT_COMPANY,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ORGANIZATION));
}
@Test
@SmallTest
public void mapAutofillHint_w3cTel_returnsPhone() {
assertEquals(
View.AUTOFILL_HINT_PHONE,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_TEL));
}
@Test
@SmallTest
public void mapAutofillHint_w3cTelNational_returnsPhone() {
assertEquals(
View.AUTOFILL_HINT_PHONE,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_TEL_NATIONAL));
}
@Test
@SmallTest
public void mapAutofillHint_w3cAddressLevel1_returnsState() {
assertEquals(
BraveAutofillViewStructureParser.HINT_STATE,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ADDRESS_LEVEL1));
}
@Test
@SmallTest
public void mapAutofillHint_w3cAddressLevel2_returnsCity() {
assertEquals(
BraveAutofillViewStructureParser.HINT_CITY,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_ADDRESS_LEVEL2));
}
@Test
@SmallTest
public void mapAutofillHint_w3cCountry_returnsCountry() {
assertEquals(
BraveAutofillViewStructureParser.HINT_COUNTRY,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_COUNTRY));
}
@Test
@SmallTest
public void mapAutofillHint_w3cCountryName_returnsCountry() {
assertEquals(
BraveAutofillViewStructureParser.HINT_COUNTRY,
BraveAutofillViewStructureParser.mapAutofillHint(
BraveAutofillViewStructureParser.W3C_COUNTRY_NAME));
}
@Test
@SmallTest
public void mapAutofillHint_unknownHint_returnsNull() {
assertNull(BraveAutofillViewStructureParser.mapAutofillHint("some-unknown-hint"));
}
// --- mapComputedAutofillHint ---
@Test
@SmallTest
public void mapComputedAutofillHint_nameFull_returnsName() {
assertEquals(
View.AUTOFILL_HINT_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_FULL));
}
@Test
@SmallTest
public void mapComputedAutofillHint_nameFirst_returnsFirstName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_FIRST_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_FIRST));
}
@Test
@SmallTest
public void mapComputedAutofillHint_nameMiddle_returnsMiddleName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_MIDDLE_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_MIDDLE));
}
@Test
@SmallTest
public void mapComputedAutofillHint_nameMiddleInitial_returnsMiddleName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_MIDDLE_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_MIDDLE_INITIAL));
}
@Test
@SmallTest
public void mapComputedAutofillHint_nameLast_returnsLastName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_LAST_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_LAST));
}
@Test
@SmallTest
public void mapComputedAutofillHint_nameLastFirst_returnsLastName() {
assertEquals(
BraveAutofillViewStructureParser.HINT_LAST_NAME,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_NAME_LAST_FIRST));
}
@Test
@SmallTest
public void mapComputedAutofillHint_emailAddress_returnsEmail() {
assertEquals(
View.AUTOFILL_HINT_EMAIL_ADDRESS,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_EMAIL_ADDRESS));
}
@Test
@SmallTest
public void mapComputedAutofillHint_phoneHomeWholeNumber_returnsPhone() {
assertEquals(
View.AUTOFILL_HINT_PHONE,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_PHONE_HOME_WHOLE_NUMBER));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeLine1_returnsPostalAddress() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_ADDRESS,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_LINE1));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeStreetAddress_returnsPostalAddress() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_ADDRESS,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_STREET_ADDRESS));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeLine2_returnsAddressLine2() {
assertEquals(
BraveAutofillViewStructureParser.HINT_ADDRESS_LINE2,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_LINE2));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeCity_returnsCity() {
assertEquals(
BraveAutofillViewStructureParser.HINT_CITY,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_CITY));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeState_returnsState() {
assertEquals(
BraveAutofillViewStructureParser.HINT_STATE,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_STATE));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeZip_returnsPostalCode() {
assertEquals(
View.AUTOFILL_HINT_POSTAL_CODE,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_ZIP));
}
@Test
@SmallTest
public void mapComputedAutofillHint_addressHomeCountry_returnsCountry() {
assertEquals(
BraveAutofillViewStructureParser.HINT_COUNTRY,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_ADDRESS_HOME_COUNTRY));
}
@Test
@SmallTest
public void mapComputedAutofillHint_companyName_returnsCompany() {
assertEquals(
BraveAutofillViewStructureParser.HINT_COMPANY,
BraveAutofillViewStructureParser.mapComputedAutofillHint(
BraveAutofillViewStructureParser.COMPUTED_COMPANY_NAME));
}
@Test
@SmallTest
public void mapComputedAutofillHint_null_returnsNull() {
assertNull(BraveAutofillViewStructureParser.mapComputedAutofillHint(null));
}
@Test
@SmallTest
public void mapComputedAutofillHint_unknownValue_returnsNull() {
assertNull(BraveAutofillViewStructureParser.mapComputedAutofillHint("UNKNOWN_FIELD_TYPE"));
}
}
@@ -0,0 +1,612 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.autofill;
import android.app.assist.AssistStructure;
import android.app.assist.AssistStructure.ViewNode;
import android.os.CancellationSignal;
import android.service.autofill.Dataset;
import android.service.autofill.FillCallback;
import android.service.autofill.FillContext;
import android.service.autofill.FillRequest;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveInfo;
import android.service.autofill.SaveRequest;
import android.text.TextUtils;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import org.chromium.chrome.browser.base.SplitCompatAutofillService;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.components.autofill.AutofillProfile;
import org.chromium.components.autofill.FieldType;
import org.chromium.components.autofill.RecordType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Implementation of the Brave autofill service for filling address data in third-party apps.
*
* <p>This class lives in the chrome module (split_chrome.apk) and is loaded at runtime by {@link
* SplitCompatAutofillService} from the base module. It extends {@code
* SplitCompatAutofillService.Impl} which delegates the Android {@link
* android.service.autofill.AutofillService} callbacks here.
*
* <p>When the user selects Brave as their autofill provider in Android Settings, the system calls
* {@link #onFillRequest} whenever a fillable field is focused in any third-party app (Chrome,
* Firefox, native apps, etc.). This class then:
*
* <ol>
* <li>Initializes the Brave browser engine (needed to access saved profiles).
* <li>Parses the app's view hierarchy ({@link AssistStructure}) to detect address fields using
* {@link BraveAutofillViewStructureParser}.
* <li>Queries Brave's saved address profiles via {@link PersonalDataManager}.
* <li>Builds a {@link FillResponse} with {@link Dataset} entries (one per saved profile) that the
* system shows as a dropdown popup in the requesting app.
* <li>Registers {@link SaveInfo} so the system shows a "Save to Brave?" prompt when the user
* manually fills a form and submits it.
* </ol>
*
* <p>The service skips its own package (Brave handles its own autofill internally).
*/
@NullMarked
public class BraveAutofillServiceImpl extends SplitCompatAutofillService.Impl {
private static final String TAG = "BraveAutofillSvc";
/**
* Called by the Android framework when a fillable field is focused in a third-party app.
*
* <p>Flow: init browser → parse view structure → detect address fields → query saved profiles →
* build fill response with datasets and save info → return to framework.
*/
@Override
public void onFillRequest(
FillRequest request, CancellationSignal cancellationSignal, FillCallback callback) {
if (!initBrowser()) {
callback.onSuccess(null);
return;
}
List<FillContext> fillContexts = request.getFillContexts();
if (fillContexts.isEmpty()) {
callback.onSuccess(null);
return;
}
// Get the most recent AssistStructure (the last one is the current screen).
AssistStructure structure = fillContexts.get(fillContexts.size() - 1).getStructure();
if (structure == null || structure.getWindowNodeCount() == 0) {
callback.onSuccess(null);
return;
}
// Skip our own app — Brave handles its own autofill internally.
String packageName = structure.getActivityComponent().getPackageName();
if (ContextUtils.getApplicationContext().getPackageName().equalsIgnoreCase(packageName)) {
callback.onSuccess(null);
return;
}
// Parse the view hierarchy to find autofillable fields and keep a reference to each
// ViewNode (needed later to determine field types like text vs dropdown).
Map<AutofillId, ViewNode> nodesMap = new HashMap<>();
Map<String, AutofillId> fields =
BraveAutofillViewStructureParser.findAutofillableFields(structure, nodesMap);
if (fields.isEmpty()) {
callback.onSuccess(null);
return;
}
if (BraveAutofillViewStructureParser.hasAddressFields(fields)) {
fillAddressAutofill(fields, nodesMap, callback);
} else {
callback.onSuccess(null);
}
}
/**
* Called by the Android framework after the user fills a form manually and the system shows a
* "Save to Brave?" prompt (triggered by the {@link SaveInfo} we set in {@link
* #fillAddressAutofill}). Only called if the user taps "Save".
*
* <p>Extracts the filled values from the view structure and saves them as a new address profile
* in Brave's {@link PersonalDataManager}.
*/
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
if (!initBrowser()) {
callback.onSuccess();
return;
}
List<FillContext> fillContexts = request.getFillContexts();
if (fillContexts.isEmpty()) {
callback.onSuccess();
return;
}
AssistStructure structure = fillContexts.get(fillContexts.size() - 1).getStructure();
if (structure == null || structure.getWindowNodeCount() == 0) {
callback.onSuccess();
return;
}
Map<AutofillId, ViewNode> nodesMap = new HashMap<>();
Map<String, AutofillId> fields =
BraveAutofillViewStructureParser.findAutofillableFields(structure, nodesMap);
if (fields.isEmpty()) {
callback.onSuccess();
return;
}
if (BraveAutofillViewStructureParser.hasAddressFields(fields)) {
saveAddressAutofill(fields, nodesMap);
}
callback.onSuccess();
}
/**
* Initializes the Brave browser engine. The browser must be running to access saved profiles
* via {@link PersonalDataManager}. Returns false if initialization fails.
*/
private boolean initBrowser() {
try {
ChromeBrowserInitializer.getInstance().handleSynchronousStartup();
} catch (Exception e) {
Log.e(TAG, "Failed to initialize browser", e);
return false;
}
return true;
}
/**
* Builds a {@link FillResponse} containing one {@link Dataset} per saved address profile.
*
* <p>For each profile, maps detected form fields to profile data (name, email, phone, address,
* etc.). Each field in the dataset gets an appropriate {@link AutofillValue}: {@code forText()}
* for text inputs or {@code forList(index)} for dropdowns/selects.
*
* <p>The response also includes {@link SaveInfo} so the system prompts "Save to Brave?" when
* the user fills a form manually — even if we had no profiles to suggest.
*
* @param fields Map of hint key → AutofillId from the view structure parser.
* @param nodesMap Map of AutofillId → ViewNode for determining field types.
* @param callback The framework callback to deliver the response.
*/
private void fillAddressAutofill(
Map<String, AutofillId> fields,
Map<AutofillId, ViewNode> nodesMap,
FillCallback callback) {
PersonalDataManager personalDataManager =
PersonalDataManagerFactory.getForProfile(
// This is a top-level entry point (AutofillService) with no
// Profile passed in from the framework.
ProfileManager.getLastUsedRegularProfile());
ArrayList<AutofillProfile> profileList = personalDataManager.getProfilesToSuggest();
FillResponse.Builder fillResponse = new FillResponse.Builder();
String appPackageName = ContextUtils.getApplicationContext().getPackageName();
if (profileList == null) {
profileList = new ArrayList<>();
}
// Build one Dataset per saved profile. Each dataset fills all matching fields at once
// when the user taps the suggestion.
for (AutofillProfile profile : profileList) {
// Map detected field hints to profile data values. Only fields that exist in
// both the form and the profile (non-empty) get included.
Map<AutofillId, String> autofillDataMap = new HashMap<>();
String fullName = profile.getInfo(FieldType.NAME_FULL);
String[] nameParts =
splitName(
fullName,
profile.getInfo(FieldType.NAME_FIRST),
profile.getInfo(FieldType.NAME_LAST));
String firstName = nameParts[0];
String lastName = nameParts[1];
mapField(fields, View.AUTOFILL_HINT_NAME, fullName, autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_FIRST_NAME,
firstName,
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_MIDDLE_NAME,
profile.getInfo(FieldType.NAME_MIDDLE),
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_LAST_NAME,
lastName,
autofillDataMap);
mapField(
fields,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
profile.getInfo(FieldType.EMAIL_ADDRESS),
autofillDataMap);
// Strip whitespace from phone numbers — profiles may store them as
// "+1 234 567 8901" but phone input fields typically expect no spaces.
String phone = profile.getInfo(FieldType.PHONE_HOME_WHOLE_NUMBER);
if (phone != null) {
phone = phone.replaceAll("\\s", "");
}
mapField(fields, View.AUTOFILL_HINT_PHONE, phone, autofillDataMap);
mapField(
fields,
View.AUTOFILL_HINT_POSTAL_ADDRESS,
profile.getInfo(FieldType.ADDRESS_HOME_STREET_ADDRESS),
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_ADDRESS_LINE2,
profile.getInfo(FieldType.ADDRESS_HOME_LINE2),
autofillDataMap);
mapField(
fields,
View.AUTOFILL_HINT_POSTAL_CODE,
profile.getInfo(FieldType.ADDRESS_HOME_ZIP),
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_CITY,
profile.getInfo(FieldType.ADDRESS_HOME_CITY),
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_STATE,
profile.getInfo(FieldType.ADDRESS_HOME_STATE),
autofillDataMap);
mapField(
fields,
BraveAutofillViewStructureParser.HINT_COMPANY,
profile.getInfo(FieldType.COMPANY_NAME),
autofillDataMap);
String countryValue =
countryCodeToDisplayName(profile.getInfo(FieldType.ADDRESS_HOME_COUNTRY));
mapField(
fields,
BraveAutofillViewStructureParser.HINT_COUNTRY,
countryValue,
autofillDataMap);
if (!autofillDataMap.isEmpty()) {
Dataset.Builder dataset = new Dataset.Builder();
for (Map.Entry<AutofillId, String> entry : autofillDataMap.entrySet()) {
AutofillId fieldId = entry.getKey();
String value = entry.getValue();
ViewNode node = nodesMap.get(fieldId);
// Create the right AutofillValue type: forText() for text inputs,
// forList(index) for <select> dropdowns.
AutofillValue autofillValue = createAutofillValue(node, value);
if (autofillValue == null) {
continue;
}
// Build the popup presentation. Title is the field value, subtitle is
// the address (or name if the field value is already the address).
String title = value;
String subtitleOrNull = profile.getInfo(FieldType.ADDRESS_HOME_STREET_ADDRESS);
String subtitle = subtitleOrNull != null ? subtitleOrNull : "";
if (title.equals(subtitle)) {
String nameOrNull = profile.getInfo(FieldType.NAME_FULL);
subtitle = nameOrNull != null ? nameOrNull : "";
}
if (title.isEmpty()) {
title = subtitle;
subtitle = "";
}
RemoteViews presentation =
new RemoteViews(appPackageName, R.layout.brave_autofill_service_item);
presentation.setTextViewText(R.id.title, title);
if (subtitle.isEmpty()) {
presentation.setViewVisibility(R.id.subtitle, View.GONE);
} else {
presentation.setViewVisibility(R.id.subtitle, View.VISIBLE);
presentation.setTextViewText(R.id.subtitle, subtitle);
}
dataset.setValue(fieldId, autofillValue, presentation);
}
fillResponse.addDataset(dataset.build());
}
}
try {
// Set up SaveInfo so Android shows "Save to Brave?" after form submission.
// We require just one key field (name, email, etc.) to be filled — the rest are
// optional. This way the save prompt appears even if the user skips some fields.
AutofillId requiredId = pickRequiredSaveField(fields);
SaveInfo.Builder saveInfoBuilder =
new SaveInfo.Builder(
SaveInfo.SAVE_DATA_TYPE_ADDRESS, new AutofillId[] {requiredId});
List<AutofillId> optionalIds = new ArrayList<>();
for (AutofillId id : fields.values()) {
if (!id.equals(requiredId)) {
optionalIds.add(id);
}
}
if (!optionalIds.isEmpty()) {
saveInfoBuilder.setOptionalIds(optionalIds.toArray(new AutofillId[0]));
}
fillResponse.setSaveInfo(saveInfoBuilder.build());
FillResponse response = fillResponse.build();
callback.onSuccess(response);
} catch (Exception e) {
Log.e(TAG, "Error sending FillResponse", e);
callback.onSuccess(null);
}
}
/**
* Extracts user-entered values from the filled form and saves them as a new address profile.
* Called from {@link #onSaveRequest} after the user confirms the system's "Save to Brave?"
* prompt.
*
* <p>If the form has a single full-name field, stores it as {@code NAME_FULL}. If the form has
* separate first/middle/last fields, stores them individually so they round-trip correctly when
* filling forms with either layout.
*/
private void saveAddressAutofill(
Map<String, AutofillId> fields, Map<AutofillId, ViewNode> nodesMap) {
String name = getFieldValue(fields, nodesMap, View.AUTOFILL_HINT_NAME);
String firstName =
getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_FIRST_NAME);
String middleName =
getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_MIDDLE_NAME);
String lastName =
getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_LAST_NAME);
String email = getFieldValue(fields, nodesMap, View.AUTOFILL_HINT_EMAIL_ADDRESS);
String phone = getFieldValue(fields, nodesMap, View.AUTOFILL_HINT_PHONE);
String address = getFieldValue(fields, nodesMap, View.AUTOFILL_HINT_POSTAL_ADDRESS);
String addressLine2 =
getFieldValue(
fields, nodesMap, BraveAutofillViewStructureParser.HINT_ADDRESS_LINE2);
String postalCode = getFieldValue(fields, nodesMap, View.AUTOFILL_HINT_POSTAL_CODE);
String city = getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_CITY);
String state = getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_STATE);
String company =
getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_COMPANY);
String country =
getFieldValue(fields, nodesMap, BraveAutofillViewStructureParser.HINT_COUNTRY);
PersonalDataManager personalDataManager =
PersonalDataManagerFactory.getForProfile(
// This is a top-level entry point (AutofillService) with no
// Profile passed in from the framework.
ProfileManager.getLastUsedRegularProfile());
AutofillProfile profile = AutofillProfile.builder().build();
profile.setRecordType(RecordType.LOCAL_OR_SYNCABLE);
if (!name.isEmpty()) {
profile.setInfo(FieldType.NAME_FULL, name);
} else {
profile.setInfo(FieldType.NAME_FIRST, firstName);
profile.setInfo(FieldType.NAME_MIDDLE, middleName);
profile.setInfo(FieldType.NAME_LAST, lastName);
}
profile.setInfo(FieldType.EMAIL_ADDRESS, email);
profile.setInfo(FieldType.PHONE_HOME_WHOLE_NUMBER, phone);
profile.setInfo(FieldType.ADDRESS_HOME_STREET_ADDRESS, address);
profile.setInfo(FieldType.ADDRESS_HOME_LINE2, addressLine2);
profile.setInfo(FieldType.ADDRESS_HOME_ZIP, postalCode);
profile.setInfo(FieldType.ADDRESS_HOME_CITY, city);
profile.setInfo(FieldType.ADDRESS_HOME_STATE, state);
profile.setInfo(FieldType.COMPANY_NAME, company);
profile.setInfo(FieldType.ADDRESS_HOME_COUNTRY, country);
personalDataManager.setProfileToLocal(profile);
}
/**
* Picks the most likely-to-be-filled field as the single required {@link SaveInfo} field.
* Android only shows the "Save?" prompt if ALL required fields are filled, so we require just
* one common field and make everything else optional.
*/
private static AutofillId pickRequiredSaveField(Map<String, AutofillId> fields) {
String[] preferred = {
View.AUTOFILL_HINT_NAME,
BraveAutofillViewStructureParser.HINT_FIRST_NAME,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
View.AUTOFILL_HINT_POSTAL_ADDRESS,
View.AUTOFILL_HINT_PHONE,
};
for (String key : preferred) {
if (fields.containsKey(key)) {
return fields.get(key);
}
}
return fields.values().iterator().next();
}
/**
* Adds a field to the autofill data map if the form contains that field and the profile has a
* non-empty value for it. Fields missing from either the form or the profile are silently
* skipped — the popup only shows fields we can actually fill.
*/
private static void mapField(
Map<String, AutofillId> fields,
String key,
@Nullable String data,
Map<AutofillId, String> autofillDataMap) {
if (data != null && !data.isEmpty() && fields.containsKey(key)) {
AutofillId id = fields.get(key);
autofillDataMap.put(id, data);
}
}
/**
* Creates an {@link AutofillValue} appropriate for the given node's type.
*
* <p>Text inputs ({@code AUTOFILL_TYPE_TEXT}) get {@code AutofillValue.forText()}.
* Dropdown/select fields ({@code AUTOFILL_TYPE_LIST}) get {@code AutofillValue.forList(index)}
* where the index is found by matching the value against the dropdown options. Using the wrong
* type causes an {@code IllegalStateException} at fill time.
*/
private static @Nullable AutofillValue createAutofillValue(
@Nullable ViewNode node, String value) {
if (node == null) return AutofillValue.forText(value);
int type = node.getAutofillType();
if (type == View.AUTOFILL_TYPE_LIST) {
return createListValue(node, value);
}
if (type == View.AUTOFILL_TYPE_TEXT || type == View.AUTOFILL_TYPE_NONE) {
return AutofillValue.forText(value);
}
return null;
}
private static @Nullable AutofillValue createListValue(ViewNode node, String value) {
CharSequence @Nullable [] options = node.getAutofillOptions();
int index = matchListOption(options, value);
return index >= 0 ? AutofillValue.forList(index) : null;
}
/**
* Derives first and last name from the full name when individual parts are missing.
*
* <p>Profiles may store only {@code NAME_FULL} (e.g. "Jane Doe") without populating {@code
* NAME_FIRST} / {@code NAME_LAST}. Forms with separate given-name/family-name fields need the
* individual parts, so this method splits the full name on whitespace: the first token becomes
* the first name and everything after becomes the last name. Existing non-empty parts are
* preserved.
*
* @return A two-element array: {@code [firstName, lastName]}, never null, elements never null.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
static String[] splitName(
@Nullable String fullName, @Nullable String firstName, @Nullable String lastName) {
if (!TextUtils.isEmpty(fullName)) {
String[] parts = fullName.trim().split("\\s+", 2);
if (TextUtils.isEmpty(firstName)) {
firstName = parts[0];
}
if (TextUtils.isEmpty(lastName) && parts.length > 1) {
lastName = parts[1];
}
}
return new String[] {firstName != null ? firstName : "", lastName != null ? lastName : ""};
}
/**
* Converts a two-letter ISO country code (e.g. "US") to its display name (e.g. "United
* States"). Returns the input unchanged if it is not a two-letter code or if {@link
* java.util.Locale} cannot resolve it. Returns empty string for null input.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
static String countryCodeToDisplayName(@Nullable String countryCode) {
if (countryCode == null) return "";
if (countryCode.length() == 2) {
String displayName = new java.util.Locale("", countryCode).getDisplayCountry();
if (!displayName.equals(countryCode)) {
return displayName;
}
}
return countryCode;
}
/**
* Finds the best matching option index for a value in a list of dropdown options.
*
* <p>Uses a three-pass strategy to handle the variety of option formats across forms:
*
* <ol>
* <li>Exact match (case-insensitive): "California" matches "California"
* <li>Option starts with value: profile stores "CA", dropdown has "California"
* <li>Value starts with option: profile stores "California", dropdown has "CA"
* </ol>
*
* <p>The minimum length of 2 for prefix matching avoids false positives where short strings
* like state codes accidentally match unrelated options.
*
* @return The index of the best matching option, or -1 if no match is found.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
static int matchListOption(CharSequence @Nullable [] options, String value) {
if (options == null) return -1;
java.util.Locale locale = java.util.Locale.getDefault();
String lowerValue = value.toLowerCase(locale);
// Pass 1: exact match.
for (int i = 0; i < options.length; i++) {
if (options[i] != null
&& options[i].toString().toLowerCase(locale).equals(lowerValue)) {
return i;
}
}
// Pass 2: option starts with value (e.g. value "CA" matches option "California").
if (lowerValue.length() >= 2) {
for (int i = 0; i < options.length; i++) {
if (options[i] != null
&& options[i].toString().toLowerCase(locale).startsWith(lowerValue)) {
return i;
}
}
}
// Pass 3: value starts with option (e.g. value "California" matches option "CA").
for (int i = 0; i < options.length; i++) {
if (options[i] != null) {
String lowerOption = options[i].toString().toLowerCase(locale);
if (lowerOption.length() >= 2 && lowerValue.startsWith(lowerOption)) {
return i;
}
}
}
return -1;
}
/**
* Reads the user-entered value from a filled form field. Used by {@link #saveAddressAutofill}
* to extract what the user typed before saving it as a new profile.
*
* <p>Handles both text inputs ({@code AUTOFILL_TYPE_TEXT}) and dropdown/select fields ({@code
* AUTOFILL_TYPE_LIST}). For dropdowns, reads the selected index and looks up the corresponding
* option text from the node's autofill options.
*/
private static String getFieldValue(
Map<String, AutofillId> fields, Map<AutofillId, ViewNode> nodesMap, String key) {
if (!fields.containsKey(key)) return "";
AutofillId autofillId = fields.get(key);
ViewNode node = nodesMap.get(autofillId);
if (node == null) return "";
AutofillValue autofillValue = node.getAutofillValue();
if (autofillValue == null) return "";
int type = node.getAutofillType();
if (type == View.AUTOFILL_TYPE_TEXT) {
return autofillValue.getTextValue().toString();
}
if (type == View.AUTOFILL_TYPE_LIST) {
int index = autofillValue.getListValue();
CharSequence[] options = node.getAutofillOptions();
if (options != null && index >= 0 && index < options.length) {
return options[index].toString();
}
}
return "";
}
}
@@ -0,0 +1,564 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
package org.chromium.chrome.browser.autofill;
import android.app.assist.AssistStructure;
import android.app.assist.AssistStructure.ViewNode;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.inputmethod.EditorInfo;
import androidx.annotation.VisibleForTesting;
import androidx.collection.ArrayMap;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import java.util.Locale;
import java.util.Map;
/**
* Traverses an {@link AssistStructure} to find autofillable fields in third-party apps.
*
* <p>When Android calls our autofill service, it provides an {@link AssistStructure} — a snapshot
* of the requesting app's view hierarchy. This class walks that tree to find input fields and
* classify them by type (name, email, phone, address, etc.).
*
* <p>Field detection uses three layers, tried in order (first match wins):
*
* <ol>
* <li><b>Explicit autofill hints</b> ({@link ViewNode#getAutofillHints}): Standard Android
* constants like {@code "postalAddress"} or W3C autocomplete tokens like {@code
* "street-address"} set by the app or HTML {@code autocomplete} attribute. Mapped via {@link
* #mapAutofillHint}.
* <li><b>Heuristic text matching</b> ({@link ViewNode#getHint}, {@link ViewNode#getIdEntry},
* {@link ViewNode#getText}): Keyword matching on the field's hint text, resource ID, or
* content. E.g. a field with hint "Enter your email" matches "email". Handled by {@link
* #inferHint}. Works for native Android apps that don't set autofill hints.
* <li><b>HTML attributes</b> ({@link ViewNode#getHtmlInfo}): For WebView nodes, Chromium exposes
* HTML metadata including {@code computed-autofill-hints} (Chromium's own field
* classification, e.g. {@code "NAME_FULL"}, {@code "ADDRESS_HOME_CITY"}) and standard HTML
* {@code name}/{@code label} attributes. This layer handles web forms that don't have {@code
* autocomplete} attributes — Chromium still classifies them internally. Handled by {@link
* #getHintFromHtmlInfo} and {@link #mapComputedAutofillHint}.
* </ol>
*
* <p>The output is a {@code Map<String, AutofillId>} where keys are canonical hint constants
* (either standard {@code View.AUTOFILL_HINT_*} or our custom {@code HINT_*} constants) and values
* are the corresponding field IDs. {@link BraveAutofillServiceImpl} uses these keys to match fields
* with profile data.
*/
@NullMarked
public class BraveAutofillViewStructureParser {
// Internal map keys for field types that lack standard Android View.AUTOFILL_HINT_* constants.
// Android only provides 7 standard hints (name, username, password, emailAddress, phone,
// postalAddress, postalCode). For everything else (first name, city, state, etc.) we define
// our own. The values are arbitrary strings — they never leave this service, they just need
// to be consistent between this parser and BraveAutofillServiceImpl.
public static final String HINT_FIRST_NAME = "givenName";
public static final String HINT_MIDDLE_NAME = "additionalName";
public static final String HINT_LAST_NAME = "familyName";
public static final String HINT_ADDRESS_LINE2 = "addressLine2";
public static final String HINT_COMPANY = "company";
public static final String HINT_CITY = "city";
public static final String HINT_STATE = "state";
public static final String HINT_COUNTRY = "country";
// HTML attribute keys exposed by Chromium on WebView nodes via ViewNode.getHtmlInfo().
private static final String HTML_ATTR_COMPUTED_AUTOFILL_HINTS = "computed-autofill-hints";
private static final String HTML_ATTR_NAME = "name";
private static final String HTML_ATTR_LABEL = "label";
// Android class name used to identify EditText nodes for Layer 2 text content matching.
private static final String EDIT_TEXT_CLASS = "EditText";
// W3C autocomplete tokens (HTML Living Standard §4.10.18.7).
// Used by Layer 1 to map HTML autocomplete attribute values to canonical hints.
static final String W3C_GIVEN_NAME = "given-name";
static final String W3C_FAMILY_NAME = "family-name";
static final String W3C_ADDITIONAL_NAME = "additional-name";
static final String W3C_HONORIFIC_PREFIX = "honorific-prefix";
static final String W3C_HONORIFIC_SUFFIX = "honorific-suffix";
static final String W3C_CC_NAME = "cc-name";
static final String W3C_EMAIL = "email";
static final String W3C_TEL = "tel";
static final String W3C_TEL_NATIONAL = "tel-national";
static final String W3C_TEL_LOCAL = "tel-local";
static final String W3C_TEL_COUNTRY_CODE = "tel-country-code";
static final String W3C_TEL_AREA_CODE = "tel-area-code";
static final String W3C_TEL_EXTENSION = "tel-extension";
static final String W3C_STREET_ADDRESS = "street-address";
static final String W3C_ADDRESS_LINE1 = "address-line1";
static final String W3C_ADDRESS_LINE2 = "address-line2";
static final String W3C_ADDRESS_LINE3 = "address-line3";
static final String W3C_POSTAL_CODE = "postal-code";
static final String W3C_ORGANIZATION = "organization";
static final String W3C_ADDRESS_LEVEL1 = "address-level1";
static final String W3C_ADDRESS_LEVEL2 = "address-level2";
static final String W3C_ADDRESS_LEVEL3 = "address-level3";
static final String W3C_COUNTRY = "country";
static final String W3C_COUNTRY_NAME = "country-name";
// Chromium computed-autofill-hints string values. These are the string representations of
// FieldType enum values, produced by FieldTypeToStringView() in
// components/autofill/core/browser/field_types.cc using the kTypeNameToFieldType mapping.
// Used by Layer 3 to map Chromium's internal field classification to canonical hints.
static final String COMPUTED_NAME_FULL = "NAME_FULL";
static final String COMPUTED_NAME_FIRST = "NAME_FIRST";
static final String COMPUTED_NAME_MIDDLE = "NAME_MIDDLE";
static final String COMPUTED_NAME_MIDDLE_INITIAL = "NAME_MIDDLE_INITIAL";
static final String COMPUTED_NAME_LAST = "NAME_LAST";
static final String COMPUTED_NAME_LAST_FIRST = "NAME_LAST_FIRST";
static final String COMPUTED_NAME_LAST_SECOND = "NAME_LAST_SECOND";
static final String COMPUTED_EMAIL_ADDRESS = "EMAIL_ADDRESS";
static final String COMPUTED_PHONE_HOME_WHOLE_NUMBER = "PHONE_HOME_WHOLE_NUMBER";
static final String COMPUTED_PHONE_HOME_NUMBER = "PHONE_HOME_NUMBER";
static final String COMPUTED_PHONE_HOME_CITY_AND_NUMBER = "PHONE_HOME_CITY_AND_NUMBER";
static final String COMPUTED_ADDRESS_HOME_LINE1 = "ADDRESS_HOME_LINE1";
static final String COMPUTED_ADDRESS_HOME_STREET_ADDRESS = "ADDRESS_HOME_STREET_ADDRESS";
static final String COMPUTED_ADDRESS_HOME_STREET_NAME = "ADDRESS_HOME_STREET_NAME";
static final String COMPUTED_ADDRESS_HOME_ADDRESS = "ADDRESS_HOME_ADDRESS";
static final String COMPUTED_ADDRESS_HOME_LINE2 = "ADDRESS_HOME_LINE2";
static final String COMPUTED_ADDRESS_HOME_LINE3 = "ADDRESS_HOME_LINE3";
static final String COMPUTED_ADDRESS_HOME_SUBPREMISE = "ADDRESS_HOME_SUBPREMISE";
static final String COMPUTED_ADDRESS_HOME_APT = "ADDRESS_HOME_APT";
static final String COMPUTED_ADDRESS_HOME_APT_NUM = "ADDRESS_HOME_APT_NUM";
static final String COMPUTED_ADDRESS_HOME_CITY = "ADDRESS_HOME_CITY";
static final String COMPUTED_ADDRESS_HOME_DEPENDENT_LOCALITY =
"ADDRESS_HOME_DEPENDENT_LOCALITY";
static final String COMPUTED_ADDRESS_HOME_STATE = "ADDRESS_HOME_STATE";
static final String COMPUTED_ADDRESS_HOME_ZIP = "ADDRESS_HOME_ZIP";
static final String COMPUTED_ADDRESS_HOME_SORTING_CODE = "ADDRESS_HOME_SORTING_CODE";
static final String COMPUTED_ADDRESS_HOME_COUNTRY = "ADDRESS_HOME_COUNTRY";
static final String COMPUTED_COMPANY_NAME = "COMPANY_NAME";
// Heuristic keywords for Layer 2 inferHint() matching.
private static final String KEYWORD_LABEL = "label";
private static final String KEYWORD_CONTAINER = "container";
private static final String KEYWORD_PASSWORD = "password";
private static final String KEYWORD_USERNAME = "username";
private static final String KEYWORD_LOGIN = "login";
private static final String KEYWORD_ID = "id";
private static final String KEYWORD_EMAIL = "email";
private static final String KEYWORD_NAME = "name";
private static final String KEYWORD_FIRST = "first";
private static final String KEYWORD_LAST = "last";
private static final String KEYWORD_FULL = "full";
private static final String KEYWORD_PHONE = "phone";
private static final String KEYWORD_MOBILE = "mobile";
private static final String KEYWORD_ADDRESS = "address";
private static final String KEYWORD_APARTMENT = "apartment";
private static final String KEYWORD_PINCODE = "pincode";
private static final String KEYWORD_POSTALCODE = "postalcode";
private static final String KEYWORD_ZIP = "zip";
private static final String KEYWORD_COMPANY = "company";
private static final String KEYWORD_ORGANIZATION = "organization";
private static final String KEYWORD_CITY = "city";
private static final String KEYWORD_TOWN = "town";
private static final String KEYWORD_STATE = "state";
private static final String KEYWORD_REGION = "region";
private static final String KEYWORD_PREFECTURE = "prefecture";
private static final String KEYWORD_COUNTRY = "country";
/**
* Finds all autofillable fields in the given {@link AssistStructure}.
*
* @param structure The assist structure to traverse.
* @return A map of canonical hint key to {@link AutofillId}.
*/
public static Map<String, AutofillId> findAutofillableFields(AssistStructure structure) {
Map<String, AutofillId> fields = new ArrayMap<>();
int nodes = structure.getWindowNodeCount();
for (int i = 0; i < nodes; i++) {
ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
addAutofillableFields(fields, node);
}
return fields;
}
/**
* Finds all autofillable fields and also collects the corresponding {@link ViewNode}
* references. The nodes are needed by {@link BraveAutofillServiceImpl} to determine field types
* (text vs dropdown) when creating {@link android.view.autofill.AutofillValue}s and to read
* user-entered values on save.
*
* @param structure The assist structure to traverse.
* @param nodesMap Output map of AutofillId to ViewNode.
* @return A map of canonical hint key to {@link AutofillId}.
*/
public static Map<String, AutofillId> findAutofillableFields(
AssistStructure structure, Map<AutofillId, ViewNode> nodesMap) {
Map<String, AutofillId> fields = new ArrayMap<>();
int nodes = structure.getWindowNodeCount();
for (int i = 0; i < nodes; i++) {
ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
addAutofillableFieldsWithNodes(fields, node, nodesMap);
}
return fields;
}
/**
* Returns true if the detected fields contain at least one address-related hint. Used to decide
* whether to offer address autofill (vs skipping login-only forms, etc.).
*/
public static boolean hasAddressFields(Map<String, AutofillId> fields) {
return fields.containsKey(View.AUTOFILL_HINT_POSTAL_ADDRESS)
|| fields.containsKey(View.AUTOFILL_HINT_POSTAL_CODE)
|| fields.containsKey(View.AUTOFILL_HINT_NAME)
|| fields.containsKey(HINT_FIRST_NAME)
|| fields.containsKey(HINT_LAST_NAME)
|| fields.containsKey(View.AUTOFILL_HINT_EMAIL_ADDRESS)
|| fields.containsKey(View.AUTOFILL_HINT_PHONE)
|| fields.containsKey(HINT_CITY)
|| fields.containsKey(HINT_STATE)
|| fields.containsKey(HINT_COUNTRY)
|| fields.containsKey(HINT_COMPANY);
}
/** Recursively walks the view tree, classifying each node and collecting autofillable ones. */
private static void addAutofillableFields(Map<String, AutofillId> fields, ViewNode node) {
String hint = getHint(node, fields);
if (hint != null) {
AutofillId id = node.getAutofillId();
if (!fields.containsKey(hint)) {
fields.put(hint, id);
}
}
int childrenSize = node.getChildCount();
for (int i = 0; i < childrenSize; i++) {
addAutofillableFields(fields, node.getChildAt(i));
}
}
/** Same as {@link #addAutofillableFields} but also records ViewNode references. */
private static void addAutofillableFieldsWithNodes(
Map<String, AutofillId> fields, ViewNode node, Map<AutofillId, ViewNode> nodesMap) {
String hint = getHint(node, fields);
if (hint != null) {
AutofillId id = node.getAutofillId();
if (!fields.containsKey(hint)) {
fields.put(hint, id);
nodesMap.put(id, node);
}
}
int childrenSize = node.getChildCount();
for (int i = 0; i < childrenSize; i++) {
addAutofillableFieldsWithNodes(fields, node.getChildAt(i), nodesMap);
}
}
/**
* Classifies a single view node using the three-layer detection strategy.
*
* <p>The {@code existingFields} map is checked so that if layer 1 returns a hint that's already
* taken by another field, we fall through to deeper layers. This handles cases where a form
* incorrectly uses the same {@code autocomplete} attribute on two fields (e.g. both street
* address and city have {@code autocomplete="address-line1"}) but Chromium's internal
* classification (layer 3) correctly distinguishes them.
*
* @param existingFields Fields already detected, used to skip duplicate hints.
* @return A canonical hint key, or null if the node is not a recognized autofill field.
*/
private static @Nullable String getHint(ViewNode node, Map<String, AutofillId> existingFields) {
// Layer 1: Explicit autofill hints from the app or HTML autocomplete attribute.
String[] hints = node.getAutofillHints();
if (hints != null && hints.length > 0 && hints[0] != null && !hints[0].isEmpty()) {
String mapped = mapAutofillHint(hints[0]);
if (mapped != null && !existingFields.containsKey(mapped)) return mapped;
// Fall through if hint is unrecognized or already taken by another field —
// deeper layers (especially computed-autofill-hints) may classify better.
}
// Layer 2: Heuristic text matching on view hint, resource ID, and EditText content.
// Skip hints already taken — e.g. id="address-line1" matches "address" but that
// slot may already be filled by the actual street address field.
String viewHint = node.getHint();
String hint = inferHint(node, viewHint);
if (hint != null && !existingFields.containsKey(hint)) return hint;
String resourceId = node.getIdEntry();
hint = inferHint(node, resourceId);
if (hint != null && !existingFields.containsKey(hint)) return hint;
CharSequence text = node.getText();
CharSequence className = node.getClassName();
if (text != null && className != null && className.toString().contains(EDIT_TEXT_CLASS)) {
hint = inferHint(node, text.toString());
if (hint != null && !existingFields.containsKey(hint)) return hint;
}
// Layer 3: HTML attributes for WebView nodes (Chromium's computed hints, name, label).
hint = getHintFromHtmlInfo(node, existingFields);
if (hint != null && !existingFields.containsKey(hint)) return hint;
return null;
}
/**
* Layer 3 implementation: reads HTML metadata attributes that Chromium attaches to WebView
* nodes. Tries in order, skipping hints already taken by other fields:
*
* <ol>
* <li>{@code computed-autofill-hints}: Chromium's internal field classification.
* <li>{@code name}: the HTML {@code name} attribute, run through heuristics.
* <li>{@code label}: the associated {@code <label>} text, run through heuristics.
* </ol>
*
* <p>The {@code existingFields} check is important here: if a form has wrong {@code
* autocomplete} attributes (e.g. city field with {@code autocomplete="address-line1"}),
* Chromium's computed hint may also reflect the wrong attribute. But the HTML {@code <label>}
* text (e.g. "City") still identifies the field correctly.
*/
private static @Nullable String getHintFromHtmlInfo(
ViewNode node, Map<String, AutofillId> existingFields) {
if (node.getHtmlInfo() == null || node.getHtmlInfo().getAttributes() == null) {
return null;
}
String computedHints = null;
String nameAttr = null;
String labelAttr = null;
for (android.util.Pair<String, String> attr : node.getHtmlInfo().getAttributes()) {
if (HTML_ATTR_COMPUTED_AUTOFILL_HINTS.equals(attr.first)) {
computedHints = attr.second;
} else if (HTML_ATTR_NAME.equals(attr.first)) {
nameAttr = attr.second;
} else if (HTML_ATTR_LABEL.equals(attr.first)) {
labelAttr = attr.second;
}
}
// Each sub-layer falls through if its result is already taken.
String hint = mapComputedAutofillHint(computedHints);
if (hint != null && !existingFields.containsKey(hint)) return hint;
hint = inferHint(node, nameAttr);
if (hint != null && !existingFields.containsKey(hint)) return hint;
hint = inferHint(node, labelAttr);
if (hint != null && !existingFields.containsKey(hint)) return hint;
return null;
}
/**
* Maps Chromium's internal field type names (from {@code computed-autofill-hints}) to our
* canonical hint constants. These are string representations of Chromium's {@code FieldType}
* enum, produced by {@code FieldTypeToStringView()} in
* components/autofill/core/browser/field_types.cc. They are the most reliable signal for web
* forms since Chromium uses ML and heuristics to classify fields.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
static @Nullable String mapComputedAutofillHint(@Nullable String hint) {
if (hint == null) return null;
switch (hint) {
case COMPUTED_NAME_FULL:
return View.AUTOFILL_HINT_NAME;
case COMPUTED_NAME_FIRST:
return HINT_FIRST_NAME;
case COMPUTED_NAME_MIDDLE:
case COMPUTED_NAME_MIDDLE_INITIAL:
return HINT_MIDDLE_NAME;
case COMPUTED_NAME_LAST:
case COMPUTED_NAME_LAST_FIRST:
case COMPUTED_NAME_LAST_SECOND:
return HINT_LAST_NAME;
case COMPUTED_EMAIL_ADDRESS:
return View.AUTOFILL_HINT_EMAIL_ADDRESS;
case COMPUTED_PHONE_HOME_WHOLE_NUMBER:
case COMPUTED_PHONE_HOME_NUMBER:
case COMPUTED_PHONE_HOME_CITY_AND_NUMBER:
return View.AUTOFILL_HINT_PHONE;
case COMPUTED_ADDRESS_HOME_LINE1:
case COMPUTED_ADDRESS_HOME_STREET_ADDRESS:
case COMPUTED_ADDRESS_HOME_STREET_NAME:
case COMPUTED_ADDRESS_HOME_ADDRESS:
return View.AUTOFILL_HINT_POSTAL_ADDRESS;
case COMPUTED_ADDRESS_HOME_LINE2:
case COMPUTED_ADDRESS_HOME_LINE3:
case COMPUTED_ADDRESS_HOME_SUBPREMISE:
case COMPUTED_ADDRESS_HOME_APT:
case COMPUTED_ADDRESS_HOME_APT_NUM:
return HINT_ADDRESS_LINE2;
case COMPUTED_ADDRESS_HOME_CITY:
case COMPUTED_ADDRESS_HOME_DEPENDENT_LOCALITY:
return HINT_CITY;
case COMPUTED_ADDRESS_HOME_STATE:
return HINT_STATE;
case COMPUTED_ADDRESS_HOME_ZIP:
case COMPUTED_ADDRESS_HOME_SORTING_CODE:
return View.AUTOFILL_HINT_POSTAL_CODE;
case COMPUTED_ADDRESS_HOME_COUNTRY:
return HINT_COUNTRY;
case COMPUTED_COMPANY_NAME:
return HINT_COMPANY;
default:
return null;
}
}
/**
* Layer 2 implementation: keyword-based heuristic matching. Checks if the given string (which
* may be a view hint, resource ID, text content, or HTML attribute) contains keywords
* associated with known field types.
*
* <p>Skips nodes that look like labels/containers (not actual input fields) and omnibox fields
* (URL bars should not trigger autofill).
*
* <p>As a fallback, returns the raw lowercased hint for any enabled autofillable field that
* didn't match a known keyword. This ensures we don't silently ignore fields — they'll appear
* in the detected fields map but won't match any profile data.
*/
private static @Nullable String inferHint(ViewNode node, @Nullable String actualHint) {
if (actualHint == null || actualHint.isEmpty()) return null;
String hint = actualHint.toLowerCase(Locale.getDefault());
// Skip labels and container views — they're not input fields.
if (hint.contains(KEYWORD_LABEL) || hint.contains(KEYWORD_CONTAINER)) {
return null;
}
// Skip the browser's URL bar.
if (isOmnibox(node)) {
return null;
}
if (hint.contains(KEYWORD_PASSWORD)) {
return View.AUTOFILL_HINT_PASSWORD;
}
if (hint.contains(KEYWORD_USERNAME)
|| (hint.contains(KEYWORD_LOGIN) && hint.contains(KEYWORD_ID))) {
return View.AUTOFILL_HINT_USERNAME;
}
if (hint.contains(KEYWORD_EMAIL)) {
return View.AUTOFILL_HINT_EMAIL_ADDRESS;
}
if (hint.equals(KEYWORD_NAME)
|| (hint.contains(KEYWORD_NAME)
&& (hint.contains(KEYWORD_FIRST)
|| hint.contains(KEYWORD_LAST)
|| hint.contains(KEYWORD_FULL)))) {
return View.AUTOFILL_HINT_NAME;
}
if (hint.contains(KEYWORD_PHONE) || hint.contains(KEYWORD_MOBILE)) {
return View.AUTOFILL_HINT_PHONE;
}
if (hint.contains(KEYWORD_ADDRESS) || hint.contains(KEYWORD_APARTMENT)) {
return View.AUTOFILL_HINT_POSTAL_ADDRESS;
}
if (hint.contains(KEYWORD_PINCODE)
|| hint.contains(KEYWORD_POSTALCODE)
|| hint.contains(KEYWORD_ZIP)) {
return View.AUTOFILL_HINT_POSTAL_CODE;
}
if (hint.contains(KEYWORD_COMPANY) || hint.contains(KEYWORD_ORGANIZATION)) {
return HINT_COMPANY;
}
if (hint.contains(KEYWORD_CITY) || hint.contains(KEYWORD_TOWN)) {
return HINT_CITY;
}
if (hint.contains(KEYWORD_STATE)
|| hint.contains(KEYWORD_REGION)
|| hint.contains(KEYWORD_PREFECTURE)) {
return HINT_STATE;
}
if (hint.contains(KEYWORD_COUNTRY)) {
return HINT_COUNTRY;
}
// Fallback: return the raw hint for any enabled autofillable field. These won't match
// any profile data in BraveAutofillServiceImpl, but they'll be included in the field
// map so SaveInfo can track them.
if (node.isEnabled() && node.getAutofillType() != View.AUTOFILL_TYPE_NONE) {
return hint;
}
return null;
}
/** Detects the browser's URL bar by checking for URI input type. */
private static boolean isOmnibox(ViewNode node) {
int inputType = node.getInputType();
return (inputType & EditorInfo.TYPE_TEXT_VARIATION_URI)
== EditorInfo.TYPE_TEXT_VARIATION_URI;
}
/**
* Layer 1 implementation: maps explicit autofill hints to our canonical constants.
*
* <p>Handles two categories:
*
* <ul>
* <li>Standard Android {@code View.AUTOFILL_HINT_*} constants (e.g. {@code "postalAddress"})
* — passed through as-is since BraveAutofillServiceImpl uses them directly.
* <li>W3C autocomplete tokens (e.g. {@code "street-address"}, {@code "given-name"}) — used by
* HTML forms with {@code autocomplete} attributes. Mapped to our canonical constants. See
* <a
* href="https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute">
* HTML Living Standard §4.10.18.7</a>.
* </ul>
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
static @Nullable String mapAutofillHint(String hint) {
switch (hint) {
// Standard Android View.AUTOFILL_HINT_* constants — pass through.
case View.AUTOFILL_HINT_NAME:
case View.AUTOFILL_HINT_USERNAME:
case View.AUTOFILL_HINT_PASSWORD:
case View.AUTOFILL_HINT_EMAIL_ADDRESS:
case View.AUTOFILL_HINT_PHONE:
case View.AUTOFILL_HINT_POSTAL_ADDRESS:
case View.AUTOFILL_HINT_POSTAL_CODE:
return hint;
// W3C autocomplete tokens.
// "name" is already handled by View.AUTOFILL_HINT_NAME above (both are "name").
case W3C_GIVEN_NAME:
return HINT_FIRST_NAME;
case W3C_FAMILY_NAME:
return HINT_LAST_NAME;
case W3C_ADDITIONAL_NAME:
return HINT_MIDDLE_NAME;
case W3C_HONORIFIC_PREFIX:
case W3C_HONORIFIC_SUFFIX:
return View.AUTOFILL_HINT_NAME;
case W3C_CC_NAME:
return View.AUTOFILL_HINT_NAME;
case W3C_EMAIL:
return View.AUTOFILL_HINT_EMAIL_ADDRESS;
case W3C_TEL:
case W3C_TEL_NATIONAL:
case W3C_TEL_LOCAL:
case W3C_TEL_COUNTRY_CODE:
case W3C_TEL_AREA_CODE:
case W3C_TEL_EXTENSION:
return View.AUTOFILL_HINT_PHONE;
case W3C_STREET_ADDRESS:
case W3C_ADDRESS_LINE1:
return View.AUTOFILL_HINT_POSTAL_ADDRESS;
case W3C_ADDRESS_LINE2:
case W3C_ADDRESS_LINE3:
return HINT_ADDRESS_LINE2;
case W3C_POSTAL_CODE:
return View.AUTOFILL_HINT_POSTAL_CODE;
case W3C_ORGANIZATION:
return HINT_COMPANY;
case W3C_ADDRESS_LEVEL2: // city
return HINT_CITY;
case W3C_ADDRESS_LEVEL1: // state/region
return HINT_STATE;
case W3C_ADDRESS_LEVEL3: // dependent locality
return HINT_CITY;
case W3C_COUNTRY:
case W3C_COUNTRY_NAME:
return HINT_COUNTRY;
default:
return null;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
# These sources are included directly into chrome_java rather than the
# brave/browser/autofill/android:java target to avoid a circular dependency
# (they depend on ChromeBrowserInitializer which is part of chrome_java).
brave_browser_autofill_service_java_sources = [
"//brave/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImpl.java",
"//brave/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/BraveAutofillViewStructureParser.java",
]
+1
View File
@@ -1568,6 +1568,7 @@ if (is_android) {
deps = [
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser",
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.app",
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.autofill",
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.custom_search_engines",
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.homepage",
"//brave/android/junit:brave_junit_tests_org.chromium.chrome.browser.ntp",