1 /* 2 * Copyright (C) 2017 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17 package com.google.android.mobly.snippet.bundled; 18 19 import android.accounts.Account; 20 import android.accounts.AccountManager; 21 import android.accounts.AccountManagerFuture; 22 import android.accounts.AccountsException; 23 import android.content.ContentResolver; 24 import android.content.Context; 25 import android.content.SyncAdapterType; 26 import android.os.Build; 27 import android.os.Bundle; 28 import androidx.annotation.RequiresApi; 29 import androidx.test.platform.app.InstrumentationRegistry; 30 import com.google.android.mobly.snippet.Snippet; 31 import com.google.android.mobly.snippet.rpc.Rpc; 32 import com.google.android.mobly.snippet.util.Log; 33 import java.io.IOException; 34 import java.util.Arrays; 35 import java.util.HashMap; 36 import java.util.HashSet; 37 import java.util.LinkedList; 38 import java.util.List; 39 import java.util.Locale; 40 import java.util.Map; 41 import java.util.Set; 42 import java.util.TreeSet; 43 import java.util.concurrent.locks.ReentrantReadWriteLock; 44 45 /** 46 * Snippet class exposing Android APIs related to management of device accounts. 47 * 48 * <p>Android devices can have accounts of any type added and synced. New types can be created by 49 * apps by implementing a {@link android.content.ContentProvider} for a particular account type. 50 * 51 * <p>Google (gmail) accounts are of type "com.google" and their handling is managed by the 52 * operating system. This class allows you to add and remove Google accounts from a device. 53 */ 54 public class AccountSnippet implements Snippet { 55 private static final String GOOGLE_ACCOUNT_TYPE = "com.google"; 56 private static final String AUTH_TOKEN_TYPE = "mail"; 57 58 private static class AccountSnippetException extends Exception { 59 private static final long serialVersionUID = 1; 60 AccountSnippetException(String msg)61 public AccountSnippetException(String msg) { 62 super(msg); 63 } 64 } 65 66 private final AccountManager mAccountManager; 67 private final List<Object> mSyncStatusObserverHandles; 68 69 private final Map<String, Set<String>> mSyncAllowList; 70 private final ReentrantReadWriteLock mLock; 71 AccountSnippet()72 public AccountSnippet() { 73 Context context = InstrumentationRegistry.getInstrumentation().getContext(); 74 mAccountManager = AccountManager.get(context); 75 mSyncStatusObserverHandles = new LinkedList<>(); 76 mSyncAllowList = new HashMap<>(); 77 mLock = new ReentrantReadWriteLock(); 78 } 79 80 /** 81 * Adds a Google account to the device. 82 * 83 * @param username Username of the account to add (including @gmail.com). 84 * @param password Password of the account to add. 85 */ 86 @Rpc( 87 description = 88 "Add a Google (GMail) account to the device, with account data sync disabled.") addAccount(String username, String password)89 public void addAccount(String username, String password) 90 throws AccountSnippetException, AccountsException, IOException { 91 // Check for existing account. If we try to re-add an existing account, Android throws an 92 // exception that says "Account does not exist or not visible. Maybe change pwd?" which is 93 // a little hard to understand. 94 if (listAccounts().contains(username)) { 95 throw new AccountSnippetException( 96 "Account " + username + " already exists on the device"); 97 } 98 Bundle addAccountOptions = new Bundle(); 99 addAccountOptions.putString("username", username); 100 addAccountOptions.putString("password", password); 101 AccountManagerFuture<Bundle> future = 102 mAccountManager.addAccount( 103 GOOGLE_ACCOUNT_TYPE, 104 AUTH_TOKEN_TYPE, 105 null /* requiredFeatures */, 106 addAccountOptions, 107 null /* activity */, 108 null /* authCallback */, 109 null /* handler */); 110 Bundle result = future.getResult(); 111 if (result.containsKey(AccountManager.KEY_ERROR_CODE)) { 112 throw new AccountSnippetException( 113 String.format( 114 Locale.US, 115 "Failed to add account due to code %d: %s", 116 result.getInt(AccountManager.KEY_ERROR_CODE), 117 result.getString(AccountManager.KEY_ERROR_MESSAGE))); 118 } 119 120 // Disable sync to avoid test flakiness as accounts fetch additional data. 121 // It takes a while for all sync adapters to be populated, so register for broadcasts when 122 // sync is starting and disable them there. 123 // NOTE: this listener is NOT unregistered because several sync requests for the new account 124 // will come in over time. 125 Account account = new Account(username, GOOGLE_ACCOUNT_TYPE); 126 Object handle = 127 ContentResolver.addStatusChangeListener( 128 ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE 129 | ContentResolver.SYNC_OBSERVER_TYPE_PENDING, 130 which -> { 131 for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) { 132 // Ignore non-Google account types. 133 if (!adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)) { 134 continue; 135 } 136 // If a content provider is not allowListed, then disable it. 137 // Because startSync and stopSync synchronously update the allowList 138 // and sync settings, writelock both the allowList check and the 139 // call to sync together. 140 mLock.writeLock().lock(); 141 try { 142 if (!isAdapterAllowListed(username, adapter.authority)) { 143 updateSync(account, adapter.authority, false /* sync */); 144 } 145 } finally { 146 mLock.writeLock().unlock(); 147 } 148 } 149 }); 150 mSyncStatusObserverHandles.add(handle); 151 } 152 153 /** 154 * Removes an account from the device. 155 * 156 * <p>The account has to be Google account. 157 * 158 * @param username the username of the account to remove. 159 * @throws AccountSnippetException if removing the account failed. 160 */ 161 @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) 162 @Rpc(description = "Remove a Google account.") removeAccount(String username)163 public void removeAccount(String username) throws AccountSnippetException { 164 if (!mAccountManager.removeAccountExplicitly(getAccountByName(username))) { 165 throw new AccountSnippetException("Failed to remove account '" + username + "'."); 166 } 167 } 168 169 /** 170 * Get an existing account by its username. 171 * 172 * <p>Google account only. 173 * 174 * @param username the username of the account to remove. 175 * @return tHe account with the username. 176 * @throws AccountSnippetException if no account has the given username. 177 */ getAccountByName(String username)178 private Account getAccountByName(String username) throws AccountSnippetException { 179 Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE); 180 for (Account account : accounts) { 181 if (account.name.equals(username)) { 182 return account; 183 } 184 } 185 throw new AccountSnippetException( 186 "Account '" + username + "' does not exist on the device."); 187 } 188 189 /** 190 * Checks to see if the SyncAdapter is allowListed. 191 * 192 * <p>AccountSnippet disables syncing by default when adding an account, except for allowListed 193 * SyncAdapters. This function checks the allowList for a specific account-authority pair. 194 * 195 * @param username Username of the account (including @gmail.com). 196 * @param authority The authority of a content provider that should be checked. 197 */ isAdapterAllowListed(String username, String authority)198 private boolean isAdapterAllowListed(String username, String authority) { 199 boolean result = false; 200 mLock.readLock().lock(); 201 try { 202 Set<String> allowListedProviders = mSyncAllowList.get(username); 203 if (allowListedProviders != null) { 204 result = allowListedProviders.contains(authority); 205 } 206 } finally { 207 mLock.readLock().unlock(); 208 } 209 return result; 210 } 211 212 /** 213 * Updates ContentResolver sync settings for an Account's specified SyncAdapter. 214 * 215 * <p>Sets an accounts SyncAdapter (selected based on authority) to sync/not-sync automatically 216 * and immediately requests/cancels a sync. 217 * 218 * <p>updateSync should always be called under {@link AccountSnippet#mLock} write lock to avoid 219 * flapping between the getSyncAutomatically and setSyncAutomatically calls. 220 * 221 * @param account A Google Account. 222 * @param authority The authority of a content provider that should (not) be synced. 223 * @param sync Whether or not the account's content provider should be synced. 224 */ updateSync(Account account, String authority, boolean sync)225 private void updateSync(Account account, String authority, boolean sync) { 226 if (ContentResolver.getSyncAutomatically(account, authority) != sync) { 227 ContentResolver.setSyncAutomatically(account, authority, sync); 228 if (sync) { 229 ContentResolver.requestSync(account, authority, new Bundle()); 230 } else { 231 ContentResolver.cancelSync(account, authority); 232 } 233 Log.i( 234 "Set sync to " 235 + sync 236 + " for account " 237 + account 238 + ", adapter " 239 + authority 240 + "."); 241 } 242 } 243 244 /** 245 * Enables syncing of a SyncAdapter for a given content provider. 246 * 247 * <p>Adds the authority to a allowList, and immediately requests a sync. 248 * 249 * @param username Username of the account (including @gmail.com). 250 * @param authority The authority of a content provider that should be synced. 251 */ 252 @Rpc(description = "Enables syncing of a SyncAdapter for a content provider.") startSync(String username, String authority)253 public void startSync(String username, String authority) throws AccountSnippetException { 254 if (!listAccounts().contains(username)) { 255 throw new AccountSnippetException("Account " + username + " is not on the device"); 256 } 257 // Add to the allowList 258 mLock.writeLock().lock(); 259 try { 260 if (mSyncAllowList.containsKey(username)) { 261 mSyncAllowList.get(username).add(authority); 262 } else { 263 mSyncAllowList.put(username, new HashSet<String>(Arrays.asList(authority))); 264 } 265 // Update the Sync settings 266 for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) { 267 // Find the Google account content provider. 268 if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE) 269 && adapter.authority.equals(authority)) { 270 Account account = new Account(username, GOOGLE_ACCOUNT_TYPE); 271 updateSync(account, authority, true); 272 } 273 } 274 } finally { 275 mLock.writeLock().unlock(); 276 } 277 } 278 279 /** 280 * Disables syncing of a SyncAdapter for a given content provider. 281 * 282 * <p>Removes the content provider authority from a allowList. 283 * 284 * @param username Username of the account (including @gmail.com). 285 * @param authority The authority of a content provider that should not be synced. 286 */ 287 @Rpc(description = "Disables syncing of a SyncAdapter for a content provider.") stopSync(String username, String authority)288 public void stopSync(String username, String authority) throws AccountSnippetException { 289 if (!listAccounts().contains(username)) { 290 throw new AccountSnippetException("Account " + username + " is not on the device"); 291 } 292 // Remove from allowList 293 mLock.writeLock().lock(); 294 try { 295 if (mSyncAllowList.containsKey(username)) { 296 Set<String> allowListedProviders = mSyncAllowList.get(username); 297 allowListedProviders.remove(authority); 298 if (allowListedProviders.isEmpty()) { 299 mSyncAllowList.remove(username); 300 } 301 } 302 // Update the Sync settings 303 for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) { 304 // Find the Google account content provider. 305 if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE) 306 && adapter.authority.equals(authority)) { 307 Account account = new Account(username, GOOGLE_ACCOUNT_TYPE); 308 updateSync(account, authority, false); 309 } 310 } 311 } finally { 312 mLock.writeLock().unlock(); 313 } 314 } 315 316 /** 317 * Returns a list of all Google accounts on the device. 318 * 319 * <p>TODO(adorokhine): Support accounts of other types with an optional 'type' kwarg. 320 */ 321 @Rpc(description = "List all Google (GMail) accounts on the device.") listAccounts()322 public Set<String> listAccounts() throws SecurityException { 323 Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE); 324 Set<String> usernames = new TreeSet<>(); 325 for (Account account : accounts) { 326 usernames.add(account.name); 327 } 328 return usernames; 329 } 330 331 @Override shutdown()332 public void shutdown() { 333 for (Object handle : mSyncStatusObserverHandles) { 334 ContentResolver.removeStatusChangeListener(handle); 335 } 336 } 337 } 338