// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.sync.settings;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.UserManager;
import androidx.annotation.LayoutRes;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceScreen;
import org.chromium.base.ContextUtils;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.settings.ChromeBaseSettingsFragment;
import org.chromium.chrome.browser.settings.ChromeManagedPreferenceDelegate;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.signin.services.DisplayableProfileData;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.ProfileDataCache;
import org.chromium.chrome.browser.signin.services.SigninManager.SignInStateObserver;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.chrome.browser.sync.TrustedVaultClient;
import org.chromium.chrome.browser.sync.settings.SyncSettingsUtils.SyncError;
import org.chromium.chrome.browser.sync.ui.PassphraseDialogFragment;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.ui.signin.SignOutCoordinator;
import org.chromium.chrome.browser.ui.signin.SigninUtils;
import org.chromium.components.browser_ui.settings.ChromeBasePreference;
import org.chromium.components.browser_ui.settings.CustomDividerFragment;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.signin.AccountManagerFacade;
import org.chromium.components.signin.AccountManagerFacadeProvider;
import org.chromium.components.signin.AccountUtils;
import org.chromium.components.signin.GAIAServiceType;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.metrics.SignoutReason;
import org.chromium.components.sync.SyncService;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.ui.UiUtils;
import org.chromium.ui.modaldialog.ModalDialogManagerHolder;
import java.util.List;
/**
* The settings screen with information and settings related to the user's accounts.
*
* <p>This shows which accounts the user is signed in with, allows the user to sign out of Chrome,
* links to sync settings, has links to add accounts and go incognito, and shows parental settings
* if a child account is in use.
*
* <p>Note: This can be triggered from a web page, e.g. a GAIA sign-in page.
*/
public class AccountManagementFragment extends ChromeBaseSettingsFragment
implements SignInStateObserver,
ProfileDataCache.Observer,
CustomDividerFragment,
IdentityErrorCardPreference.Listener,
PassphraseDialogFragment.Delegate {
private static final int REQUEST_CODE_TRUSTED_VAULT_KEY_RETRIEVAL = 1;
private static final int REQUEST_CODE_TRUSTED_VAULT_RECOVERABILITY_DEGRADED = 2;
@VisibleForTesting public static final String FRAGMENT_ENTER_PASSPHRASE = "enter_passphase";
/**
* The key for an integer value in arguments bundle to specify the correct GAIA service that has
* triggered the dialog. If the argument is not set, GAIA_SERVICE_TYPE_NONE is used as the
* origin of the dialog.
*/
private static final String SHOW_GAIA_SERVICE_TYPE_EXTRA = "ShowGAIAServiceType";
private static final String PREF_IDENTITY_ERROR_CARD_PREFERENCE = "identity_error_card";
private static final String PREF_ACCOUNTS_CATEGORY = "accounts_category";
private static final String PREF_PARENT_ACCOUNT_CATEGORY = "parent_account_category";
private static final String PREF_SIGN_OUT = "sign_out";
private static final String PREF_SIGN_OUT_DIVIDER = "sign_out_divider";
private @GAIAServiceType int mGaiaServiceType = GAIAServiceType.GAIA_SERVICE_TYPE_NONE;
private IdentityErrorCardPreference mIdentityErrorCardPreference;
private CoreAccountInfo mSignedInCoreAccountInfo;
private ProfileDataCache mProfileDataCache;
private SyncService mSyncService;
private @Nullable SyncService.SyncSetupInProgressHandle mSyncSetupInProgressHandle;
private OneshotSupplier<SnackbarManager> mSnackbarManagerSupplier;
private final ObservableSupplierImpl<String> mPageTitle = new ObservableSupplierImpl<>();
@Override
public void onCreatePreferences(Bundle savedState, String rootKey) {
mSyncService = SyncServiceFactory.getForProfile(getProfile());
if (mSyncService != null) {
// Prevent sync settings changes from taking effect until the user leaves this screen.
mSyncSetupInProgressHandle = mSyncService.getSetupInProgressHandle();
}
if (getArguments() != null) {
mGaiaServiceType =
getArguments().getInt(SHOW_GAIA_SERVICE_TYPE_EXTRA, mGaiaServiceType);
}
mProfileDataCache = ProfileDataCache.createWithDefaultImageSizeAndNoBadge(requireContext());
}
@Override
public ObservableSupplier<String> getPageTitle() {
return mPageTitle;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// Disable animations of preference changes (crbug.com/986401).
getListView().setItemAnimator(null);
}
@Override
public boolean hasDivider() {
return false;
}
@Override
public void onDestroy() {
super.onDestroy();
if (mSyncSetupInProgressHandle != null) {
mSyncSetupInProgressHandle.close();
}
}
@Override
public void onResume() {
super.onResume();
IdentityServicesProvider.get().getSigninManager(getProfile()).addSignInStateObserver(this);
mProfileDataCache.addObserver(this);
update();
}
@Override
public void onPause() {
super.onPause();
mProfileDataCache.removeObserver(this);
IdentityServicesProvider.get()
.getSigninManager(getProfile())
.removeSignInStateObserver(this);
}
public void update() {
final Context context = getActivity();
if (context == null) return;
if (getPreferenceScreen() != null) getPreferenceScreen().removeAll();
mSignedInCoreAccountInfo =
IdentityServicesProvider.get()
.getIdentityManager(getProfile())
.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
List<CoreAccountInfo> coreAccountInfos =
AccountUtils.getCoreAccountInfosIfFulfilledOrEmpty(
AccountManagerFacadeProvider.getInstance().getCoreAccountInfos());
if (mSignedInCoreAccountInfo == null || coreAccountInfos.isEmpty()) {
// The AccountManagementFragment can only be shown when the user is signed in. If the
// user is signed out, exit the fragment.
getActivity().finish();
return;
}
DisplayableProfileData profileData =
mProfileDataCache.getProfileDataOrDefault(mSignedInCoreAccountInfo.getEmail());
mPageTitle.set(
SyncSettingsUtils.getDisplayableFullNameOrEmailWithPreference(
profileData, getContext(), SyncSettingsUtils.TitlePreference.FULL_NAME));
addPreferencesFromResource(R.xml.account_management_preferences);
configureSignOutSwitch();
configureChildAccountPreferences();
AccountManagerFacadeProvider.getInstance()
.getCoreAccountInfos()
.then(this::updateAccountsList);
if (!ChromeFeatureList.isEnabled(
ChromeFeatureList.REPLACE_SYNC_PROMOS_WITH_SIGN_IN_PROMOS)) {
// TODO(crbug.com/40944114): Figure out the behaviour for child accounts.
mIdentityErrorCardPreference =
(IdentityErrorCardPreference)
findPreference(PREF_IDENTITY_ERROR_CARD_PREFERENCE);
mIdentityErrorCardPreference.initialize(getProfile(), this);
}
}
/**
* The ProfileDataCache object needs to be accessible in some tests, for example in order to
* await the completion of async population of the cache.
*/
public ProfileDataCache getProfileDataCacheForTesting() {
return mProfileDataCache;
}
private boolean canAddAccounts() {
UserManager userManager =
(UserManager) getActivity().getSystemService(Context.USER_SERVICE);
return !userManager.hasUserRestriction(UserManager.DISALLOW_MODIFY_ACCOUNTS);
}
public void setSnackbarManagerSupplier(
OneshotSupplier<SnackbarManager> snackbarManagerSupplier) {
mSnackbarManagerSupplier = snackbarManagerSupplier;
}
private void configureSignOutSwitch() {
Preference signOutPreference = findPreference(PREF_SIGN_OUT);
if (getProfile().isChild()) {
getPreferenceScreen().removePreference(signOutPreference);
getPreferenceScreen().removePreference(findPreference(PREF_SIGN_OUT_DIVIDER));
} else {
signOutPreference.setLayoutResource(R.layout.account_management_account_row);
signOutPreference.setIcon(R.drawable.ic_signout_40dp);
signOutPreference.setTitle(
IdentityServicesProvider.get()
.getIdentityManager(getProfile())
.hasPrimaryAccount(ConsentLevel.SYNC)
? R.string.sign_out_and_turn_off_sync
: R.string.sign_out);
signOutPreference.setOnPreferenceClickListener(
preference -> {
if (!isVisible() || !isResumed() || mSignedInCoreAccountInfo == null) {
return false;
}
if (ChromeFeatureList.isEnabled(
ChromeFeatureList.REPLACE_SYNC_PROMOS_WITH_SIGN_IN_PROMOS)) {
SignOutCoordinator.startSignOutFlow(
requireContext(),
getProfile(),
getChildFragmentManager(),
((ModalDialogManagerHolder) getActivity())
.getModalDialogManager(),
mSnackbarManagerSupplier.get(),
SignoutReason.USER_CLICKED_SIGNOUT_SETTINGS,
/* showConfirmDialog= */ false,
() -> {});
return true;
}
if (IdentityServicesProvider.get()
.getIdentityManager(getProfile())
.getPrimaryAccountInfo(ConsentLevel.SYNC)
!= null) {
// Only show the sign-out dialog if the user has given sync consent.
SignOutCoordinator.startSignOutFlow(
requireContext(),
getProfile(),
getChildFragmentManager(),
((ModalDialogManagerHolder) getActivity())
.getModalDialogManager(),
mSnackbarManagerSupplier.get(),
SignoutReason.USER_CLICKED_SIGNOUT_SETTINGS,
/* showConfirmDialog= */ false,
() -> {});
} else {
IdentityServicesProvider.get()
.getSigninManager(getProfile())
.signOut(
SignoutReason.USER_CLICKED_SIGNOUT_SETTINGS,
null,
false);
}
return true;
});
}
}
private void configureChildAccountPreferences() {
Preference parentAccounts = findPreference(PREF_PARENT_ACCOUNT_CATEGORY);
if (getProfile().isChild()) {
PrefService prefService = UserPrefs.get(getProfile());
String firstParent = prefService.getString(Pref.SUPERVISED_USER_CUSTODIAN_EMAIL);
String secondParent =
prefService.getString(Pref.SUPERVISED_USER_SECOND_CUSTODIAN_EMAIL);
final String parentText;
if (!secondParent.isEmpty()) {
parentText =
getString(
R.string.account_management_header_two_parent_names,
firstParent,
secondParent);
} else if (!firstParent.isEmpty()) {
parentText =
getString(R.string.account_management_header_one_parent_name, firstParent);
} else {
parentText = getString(R.string.account_management_header_no_parental_data);
}
parentAccounts.setSummary(parentText);
} else {
PreferenceScreen prefScreen = getPreferenceScreen();
prefScreen.removePreference(findPreference(PREF_PARENT_ACCOUNT_CATEGORY));
}
}
private void updateAccountsList(List<CoreAccountInfo> coreAccountInfos) {
// This method is called asynchronously on accounts fetched from AccountManagerFacade.
// Make sure the fragment is alive before updating preferences.
if (!isResumed()) return;
setAccountBadges(coreAccountInfos);
PreferenceCategory accountsCategory = findPreference(PREF_ACCOUNTS_CATEGORY);
if (accountsCategory == null) {
// This pref is dynamically added/removed many times, so it might not be present by now.
// More details can be found in crbug/1221491.
return;
}
accountsCategory.removeAll();
accountsCategory.addPreference(createAccountPreference(mSignedInCoreAccountInfo));
accountsCategory.addPreference(
createDividerPreference(R.layout.account_divider_preference));
accountsCategory.addPreference(createManageYourGoogleAccountPreference());
accountsCategory.addPreference(createDividerPreference(R.layout.horizontal_divider));
for (CoreAccountInfo coreAccountInfo : coreAccountInfos) {
if (!mSignedInCoreAccountInfo.equals(coreAccountInfo)) {
accountsCategory.addPreference(createAccountPreference(coreAccountInfo));
}
}
accountsCategory.addPreference(createAddAccountPreference());
}
private Preference createAccountPreference(CoreAccountInfo coreAccountInfo) {
Preference accountPreference = new Preference(getStyledContext());
accountPreference.setLayoutResource(R.layout.account_management_account_row);
DisplayableProfileData profileData =
mProfileDataCache.getProfileDataOrDefault(coreAccountInfo.getEmail());
accountPreference.setTitle(
SyncSettingsUtils.getDisplayableFullNameOrEmailWithPreference(
profileData, getContext(), SyncSettingsUtils.TitlePreference.EMAIL));
accountPreference.setIcon(profileData.getImage());
accountPreference.setOnPreferenceClickListener(
SyncSettingsUtils.toOnClickListener(
this,
() ->
SigninUtils.openSettingsForAccount(
getActivity(), coreAccountInfo.getEmail())));
return accountPreference;
}
private Preference createManageYourGoogleAccountPreference() {
Preference manageYourGoogleAccountPreference = new Preference(getStyledContext());
manageYourGoogleAccountPreference.setLayoutResource(
R.layout.account_management_account_row);
manageYourGoogleAccountPreference.setTitle(R.string.manage_your_google_account);
Drawable googleServicesIcon =
UiUtils.getTintedDrawable(
getContext(),
R.drawable.ic_google_services_48dp,
R.color.default_icon_color_tint_list);
manageYourGoogleAccountPreference.setIcon(googleServicesIcon);
manageYourGoogleAccountPreference.setOnPreferenceClickListener(
SyncSettingsUtils.toOnClickListener(
this,
() -> {
assert IdentityServicesProvider.get()
.getIdentityManager(getProfile())
.hasPrimaryAccount(ConsentLevel.SIGNIN);
SyncSettingsUtils.openGoogleMyAccount(getActivity());
}));
return manageYourGoogleAccountPreference;
}
private Preference createDividerPreference(@LayoutRes int layoutResId) {
Preference dividerPreference = new Preference(getStyledContext());
dividerPreference.setLayoutResource(layoutResId);
return dividerPreference;
}
private ChromeBasePreference createAddAccountPreference() {
ChromeBasePreference addAccountPreference = new ChromeBasePreference(getStyledContext());
addAccountPreference.setLayoutResource(R.layout.account_management_account_row);
addAccountPreference.setIcon(R.drawable.ic_person_add_40dp);
addAccountPreference.setTitle(R.string.signin_add_account_to_device);
addAccountPreference.setOnPreferenceClickListener(
preference -> {
if (!isVisible() || !isResumed()) return false;
AccountManagerFacade accountManagerFacade =
AccountManagerFacadeProvider.getInstance();
accountManagerFacade.createAddAccountIntent(
(@Nullable Intent intent) -> {
if (!isVisible() || !isResumed()) return;
if (intent != null) {
startActivity(intent);
} else {
// AccountManagerFacade couldn't create intent, use SigninUtils
// to open settings instead.
SigninUtils.openSettingsForAllAccounts(getActivity());
}
// Return to the last opened tab if triggered from the content area.
if (mGaiaServiceType != GAIAServiceType.GAIA_SERVICE_TYPE_NONE) {
if (isAdded()) getActivity().finish();
}
});
return true;
});
addAccountPreference.setManagedPreferenceDelegate(
new ChromeManagedPreferenceDelegate(getProfile()) {
@Override
public boolean isPreferenceControlledByPolicy(Preference preference) {
return !canAddAccounts();
}
});
return addAccountPreference;
}
private Context getStyledContext() {
return getPreferenceManager().getContext();
}
private void setAccountBadges(List<CoreAccountInfo> coreAccountInfos) {
for (CoreAccountInfo coreAccountInfo : coreAccountInfos) {
AccountManagerFacadeProvider.getInstance()
.checkChildAccountStatus(
coreAccountInfo,
(isChild, childAccount) -> {
if (isChild) {
mProfileDataCache.setBadge(
childAccount.getEmail(),
R.drawable.ic_account_child_20dp);
}
});
}
}
// ProfileDataCache.Observer implementation:
@Override
public void onProfileDataUpdated(String accountEmail) {
AccountManagerFacadeProvider.getInstance()
.getCoreAccountInfos()
.then(this::updateAccountsList);
}
// SignInStateObserver implementation:
@Override
public void onSignedIn() {
update();
}
@Override
public void onSignedOut() {
update();
}
// IdentityErrorCardPreference.Listener implementation.
@Override
public void onIdentityErrorCardButtonClicked(@SyncError int error) {
assert mSignedInCoreAccountInfo != null;
switch (error) {
case SyncError.AUTH_ERROR:
AccountManagerFacadeProvider.getInstance()
.updateCredentials(
CoreAccountInfo.getAndroidAccountFrom(mSignedInCoreAccountInfo),
getActivity(),
null);
return;
case SyncError.CLIENT_OUT_OF_DATE:
// Opens the client in play store for update.
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(
Uri.parse(
"market://details?id="
+ ContextUtils.getApplicationContext().getPackageName()));
startActivity(intent);
return;
case SyncError.PASSPHRASE_REQUIRED:
displayPassphraseDialog();
return;
case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
SyncSettingsUtils.openTrustedVaultKeyRetrievalDialog(
this, mSignedInCoreAccountInfo, REQUEST_CODE_TRUSTED_VAULT_KEY_RETRIEVAL);
return;
case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
SyncSettingsUtils.openTrustedVaultRecoverabilityDegradedDialog(
this,
mSignedInCoreAccountInfo,
REQUEST_CODE_TRUSTED_VAULT_RECOVERABILITY_DEGRADED);
return;
case SyncError.OTHER_ERRORS:
case SyncError.SYNC_SETUP_INCOMPLETE:
// Identity error card is not shown for unrecoverable errors nor for sync setup
// incomplete error (the latter is shown as part of sync error in the manage sync
// settings page).
assert false; // NOTREACHED()
// fall through
case SyncError.NO_ERROR:
default:
return;
}
}
/**
* Called upon completion of an activity started by a previous call to startActivityForResult()
* via SyncSettingsUtils.openTrustedVaultKeyRetrievalDialog() or
* SyncSettingsUtils.openTrustedVaultRecoverabilityDegradedDialog().
*
* @param requestCode Request code of the requested intent.
* @param resultCode Result code of the requested intent.
* @param data The data returned by the intent.
*/
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
// Upon key retrieval completion, the keys in TrustedVaultClient could have changed. This is
// done even if the user cancelled the flow (i.e. resultCode != RESULT_OK) because it's
// harmless to issue a redundant notifyKeysChanged().
if (requestCode == REQUEST_CODE_TRUSTED_VAULT_KEY_RETRIEVAL) {
TrustedVaultClient.get().notifyKeysChanged();
}
if (requestCode == REQUEST_CODE_TRUSTED_VAULT_RECOVERABILITY_DEGRADED) {
TrustedVaultClient.get().notifyRecoverabilityChanged();
}
}
private void displayPassphraseDialog() {
FragmentTransaction ft = getFragmentManager().beginTransaction();
PassphraseDialogFragment.newInstance(this).show(ft, FRAGMENT_ENTER_PASSPHRASE);
}
/** Returns whether the passphrase successfully decrypted the pending keys. */
private boolean handleDecryption(String passphrase) {
if (passphrase.isEmpty() || !mSyncService.setDecryptionPassphrase(passphrase)) {
return false;
}
// PassphraseDialogFragment doesn't handle closing itself, so do it here. This is not done
// in updateSyncStateFromAndroidSyncSettings() because that happens onResume and possibly in
// other cases where the dialog should stay open.
closeDialogIfOpen(FRAGMENT_ENTER_PASSPHRASE);
// Update UI.
update();
return true;
}
/** Callback for PassphraseDialogFragment.Listener */
@Override
public boolean onPassphraseEntered(String passphrase) {
if (!mSyncService.isEngineInitialized()
|| !mSyncService.isPassphraseRequiredForPreferredDataTypes()) {
// If the engine was shut down since the dialog was opened, or the passphrase isn't
// required anymore, do nothing.
return false;
}
return handleDecryption(passphrase);
}
/** Callback for PassphraseDialogFragment.Listener */
@Override
public void onPassphraseCanceled() {}
/**
* Open the account management UI.
* @param serviceType A signin::GAIAServiceType that triggered the dialog.
*/
public static void openAccountManagementScreen(
Context context, @GAIAServiceType int serviceType) {
Bundle arguments = new Bundle();
arguments.putInt(SHOW_GAIA_SERVICE_TYPE_EXTRA, serviceType);
SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
settingsLauncher.launchSettingsActivity(
context, AccountManagementFragment.class, arguments);
}
private void closeDialogIfOpen(String tag) {
FragmentManager manager = getFragmentManager();
if (manager == null) {
// Do nothing if the manager doesn't exist yet; see http://crbug.com/480544.
return;
}
DialogFragment df = (DialogFragment) manager.findFragmentByTag(tag);
if (df != null) {
df.dismiss();
}
}
}