// 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.accounts.Account;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import com.google.android.gms.auth.AccountChangeEvent;
import com.google.android.gms.auth.GoogleAuthException;
import com.google.android.gms.auth.GoogleAuthUtil;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.browser.invalidation.InvalidationServiceFactory;
import org.chromium.chrome.browser.preferences.ChromePreferenceManager;
import org.chromium.chrome.browser.preferences.PrefServiceBridge;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.signin.SigninManager.SignInCallback;
import org.chromium.chrome.browser.sync.ProfileSyncService;
import org.chromium.components.signin.AccountManagerHelper;
import org.chromium.components.signin.ChromeSigninController;
import org.chromium.components.sync.AndroidSyncSettings;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
/**
* A helper for tasks like re-signin.
*
* This should be merged into SigninManager when it is upstreamed.
*/
public class SigninHelper {
private static final String TAG = "SigninHelper";
private static final Object LOCK = new Object();
private static final String ACCOUNTS_CHANGED_PREFS_KEY = "prefs_sync_accounts_changed";
// Key to the shared pref that holds the new account's name if the currently signed
// in account has been renamed.
private static final String ACCOUNT_RENAMED_PREFS_KEY = "prefs_sync_account_renamed";
// Key to the shared pref that holds the last read index of all the account changed
// events of the current signed in account.
private static final String ACCOUNT_RENAME_EVENT_INDEX_PREFS_KEY =
"prefs_sync_account_rename_event_index";
private static final String ANDROID_ACCOUNTS_PREFS_KEY = "prefs_sync_android_accounts";
private static SigninHelper sInstance;
/**
* Retrieve more detailed information from account changed intents.
*/
public static interface AccountChangeEventChecker {
public List<String> getAccountChangeEvents(
Context context, int index, String accountName);
}
/**
* Uses GoogleAuthUtil.getAccountChangeEvents to detect if account
* renaming has occured.
*/
public static final class SystemAccountChangeEventChecker
implements SigninHelper.AccountChangeEventChecker {
@Override
public List<String> getAccountChangeEvents(
Context context, int index, String accountName) {
try {
List<AccountChangeEvent> list = GoogleAuthUtil.getAccountChangeEvents(
context, index, accountName);
List<String> result = new ArrayList<String>(list.size());
for (AccountChangeEvent e : list) {
if (e.getChangeType() == GoogleAuthUtil.CHANGE_TYPE_ACCOUNT_RENAMED_TO) {
result.add(e.getChangeData());
} else {
result.add(null);
}
}
return result;
} catch (IOException e) {
Log.w(TAG, "Failed to get change events", e);
} catch (GoogleAuthException e) {
Log.w(TAG, "Failed to get change events", e);
}
return new ArrayList<String>(0);
}
}
@VisibleForTesting
protected final Context mContext;
private final ChromeSigninController mChromeSigninController;
@Nullable private final ProfileSyncService mProfileSyncService;
private final SigninManager mSigninManager;
private final AccountTrackerService mAccountTrackerService;
private final OAuth2TokenService mOAuth2TokenService;
public static SigninHelper get(Context context) {
synchronized (LOCK) {
if (sInstance == null) {
sInstance = new SigninHelper(context.getApplicationContext());
}
}
return sInstance;
}
private SigninHelper(Context context) {
mContext = context;
mProfileSyncService = ProfileSyncService.get();
mSigninManager = SigninManager.get(mContext);
mAccountTrackerService = AccountTrackerService.get(mContext);
mOAuth2TokenService = OAuth2TokenService.getForProfile(Profile.getLastUsedProfile());
mChromeSigninController = ChromeSigninController.get(mContext);
}
public void validateAccountSettings(boolean accountsChanged) {
// Ensure System accounts have been seeded.
mAccountTrackerService.checkAndSeedSystemAccounts();
if (!accountsChanged) {
mAccountTrackerService.validateSystemAccounts();
}
Account syncAccount = mChromeSigninController.getSignedInUser();
if (syncAccount == null) {
// Never shows a signin promo if user has manually disconnected.
String lastSyncAccountName =
PrefServiceBridge.getInstance().getSyncLastAccountName();
if (lastSyncAccountName != null && !lastSyncAccountName.isEmpty()) return;
SharedPreferences sharedPrefs = ContextUtils.getAppSharedPreferences();
boolean hasKnownAccountKeys = sharedPrefs.contains(ANDROID_ACCOUNTS_PREFS_KEY);
// Nothing to do if Android accounts are not changed and already known to Chrome.
if (hasKnownAccountKeys && !accountsChanged) return;
List<String> currentAccountNames =
AccountManagerHelper.get(mContext).getGoogleAccountNames();
if (hasKnownAccountKeys) {
ChromePreferenceManager chromePreferenceManager =
ChromePreferenceManager.getInstance(mContext);
if (!chromePreferenceManager.getSigninPromoShown()) {
Set<String> lastKnownAccountNames = sharedPrefs.getStringSet(
ANDROID_ACCOUNTS_PREFS_KEY, new HashSet<String>());
Set<String> newAccountNames = new HashSet<String>(currentAccountNames);
newAccountNames.removeAll(lastKnownAccountNames);
if (!newAccountNames.isEmpty()) {
chromePreferenceManager.setShowSigninPromo(true);
}
}
}
sharedPrefs.edit().putStringSet(
ANDROID_ACCOUNTS_PREFS_KEY, new HashSet<String>(currentAccountNames)).apply();
return;
}
String renamedAccount = getNewSignedInAccountName(mContext);
if (accountsChanged && renamedAccount != null) {
handleAccountRename(ChromeSigninController.get(mContext).getSignedInAccountName(),
renamedAccount);
return;
}
// Always check for account deleted.
if (!accountExists(mContext, syncAccount)) {
// It is possible that Chrome got to this point without account
// rename notification. Let us signout before doing a rename.
// updateAccountRenameData(mContext, new SystemAccountChangeEventChecker());
AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
updateAccountRenameData(mContext, new SystemAccountChangeEventChecker());
return null;
}
@Override
protected void onPostExecute(Void result) {
String renamedAccount = getNewSignedInAccountName(mContext);
if (renamedAccount == null) {
mSigninManager.signOut();
} else {
validateAccountSettings(true);
}
}
};
task.execute();
return;
}
if (accountsChanged) {
// Account details have changed so inform the token service that credentials
// should now be available.
mOAuth2TokenService.validateAccounts(mContext, false);
}
if (mProfileSyncService != null && AndroidSyncSettings.isSyncEnabled(mContext)) {
if (mProfileSyncService.isFirstSetupComplete()) {
if (accountsChanged) {
// Nudge the syncer to ensure it does a full sync.
InvalidationServiceFactory.getForProfile(Profile.getLastUsedProfile())
.requestSyncFromNativeChromeForAllTypes();
}
} else {
// We should have set up sync but for some reason it's not enabled. Tell the sync
// engine to start.
mProfileSyncService.requestStart();
}
}
}
/**
* Deal with account rename. The current approach is to sign out and then sign back in.
* In the (near) future, we should just be clearing all the cached email address here
* and have the UI re-fetch the emailing address based on the ID.
*/
private void handleAccountRename(final String oldName, final String newName) {
Log.i(TAG, "handleAccountRename from: " + oldName + " to " + newName);
// TODO(acleung): I think most of the operations need to run on the main
// thread. May be we should have a progress Dialog?
// TODO(acleung): Deal with passphrase or just prompt user to re-enter it?
// Perform a sign-out with a callback to sign-in again.
mSigninManager.signOut(new Runnable() {
@Override
public void run() {
// Clear the shared perf only after signOut is successful.
// If Chrome dies, we can try it again on next run.
// Otherwise, if re-sign-in fails, we'll just leave chrome
// signed-out.
clearNewSignedInAccountName(mContext);
performResignin(newName);
}
});
}
private void performResignin(String newName) {
// This is the correct account now.
final Account account = AccountManagerHelper.createAccountFromName(newName);
mSigninManager.signIn(account, null, new SignInCallback() {
@Override
public void onSignInComplete() {
if (mProfileSyncService != null) {
mProfileSyncService.setSetupInProgress(false);
}
validateAccountSettings(true);
}
@Override
public void onSignInAborted() {}
});
}
private static boolean accountExists(Context context, Account account) {
Account[] accounts = AccountManagerHelper.get(context).getGoogleAccounts();
for (Account a : accounts) {
if (a.equals(account)) {
return true;
}
}
return false;
}
/**
* Sets the ACCOUNTS_CHANGED_PREFS_KEY to true.
*/
public static void markAccountsChangedPref(Context context) {
// The process may go away as soon as we return from onReceive but Android makes sure
// that in-flight disk writes from apply() complete before changing component states.
ContextUtils.getAppSharedPreferences()
.edit().putBoolean(ACCOUNTS_CHANGED_PREFS_KEY, true).apply();
}
/**
* @return The new account name of the current user. Null if it wasn't renamed.
*/
public static String getNewSignedInAccountName(Context context) {
return (ContextUtils.getAppSharedPreferences()
.getString(ACCOUNT_RENAMED_PREFS_KEY, null));
}
private static void clearNewSignedInAccountName(Context context) {
ContextUtils.getAppSharedPreferences()
.edit()
.putString(ACCOUNT_RENAMED_PREFS_KEY, null)
.apply();
}
private static String getLastKnownAccountName(Context context) {
// This is the last known name of the currently signed in user.
// It can be:
// 1. The signed in account name known to the ChromeSigninController.
// 2. A pending newly choosen name that is differed from the one known to
// ChromeSigninController but is stored in ACCOUNT_RENAMED_PREFS_KEY.
String name = ContextUtils.getAppSharedPreferences().getString(
ACCOUNT_RENAMED_PREFS_KEY, null);
// If there is no pending rename, take the name known to ChromeSigninController.
return name == null ? ChromeSigninController.get(context).getSignedInAccountName() : name;
}
public static void updateAccountRenameData(Context context) {
updateAccountRenameData(context, new SystemAccountChangeEventChecker());
}
@VisibleForTesting
public static void updateAccountRenameData(Context context, AccountChangeEventChecker checker) {
String curName = getLastKnownAccountName(context);
// Skip the search if there is no signed in account.
if (curName == null) return;
String newName = curName;
// This is the last read index of all the account change event.
int eventIndex = ContextUtils.getAppSharedPreferences().getInt(
ACCOUNT_RENAME_EVENT_INDEX_PREFS_KEY, 0);
int newIndex = eventIndex;
try {
outerLoop:
while (true) {
List<String> nameChanges = checker.getAccountChangeEvents(context,
newIndex, newName);
for (String name : nameChanges) {
if (name != null) {
// We have found a rename event of the current account.
// We need to check if that account is further renamed.
newName = name;
if (!accountExists(
context, AccountManagerHelper.createAccountFromName(newName))) {
newIndex = 0; // Start from the beginning of the new account.
continue outerLoop;
}
break;
}
}
// If there is no rename event pending. Update the last read index to avoid
// re-reading them in the future.
newIndex = nameChanges.size();
break;
}
} catch (Exception e) {
Log.w(TAG, "Error while looking for rename events.", e);
}
if (!curName.equals(newName)) {
ContextUtils.getAppSharedPreferences()
.edit().putString(ACCOUNT_RENAMED_PREFS_KEY, newName).apply();
}
if (newIndex != eventIndex) {
ContextUtils.getAppSharedPreferences()
.edit().putInt(ACCOUNT_RENAME_EVENT_INDEX_PREFS_KEY, newIndex).apply();
}
}
@VisibleForTesting
public static void resetAccountRenameEventIndex(Context context) {
ContextUtils.getAppSharedPreferences()
.edit().putInt(ACCOUNT_RENAME_EVENT_INDEX_PREFS_KEY, 0).apply();
}
public static boolean checkAndClearAccountsChangedPref(Context context) {
if (ContextUtils.getAppSharedPreferences()
.getBoolean(ACCOUNTS_CHANGED_PREFS_KEY, false)) {
// Clear the value in prefs.
ContextUtils.getAppSharedPreferences()
.edit().putBoolean(ACCOUNTS_CHANGED_PREFS_KEY, false).apply();
return true;
} else {
return false;
}
}
}