From 5addb66841d6f201efd531b0d1c95d018e6c8757 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 24 Sep 2025 16:07:14 +0200 Subject: [PATCH] [Android] Support import of passwords from CSV (#31056) * [Android] Refactor PasswordSettings.java to use an XML layout file. The PasswordSettings.java file now integrates with a new XML file that contains the layout for the page. This modification was completed using Cursor but does not appear to have introduced any breaks. There should be no semantic changes caused by this commit and the Brave Password settings screen should continue to function exactly as it did prior to this commit. * [Android] Introduce new Import/Export items to password manager menu. The password manager dialog now features two items for export and import. Prior to this commit, Export was hidden away in a small drop-down menu accessible from the top-right of the screen. The import item is also present, but non-functional in this commit. * [Android] Implement importing passwords from CSV. We now support importing a CSV file containing the user's passwords. This commit builds upon its parent by implementing the necessary code to call Chromium's password manager which is responsible for executing the import. Additionally, the icons in the password manager screen have been changed to new ones taken from Google's Material UI icon set. Localisation strings have been introduced - some of which are copied from Google's base since, after discussion, this is the way we currently make strings usable for Android. If this changes in future, these could be de-duplicated. Translations into other languages are still necessary since only English is implemented. Closes brave/brave-browser#35729 --- android/brave_java_resources.gni | 3 + .../settings/PasswordSettings.java | 344 +++++++++++----- android/java/res/drawable/file_download.xml | 15 + android/java/res/drawable/file_upload.xml | 15 + .../brave_password_settings_preferences.xml | 64 +++ .../password_manager/settings/ImportFlow.java | 388 ++++++++++++++++++ .../settings/PasswordManagerHandler.java | 19 + .../settings/PasswordUiView.java | 21 + .../password_manager/android/java_sources.gni | 1 + .../android/password_ui_view_android.cc | 45 ++ .../android/password_ui_view_android.h | 5 + browser/sources.gni | 1 + .../android/strings/android_brave_strings.grd | 47 +++ 13 files changed, 858 insertions(+), 110 deletions(-) create mode 100644 android/java/res/drawable/file_download.xml create mode 100644 android/java/res/drawable/file_upload.xml create mode 100644 android/java/res/xml/brave_password_settings_preferences.xml create mode 100644 browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ImportFlow.java diff --git a/android/brave_java_resources.gni b/android/brave_java_resources.gni index df4546f1377..a6b1893d5b3 100644 --- a/android/brave_java_resources.gni +++ b/android/brave_java_resources.gni @@ -299,6 +299,8 @@ brave_java_resources = [ "java/res/drawable/default_indicator.xml", "java/res/drawable/ellipse_217.xml", "java/res/drawable/exchange_shape.xml", + "java/res/drawable/file_download.xml", + "java/res/drawable/file_upload.xml", "java/res/drawable/fingerprint_unlock_layer_list.xml", "java/res/drawable/ic_ac_onboarding.xml", "java/res/drawable/ic_accessibility.xml", @@ -931,6 +933,7 @@ brave_java_resources = [ "java/res/xml/brave_leo_radio_button_group_default_model_preference.xml", "java/res/xml/brave_license_preferences.xml", "java/res/xml/brave_main_preferences.xml", + "java/res/xml/brave_password_settings_preferences.xml", "java/res/xml/brave_playlist_preferences.xml", "java/res/xml/brave_privacy_preferences.xml", "java/res/xml/brave_rewards_debug_preferences.xml", diff --git a/android/java/org/chromium/chrome/browser/password_manager/settings/PasswordSettings.java b/android/java/org/chromium/chrome/browser/password_manager/settings/PasswordSettings.java index b60f52761cc..e7c0ce20adb 100644 --- a/android/java/org/chromium/chrome/browser/password_manager/settings/PasswordSettings.java +++ b/android/java/org/chromium/chrome/browser/password_manager/settings/PasswordSettings.java @@ -45,6 +45,7 @@ import org.chromium.chrome.browser.settings.ChromeManagedPreferenceDelegate; import org.chromium.components.browser_ui.settings.ChromeSwitchPreference; import org.chromium.components.browser_ui.settings.SearchUtils; import org.chromium.components.browser_ui.settings.SettingsFragment.AnimationType; +import org.chromium.components.browser_ui.settings.SettingsUtils; import org.chromium.components.browser_ui.settings.TextMessagePreference; import org.chromium.components.prefs.PrefService; import org.chromium.components.user_prefs.UserPrefs; @@ -59,7 +60,9 @@ import java.util.Locale; */ @NullMarked public class PasswordSettings extends ChromeBaseSettingsFragment - implements PasswordListObserver, Preference.OnPreferenceClickListener { + implements PasswordListObserver, + Preference.OnPreferenceChangeListener, + Preference.OnPreferenceClickListener { // Keys for name/password dictionaries. public static final String PASSWORD_LIST_URL = "url"; @@ -74,19 +77,21 @@ public class PasswordSettings extends ChromeBaseSettingsFragment public static final String PREF_SAVE_PASSWORDS_SWITCH = "save_passwords_switch"; public static final String PREF_AUTOSIGNIN_SWITCH = "autosignin_switch"; + public static final String PREF_IMPORT_PASSWORDS = "import_passwords"; + public static final String PREF_EXPORT_PASSWORDS = "export_passwords"; private static final String PREF_KEY_CATEGORY_SAVED_PASSWORDS = "saved_passwords"; private static final String PREF_KEY_CATEGORY_EXCEPTIONS = "exceptions"; private static final String PREF_KEY_SAVED_PASSWORDS_NO_TEXT = "saved_passwords_no_text"; - private static final int ORDER_SWITCH = 0; - private static final int ORDER_AUTO_SIGNIN_CHECKBOX = 1; private static final int ORDER_SAVED_PASSWORDS = 6; - private static final int ORDER_EXCEPTIONS = 7; - private static final int ORDER_SAVED_PASSWORDS_NO_TEXT = 8; + private static final int ORDER_SAVED_PASSWORDS_NO_TEXT = 7; + private static final int ORDER_EXCEPTIONS = 8; // Unique request code for the password exporting activity. private static final int PASSWORD_EXPORT_INTENT_REQUEST_CODE = 3485764; + // Unique request code for the password importing activity. + private static final int PASSWORD_IMPORT_INTENT_REQUEST_CODE = 3485765; private boolean mNoPasswords; private boolean mNoPasswordExceptions; @@ -97,6 +102,7 @@ public class PasswordSettings extends ChromeBaseSettingsFragment private @Nullable String mSearchQuery; private @Nullable Preference mLinkPref; private /*@Nullable*/ Menu mMenu; + private @Nullable Preference mExportPasswordsPreference; private @ManagePasswordsReferrer int mManagePasswordsReferrer; private final ObservableSupplierImpl mPageTitle = new ObservableSupplierImpl<>(); @@ -104,6 +110,9 @@ public class PasswordSettings extends ChromeBaseSettingsFragment /** For controlling the UX flow of exporting passwords. */ private final ExportFlow mExportFlow = new ExportFlow(); + /** For controlling the UX flow of importing passwords. */ + private final ImportFlow mImportFlow = new ImportFlow(); + public ExportFlow getExportFlowForTesting() { return mExportFlow; } @@ -139,14 +148,43 @@ public class PasswordSettings extends ChromeBaseSettingsFragment } }, PASSWORD_SETTINGS_EXPORT_METRICS_ID); - mPageTitle.set(getString(R.string.password_manager_settings_title)); - setPreferenceScreen(getPreferenceManager().createPreferenceScreen(getStyledContext())); - PasswordManagerHandlerProvider.getForProfile(getProfile()).addObserver(this); + mImportFlow.onCreate( + savedInstanceState, + new ImportFlow.Delegate() { + @Override + public Activity getActivity() { + return PasswordSettings.this.getActivity(); + } + + @Override + public FragmentManager getFragmentManager() { + return assertNonNull(PasswordSettings.this.getFragmentManager()); + } + + @Override + public Profile getProfile() { + return PasswordSettings.this.getProfile(); + } + + @Override + public void runCreateFilePickerIntent(Intent intent) { + startActivityForResult(intent, PASSWORD_IMPORT_INTENT_REQUEST_CODE); + } + }); + mPageTitle.set(getString(R.string.password_manager_settings_title)); + + // Load preferences from XML instead of creating programmatically + SettingsUtils.addPreferencesFromResource(this, R.xml.brave_password_settings_preferences); + + PasswordManagerHandlerProvider.getForProfile(getProfile()).addObserver(this); setHasOptionsMenu(true); // Password Export might be optional but Search is always present. mManagePasswordsReferrer = getReferrerFromInstanceStateOrLaunchBundle(savedInstanceState); + // Set up preference change listeners + setupPreferenceListeners(); + if (savedInstanceState == null) return; if (savedInstanceState.containsKey(SAVED_STATE_SEARCH_QUERY)) { @@ -154,6 +192,86 @@ public class PasswordSettings extends ChromeBaseSettingsFragment } } + private void setupPreferenceListeners() { + // Set up save passwords switch + ChromeSwitchPreference savePasswordsSwitch = + (ChromeSwitchPreference) findPreference(PREF_SAVE_PASSWORDS_SWITCH); + if (savePasswordsSwitch != null) { + savePasswordsSwitch.setOnPreferenceChangeListener(this); + savePasswordsSwitch.setManagedPreferenceDelegate( + new ChromeManagedPreferenceDelegate(getProfile()) { + @Override + public boolean isPreferenceControlledByPolicy(Preference preference) { + return getPrefService() + .isManagedPreference(Pref.CREDENTIALS_ENABLE_SERVICE); + } + }); + // Set initial state + savePasswordsSwitch.setChecked( + getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_SERVICE)); + } + + // Set up auto sign-in switch + ChromeSwitchPreference autoSignInSwitch = + (ChromeSwitchPreference) findPreference(PREF_AUTOSIGNIN_SWITCH); + if (autoSignInSwitch != null) { + if (shouldShowAutoSigninOption()) { + autoSignInSwitch.setOnPreferenceChangeListener(this); + autoSignInSwitch.setManagedPreferenceDelegate( + new ChromeManagedPreferenceDelegate(getProfile()) { + @Override + public boolean isPreferenceControlledByPolicy(Preference preference) { + return getPrefService() + .isManagedPreference(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN); + } + }); + // Set initial state + autoSignInSwitch.setChecked( + getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN)); + } else { + // Hide the auto sign-in switch if not needed + getPreferenceScreen().removePreference(autoSignInSwitch); + } + } + + // Set up no passwords message divider settings + TextMessagePreference noPasswordsMessage = + (TextMessagePreference) findPreference(PREF_KEY_SAVED_PASSWORDS_NO_TEXT); + if (noPasswordsMessage != null) { + noPasswordsMessage.setDividerAllowedAbove(false); + noPasswordsMessage.setDividerAllowedBelow(false); + } + + // Set up import passwords preference + Preference importPasswordsPreference = findPreference(PREF_IMPORT_PASSWORDS); + if (importPasswordsPreference != null) { + importPasswordsPreference.setOnPreferenceClickListener(this); + } + + // Set up export passwords preference + mExportPasswordsPreference = findPreference(PREF_EXPORT_PASSWORDS); + if (mExportPasswordsPreference != null) { + mExportPasswordsPreference.setOnPreferenceClickListener(this); + // Set initial enabled state - only show if export is supported + mExportPasswordsPreference.setVisible(ExportFlow.providesPasswordExport()); + mExportPasswordsPreference.setEnabled( + false); // Will be enabled when passwords are available + } + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String key = preference.getKey(); + if (PREF_SAVE_PASSWORDS_SWITCH.equals(key)) { + getPrefService().setBoolean(Pref.CREDENTIALS_ENABLE_SERVICE, (boolean) newValue); + return true; + } else if (PREF_AUTOSIGNIN_SWITCH.equals(key)) { + getPrefService().setBoolean(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN, (boolean) newValue); + return true; + } + return false; + } + @Override public ObservableSupplier getPageTitle() { return mPageTitle; @@ -187,8 +305,8 @@ public class PasswordSettings extends ChromeBaseSettingsFragment menu.clear(); mMenu = menu; inflater.inflate(R.menu.save_password_preferences_action_bar_menu, menu); - menu.findItem(R.id.export_passwords).setVisible(ExportFlow.providesPasswordExport()); - menu.findItem(R.id.export_passwords).setEnabled(false); + // Hide the export passwords menu item since we now have it as a preference + menu.findItem(R.id.export_passwords).setVisible(false); mSearchItem = menu.findItem(R.id.menu_id_search); mSearchItem.setVisible(true); mHelpItem = menu.findItem(R.id.menu_id_targeted_help); @@ -198,21 +316,13 @@ public class PasswordSettings extends ChromeBaseSettingsFragment @Override public void onPrepareOptionsMenu(Menu menu) { - menu.findItem(R.id.export_passwords).setEnabled(!mNoPasswords && !mExportFlow.isActive()); + // Export passwords menu item is now hidden, no need to enable/disable it super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { int id = item.getItemId(); - if (id == R.id.export_passwords) { - RecordHistogram.recordEnumeratedHistogram( - mExportFlow.getExportEventHistogramName(), - ExportFlow.PasswordExportEvent.EXPORT_OPTION_SELECTED, - ExportFlow.PasswordExportEvent.COUNT); - mExportFlow.startExporting(); - return true; - } if (SearchUtils.handleSearchNavigation(item, mSearchItem, mSearchQuery, getActivity())) { filterPasswords(null); return true; @@ -262,7 +372,9 @@ public class PasswordSettings extends ChromeBaseSettingsFragment void rebuildPasswordLists() { mNoPasswords = false; mNoPasswordExceptions = false; - getPreferenceScreen().removeAll(); + + // Clear existing password and exception preferences + clearPasswordLists(); PasswordManagerHandlerProvider passwordManagerHandlerProvider = assertNonNull(PasswordManagerHandlerProvider.getForProfile(getProfile())); @@ -278,11 +390,8 @@ public class PasswordSettings extends ChromeBaseSettingsFragment return; } - createSavePasswordsSwitch(); - if (shouldShowAutoSigninOption()) { - createAutoSignInCheckbox(); - } - + // Update preference states + updatePreferenceStates(); passwordManagerHandler.updatePasswordLists(); } @@ -290,17 +399,59 @@ public class PasswordSettings extends ChromeBaseSettingsFragment return !DeviceInfo.isAutomotive(); } + private void updateExportPasswordsPreferenceState() { + if (mExportPasswordsPreference != null) { + mExportPasswordsPreference.setEnabled(!mNoPasswords && !mExportFlow.isActive()); + } + } + + private void clearPasswordLists() { + // Clear saved passwords category + PreferenceCategory savedPasswordsCategory = + (PreferenceCategory) findPreference(PREF_KEY_CATEGORY_SAVED_PASSWORDS); + if (savedPasswordsCategory != null) { + savedPasswordsCategory.removeAll(); + } + + // Clear exceptions category + PreferenceCategory exceptionsCategory = + (PreferenceCategory) findPreference(PREF_KEY_CATEGORY_EXCEPTIONS); + if (exceptionsCategory != null) { + exceptionsCategory.removeAll(); + } + + // Remove no entries message + resetNoEntriesTextMessage(); + } + + private void updatePreferenceStates() { + // Update save passwords switch state + ChromeSwitchPreference savePasswordsSwitch = + (ChromeSwitchPreference) findPreference(PREF_SAVE_PASSWORDS_SWITCH); + if (savePasswordsSwitch != null) { + savePasswordsSwitch.setChecked( + getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_SERVICE)); + } + + // Update auto sign-in switch state + ChromeSwitchPreference autoSignInSwitch = + (ChromeSwitchPreference) findPreference(PREF_AUTOSIGNIN_SWITCH); + if (autoSignInSwitch != null) { + autoSignInSwitch.setChecked( + getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN)); + } + } + /** - * Removes the UI displaying the list of saved passwords or exceptions. + * Clears the contents of the saved passwords or exceptions category. * - * @param preferenceCategoryKey The key string identifying the PreferenceCategory to be removed. + * @param preferenceCategoryKey The key string identifying the PreferenceCategory to be cleared. */ private void resetList(String preferenceCategoryKey) { PreferenceCategory profileCategory = (PreferenceCategory) getPreferenceScreen().findPreference(preferenceCategoryKey); if (profileCategory != null) { profileCategory.removeAll(); - getPreferenceScreen().removePreference(profileCategory); } } @@ -320,16 +471,24 @@ public class PasswordSettings extends ChromeBaseSettingsFragment mNoPasswords = count == 0; if (mNoPasswords) { if (mNoPasswordExceptions) displayEmptyScreenMessage(); + // Update export preference state when no passwords + updateExportPasswordsPreferenceState(); return; } PreferenceGroup passwordParent; if (mSearchQuery == null) { - PreferenceCategory profileCategory = new PreferenceCategory(getStyledContext()); - profileCategory.setKey(PREF_KEY_CATEGORY_SAVED_PASSWORDS); - profileCategory.setTitle(R.string.password_list_title); - profileCategory.setOrder(ORDER_SAVED_PASSWORDS); - getPreferenceScreen().addPreference(profileCategory); + // Use the existing category from XML instead of creating a new one + PreferenceCategory profileCategory = + (PreferenceCategory) findPreference(PREF_KEY_CATEGORY_SAVED_PASSWORDS); + if (profileCategory == null) { + // Fallback: create new category if XML one doesn't exist (shouldn't happen) + profileCategory = new PreferenceCategory(getStyledContext()); + profileCategory.setKey(PREF_KEY_CATEGORY_SAVED_PASSWORDS); + profileCategory.setTitle(R.string.password_list_title); + profileCategory.setOrder(ORDER_SAVED_PASSWORDS); + getPreferenceScreen().addPreference(profileCategory); + } passwordParent = profileCategory; } else { passwordParent = getPreferenceScreen(); @@ -358,17 +517,13 @@ public class PasswordSettings extends ChromeBaseSettingsFragment passwordParent.addPreference(preference); } mNoPasswords = passwordParent.getPreferenceCount() == 0; - if (mMenu != null) { - MenuItem menuItem = mMenu.findItem(R.id.export_passwords); - if (menuItem != null) { - menuItem.setEnabled(!mNoPasswords && !mExportFlow.isActive()); - } - } + // Update export passwords preference enabled state + updateExportPasswordsPreferenceState(); if (mNoPasswords) { if (count == 0) displayEmptyScreenMessage(); // Show if the list was already empty. if (mSearchQuery == null) { - // If not searching, the category needs to be removed again. - getPreferenceScreen().removePreference(passwordParent); + // Keep the XML-defined category visible even when empty + // Don't remove it: getPreferenceScreen().removePreference(passwordParent); } else { displayPasswordNoResultScreenMessage(); } @@ -404,11 +559,17 @@ public class PasswordSettings extends ChromeBaseSettingsFragment return; } - PreferenceCategory profileCategory = new PreferenceCategory(getStyledContext()); - profileCategory.setKey(PREF_KEY_CATEGORY_EXCEPTIONS); - profileCategory.setTitle(R.string.section_saved_passwords_exceptions); - profileCategory.setOrder(ORDER_EXCEPTIONS); - getPreferenceScreen().addPreference(profileCategory); + // Use the existing category from XML instead of creating a new one + PreferenceCategory profileCategory = + (PreferenceCategory) findPreference(PREF_KEY_CATEGORY_EXCEPTIONS); + if (profileCategory == null) { + // Fallback: create new category if XML one doesn't exist (shouldn't happen) + profileCategory = new PreferenceCategory(getStyledContext()); + profileCategory.setKey(PREF_KEY_CATEGORY_EXCEPTIONS); + profileCategory.setTitle(R.string.section_saved_passwords_exceptions); + profileCategory.setOrder(ORDER_EXCEPTIONS); + getPreferenceScreen().addPreference(profileCategory); + } PasswordManagerHandlerProvider passwordManagerHandlerProvider = assertNonNull(PasswordManagerHandlerProvider.getForProfile(getProfile())); PasswordManagerHandler passwordManagerHandler = @@ -435,22 +596,30 @@ public class PasswordSettings extends ChromeBaseSettingsFragment public void onResume() { super.onResume(); mExportFlow.onResume(); + // Update export preference state in case export flow state changed + updateExportPasswordsPreferenceState(); } @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent intent) { super.onActivityResult(requestCode, resultCode, intent); - if (requestCode != PASSWORD_EXPORT_INTENT_REQUEST_CODE) return; - if (resultCode != Activity.RESULT_OK) return; - if (intent == null || intent.getData() == null) return; - mExportFlow.savePasswordsToDownloads(intent.getData()); + if (resultCode != Activity.RESULT_OK || intent == null || intent.getData() == null) { + return; + } + + if (requestCode == PASSWORD_EXPORT_INTENT_REQUEST_CODE) { + mExportFlow.savePasswordsToDownloads(intent.getData()); + } else if (requestCode == PASSWORD_IMPORT_INTENT_REQUEST_CODE) { + mImportFlow.processImportFile(intent); + } } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); mExportFlow.onSaveInstanceState(outState); + mImportFlow.onSaveInstanceState(outState); if (mSearchQuery != null) { outState.putString(SAVED_STATE_SEARCH_QUERY, mSearchQuery); } @@ -476,7 +645,21 @@ public class PasswordSettings extends ChromeBaseSettingsFragment */ @Override public boolean onPreferenceClick(Preference preference) { - if (preference == mLinkPref) { + String key = preference.getKey(); + + if (PREF_IMPORT_PASSWORDS.equals(key)) { + // Start the password import flow + mImportFlow.startImporting(); + return true; + } else if (PREF_EXPORT_PASSWORDS.equals(key)) { + // Moved from menu item - export passwords functionality + RecordHistogram.recordEnumeratedHistogram( + mExportFlow.getExportEventHistogramName(), + ExportFlow.PasswordExportEvent.EXPORT_OPTION_SELECTED, + ExportFlow.PasswordExportEvent.COUNT); + mExportFlow.startExporting(); + return true; + } else if (preference == mLinkPref) { Intent intent = new Intent( Intent.ACTION_VIEW, Uri.parse(PasswordUiView.getAccountDashboardURL())); @@ -498,65 +681,6 @@ public class PasswordSettings extends ChromeBaseSettingsFragment return true; } - private void createSavePasswordsSwitch() { - ChromeSwitchPreference savePasswordsSwitch = - new ChromeSwitchPreference(getStyledContext(), null); - savePasswordsSwitch.setKey(PREF_SAVE_PASSWORDS_SWITCH); - savePasswordsSwitch.setTitle(R.string.password_settings_save_passwords); - savePasswordsSwitch.setOrder(ORDER_SWITCH); - savePasswordsSwitch.setSummaryOn(R.string.text_on); - savePasswordsSwitch.setSummaryOff(R.string.text_off); - savePasswordsSwitch.setOnPreferenceChangeListener( - (preference, newValue) -> { - getPrefService() - .setBoolean(Pref.CREDENTIALS_ENABLE_SERVICE, (boolean) newValue); - return true; - }); - savePasswordsSwitch.setManagedPreferenceDelegate( - new ChromeManagedPreferenceDelegate(getProfile()) { - @Override - public boolean isPreferenceControlledByPolicy(Preference preference) { - return getPrefService() - .isManagedPreference(Pref.CREDENTIALS_ENABLE_SERVICE); - } - }); - - getPreferenceScreen().addPreference(savePasswordsSwitch); - - // Note: setting the switch state before the preference is added to the screen results in - // some odd behavior where the switch state doesn't always match the internal enabled state - // (e.g. the switch will say "On" when save passwords is really turned off), so - // .setChecked() should be called after .addPreference() - savePasswordsSwitch.setChecked( - getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_SERVICE)); - } - - private void createAutoSignInCheckbox() { - ChromeSwitchPreference autoSignInSwitch = - new ChromeSwitchPreference(getStyledContext(), null); - autoSignInSwitch.setKey(PREF_AUTOSIGNIN_SWITCH); - autoSignInSwitch.setTitle(R.string.passwords_auto_signin_title); - autoSignInSwitch.setOrder(ORDER_AUTO_SIGNIN_CHECKBOX); - autoSignInSwitch.setSummary(R.string.passwords_auto_signin_description); - autoSignInSwitch.setOnPreferenceChangeListener( - (preference, newValue) -> { - getPrefService() - .setBoolean(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN, (boolean) newValue); - return true; - }); - autoSignInSwitch.setManagedPreferenceDelegate( - new ChromeManagedPreferenceDelegate(getProfile()) { - @Override - public boolean isPreferenceControlledByPolicy(Preference preference) { - return getPrefService() - .isManagedPreference(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN); - } - }); - getPreferenceScreen().addPreference(autoSignInSwitch); - autoSignInSwitch.setChecked( - getPrefService().getBoolean(Pref.CREDENTIALS_ENABLE_AUTOSIGNIN)); - } - private Context getStyledContext() { return getPreferenceManager().getContext(); } diff --git a/android/java/res/drawable/file_download.xml b/android/java/res/drawable/file_download.xml new file mode 100644 index 00000000000..e38da1583fd --- /dev/null +++ b/android/java/res/drawable/file_download.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/java/res/drawable/file_upload.xml b/android/java/res/drawable/file_upload.xml new file mode 100644 index 00000000000..a47806c4df2 --- /dev/null +++ b/android/java/res/drawable/file_upload.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/java/res/xml/brave_password_settings_preferences.xml b/android/java/res/xml/brave_password_settings_preferences.xml new file mode 100644 index 00000000000..9347a07a4cf --- /dev/null +++ b/android/java/res/xml/brave_password_settings_preferences.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ImportFlow.java b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ImportFlow.java new file mode 100644 index 00000000000..906b32e700b --- /dev/null +++ b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ImportFlow.java @@ -0,0 +1,388 @@ +/* Copyright (c) 2025 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.password_manager.settings; + +import android.app.Activity; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.IntDef; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentManager; + +import org.chromium.base.task.AsyncTask; +import org.chromium.build.annotations.NullMarked; +import org.chromium.build.annotations.Nullable; +import org.chromium.chrome.browser.password_manager.R; +import org.chromium.chrome.browser.profiles.Profile; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * This class allows to trigger and complete the UX flow for importing passwords from a CSV file. A + * Fragment can use it to display the flow UI over the fragment. + */ +@NullMarked +public class ImportFlow { + @IntDef({ + ImportState.INACTIVE, + ImportState.REQUESTED, + ImportState.IN_PROGRESS, + ImportState.FINISHED + }) + @Retention(RetentionPolicy.SOURCE) + private @interface ImportState { + /** + * INACTIVE: there is no currently running import. Either the user did not request one, or + * the last one completed. + */ + int INACTIVE = 0; + + /** REQUESTED: the user requested the import by clicking the preference. */ + int REQUESTED = 1; + + /** IN_PROGRESS: the user selected a file and the import is being processed. */ + int IN_PROGRESS = 2; + + /** FINISHED: import has successfully finished or completed with errors. */ + int FINISHED = 3; + } + + /** Describes at which state the password import flow is. */ + @ImportState private int mImportState; + + /** The key for saving {@link #mImportState} to instance bundle. */ + private static final String SAVED_STATE_IMPORT_STATE = "saved-state-import-state"; + + /** Values of the histogram recording password import related events. */ + @IntDef({ + PasswordImportEvent.IMPORT_OPTION_SELECTED, + PasswordImportEvent.IMPORT_DISMISSED, + PasswordImportEvent.IMPORT_STARTED, + PasswordImportEvent.IMPORT_COMPLETED, + PasswordImportEvent.COUNT + }) + @Retention(RetentionPolicy.SOURCE) + public @interface PasswordImportEvent { + int IMPORT_OPTION_SELECTED = 0; + int IMPORT_DISMISSED = 1; + int IMPORT_STARTED = 2; + int IMPORT_COMPLETED = 3; + int COUNT = 4; + } + + /** Delegate interface to access the hosting fragment/activity */ + public interface Delegate { + Activity getActivity(); + + FragmentManager getFragmentManager(); + + Profile getProfile(); + + void runCreateFilePickerIntent(Intent intent); + } + + private @Nullable Delegate mDelegate; + + /** Constructor */ + public ImportFlow() { + mImportState = ImportState.INACTIVE; + } + + /** + * Sets up the import flow with the given delegate. + * + * @param savedInstanceState Bundle containing saved state + * @param delegate The delegate to use for accessing activity/fragment functionality + */ + public void onCreate(@Nullable Bundle savedInstanceState, Delegate delegate) { + mDelegate = delegate; + + if (savedInstanceState == null) return; + + if (savedInstanceState.containsKey(SAVED_STATE_IMPORT_STATE)) { + mImportState = savedInstanceState.getInt(SAVED_STATE_IMPORT_STATE); + } + } + + /** Starts the password import flow by showing a file picker. */ + public void startImporting() { + if (mDelegate == null) return; + + mImportState = ImportState.REQUESTED; + + // Create file picker intent for CSV files + Intent chooseFile = new Intent(Intent.ACTION_GET_CONTENT); + chooseFile.setType("text/*"); + chooseFile.addCategory(Intent.CATEGORY_OPENABLE); + + // Add MIME types for CSV files + String[] mimeTypes = {"text/csv", "text/comma-separated-values", "application/csv"}; + chooseFile.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + + chooseFile = + Intent.createChooser( + chooseFile, + mDelegate + .getActivity() + .getResources() + .getString(R.string.password_manager_ui_select_file)); + + mDelegate.runCreateFilePickerIntent(chooseFile); + } + + /** + * Processes the selected CSV file and imports passwords. + * + * @param data The intent data from the file picker + */ + public void processImportFile(Intent data) { + if (data == null || data.getData() == null || mDelegate == null) { + mImportState = ImportState.INACTIVE; + return; + } + + mImportState = ImportState.IN_PROGRESS; + Uri fileUri = data.getData(); + + // Read and process the CSV file in a background task + new ImportPasswordsTask(fileUri).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + /** + * Saves the import state to the instance bundle. + * + * @param outState Bundle to save state to + */ + public void onSaveInstanceState(Bundle outState) { + outState.putInt(SAVED_STATE_IMPORT_STATE, mImportState); + } + + /** + * Returns whether the import flow is currently active. + * + * @return true if import is in progress + */ + public boolean isActive() { + return mImportState == ImportState.IN_PROGRESS; + } + + private static class CSVReadResult { + public final @Nullable String csv; + public final @Nullable Exception err; + + public CSVReadResult(@Nullable String csv, @Nullable Exception err) { + this.csv = csv; + this.err = err; + } + } + + /** AsyncTask to handle CSV file reading and password import in the background */ + private class ImportPasswordsTask extends AsyncTask { + private final Uri mFileUri; + + public ImportPasswordsTask(Uri fileUri) { + mFileUri = fileUri; + } + + @Override + protected CSVReadResult doInBackground() { + if (mFileUri == null || mDelegate == null) { + return new CSVReadResult("", null); + } + + try (InputStream inputStream = + mDelegate.getActivity().getContentResolver().openInputStream(mFileUri)) { + if (inputStream == null) { + return new CSVReadResult(null, new FileNotFoundException(mFileUri.getPath())); + } + + try (InputStreamReader iStream = new InputStreamReader(inputStream); + BufferedReader reader = new BufferedReader(iStream)) { + StringBuilder csvContent = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + csvContent.append(line).append("\n"); + } + + return new CSVReadResult(csvContent.toString(), null); + } + } catch (Exception e) { + return new CSVReadResult(null, e); + } + } + + @Override + protected void onPostExecute(CSVReadResult csvResult) { + if (mDelegate == null) { + return; + } + if (csvResult.err == null && csvResult.csv != null && !csvResult.csv.trim().isEmpty()) { + importPasswordsFromCsvContent(csvResult.csv); + } else { + showImportErrorDialog( + mDelegate + .getActivity() + .getResources() + .getString( + R.string.password_settings_import_file_read_error, + csvResult.err != null ? csvResult.err.getMessage() : "")); + } + } + } + + // Tied to chromium/components/password_manager/core/browser/import/import_results.h + private static enum ImportStatusResult { + NONE(0, R.string.password_manager_ui_import_error_unknown), + UNKNOWN_ERROR(1, R.string.password_manager_ui_import_error_unknown), + SUCCESS(2, R.string.password_manager_ui_import_success_title), + IO_ERROR(3, R.string.password_manager_ui_import_error_unknown), + BAD_FORMAT(4, R.string.password_manager_ui_import_error_bad_format), + DISMISSED(5, R.string.password_manager_ui_import_error_unknown), + MAX_FILE_SIZE(6, R.string.password_manager_ui_import_file_size_exceeded), + IMPORT_ALREADY_ACTIVE(7, R.string.password_manager_ui_import_already_active), + NUM_PASSWORDS_EXCEEDED(8, R.plurals.password_manager_ui_import_error_limit_exceeded), + CONFLICTS(9, R.string.password_manager_ui_import_conflict_device); + + private final int mValue; + private final int mLocId; + + private ImportStatusResult(int val, int locId) { + mValue = val; + mLocId = locId; + } + + public int value() { + return mValue; + } + + public int locStrId() { + return mLocId; + } + + public static ImportStatusResult fromInt(int id) { + for (ImportStatusResult elem : ImportStatusResult.values()) { + if (elem.value() == id) return elem; + } + return ImportStatusResult.NONE; + } + } + + /** + * Processes the CSV content and imports passwords using the PasswordManagerHandler. + * + * @param csvContent The CSV content to process + */ + private void importPasswordsFromCsvContent(String csvContent) { + if (mDelegate == null) { + return; + } + // Redeclared to avoid Nullable warning in capturing lambda. + Delegate mDelegate = this.mDelegate; + try { + PasswordManagerHandler handler = + PasswordManagerHandlerProvider.getForProfile(mDelegate.getProfile()) + .getPasswordManagerHandler(); + + if (handler != null) { + // Call the native import method (to be implemented) + handler.importPasswordsFromCsv( + csvContent, + (count) -> { + // Success callback + mImportState = ImportState.FINISHED; + showImportSuccessDialog(count); + }, + (errorId) -> { + // Error callback + ImportStatusResult result = ImportStatusResult.fromInt(errorId); + var res = mDelegate.getActivity().getResources(); + mImportState = ImportState.FINISHED; + showImportErrorDialog( + result == ImportStatusResult.NUM_PASSWORDS_EXCEEDED + ? res.getQuantityString( + result.locStrId(), + handler.getMaxPasswordsPerCsvFile()) + : res.getString(result.locStrId())); + }); + } else { + showImportErrorDialog( + mDelegate + .getActivity() + .getResources() + .getString(R.string.password_settings_manager_not_available)); + } + } catch (Exception e) { + // We can re-use the export localisation string since it doesn't mention exporting at + // all. + showImportErrorDialog( + mDelegate + .getActivity() + .getResources() + .getString( + R.string.password_settings_export_error_details, + e.getMessage())); + } + } + + /** + * Shows a success dialog after successful import. + * + * @param count Number of passwords imported + */ + private void showImportSuccessDialog(int count) { + if (mDelegate == null) { + return; + } + + Resources res = mDelegate.getActivity().getResources(); + + new AlertDialog.Builder(mDelegate.getActivity()) + .setTitle(res.getString(R.string.password_manager_ui_import_success_title)) + .setMessage( + res.getQuantityString( + R.plurals.password_settings_import_file_success_count, count)) + .setPositiveButton( + res.getString(R.string.ok), + (dialog, which) -> { + mImportState = ImportState.INACTIVE; + dialog.dismiss(); + }) + .show(); + } + + /** + * Shows an error dialog when import fails. + * + * @param errorMessage The error message to display. Caller is required to do any localisation. + */ + private void showImportErrorDialog(String errorMessage) { + if (mDelegate == null) { + return; + } + + Resources res = mDelegate.getActivity().getResources(); + + new AlertDialog.Builder(mDelegate.getActivity()) + .setTitle(res.getString(R.string.password_settings_import_file_error_title)) + .setMessage(errorMessage) + .setPositiveButton( + res.getString(R.string.ok), + (dialog, which) -> { + mImportState = ImportState.INACTIVE; + dialog.dismiss(); + }) + .show(); + } +} diff --git a/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordManagerHandler.java b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordManagerHandler.java index fb4b1cf0231..aa0d3d8b1d8 100644 --- a/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordManagerHandler.java +++ b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordManagerHandler.java @@ -85,4 +85,23 @@ public interface PasswordManagerHandler { * @return Returns true if the request to fetch the passwords is still pending. */ boolean isWaitingForPasswordStore(); + + /** + * Trigger importing passwords from CSV content in the background. + * + * @param csvContent is the CSV content containing passwords to import. + * @param successCallback is called on successful completion, with the count of imported + * passwords and a success message as arguments. + * @param errorCallback is called on failure, with the error message as argument. + */ + void importPasswordsFromCsv( + String csvContent, Callback successCallback, Callback errorCallback); + + /** + * Used to obtain the compile-time constant of the max number of passwords that can be imported + * from a CSV + * + * @return Returns the constant value defined in C++ + */ + int getMaxPasswordsPerCsvFile(); } diff --git a/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordUiView.java b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordUiView.java index 48fb6dc86de..2ab22d371f1 100644 --- a/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordUiView.java +++ b/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordUiView.java @@ -142,6 +142,19 @@ public final class PasswordUiView implements PasswordManagerHandler { return PasswordUiViewJni.get().isWaitingForPasswordStore(mNativePasswordUiViewAndroid); } + @Override + public void importPasswordsFromCsv( + String csvContent, Callback successCallback, Callback errorCallback) { + PasswordUiViewJni.get() + .handleImportPasswordsFromCsv( + mNativePasswordUiViewAndroid, csvContent, successCallback, errorCallback); + } + + @Override + public int getMaxPasswordsPerCsvFile() { + return PasswordUiViewJni.get().getMaxPasswordsPerCsvFile(); + } + /** Destroy the native object. */ public void destroy() { if (mNativePasswordUiViewAndroid != 0) { @@ -192,5 +205,13 @@ public final class PasswordUiView implements PasswordManagerHandler { void handleShowBlockedCredentialView( long nativePasswordUiViewAndroid, Context context, int index); + + void handleImportPasswordsFromCsv( + long nativePasswordUiViewAndroid, + @JniType("std::string") String csvContent, + Callback successCallback, + Callback errorCallback); + + int getMaxPasswordsPerCsvFile(); } } diff --git a/browser/password_manager/android/java_sources.gni b/browser/password_manager/android/java_sources.gni index 24d3dfbb1e4..9dd982960c4 100644 --- a/browser/password_manager/android/java_sources.gni +++ b/browser/password_manager/android/java_sources.gni @@ -11,6 +11,7 @@ brave_browser_password_manager_java_sources = [ "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ExportErrorDialogFragment.java", "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ExportFlow.java", "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ExportFlowInterface.java", + "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/ImportFlow.java", "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/NonCancelableProgressBar.java", "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordAccessReauthenticationHelper.java", "//brave/browser/password_manager/android/java/src/org/chromium/chrome/browser/password_manager/settings/PasswordListObserver.java", diff --git a/browser/password_manager/android/password_ui_view_android.cc b/browser/password_manager/android/password_ui_view_android.cc index f96b92082bd..3e229acdf3a 100644 --- a/browser/password_manager/android/password_ui_view_android.cc +++ b/browser/password_manager/android/password_ui_view_android.cc @@ -37,11 +37,13 @@ #include "chrome/grit/generated_resources.h" #include "components/password_manager/core/browser/export/password_csv_writer.h" #include "components/password_manager/core/browser/form_parsing/form_data_parser.h" +#include "components/password_manager/core/browser/import/password_importer.h" #include "components/password_manager/core/browser/leak_detection/leak_detection_check_impl.h" #include "components/password_manager/core/browser/password_form.h" #include "components/password_manager/core/browser/password_ui_utils.h" #include "components/password_manager/core/browser/ui/credential_provider_interface.h" #include "components/password_manager/core/browser/ui/credential_ui_entry.h" +#include "components/password_manager/core/common/password_manager_constants.h" #include "content/public/browser/browser_thread.h" #include "ui/base/l10n/l10n_util.h" #include "url/gurl.h" @@ -305,6 +307,49 @@ jboolean PasswordUiViewAndroid::IsWaitingForPasswordStore(JNIEnv* env) { return saved_passwords_presenter_.IsWaitingForPasswordStore(); } +void PasswordUiViewAndroid::HandleImportPasswordsFromCsv( + JNIEnv* env, + const std::string& csv_content, + const JavaRef& success_callback, + const JavaRef& error_callback) { + // Create a PasswordImporter instance + auto importer = std::make_unique( + &saved_passwords_presenter_); + + // Capture the importer as a raw pointer before moving it into the callback + password_manager::PasswordImporter* importer_ptr = importer.get(); + + // Create callbacks that will be called when import completes + auto results_callback = base::BindOnce( + [](const base::android::JavaRef& success_callback, + const base::android::JavaRef& error_callback, + std::unique_ptr importer, + const password_manager::ImportResults& results) { + if (results.status == + password_manager::ImportResults::Status::SUCCESS) { + // Success - call success callback with number of imported passwords + base::android::RunIntCallbackAndroid(success_callback, + results.number_imported); + } else { + // Error - call error callback with error message + base::android::RunIntCallbackAndroid( + error_callback, static_cast(results.status)); + } + }, + base::android::ScopedJavaGlobalRef(env, success_callback), + base::android::ScopedJavaGlobalRef(env, error_callback), + std::move(importer)); + + // Start the import with the CSV content + importer_ptr->Import(csv_content, + password_manager::PasswordForm::Store::kProfileStore, + std::move(results_callback)); +} + +jint JNI_PasswordUiView_GetMaxPasswordsPerCsvFile(JNIEnv* env) { + return password_manager::constants::kMaxPasswordsPerCSVFile; +} + // static static jlong JNI_PasswordUiView_Init( JNIEnv* env, diff --git a/browser/password_manager/android/password_ui_view_android.h b/browser/password_manager/android/password_ui_view_android.h index 22a20c305e8..e87e333ff28 100644 --- a/browser/password_manager/android/password_ui_view_android.h +++ b/browser/password_manager/android/password_ui_view_android.h @@ -86,6 +86,11 @@ class PasswordUiViewAndroid JNIEnv* env, const base::android::JavaParamRef& context, int index); + void HandleImportPasswordsFromCsv( + JNIEnv* env, + const std::string& csv_content, + const base::android::JavaRef& success_callback, + const base::android::JavaRef& error_callback); jboolean IsWaitingForPasswordStore(JNIEnv* env); // Destroy the native implementation. void Destroy(JNIEnv*); diff --git a/browser/sources.gni b/browser/sources.gni index 5f974581a0a..e2c33cc6064 100644 --- a/browser/sources.gni +++ b/browser/sources.gni @@ -460,6 +460,7 @@ if (is_android) { "//brave/components/brave_sync:sync_service_impl_helper", "//chrome/android:jni_headers", "//chrome/browser/password_manager/android:jni_headers", + "//components/password_manager/core/browser/import:importer", "//components/sync_device_info", ] } else { diff --git a/browser/ui/android/strings/android_brave_strings.grd b/browser/ui/android/strings/android_brave_strings.grd index 00ff17b6ed9..c18ec8805e5 100644 --- a/browser/ui/android/strings/android_brave_strings.grd +++ b/browser/ui/android/strings/android_brave_strings.grd @@ -167,6 +167,12 @@ This file contains all "about" strings. It is set to NOT be translated, in tran Select your country so we can show you the right options and ads for your region. + + Import passwords + + + Select a .csv file that contains your passwords to import them to Brave's password manager. + Now you can get rewarded for viewing ads. You’re helping make the web a better place for everyone. And that’s awesome! @@ -4151,6 +4157,47 @@ If you don't accept this request, VPN will not reconnect and your internet conne Details: %1$sIOException: No space left on device + + Select file + + + Your passwords weren't imported + + + Import successful! + + + Can't import passwords. Check <span class="bold-text">$1</span><span class="bold-text">filename.csv</span> and make sure it's formatted correctly. <a href="$2" target="_blank">Learn more</a> + + + Can't import passwords. The file size should be less than 1 MB. + + + + {COUNT, plural, + =1 {Can't import passwords. You can only import 1 password at a time.} + other {Can't import passwords. You can only import up to 3000%1$d passwords at a time.}} + + + A password for this account is already saved on this device + + + You're already importing passwords in another tab + + + Failed to read the selected file. %1$s + + + {IMPORT_COUNT, plural, + =1 {Successfully imported 1 password.} + other {Successfully imported %1$d passwords.}} + + + Import failed. + + + Password manager not available. + Unlock to export your passwords