diff --git a/android/BUILD.gn b/android/BUILD.gn index 430baccec1f..b295d036ae8 100644 --- a/android/BUILD.gn +++ b/android/BUILD.gn @@ -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", diff --git a/android/brave_java_sources.gni b/android/brave_java_sources.gni index b1544864c56..591b13fb094 100644 --- a/android/brave_java_sources.gni +++ b/android/brave_java_sources.gni @@ -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", diff --git a/android/java/AndroidManifest.xml b/android/java/AndroidManifest.xml index 1dfc960047c..ac5c7e8d39e 100644 --- a/android/java/AndroidManifest.xml +++ b/android/java/AndroidManifest.xml @@ -180,6 +180,19 @@ android:permission="android.permission.BIND_REMOTEVIEWS" android:exported="false" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/android/java/brave-res/xml/autofill_service_configuration.xml b/android/java/brave-res/xml/autofill_service_configuration.xml new file mode 100644 index 00000000000..cc1515cbe49 --- /dev/null +++ b/android/java/brave-res/xml/autofill_service_configuration.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/android/java/org/chromium/chrome/browser/autofill/BraveAutofillService.java b/android/java/org/chromium/chrome/browser/autofill/BraveAutofillService.java new file mode 100644 index 00000000000..75b545dcf79 --- /dev/null +++ b/android/java/org/chromium/chrome/browser/autofill/BraveAutofillService.java @@ -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); + } +} diff --git a/android/java/org/chromium/chrome/browser/base/SplitCompatAutofillService.java b/android/java/org/chromium/chrome/browser/base/SplitCompatAutofillService.java new file mode 100644 index 00000000000..a465e79546c --- /dev/null +++ b/android/java/org/chromium/chrome/browser/base/SplitCompatAutofillService.java @@ -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() {} + } +} diff --git a/android/junit/BUILD.gn b/android/junit/BUILD.gn index 1252333a034..e0933e4d348 100644 --- a/android/junit/BUILD.gn +++ b/android/junit/BUILD.gn @@ -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" ] diff --git a/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImplUnitTest.java b/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImplUnitTest.java new file mode 100644 index 00000000000..cc1d74951a5 --- /dev/null +++ b/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImplUnitTest.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")); + } +} diff --git a/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillViewStructureParserUnitTest.java b/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillViewStructureParserUnitTest.java new file mode 100644 index 00000000000..211d58bc8a8 --- /dev/null +++ b/android/junit/src/org/chromium/chrome/browser/autofill/BraveAutofillViewStructureParserUnitTest.java @@ -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 mapWithKeys(String... keys) { + Map 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")); + } +} diff --git a/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImpl.java b/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImpl.java new file mode 100644 index 00000000000..a70dc4edc65 --- /dev/null +++ b/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/BraveAutofillServiceImpl.java @@ -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. + * + *

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. + * + *

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: + * + *

    + *
  1. Initializes the Brave browser engine (needed to access saved profiles). + *
  2. Parses the app's view hierarchy ({@link AssistStructure}) to detect address fields using + * {@link BraveAutofillViewStructureParser}. + *
  3. Queries Brave's saved address profiles via {@link PersonalDataManager}. + *
  4. Builds a {@link FillResponse} with {@link Dataset} entries (one per saved profile) that the + * system shows as a dropdown popup in the requesting app. + *
  5. Registers {@link SaveInfo} so the system shows a "Save to Brave?" prompt when the user + * manually fills a form and submits it. + *
+ * + *

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. + * + *

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 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 nodesMap = new HashMap<>(); + Map 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". + * + *

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 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 nodesMap = new HashMap<>(); + Map 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. + * + *

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. + * + *

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 fields, + Map 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 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 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 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