blob: 2c8b62a0c4dbb948d14ae1043ec0a93894adfe23 [file] [log] [blame]
// Copyright 2011 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.components.signin;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorDescription;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.os.UserManager;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.components.signin.util.PatternMatcher;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
/**
* AccountManagerFacade wraps our access of AccountManager in Android.
*
*/
public class AccountManagerFacadeImpl implements AccountManagerFacade {
private static final String TAG = "Sync_Signin";
/**
* An account feature (corresponding to a Gaia service flag) that specifies whether the account
* is a child account.
*/
@VisibleForTesting
public static final String FEATURE_IS_CHILD_ACCOUNT_KEY = "service_uca";
/**
* An account feature (corresponding to a Gaia service flag) that specifies whether the account
* is a USM account.
*/
@VisibleForTesting
public static final String FEATURE_IS_USM_ACCOUNT_KEY = "service_usm";
@VisibleForTesting
public static final String ACCOUNT_RESTRICTION_PATTERNS_KEY = "RestrictAccountsToPatterns";
private final AccountManagerDelegate mDelegate;
private final ObserverList<AccountsChangeObserver> mObservers = new ObserverList<>();
// These two variables should be accessed from either UI thread or during initialization phase.
private PatternMatcher[] mAccountRestrictionPatterns;
private AccountManagerResult<List<Account>> mAllAccounts;
private final AtomicReference<AccountManagerResult<List<Account>>> mFilteredAccounts =
new AtomicReference<>();
private final CountDownLatch mPopulateAccountCacheLatch = new CountDownLatch(1);
private final ArrayList<Runnable> mCallbacksWaitingForCachePopulation = new ArrayList<>();
private int mUpdateTasksCounter;
private final ArrayList<Runnable> mCallbacksWaitingForPendingUpdates = new ArrayList<>();
private ObservableValue<Boolean> mUpdatePendingState = new MutableObservableValue<>(true);
/**
* @param delegate the AccountManagerDelegate to use as a backend
*/
public AccountManagerFacadeImpl(AccountManagerDelegate delegate) {
ThreadUtils.assertOnUiThread();
mDelegate = delegate;
mDelegate.registerObservers();
mDelegate.addObserver(this::updateAccounts);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
subscribeToAppRestrictionChanges();
}
new InitializeTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
}
/**
* Adds an observer to receive accounts change notifications.
* @param observer the observer to add.
*/
@Override
public void addObserver(AccountsChangeObserver observer) {
ThreadUtils.assertOnUiThread();
boolean success = mObservers.addObserver(observer);
assert success : "Observer already added!";
}
/**
* Removes an observer that was previously added using {@link #addObserver}.
* @param observer the observer to remove.
*/
@Override
public void removeObserver(AccountsChangeObserver observer) {
ThreadUtils.assertOnUiThread();
boolean success = mObservers.removeObserver(observer);
assert success : "Can't find observer";
}
/**
* Runs a callback after the account list cache is populated. In the callback
* {@link #getGoogleAccounts()} and similar methods are guaranteed to return instantly (without
* blocking and waiting for the cache to be populated). If the cache has already been populated,
* the callback will be posted on UI thread.
* @param runnable The callback to call after cache is populated. Invoked on the main thread.
*/
@Override
public void runAfterCacheIsPopulated(Runnable runnable) {
ThreadUtils.assertOnUiThread();
if (isCachePopulated()) {
ThreadUtils.postOnUiThread(runnable);
return;
}
mCallbacksWaitingForCachePopulation.add(runnable);
}
/**
* Returns whether the account cache has already been populated. {@link #getGoogleAccounts()}
* and similar methods will return instantly if the cache has been populated, otherwise these
* methods may block waiting for the cache to be populated.
*/
@Override
public boolean isCachePopulated() {
return mFilteredAccounts.get() != null;
}
/**
* Retrieves all Google accounts on the device.
*
* @throws AccountManagerDelegateException if Google Play Services are out of date,
* Chrome lacks necessary permissions, etc.
*/
@Override
public List<Account> getGoogleAccounts() throws AccountManagerDelegateException {
AccountManagerResult<List<Account>> maybeAccounts = mFilteredAccounts.get();
if (maybeAccounts == null) {
try {
// First call to update hasn't finished executing yet, should wait for it
long now = SystemClock.elapsedRealtime();
mPopulateAccountCacheLatch.await();
maybeAccounts = mFilteredAccounts.get();
if (ThreadUtils.runningOnUiThread()) {
RecordHistogram.recordTimesHistogram(
"Signin.AndroidPopulateAccountCacheWaitingTime",
SystemClock.elapsedRealtime() - now);
}
} catch (InterruptedException e) {
throw new RuntimeException("Interrupted waiting for accounts", e);
}
}
return maybeAccounts.get();
}
/**
* Asynchronous version of {@link #getGoogleAccounts()}.
*/
@Override
public void getGoogleAccounts(Callback<AccountManagerResult<List<Account>>> callback) {
runAfterCacheIsPopulated(() -> callback.onResult(mFilteredAccounts.get()));
}
/**
* @return Whether or not there is an account authenticator for Google accounts.
*/
@Override
public boolean hasGoogleAccountAuthenticator() {
AuthenticatorDescription[] descs = mDelegate.getAuthenticatorTypes();
for (AuthenticatorDescription desc : descs) {
if (AccountUtils.GOOGLE_ACCOUNT_TYPE.equals(desc.type)) return true;
}
return false;
}
/**
* Synchronously gets an OAuth2 access token. May return a cached version, use
* {@link #invalidateAccessToken} to invalidate a token in the cache.
* @param account The {@link Account} for which the token is requested.
* @param scope OAuth2 scope for which the requested token should be valid.
* @return The OAuth2 access token as an AccessTokenData with a string and an expiration time..
*/
@Override
public AccessTokenData getAccessToken(Account account, String scope) throws AuthException {
assert account != null;
assert scope != null;
// TODO(bsazonov): Rename delegate's getAuthToken to getAccessToken.
return mDelegate.getAuthToken(account, scope);
}
/**
* Synchronously clears an OAuth2 access token from the cache. Use {@link #getAccessToken}
* to issue a new token after invalidating the old one.
* @param accessToken The access token to invalidate.
*/
@Override
public void invalidateAccessToken(String accessToken) throws AuthException {
assert accessToken != null;
// TODO(bsazonov): Rename delegate's invalidateAuthToken to invalidateAccessToken.
mDelegate.invalidateAuthToken(accessToken);
}
// Incorrectly infers that this is called on a worker thread because of AsyncTask doInBackground
// overriding.
@SuppressWarnings("WrongThread")
@Override
public void checkChildAccountStatus(Account account, ChildAccountStatusListener listener) {
ThreadUtils.assertOnUiThread();
new AsyncTask<Integer>() {
@Override
public @ChildAccountStatus.Status Integer doInBackground() {
if (hasFeature(account, FEATURE_IS_CHILD_ACCOUNT_KEY)) {
return ChildAccountStatus.REGULAR_CHILD;
}
if (hasFeature(account, FEATURE_IS_USM_ACCOUNT_KEY)) {
return ChildAccountStatus.USM_CHILD;
}
return ChildAccountStatus.NOT_CHILD;
}
@Override
public void onPostExecute(@ChildAccountStatus.Status Integer status) {
listener.onStatusReady(status);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Creates an intent that will ask the user to add a new account to the device. See
* {@link AccountManager#addAccount} for details.
* @param callback The callback to get the created intent. Will be invoked on the main thread.
* If there is an issue while creating the intent, callback will receive null.
*/
@Override
public void createAddAccountIntent(Callback<Intent> callback) {
mDelegate.createAddAccountIntent(callback);
}
/**
* Asks the user to enter a new password for an account, updating the saved credentials for the
* account.
*/
@Override
public void updateCredentials(
Account account, Activity activity, @Nullable Callback<Boolean> callback) {
mDelegate.updateCredentials(account, activity, callback);
}
/**
* Gets profile data source.
* @return {@link ProfileDataSource} if it is supported by implementation, null otherwise.
*/
@Override
public ProfileDataSource getProfileDataSource() {
return mDelegate.getProfileDataSource();
}
/**
* Executes the callback after all pending account list updates finish. If there are no pending
* account list updates, executes the callback right away.
* @param callback the callback to be executed
*/
@Override
public void waitForPendingUpdates(Runnable callback) {
ThreadUtils.assertOnUiThread();
if (!isUpdatePending().get()) {
callback.run();
return;
}
mCallbacksWaitingForPendingUpdates.add(callback);
}
/**
* Checks whether there are pending updates for account list cache.
* @return true if there are no pending updates, false otherwise
*/
@VisibleForTesting
@MainThread
public ObservableValue<Boolean> isUpdatePending() {
ThreadUtils.assertOnUiThread();
return mUpdatePendingState;
}
/**
* Returns the Gaia id for the account associated with the given email address.
* If an account with the given email address is not installed on the device
* then null is returned.
*
* This method will throw IllegalStateException if called on the main thread.
*
* @param accountEmail The email address of a Google account.
*/
@Override
public String getAccountGaiaId(String accountEmail) {
return mDelegate.getAccountGaiaId(accountEmail);
}
/**
* Checks whether Google Play services is available.
*/
@Override
public boolean isGooglePlayServicesAvailable() {
return mDelegate.isGooglePlayServicesAvailable();
}
private boolean hasFeature(Account account, String feature) {
return mDelegate.hasFeatures(account, new String[] {feature});
}
private void updateAccounts() {
ThreadUtils.assertOnUiThread();
new UpdateAccountsTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
}
private void updateAccountRestrictionPatterns() {
ThreadUtils.assertOnUiThread();
new UpdateAccountRestrictionPatternsTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
}
private void subscribeToAppRestrictionChanges() {
IntentFilter filter = new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
updateAccountRestrictionPatterns();
}
};
ContextUtils.getApplicationContext().registerReceiver(receiver, filter);
}
private AccountManagerResult<List<Account>> getAllAccounts() {
try {
List<Account> accounts = Arrays.asList(mDelegate.getAccountsSync());
return new AccountManagerResult<>(Collections.unmodifiableList(accounts));
} catch (AccountManagerDelegateException ex) {
return new AccountManagerResult<>(ex);
}
}
private AccountManagerResult<List<Account>> getFilteredAccounts() {
if (mAllAccounts.hasException() || mAccountRestrictionPatterns == null) return mAllAccounts;
ArrayList<Account> filteredAccounts = new ArrayList<>();
for (Account account : mAllAccounts.getValue()) {
for (PatternMatcher pattern : mAccountRestrictionPatterns) {
if (pattern.matches(account.name)) {
filteredAccounts.add(account);
break; // Don't check other patterns
}
}
}
return new AccountManagerResult<>(Collections.unmodifiableList(filteredAccounts));
}
private static PatternMatcher[] getAccountRestrictionPatterns() {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return null;
String[] patterns = getAccountRestrictionPatternPostJellyBeanMr2();
if (patterns == null) return null;
ArrayList<PatternMatcher> matchers = new ArrayList<>();
for (String pattern : patterns) {
matchers.add(new PatternMatcher(pattern));
}
return matchers.toArray(new PatternMatcher[0]);
} catch (PatternMatcher.IllegalPatternException ex) {
Log.e(TAG, "Can't get account restriction patterns", ex);
return null;
}
}
private static String[] getAccountRestrictionPatternPostJellyBeanMr2() {
// This method uses AppRestrictions directly, rather than using the Policy interface,
// because it must be callable in contexts in which the native library hasn't been loaded.
Context context = ContextUtils.getApplicationContext();
UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
Bundle appRestrictions = userManager.getApplicationRestrictions(context.getPackageName());
return appRestrictions.getStringArray(ACCOUNT_RESTRICTION_PATTERNS_KEY);
}
private void setAccountRestrictionPatterns(PatternMatcher[] patternMatchers) {
mAccountRestrictionPatterns = patternMatchers;
mFilteredAccounts.set(getFilteredAccounts());
fireOnAccountsChangedNotification();
}
private void setAllAccounts(AccountManagerResult<List<Account>> allAccounts) {
mAllAccounts = allAccounts;
mFilteredAccounts.set(getFilteredAccounts());
fireOnAccountsChangedNotification();
}
private void fireOnAccountsChangedNotification() {
for (AccountsChangeObserver observer : mObservers) {
observer.onAccountsChanged();
}
}
private void incrementUpdateCounter() {
assert mUpdateTasksCounter >= 0;
if (mUpdateTasksCounter++ > 0) return;
mUpdatePendingState.set(true);
}
private void decrementUpdateCounter() {
assert mUpdateTasksCounter > 0;
if (--mUpdateTasksCounter > 0) return;
for (Runnable callback : mCallbacksWaitingForPendingUpdates) {
callback.run();
}
mCallbacksWaitingForPendingUpdates.clear();
mUpdatePendingState.set(false);
}
private class InitializeTask extends AsyncTask<Void> {
@Override
protected void onPreExecute() {
incrementUpdateCounter();
}
@Override
protected Void doInBackground() {
mAccountRestrictionPatterns = getAccountRestrictionPatterns();
mAllAccounts = getAllAccounts();
mFilteredAccounts.set(getFilteredAccounts());
// It's important that countDown() is called on background thread and not in
// onPostExecute, as UI thread may be blocked in getGoogleAccounts waiting on the latch.
mPopulateAccountCacheLatch.countDown();
return null;
}
@Override
protected void onPostExecute(Void v) {
// Records number of Android accounts present on device.
RecordHistogram.recordExactLinearHistogram(
"Signin.AndroidNumberOfDeviceAccounts", tryGetGoogleAccounts().size(), 50);
for (Runnable callback : mCallbacksWaitingForCachePopulation) {
callback.run();
}
mCallbacksWaitingForCachePopulation.clear();
fireOnAccountsChangedNotification();
decrementUpdateCounter();
}
}
private class UpdateAccountRestrictionPatternsTask extends AsyncTask<PatternMatcher[]> {
@Override
protected void onPreExecute() {
incrementUpdateCounter();
}
@Override
protected PatternMatcher[] doInBackground() {
return getAccountRestrictionPatterns();
}
@Override
protected void onPostExecute(PatternMatcher[] patternMatchers) {
setAccountRestrictionPatterns(patternMatchers);
decrementUpdateCounter();
}
}
private class UpdateAccountsTask extends AsyncTask<AccountManagerResult<List<Account>>> {
@Override
protected void onPreExecute() {
incrementUpdateCounter();
}
@Override
protected AccountManagerResult<List<Account>> doInBackground() {
return getAllAccounts();
}
@Override
protected void onPostExecute(AccountManagerResult<List<Account>> allAccounts) {
setAllAccounts(allAccounts);
decrementUpdateCounter();
}
}
}