/*
* Copyright (C) 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.android.mobly.snippet.bundled;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerFuture;
import android.accounts.AccountsException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SyncAdapterType;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.RequiresApi;
import androidx.test.platform.app.InstrumentationRegistry;
import com.google.android.mobly.snippet.Snippet;
import com.google.android.mobly.snippet.rpc.Rpc;
import com.google.android.mobly.snippet.util.Log;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Snippet class exposing Android APIs related to management of device accounts.
*
*
Android devices can have accounts of any type added and synced. New types can be created by
* apps by implementing a {@link android.content.ContentProvider} for a particular account type.
*
*
Google (gmail) accounts are of type "com.google" and their handling is managed by the
* operating system. This class allows you to add and remove Google accounts from a device.
*/
public class AccountSnippet implements Snippet {
private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
private static final String AUTH_TOKEN_TYPE = "mail";
private static class AccountSnippetException extends Exception {
private static final long serialVersionUID = 1;
public AccountSnippetException(String msg) {
super(msg);
}
}
private final AccountManager mAccountManager;
private final List mSyncStatusObserverHandles;
private final Map> mSyncAllowList;
private final ReentrantReadWriteLock mLock;
public AccountSnippet() {
Context context = InstrumentationRegistry.getInstrumentation().getContext();
mAccountManager = AccountManager.get(context);
mSyncStatusObserverHandles = new LinkedList<>();
mSyncAllowList = new HashMap<>();
mLock = new ReentrantReadWriteLock();
}
/**
* Adds a Google account to the device.
*
* @param username Username of the account to add (including @gmail.com).
* @param password Password of the account to add.
*/
@Rpc(
description =
"Add a Google (GMail) account to the device, with account data sync disabled.")
public void addAccount(String username, String password)
throws AccountSnippetException, AccountsException, IOException {
// Check for existing account. If we try to re-add an existing account, Android throws an
// exception that says "Account does not exist or not visible. Maybe change pwd?" which is
// a little hard to understand.
if (listAccounts().contains(username)) {
throw new AccountSnippetException(
"Account " + username + " already exists on the device");
}
Bundle addAccountOptions = new Bundle();
addAccountOptions.putString("username", username);
addAccountOptions.putString("password", password);
AccountManagerFuture future =
mAccountManager.addAccount(
GOOGLE_ACCOUNT_TYPE,
AUTH_TOKEN_TYPE,
null /* requiredFeatures */,
addAccountOptions,
null /* activity */,
null /* authCallback */,
null /* handler */);
Bundle result = future.getResult();
if (result.containsKey(AccountManager.KEY_ERROR_CODE)) {
throw new AccountSnippetException(
String.format(
Locale.US,
"Failed to add account due to code %d: %s",
result.getInt(AccountManager.KEY_ERROR_CODE),
result.getString(AccountManager.KEY_ERROR_MESSAGE)));
}
// Disable sync to avoid test flakiness as accounts fetch additional data.
// It takes a while for all sync adapters to be populated, so register for broadcasts when
// sync is starting and disable them there.
// NOTE: this listener is NOT unregistered because several sync requests for the new account
// will come in over time.
Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
Object handle =
ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
| ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
which -> {
for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
// Ignore non-Google account types.
if (!adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)) {
continue;
}
// If a content provider is not allowListed, then disable it.
// Because startSync and stopSync synchronously update the allowList
// and sync settings, writelock both the allowList check and the
// call to sync together.
mLock.writeLock().lock();
try {
if (!isAdapterAllowListed(username, adapter.authority)) {
updateSync(account, adapter.authority, false /* sync */);
}
} finally {
mLock.writeLock().unlock();
}
}
});
mSyncStatusObserverHandles.add(handle);
}
/**
* Removes an account from the device.
*
* The account has to be Google account.
*
* @param username the username of the account to remove.
* @throws AccountSnippetException if removing the account failed.
*/
@RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
@Rpc(description = "Remove a Google account.")
public void removeAccount(String username) throws AccountSnippetException {
if (!mAccountManager.removeAccountExplicitly(getAccountByName(username))) {
throw new AccountSnippetException("Failed to remove account '" + username + "'.");
}
}
/**
* Get an existing account by its username.
*
*
Google account only.
*
* @param username the username of the account to remove.
* @return tHe account with the username.
* @throws AccountSnippetException if no account has the given username.
*/
private Account getAccountByName(String username) throws AccountSnippetException {
Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE);
for (Account account : accounts) {
if (account.name.equals(username)) {
return account;
}
}
throw new AccountSnippetException(
"Account '" + username + "' does not exist on the device.");
}
/**
* Checks to see if the SyncAdapter is allowListed.
*
*
AccountSnippet disables syncing by default when adding an account, except for allowListed
* SyncAdapters. This function checks the allowList for a specific account-authority pair.
*
* @param username Username of the account (including @gmail.com).
* @param authority The authority of a content provider that should be checked.
*/
private boolean isAdapterAllowListed(String username, String authority) {
boolean result = false;
mLock.readLock().lock();
try {
Set allowListedProviders = mSyncAllowList.get(username);
if (allowListedProviders != null) {
result = allowListedProviders.contains(authority);
}
} finally {
mLock.readLock().unlock();
}
return result;
}
/**
* Updates ContentResolver sync settings for an Account's specified SyncAdapter.
*
* Sets an accounts SyncAdapter (selected based on authority) to sync/not-sync automatically
* and immediately requests/cancels a sync.
*
*
updateSync should always be called under {@link AccountSnippet#mLock} write lock to avoid
* flapping between the getSyncAutomatically and setSyncAutomatically calls.
*
* @param account A Google Account.
* @param authority The authority of a content provider that should (not) be synced.
* @param sync Whether or not the account's content provider should be synced.
*/
private void updateSync(Account account, String authority, boolean sync) {
if (ContentResolver.getSyncAutomatically(account, authority) != sync) {
ContentResolver.setSyncAutomatically(account, authority, sync);
if (sync) {
ContentResolver.requestSync(account, authority, new Bundle());
} else {
ContentResolver.cancelSync(account, authority);
}
Log.i(
"Set sync to "
+ sync
+ " for account "
+ account
+ ", adapter "
+ authority
+ ".");
}
}
/**
* Enables syncing of a SyncAdapter for a given content provider.
*
*
Adds the authority to a allowList, and immediately requests a sync.
*
* @param username Username of the account (including @gmail.com).
* @param authority The authority of a content provider that should be synced.
*/
@Rpc(description = "Enables syncing of a SyncAdapter for a content provider.")
public void startSync(String username, String authority) throws AccountSnippetException {
if (!listAccounts().contains(username)) {
throw new AccountSnippetException("Account " + username + " is not on the device");
}
// Add to the allowList
mLock.writeLock().lock();
try {
if (mSyncAllowList.containsKey(username)) {
mSyncAllowList.get(username).add(authority);
} else {
mSyncAllowList.put(username, new HashSet(Arrays.asList(authority)));
}
// Update the Sync settings
for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
// Find the Google account content provider.
if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)
&& adapter.authority.equals(authority)) {
Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
updateSync(account, authority, true);
}
}
} finally {
mLock.writeLock().unlock();
}
}
/**
* Disables syncing of a SyncAdapter for a given content provider.
*
* Removes the content provider authority from a allowList.
*
* @param username Username of the account (including @gmail.com).
* @param authority The authority of a content provider that should not be synced.
*/
@Rpc(description = "Disables syncing of a SyncAdapter for a content provider.")
public void stopSync(String username, String authority) throws AccountSnippetException {
if (!listAccounts().contains(username)) {
throw new AccountSnippetException("Account " + username + " is not on the device");
}
// Remove from allowList
mLock.writeLock().lock();
try {
if (mSyncAllowList.containsKey(username)) {
Set allowListedProviders = mSyncAllowList.get(username);
allowListedProviders.remove(authority);
if (allowListedProviders.isEmpty()) {
mSyncAllowList.remove(username);
}
}
// Update the Sync settings
for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
// Find the Google account content provider.
if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)
&& adapter.authority.equals(authority)) {
Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
updateSync(account, authority, false);
}
}
} finally {
mLock.writeLock().unlock();
}
}
/**
* Returns a list of all Google accounts on the device.
*
* TODO(adorokhine): Support accounts of other types with an optional 'type' kwarg.
*/
@Rpc(description = "List all Google (GMail) accounts on the device.")
public Set listAccounts() throws SecurityException {
Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE);
Set usernames = new TreeSet<>();
for (Account account : accounts) {
usernames.add(account.name);
}
return usernames;
}
@Override
public void shutdown() {
for (Object handle : mSyncStatusObserverHandles) {
ContentResolver.removeStatusChangeListener(handle);
}
}
}