// Copyright 2015 The Chromium Authors. All rights reserved. // 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.signin; import android.app.Activity; import android.app.FragmentManager; import android.content.Context; import android.graphics.Bitmap; import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.util.AttributeSet; import android.view.View; import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.Callback; import org.chromium.base.metrics.RecordUserAction; import org.chromium.chrome.R; import org.chromium.chrome.browser.externalauth.ExternalAuthUtils; import org.chromium.chrome.browser.externalauth.UserRecoverableErrorHandler; import org.chromium.chrome.browser.firstrun.ProfileDataCache; import org.chromium.chrome.browser.preferences.PrefServiceBridge; import org.chromium.chrome.browser.profiles.ProfileDownloader; import org.chromium.chrome.browser.signin.AccountTrackerService.OnSystemAccountsSeededListener; import org.chromium.chrome.browser.signin.ConfirmImportSyncDataDialog.ImportSyncType; import org.chromium.components.signin.AccountManagerHelper; import org.chromium.ui.text.NoUnderlineClickableSpan; import org.chromium.ui.text.SpanApplier; import org.chromium.ui.text.SpanApplier.SpanInfo; import org.chromium.ui.widget.ButtonCompat; import java.util.List; // TODO(gogerald): refactor common part into one place after redesign all sign in screens. /** * This view allows the user to select an account to log in to, add an account, * cancel account selection, etc. Users of this class should * {@link AccountSigninView#setListener(Listener)} and * {@link AccountSigninView#setDelegate(Delegate)} after the view has been inflated. */ public class AccountSigninView extends FrameLayout implements ProfileDownloader.Observer { /** * Callbacks for various account selection events. */ public interface Listener { /** * The user canceled account selection. */ public void onAccountSelectionCanceled(); /** * The user wants to make a new account. */ public void onNewAccount(); /** * The user completed the View and selected an account. * @param accountName The name of the account * @param settingsClicked If true, user requested to see their sync settings, if false * they just clicked Done. */ public void onAccountSelected(String accountName, boolean settingsClicked); /** * Failed to set the forced account because it wasn't found. * @param forcedAccountName The name of the forced-sign-in account */ public void onFailedToSetForcedAccount(String forcedAccountName); } // TODO(peconn): Investigate expanding the Delegate to simplify the Listener implementations. /** * Provides UI objects for new UI component creation. */ public interface Delegate { /** * Provides an Activity for the View to check GMSCore version. */ public Activity getActivity(); /** * Provides a FragmentManager for the View to create dialogs. This is done through a * different mechanism than getActivity().getFragmentManager() as a potential fix to * https://crbug.com/646978 on the theory that getActivity() and getFragmentManager() * return null at different times. */ public FragmentManager getFragmentManager(); } private static final String TAG = "AccountSigninView"; private static final String SETTINGS_LINK_OPEN = "<LINK1>"; private static final String SETTINGS_LINK_CLOSE = "</LINK1>"; private AccountManagerHelper mAccountManagerHelper; private List<String> mAccountNames; private AccountSigninChooseView mSigninChooseView; private ButtonCompat mPositiveButton; private Button mNegativeButton; private Button mMoreButton; private Listener mListener; private Delegate mDelegate; private String mForcedAccountName; private ProfileDataCache mProfileData; private boolean mSignedIn; private int mCancelButtonTextId; private boolean mIsChildAccount; private AccountSigninConfirmationView mSigninConfirmationView; private ImageView mSigninAccountImage; private TextView mSigninAccountName; private TextView mSigninAccountEmail; private TextView mSigninSettingsControl; public AccountSigninView(Context context, AttributeSet attrs) { super(context, attrs); mAccountManagerHelper = AccountManagerHelper.get(getContext().getApplicationContext()); } /** * Initializes this view with profile images and full names. * @param profileData ProfileDataCache that will be used to call to retrieve user account info. */ public void init(ProfileDataCache profileData) { mProfileData = profileData; mProfileData.setObserver(this); showSigninPage(); } @Override protected void onFinishInflate() { super.onFinishInflate(); mSigninChooseView = (AccountSigninChooseView) findViewById(R.id.account_signin_choose_view); mSigninChooseView.setAddNewAccountObserver(new AccountSigninChooseView.Observer() { @Override public void onAddNewAccount() { mListener.onNewAccount(); RecordUserAction.record("Signin_AddAccountToDevice"); } }); mPositiveButton = (ButtonCompat) findViewById(R.id.positive_button); mNegativeButton = (Button) findViewById(R.id.negative_button); mMoreButton = (Button) findViewById(R.id.more_button); // A workaround for Android support library ignoring padding set in XML. b/20307607 int padding = getResources().getDimensionPixelSize(R.dimen.fre_button_padding); ApiCompatibilityUtils.setPaddingRelative(mPositiveButton, padding, 0, padding, 0); ApiCompatibilityUtils.setPaddingRelative(mNegativeButton, padding, 0, padding, 0); // TODO(peconn): Ensure this is changed to R.string.cancel when used in Settings > Sign In. mCancelButtonTextId = R.string.no_thanks; mSigninConfirmationView = (AccountSigninConfirmationView) findViewById(R.id.signin_confirmation_view); mSigninConfirmationView.setScrolledToBottomObserver( new AccountSigninConfirmationView.Observer() { @Override public void onScrolledToBottom() { setUpMoreButtonVisible(false); } }); mSigninAccountImage = (ImageView) findViewById(R.id.signin_account_image); mSigninAccountName = (TextView) findViewById(R.id.signin_account_name); mSigninAccountEmail = (TextView) findViewById(R.id.signin_account_email); mSigninSettingsControl = (TextView) findViewById(R.id.signin_settings_control); // For the spans to be clickable. mSigninSettingsControl.setMovementMethod(LinkMovementMethod.getInstance()); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); updateAccounts(); } @Override public void onWindowVisibilityChanged(int visibility) { super.onWindowVisibilityChanged(visibility); if (visibility == View.VISIBLE) { updateAccounts(); } } /** * Changes the visuals slightly for when this view appears in the recent tabs page instead of * in first run. * This is currently used when signing in from the Recent Tabs or Bookmarks pages. */ public void configureForRecentTabsOrBookmarksPage() { mCancelButtonTextId = R.string.cancel; setUpCancelButton(); } /** * Enable or disable UI elements so the user can't select an account, cancel, etc. * * @param enabled The state to change to. */ public void setButtonsEnabled(boolean enabled) { mPositiveButton.setEnabled(enabled); mNegativeButton.setEnabled(enabled); } /** * Set the account selection event listener. See {@link Listener} * * @param listener The listener. */ public void setListener(Listener listener) { mListener = listener; } /** * Set the UI object creation delegate. See {@link Delegate} * @param delegate The delegate. */ public void setDelegate(Delegate delegate) { mDelegate = delegate; } /** * Refresh the list of available system accounts. */ private void updateAccounts() { if (mSignedIn || mProfileData == null) return; List<String> oldAccountNames = mAccountNames; mAccountNames = mAccountManagerHelper.getGoogleAccountNames(); int accountToSelect = 0; if (isInForcedAccountMode()) { accountToSelect = mAccountNames.indexOf(mForcedAccountName); if (accountToSelect < 0) { mListener.onFailedToSetForcedAccount(mForcedAccountName); return; } } else { accountToSelect = getIndexOfNewElement( oldAccountNames, mAccountNames, mSigninChooseView.getSelectedAccountPosition()); } int oldSelectedAccount = mSigninChooseView.getSelectedAccountPosition(); mSigninChooseView.updateAccounts(mAccountNames, accountToSelect, mProfileData); if (mAccountNames.isEmpty()) { setUpSigninButton(false); return; } setUpSigninButton(true); mProfileData.update(); // Determine how the accounts have changed. Each list should only have unique elements. if (oldAccountNames == null || oldAccountNames.isEmpty()) return; if (!mAccountNames.get(accountToSelect).equals(oldAccountNames.get(oldSelectedAccount))) { // Any dialogs that may have been showing are now invalid (they were created for the // previously selected account). ConfirmSyncDataStateMachine .cancelAllDialogs(mDelegate.getFragmentManager()); if (mAccountNames.containsAll(oldAccountNames)) { // A new account has been added and no accounts have been deleted. We will have // changed the account selection to the newly added account, so shortcut to the // confirm signin page. showConfirmSigninPageAccountTrackerServiceCheck(); } } } /** * Attempt to select a new element that is in the new list, but not in the old list. * If no such element exist and both the new and the old lists are the same then keep * the selection. Otherwise select the first element. * @param oldList Old list of user accounts. * @param newList New list of user accounts. * @param oldIndex Index of the selected account in the old list. * @return The index of the new element, if it does not exist but lists are the same the * return the old index, otherwise return 0. */ private static int getIndexOfNewElement( List<String> oldList, List<String> newList, int oldIndex) { if (oldList == null || newList == null) return 0; if (oldList.size() == newList.size() && oldList.containsAll(newList)) return oldIndex; if (oldList.size() + 1 == newList.size()) { for (int i = 0; i < newList.size(); i++) { if (!oldList.contains(newList.get(i))) return i; } } return 0; } @Override public void onProfileDownloaded(String accountId, String fullName, String givenName, Bitmap bitmap) { mSigninChooseView.updateAccountProfileImages(mProfileData); if (mSignedIn) updateSignedInAccountInfo(); } private void updateSignedInAccountInfo() { String selectedAccountEmail = getSelectedAccountName(); mSigninAccountImage.setImageBitmap(mProfileData.getImage(selectedAccountEmail)); String name = null; if (mIsChildAccount) name = mProfileData.getGivenName(selectedAccountEmail); if (name == null) name = mProfileData.getFullName(selectedAccountEmail); if (name == null) name = selectedAccountEmail; String text = String.format(getResources().getString(R.string.signin_hi_name), name); mSigninAccountName.setText(text); mSigninAccountEmail.setText(selectedAccountEmail); } /** * Updates the view to show that sign in has completed. * This should only be used if the user is not currently signed in (eg on the First * Run Experience). */ public void switchToSignedMode() { // TODO(peconn): Add a warning here showConfirmSigninPage(); } private void showSigninPage() { mSignedIn = false; mSigninConfirmationView.setVisibility(View.GONE); mSigninChooseView.setVisibility(View.VISIBLE); setUpCancelButton(); updateAccounts(); } private void showConfirmSigninPage() { mSignedIn = true; updateSignedInAccountInfo(); mSigninChooseView.setVisibility(View.GONE); mSigninConfirmationView.setVisibility(View.VISIBLE); setButtonsEnabled(true); setUpConfirmButton(); setUpUndoButton(); NoUnderlineClickableSpan settingsSpan = new NoUnderlineClickableSpan() { @Override public void onClick(View widget) { mListener.onAccountSelected(getSelectedAccountName(), true); RecordUserAction.record("Signin_Signin_WithAdvancedSyncSettings"); } }; mSigninSettingsControl.setText( SpanApplier.applySpans(getSettingsControlDescription(mIsChildAccount), new SpanInfo(SETTINGS_LINK_OPEN, SETTINGS_LINK_CLOSE, settingsSpan))); } private void showConfirmSigninPageAccountTrackerServiceCheck() { if (!ExternalAuthUtils.getInstance().canUseGooglePlayServices(getContext(), new UserRecoverableErrorHandler.ModalDialog(mDelegate.getActivity()))) { return; } // Disable the buttons to prevent them being clicked again while waiting for the callbacks. setButtonsEnabled(false); // Ensure that the AccountTrackerService has a fully up to date GAIA id <-> email mapping, // as this is needed for the previous account check. if (AccountTrackerService.get(getContext()).checkAndSeedSystemAccounts()) { showConfirmSigninPagePreviousAccountCheck(); } else { AccountTrackerService.get(getContext()).addSystemAccountsSeededListener( new OnSystemAccountsSeededListener() { @Override public void onSystemAccountsSeedingComplete() { AccountTrackerService.get(getContext()) .removeSystemAccountsSeededListener(this); showConfirmSigninPagePreviousAccountCheck(); } @Override public void onSystemAccountsChanged() {} }); } } private void showConfirmSigninPagePreviousAccountCheck() { String accountName = getSelectedAccountName(); ConfirmSyncDataStateMachine.run(PrefServiceBridge.getInstance().getSyncLastAccountName(), accountName, ImportSyncType.PREVIOUS_DATA_FOUND, mDelegate.getFragmentManager(), getContext(), new ConfirmImportSyncDataDialog.Listener() { @Override public void onConfirm(boolean wipeData) { SigninManager.wipeSyncUserDataIfRequired(wipeData) .then(new Callback<Void>() { @Override public void onResult(Void v) { showConfirmSigninPage(); } }); } @Override public void onCancel() { setButtonsEnabled(true); } }); } private void setUpCancelButton() { setNegativeButtonVisible(true); mNegativeButton.setText(getResources().getText(mCancelButtonTextId)); mNegativeButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { setButtonsEnabled(false); mListener.onAccountSelectionCanceled(); } }); } private void setUpSigninButton(boolean hasAccounts) { if (hasAccounts) { mPositiveButton.setText(R.string.continue_sign_in); mPositiveButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { showConfirmSigninPageAccountTrackerServiceCheck(); } }); } else { mPositiveButton.setText(R.string.choose_account_sign_in); mPositiveButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { RecordUserAction.record("Signin_AddAccountToDevice"); mListener.onNewAccount(); } }); } setUpMoreButtonVisible(false); } private void setUpUndoButton() { setNegativeButtonVisible(!isInForcedAccountMode()); if (isInForcedAccountMode()) return; mNegativeButton.setText(getResources().getText(R.string.undo)); mNegativeButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { RecordUserAction.record("Signin_Undo_Signin"); showSigninPage(); } }); } private void setUpConfirmButton() { mPositiveButton.setText(getResources().getText(R.string.signin_accept)); mPositiveButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { mListener.onAccountSelected(getSelectedAccountName(), false); RecordUserAction.record("Signin_Signin_WithDefaultSyncSettings"); } }); setUpMoreButtonVisible(true); } /* * mMoreButton is used to scroll mSigninConfirmationView down. It displays at the same position * as mPositiveButton. */ private void setUpMoreButtonVisible(boolean enabled) { if (enabled) { mPositiveButton.setVisibility(View.GONE); mMoreButton.setVisibility(View.VISIBLE); mMoreButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { mSigninConfirmationView.smoothScrollBy(0, mSigninConfirmationView.getHeight()); RecordUserAction.record("Signin_MoreButton_Shown"); } }); } else { mPositiveButton.setVisibility(View.VISIBLE); mMoreButton.setVisibility(View.GONE); } } private void setNegativeButtonVisible(boolean enabled) { if (enabled) { mNegativeButton.setVisibility(View.VISIBLE); findViewById(R.id.positive_button_end_padding).setVisibility(View.GONE); } else { mNegativeButton.setVisibility(View.GONE); findViewById(R.id.positive_button_end_padding).setVisibility(View.INVISIBLE); } } private String getSettingsControlDescription(boolean childAccount) { if (childAccount) { return getResources().getString(R.string.signin_signed_in_settings_description) + '\n' + getResources().getString(R.string.signin_signed_in_description_uca_addendum); } else { return getResources().getString(R.string.signin_signed_in_settings_description); } } /** * @param isChildAccount Whether this view is for a child account. */ public void setIsChildAccount(boolean isChildAccount) { mIsChildAccount = isChildAccount; } /** * Switches the view to "no choice, just a confirmation" forced-account mode. * @param forcedAccountName An account that should be force-selected. */ public void switchToForcedAccountMode(String forcedAccountName) { mForcedAccountName = forcedAccountName; updateAccounts(); assert TextUtils.equals(getSelectedAccountName(), mForcedAccountName); switchToSignedMode(); assert TextUtils.equals(getSelectedAccountName(), mForcedAccountName); } /** * @return Whether the view is in signed in mode. */ public boolean isSignedIn() { return mSignedIn; } /** * @return Whether the view is in "no choice, just a confirmation" forced-account mode. */ public boolean isInForcedAccountMode() { return mForcedAccountName != null; } private String getSelectedAccountName() { return mAccountNames.get(mSigninChooseView.getSelectedAccountPosition()); } }