blob: 3b8e145683611e9981055ed44665c7acbef70eed [file] [log] [blame]
// Copyright 2013 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.sync.test.util;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorDescription;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import static org.chromium.base.test.util.ScalableTimeout.scaleTimeout;
import org.chromium.sync.signin.AccountManagerDelegate;
import org.chromium.sync.signin.AccountManagerHelper;
import java.io.IOException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.FutureTask;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.Nullable;
/**
* The MockAccountManager helps out if you want to mock out all calls to the Android AccountManager.
*
* You should provide a set of accounts as a constructor argument, or use the more direct approach
* and provide an array of AccountHolder objects.
*
* Currently, this implementation supports adding and removing accounts, handling credentials
* (including confirming them), and handling of dummy auth tokens.
*
* If you want the MockAccountManager to popup an activity for granting/denying access to an
* authtokentype for a given account, use prepareGrantAppPermission(...).
*
* If you want to auto-approve a given authtokentype, use addAccountHolderExplicitly(...) with
* an AccountHolder you have built with hasBeenAccepted("yourAuthTokenType", true).
*
* If you want to auto-approve all auth token types for a given account, use the {@link
* AccountHolder} builder method alwaysAccept(true).
*/
public class MockAccountManager implements AccountManagerDelegate {
private static final String TAG = "MockAccountManager";
private static final long WAIT_TIME_FOR_GRANT_BROADCAST_MS = scaleTimeout(20000);
static final String MUTEX_WAIT_ACTION =
"org.chromium.sync.test.util.MockAccountManager.MUTEX_WAIT_ACTION";
protected final Context mContext;
private final Context mTestContext;
private final Set<AccountHolder> mAccounts;
private final List<AccountAuthTokenPreparation> mAccountPermissionPreparations;
private final Handler mMainHandler;
private final SingleThreadedExecutor mExecutor;
public MockAccountManager(Context context, Context testContext, Account... accounts) {
mContext = context;
// The manifest that is backing testContext needs to provide the
// MockGrantCredentialsPermissionActivity.
mTestContext = testContext;
mMainHandler = new Handler(mContext.getMainLooper());
mExecutor = new SingleThreadedExecutor();
mAccounts = new HashSet<AccountHolder>();
mAccountPermissionPreparations = new LinkedList<AccountAuthTokenPreparation>();
if (accounts != null) {
for (Account account : accounts) {
mAccounts.add(AccountHolder.create().account(account).alwaysAccept(true).build());
}
}
}
private static class SingleThreadedExecutor extends ThreadPoolExecutor {
public SingleThreadedExecutor() {
super(1, 1, 1, TimeUnit.SECONDS, new LinkedBlockingDeque<Runnable>());
}
}
@Override
public Account[] getAccounts() {
return getAccountsByType(null);
}
@Override
public Account[] getAccountsByType(@Nullable String type) {
if (!AccountManagerHelper.GOOGLE_ACCOUNT_TYPE.equals(type)) {
throw new IllegalArgumentException("Invalid account type: " + type);
}
if (mAccounts == null) {
return new Account[0];
} else {
Account[] accounts = new Account[mAccounts.size()];
int i = 0;
for (AccountHolder ah : mAccounts) {
accounts[i++] = ah.getAccount();
}
return accounts;
}
}
@Override
public boolean addAccountExplicitly(Account account, String password, Bundle userdata) {
AccountHolder accountHolder =
AccountHolder.create().account(account).password(password).build();
return addAccountHolderExplicitly(accountHolder);
}
public boolean addAccountHolderExplicitly(AccountHolder accountHolder) {
boolean result = mAccounts.add(accountHolder);
postAsyncAccountChangedEvent();
return result;
}
public boolean removeAccountHolderExplicitly(AccountHolder accountHolder) {
boolean result = mAccounts.remove(accountHolder);
postAsyncAccountChangedEvent();
return result;
}
@Override
public AccountManagerFuture<Boolean> removeAccount(Account account,
AccountManagerCallback<Boolean> callback, Handler handler) {
mAccounts.remove(getAccountHolder(account));
postAsyncAccountChangedEvent();
return runTask(mExecutor,
new AccountManagerTask<Boolean>(handler, callback, new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
// Removal always successful.
return true;
}
}));
}
@Override
public String getPassword(Account account) {
return getAccountHolder(account).getPassword();
}
@Override
public void setPassword(Account account, String password) {
mAccounts.add(getAccountHolder(account).withPassword(password));
}
@Override
public void clearPassword(Account account) {
setPassword(account, null);
}
@Override
public AccountManagerFuture<Bundle> confirmCredentials(Account account, Bundle bundle,
Activity activity, AccountManagerCallback<Bundle> callback, Handler handler) {
String password = bundle.getString(AccountManager.KEY_PASSWORD);
if (password == null) {
throw new IllegalArgumentException("Password is null");
}
final AccountHolder accountHolder = getAccountHolder(account);
final boolean correctPassword = password.equals(accountHolder.getPassword());
return runTask(mExecutor, new AccountManagerTask<Bundle>(handler, callback,
new Callable<Bundle>() {
@Override
public Bundle call() throws Exception {
Bundle result = new Bundle();
result.putString(AccountManager.KEY_ACCOUNT_NAME,
accountHolder.getAccount().name);
result.putString(
AccountManager.KEY_ACCOUNT_TYPE,
AccountManagerHelper.GOOGLE_ACCOUNT_TYPE);
result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, correctPassword);
return result;
}
}));
}
@Override
public String blockingGetAuthToken(Account account, String authTokenType,
boolean notifyAuthFailure)
throws OperationCanceledException, IOException, AuthenticatorException {
AccountHolder accountHolder = getAccountHolder(account);
if (accountHolder.hasBeenAccepted(authTokenType)) {
// If account has already been accepted we can just return the auth token.
return internalGenerateAndStoreAuthToken(accountHolder, authTokenType);
}
AccountAuthTokenPreparation prepared = getPreparedPermission(account, authTokenType);
Intent intent = newGrantCredentialsPermissionIntent(false, account, authTokenType);
waitForActivity(mContext, intent);
applyPreparedPermission(prepared);
return internalGenerateAndStoreAuthToken(accountHolder, authTokenType);
}
@Override
public AccountManagerFuture<Bundle> getAuthToken(Account account, String authTokenType,
Bundle options, Activity activity, AccountManagerCallback<Bundle> callback,
Handler handler) {
return getAuthTokenFuture(account, authTokenType, activity, callback, handler);
}
@Override
public AccountManagerFuture<Bundle> getAuthToken(Account account, String authTokenType,
boolean notifyAuthFailure, AccountManagerCallback<Bundle> callback, Handler handler) {
return getAuthTokenFuture(account, authTokenType, null, callback, handler);
}
private AccountManagerFuture<Bundle> getAuthTokenFuture(Account account, String authTokenType,
Activity activity, AccountManagerCallback<Bundle> callback, Handler handler) {
final AccountHolder ah = getAccountHolder(account);
if (ah.hasBeenAccepted(authTokenType)) {
final String authToken = internalGenerateAndStoreAuthToken(ah, authTokenType);
return runTask(mExecutor,
new AccountManagerAuthTokenTask(activity, handler, callback,
account, authTokenType,
new Callable<Bundle>() {
@Override
public Bundle call() throws Exception {
return getAuthTokenBundle(ah.getAccount(), authToken);
}
}));
} else {
Log.d(TAG, "getAuthTokenFuture: Account " + ah.getAccount()
+ " is asking for permission for " + authTokenType);
final Intent intent = newGrantCredentialsPermissionIntent(
activity != null, account, authTokenType);
return runTask(mExecutor,
new AccountManagerAuthTokenTask(activity, handler, callback,
account, authTokenType,
new Callable<Bundle>() {
@Override
public Bundle call() throws Exception {
Bundle result = new Bundle();
result.putParcelable(AccountManager.KEY_INTENT, intent);
return result;
}
}));
}
}
private static Bundle getAuthTokenBundle(Account account, String authToken) {
Bundle result = new Bundle();
result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
return result;
}
private String internalGenerateAndStoreAuthToken(AccountHolder ah, String authTokenType) {
synchronized (mAccounts) {
// Some tests register auth tokens with value null, and those should be preserved.
if (!ah.hasAuthTokenRegistered(authTokenType)
&& ah.getAuthToken(authTokenType) == null) {
// No authtoken registered. Need to create one.
String authToken = UUID.randomUUID().toString();
Log.d(TAG, "Created new auth token for " + ah.getAccount()
+ ": autTokenType = " + authTokenType + ", authToken = " + authToken);
ah = ah.withAuthToken(authTokenType, authToken);
mAccounts.add(ah);
}
}
return ah.getAuthToken(authTokenType);
}
@Override
public String peekAuthToken(Account account, String authTokenType) {
return getAccountHolder(account).getAuthToken(authTokenType);
}
@Override
public void invalidateAuthToken(String accountType, String authToken) {
if (!AccountManagerHelper.GOOGLE_ACCOUNT_TYPE.equals(accountType)) {
throw new IllegalArgumentException("Invalid account type: " + accountType);
}
if (authToken == null) {
throw new IllegalArgumentException("AuthToken can not be null");
}
for (AccountHolder ah : mAccounts) {
if (ah.removeAuthToken(authToken)) {
break;
}
}
}
@Override
public AuthenticatorDescription[] getAuthenticatorTypes() {
AuthenticatorDescription googleAuthenticator = new AuthenticatorDescription(
AccountManagerHelper.GOOGLE_ACCOUNT_TYPE, "p1", 0, 0, 0, 0);
return new AuthenticatorDescription[] { googleAuthenticator };
}
public void prepareAllowAppPermission(Account account, String authTokenType) {
addPreparedAppPermission(new AccountAuthTokenPreparation(account, authTokenType, true));
}
public void prepareDenyAppPermission(Account account, String authTokenType) {
addPreparedAppPermission(new AccountAuthTokenPreparation(account, authTokenType, false));
}
private void addPreparedAppPermission(AccountAuthTokenPreparation accountAuthTokenPreparation) {
Log.d(TAG, "Adding " + accountAuthTokenPreparation);
mAccountPermissionPreparations.add(accountAuthTokenPreparation);
}
private AccountAuthTokenPreparation getPreparedPermission(Account account,
String authTokenType) {
for (AccountAuthTokenPreparation accountPrep : mAccountPermissionPreparations) {
if (accountPrep.getAccount().equals(account)
&& accountPrep.getAuthTokenType().equals(authTokenType)) {
return accountPrep;
}
}
return null;
}
private void applyPreparedPermission(AccountAuthTokenPreparation prep) {
if (prep != null) {
Log.d(TAG, "Applying " + prep);
mAccountPermissionPreparations.remove(prep);
mAccounts.add(getAccountHolder(prep.getAccount()).withHasBeenAccepted(
prep.getAuthTokenType(), prep.isAllowed()));
}
}
private Intent newGrantCredentialsPermissionIntent(boolean hasActivity, Account account,
String authTokenType) {
ComponentName component = new ComponentName(mTestContext,
MockGrantCredentialsPermissionActivity.class.getCanonicalName());
// Make sure we can start the activity.
ActivityInfo ai = null;
try {
ai = mContext.getPackageManager().getActivityInfo(component, 0);
} catch (PackageManager.NameNotFoundException e) {
throw new IllegalStateException(
"Unable to find " + component.getClassName());
}
if (ai.applicationInfo != mContext.getApplicationInfo() && !ai.exported) {
throw new IllegalStateException(
"Unable to start " + ai.name + ". "
+ "The accounts you added to MockAccountManager may not be "
+ "configured correctly.");
}
Intent intent = new Intent();
intent.setComponent(component);
intent.putExtra(MockGrantCredentialsPermissionActivity.ACCOUNT, account);
intent.putExtra(MockGrantCredentialsPermissionActivity.AUTH_TOKEN_TYPE, authTokenType);
if (!hasActivity) {
// No activity provided, so we help the caller by adding the new task flag
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
return intent;
}
private AccountHolder getAccountHolder(Account account) {
if (account == null) {
throw new IllegalArgumentException("Account can not be null");
}
for (AccountHolder accountHolder : mAccounts) {
if (account.equals(accountHolder.getAccount())) {
return accountHolder;
}
}
throw new IllegalArgumentException("Can not find AccountHolder for account " + account);
}
private static <T> AccountManagerFuture<T> runTask(Executor executorService,
AccountManagerTask<T> accountManagerBundleTask) {
executorService.execute(accountManagerBundleTask);
return accountManagerBundleTask;
}
private class AccountManagerTask<T> extends FutureTask<T> implements AccountManagerFuture<T> {
protected final Handler mHandler;
protected final AccountManagerCallback<T> mCallback;
protected final Callable<T> mCallable;
public AccountManagerTask(Handler handler,
AccountManagerCallback<T> callback, Callable<T> callable) {
super(new Callable<T>() {
@Override
public T call() throws Exception {
throw new IllegalStateException("this should never be called, "
+ "but call must be overridden.");
}
});
mHandler = handler;
mCallback = callback;
mCallable = callable;
}
private T internalGetResult(long timeout, TimeUnit unit)
throws OperationCanceledException, IOException, AuthenticatorException {
try {
if (timeout == -1) {
return get();
} else {
return get(timeout, unit);
}
} catch (CancellationException e) {
throw new OperationCanceledException();
} catch (TimeoutException e) {
// Fall through and cancel.
} catch (InterruptedException e) {
// Fall through and cancel.
} catch (ExecutionException e) {
final Throwable cause = e.getCause();
if (cause instanceof IOException) {
throw (IOException) cause;
} else if (cause instanceof UnsupportedOperationException) {
throw new AuthenticatorException(cause);
} else if (cause instanceof AuthenticatorException) {
throw (AuthenticatorException) cause;
} else if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else if (cause instanceof Error) {
throw (Error) cause;
} else {
throw new IllegalStateException(cause);
}
} finally {
cancel(true /* Interrupt if running. */);
}
throw new OperationCanceledException();
}
@Override
public T getResult()
throws OperationCanceledException, IOException, AuthenticatorException {
return internalGetResult(-1, null);
}
@Override
public T getResult(long timeout, TimeUnit unit)
throws OperationCanceledException, IOException, AuthenticatorException {
return internalGetResult(timeout, unit);
}
@Override
public void run() {
try {
set(mCallable.call());
} catch (Exception e) {
setException(e);
}
}
@Override
protected void done() {
if (mCallback != null) {
postToHandler(getHandler(), mCallback, this);
}
}
protected Handler getHandler() {
return mHandler == null ? mMainHandler : mHandler;
}
}
private static <T> void postToHandler(Handler handler, final AccountManagerCallback<T> callback,
final AccountManagerFuture<T> future) {
handler.post(new Runnable() {
@Override
public void run() {
callback.run(future);
}
});
}
private class AccountManagerAuthTokenTask extends AccountManagerTask<Bundle> {
private final Activity mActivity;
private final AccountAuthTokenPreparation mAccountAuthTokenPreparation;
private final Account mAccount;
private final String mAuthTokenType;
public AccountManagerAuthTokenTask(Activity activity, Handler handler,
AccountManagerCallback<Bundle> callback,
Account account, String authTokenType,
Callable<Bundle> callable) {
super(handler, callback, callable);
mActivity = activity;
mAccountAuthTokenPreparation = getPreparedPermission(account, authTokenType);
mAccount = account;
mAuthTokenType = authTokenType;
}
@Override
public void run() {
try {
Bundle bundle = mCallable.call();
Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT);
if (intent != null) {
// Start the intent activity and wait for it to finish.
if (mActivity != null) {
waitForActivity(mActivity, intent);
} else {
waitForActivity(mContext, intent);
}
if (mAccountAuthTokenPreparation == null) {
throw new IllegalStateException("No account preparation ready for "
+ mAccount + ", authTokenType = " + mAuthTokenType
+ ". Add a call to either prepareGrantAppPermission(...) or "
+ "prepareRevokeAppPermission(...) in your test before asking for "
+ "an auth token");
} else {
// We have shown the Allow/Deny activity, and it has gone away. We can now
// apply the pre-stored permission.
applyPreparedPermission(mAccountAuthTokenPreparation);
generateResult(getAccountHolder(mAccount), mAuthTokenType);
}
} else {
set(bundle);
}
} catch (Exception e) {
setException(e);
}
}
private void generateResult(AccountHolder accountHolder, String authTokenType)
throws OperationCanceledException {
if (accountHolder.hasBeenAccepted(authTokenType)) {
String authToken = internalGenerateAndStoreAuthToken(accountHolder, authTokenType);
// Return a valid auth token.
set(getAuthTokenBundle(accountHolder.getAccount(), authToken));
} else {
// Throw same exception as when user clicks "Deny".
throw new OperationCanceledException("User denied request");
}
}
}
/**
* This method starts {@link MockGrantCredentialsPermissionActivity} and waits for it
* to be started before it returns.
*
* @param context the context to start the intent in
* @param intent the intent to use to start MockGrantCredentialsPermissionActivity
*/
private void waitForActivity(Context context, Intent intent) {
final Object mutex = new Object();
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
synchronized (mutex) {
mutex.notifyAll();
}
}
};
if (!MockGrantCredentialsPermissionActivity.class.getCanonicalName()
.equals(intent.getComponent().getClassName())) {
throw new IllegalArgumentException("Can only wait for "
+ "MockGrantCredentialsPermissionActivity");
}
mContext.registerReceiver(receiver, new IntentFilter(MUTEX_WAIT_ACTION));
context.startActivity(intent);
try {
Log.d(TAG, "Waiting for broadcast of " + MUTEX_WAIT_ACTION);
synchronized (mutex) {
mutex.wait(WAIT_TIME_FOR_GRANT_BROADCAST_MS);
}
} catch (InterruptedException e) {
throw new IllegalStateException("Got unexpected InterruptedException");
}
Log.d(TAG, "Got broadcast of " + MUTEX_WAIT_ACTION);
mContext.unregisterReceiver(receiver);
}
private void postAsyncAccountChangedEvent() {
// Mimic that this does not happen on the main thread.
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
mContext.sendBroadcast(new Intent(AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION));
return null;
}
}.execute();
}
/**
* Internal class for storage of prepared account auth token permissions.
*
* This is used internally by {@link MockAccountManager} to mock the same behavior as clicking
* Allow/Deny in the Android {@link GrantCredentialsPermissionActivity}.
*/
private static class AccountAuthTokenPreparation {
private final Account mAccount;
private final String mAuthTokenType;
private final boolean mAllowed;
private AccountAuthTokenPreparation(Account account, String authTokenType,
boolean allowed) {
mAccount = account;
mAuthTokenType = authTokenType;
mAllowed = allowed;
}
public Account getAccount() {
return mAccount;
}
public String getAuthTokenType() {
return mAuthTokenType;
}
public boolean isAllowed() {
return mAllowed;
}
@Override
public String toString() {
return "AccountAuthTokenPreparation{"
+ "mAccount=" + mAccount
+ ", mAuthTokenType='" + mAuthTokenType + '\''
+ ", mAllowed=" + mAllowed
+ '}';
}
}
}