1 // Copyright 2013 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.chrome.test.util.browser.sync; 6 7 import static org.chromium.base.test.util.ScalableTimeout.scaleTimeout; 8 9 import android.accounts.Account; 10 import android.content.Context; 11 import android.util.Log; 12 import android.util.Pair; 13 14 import junit.framework.Assert; 15 16 import org.chromium.base.CommandLine; 17 import org.chromium.base.ThreadUtils; 18 import org.chromium.base.test.util.AdvancedMockContext; 19 import org.chromium.chrome.browser.sync.ProfileSyncService; 20 import org.chromium.chrome.test.util.TestHttpServerClient; 21 import org.chromium.content.browser.test.util.Criteria; 22 import org.chromium.content.browser.test.util.CriteriaHelper; 23 import org.chromium.sync.signin.AccountManagerHelper; 24 import org.chromium.sync.signin.ChromeSigninController; 25 import org.chromium.sync.test.util.AccountHolder; 26 import org.chromium.sync.test.util.MockAccountManager; 27 import org.json.JSONArray; 28 import org.json.JSONException; 29 import org.json.JSONObject; 30 31 import java.util.HashMap; 32 import java.util.Locale; 33 import java.util.Map; 34 import java.util.concurrent.Callable; 35 import java.util.concurrent.Semaphore; 36 import java.util.concurrent.TimeUnit; 37 import java.util.concurrent.atomic.AtomicBoolean; 38 import java.util.concurrent.atomic.AtomicLong; 39 40 /** 41 * Utility class for shared sync test functionality. 42 */ 43 public final class SyncTestUtil { 44 45 public static final String DEFAULT_TEST_ACCOUNT = "test (at) gmail.com"; 46 public static final String DEFAULT_PASSWORD = "myPassword"; 47 private static final String TAG = "SyncTestUtil"; 48 49 public static final long UI_TIMEOUT_MS = scaleTimeout(20000); 50 public static final int CHECK_INTERVAL_MS = 250; 51 52 private static final long SYNC_WAIT_TIMEOUT_MS = scaleTimeout(30 * 1000); 53 private static final int SYNC_CHECK_INTERVAL_MS = 250; 54 55 public static final Pair<String, String> SYNC_SUMMARY_STATUS = 56 newPair("Summary", "Summary"); 57 protected static final String UNINITIALIZED = "Uninitialized"; 58 protected static final Pair<String, String> USERNAME_STAT = 59 newPair("Identity", "Username"); 60 61 // Override the default server used for profile sync. 62 // Native switch - chrome_switches::kSyncServiceURL 63 private static final String SYNC_URL = "sync-url"; 64 65 private SyncTestUtil() { 66 } 67 68 /** 69 * Creates a Pair of lowercased and trimmed Strings. Makes it easier to avoid running afoul of 70 * case-sensitive comparison since getAboutInfoStats(), et al, use Pair<String, String> as map 71 * keys. 72 */ 73 private static Pair<String, String> newPair(String first, String second) { 74 return Pair.create(first.toLowerCase(Locale.US).trim(), 75 second.toLowerCase(Locale.US).trim()); 76 } 77 78 /** 79 * Parses raw JSON into a map with keys Pair<String, String>. The first string in each Pair 80 * corresponds to the title under which a given stat_name/stat_value is situated, and the second 81 * contains the name of the actual stat. For example, a stat named "Syncing" which falls under 82 * "Local State" would be a Pair of newPair("Local State", "Syncing"). 83 * 84 * @param rawJson the JSON to parse into a map 85 * @return a map containing a mapping of titles and stat names to stat values 86 * @throws org.json.JSONException 87 */ 88 public static Map<Pair<String, String>, String> getAboutInfoStats(String rawJson) 89 throws JSONException { 90 91 // What we get back is what you'd get from chrome.sync.aboutInfo at chrome://sync. This is 92 // a JSON object, and we care about the "details" field in that object. "details" itself has 93 // objects with two fields: data and title. The data field itself contains an array of 94 // objects. These objects contains two fields: stat_name and stat_value. Ultimately these 95 // are the values displayed on the page and the values we care about in this method. 96 Map<Pair<String, String>, String> statLookup = new HashMap<Pair<String, String>, String>(); 97 JSONObject aboutInfo = new JSONObject(rawJson); 98 JSONArray detailsArray = aboutInfo.getJSONArray("details"); 99 for (int i = 0; i < detailsArray.length(); i++) { 100 JSONObject dataObj = detailsArray.getJSONObject(i); 101 String dataTitle = dataObj.getString("title"); 102 JSONArray dataArray = dataObj.getJSONArray("data"); 103 for (int j = 0; j < dataArray.length(); j++) { 104 JSONObject statObj = dataArray.getJSONObject(j); 105 String statName = statObj.getString("stat_name"); 106 Pair<String, String> key = newPair(dataTitle, statName); 107 statLookup.put(key, statObj.getString("stat_value")); 108 } 109 } 110 111 return statLookup; 112 } 113 114 /** 115 * Verifies that sync is signed out and its status is "Syncing not enabled". 116 * TODO(mmontgomery): check whether or not this method is necessary. It queries 117 * syncSummaryStatus(), which is a slightly more direct route than via JSON. 118 */ 119 public static void verifySyncIsSignedOut(Context context) { 120 Map<Pair<String, String>, String> expectedStats = 121 new HashMap<Pair<String, String>, String>(); 122 expectedStats.put(SYNC_SUMMARY_STATUS, UNINITIALIZED); 123 expectedStats.put(USERNAME_STAT, ""); // Expect an empty username when sync is signed out. 124 Assert.assertTrue("Expected sync to be disabled.", 125 pollAboutSyncStats(context, expectedStats)); 126 } 127 128 /** 129 * Polls the stats on about:sync until timeout or all expected stats match actual stats. The 130 * comparison is case insensitive. *All* stats must match those passed in via expectedStats. 131 * 132 * 133 * @param expectedStats a map of stat names to their expected values 134 * @return whether the stats matched up before the timeout 135 */ 136 public static boolean pollAboutSyncStats( 137 Context context, final Map<Pair<String, String>, String> expectedStats) { 138 final AboutSyncInfoGetter aboutInfoGetter = 139 new AboutSyncInfoGetter(context); 140 141 Criteria statChecker = new Criteria() { 142 @Override 143 public boolean isSatisfied() { 144 try { 145 ThreadUtils.runOnUiThreadBlocking(aboutInfoGetter); 146 Map<Pair<String, String>, String> actualStats = aboutInfoGetter.getAboutInfo(); 147 return areExpectedStatsAmongActual(expectedStats, actualStats); 148 } catch (Throwable e) { 149 Log.w(TAG, "Interrupted while attempting to fetch sync internals info.", e); 150 } 151 return false; 152 } 153 }; 154 155 boolean matched = false; 156 try { 157 matched = CriteriaHelper.pollForCriteria(statChecker, UI_TIMEOUT_MS, CHECK_INTERVAL_MS); 158 } catch (InterruptedException e) { 159 Log.w(TAG, "Interrupted while polling sync internals info.", e); 160 Assert.fail("Interrupted while polling sync internals info."); 161 } 162 return matched; 163 } 164 165 /** 166 * Checks whether the expected map's keys and values are a subset of those in another map. Both 167 * keys and values are compared in a case-insensitive fashion. 168 * 169 * @param expectedStats a map which may be a subset of actualSet 170 * @param actualStats a map which may be a superset of expectedSet 171 * @return true if all key/value pairs in expectedSet are in actualSet; false otherwise 172 */ 173 private static boolean areExpectedStatsAmongActual( 174 Map<Pair<String, String>, String> expectedStats, 175 Map<Pair<String, String>, String> actualStats) { 176 for (Map.Entry<Pair<String, String>, String> statEntry : expectedStats.entrySet()) { 177 // Make stuff lowercase here, at the site of comparison. 178 String expectedValue = statEntry.getValue().toLowerCase(Locale.US).trim(); 179 String actualValue = actualStats.get(statEntry.getKey()); 180 if (actualValue == null) { 181 return false; 182 } 183 actualValue = actualValue.toLowerCase(Locale.US).trim(); 184 if (!expectedValue.contentEquals(actualValue)) { 185 return false; 186 } 187 } 188 return true; 189 } 190 191 /** 192 * Triggers a sync and waits till it is complete. 193 */ 194 public static void triggerSyncAndWaitForCompletion(final Context context) 195 throws InterruptedException { 196 final long oldSyncTime = getCurrentSyncTime(context); 197 // Request sync. 198 ThreadUtils.runOnUiThreadBlocking(new Runnable() { 199 @Override 200 public void run() { 201 ProfileSyncService.get(context).requestSyncCycleForTest(); 202 } 203 }); 204 205 // Wait till lastSyncedTime > oldSyncTime. 206 Assert.assertTrue("Timed out waiting for syncing to complete.", 207 CriteriaHelper.pollForCriteria(new Criteria() { 208 @Override 209 public boolean isSatisfied() { 210 long currentSyncTime = 0; 211 try { 212 currentSyncTime = getCurrentSyncTime(context); 213 } catch (InterruptedException e) { 214 Log.w(TAG, "Interrupted while getting sync time.", e); 215 } 216 return currentSyncTime > oldSyncTime; 217 } 218 }, SYNC_WAIT_TIMEOUT_MS, SYNC_CHECK_INTERVAL_MS)); 219 } 220 221 private static long getCurrentSyncTime(final Context context) throws InterruptedException { 222 final Semaphore s = new Semaphore(0); 223 final AtomicLong result = new AtomicLong(); 224 ThreadUtils.runOnUiThreadBlocking(new Runnable() { 225 @Override 226 public void run() { 227 result.set(ProfileSyncService.get(context).getLastSyncedTimeForTest()); 228 s.release(); 229 } 230 }); 231 Assert.assertTrue(s.tryAcquire(SYNC_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)); 232 return result.get(); 233 } 234 235 /** 236 * Waits for a possible async initialization of the sync backend. 237 */ 238 public static void ensureSyncInitialized(final Context context) throws InterruptedException { 239 Assert.assertTrue("Timed out waiting for syncing to be initialized.", 240 CriteriaHelper.pollForCriteria(new Criteria() { 241 @Override 242 public boolean isSatisfied() { 243 return ThreadUtils.runOnUiThreadBlockingNoException( 244 new Callable<Boolean>() { 245 @Override 246 public Boolean call() throws Exception { 247 return ProfileSyncService.get(context) 248 .isSyncInitialized(); 249 250 } 251 }); 252 } 253 }, SYNC_WAIT_TIMEOUT_MS, SYNC_CHECK_INTERVAL_MS)); 254 } 255 256 /** 257 * Verifies that the sync status is "READY" and sync is signed in with the account. 258 */ 259 public static void verifySyncIsSignedIn(Context context, Account account) 260 throws InterruptedException { 261 ensureSyncInitialized(context); 262 triggerSyncAndWaitForCompletion(context); 263 verifySignedInWithAccount(context, account); 264 } 265 266 /** 267 * Makes sure that sync is enabled with the correct account. 268 */ 269 public static void verifySignedInWithAccount(Context context, Account account) { 270 if (account == null) return; 271 272 Assert.assertEquals( 273 account.name, ChromeSigninController.get(context).getSignedInAccountName()); 274 } 275 276 /** 277 * Makes sure that the Python sync server was successfully started by checking for a well known 278 * response to a request for the server time. The command line argument for the sync server must 279 * be present in order for this check to be valid. 280 */ 281 public static void verifySyncServerIsRunning() { 282 boolean hasSwitch = CommandLine.getInstance().hasSwitch(SYNC_URL); 283 Assert.assertTrue(SYNC_URL + " is a required parameter for the sync tests.", hasSwitch); 284 String syncTimeUrl = CommandLine.getInstance().getSwitchValue(SYNC_URL) + "/time"; 285 TestHttpServerClient.checkServerIsUp(syncTimeUrl, "0123456789"); 286 } 287 288 /** 289 * Sets up a test Google account on the device with specified auth token types. 290 */ 291 public static Account setupTestAccount(MockAccountManager accountManager, String accountName, 292 String password, String... allowedAuthTokenTypes) { 293 Account account = AccountManagerHelper.createAccountFromName(accountName); 294 AccountHolder.Builder accountHolder = 295 AccountHolder.create().account(account).password(password); 296 if (allowedAuthTokenTypes != null) { 297 // Auto-allowing provided auth token types 298 for (String authTokenType : allowedAuthTokenTypes) { 299 accountHolder.hasBeenAccepted(authTokenType, true); 300 } 301 } 302 accountManager.addAccountHolderExplicitly(accountHolder.build()); 303 return account; 304 } 305 306 /** 307 * Sets up a test Google account on the device, that accepts all auth tokens. 308 */ 309 public static Account setupTestAccountThatAcceptsAllAuthTokens( 310 MockAccountManager accountManager, 311 String accountName, String password) { 312 Account account = AccountManagerHelper.createAccountFromName(accountName); 313 AccountHolder.Builder accountHolder = 314 AccountHolder.create().account(account).password(password).alwaysAccept(true); 315 accountManager.addAccountHolderExplicitly(accountHolder.build()); 316 return account; 317 } 318 319 /** 320 * Returns whether the sync engine has keep everything synced set to true. 321 */ 322 public static boolean isSyncEverythingEnabled(final Context context) { 323 final AtomicBoolean result = new AtomicBoolean(); 324 ThreadUtils.runOnUiThreadBlocking(new Runnable() { 325 @Override 326 public void run() { 327 result.set(ProfileSyncService.get(context).hasKeepEverythingSynced()); 328 } 329 }); 330 return result.get(); 331 } 332 333 /** 334 * Verifies that the sync status is "Syncing not enabled" and that sync is signed in with the 335 * account. 336 */ 337 public static void verifySyncIsDisabled(Context context, Account account) { 338 Map<Pair<String, String>, String> expectedStats = 339 new HashMap<Pair<String, String>, String>(); 340 expectedStats.put(SYNC_SUMMARY_STATUS, UNINITIALIZED); 341 Assert.assertTrue( 342 "Expected sync to be disabled.", pollAboutSyncStats(context, expectedStats)); 343 verifySignedInWithAccount(context, account); 344 } 345 346 /** 347 * Retrieves the sync internals information which is the basis for chrome://sync-internals and 348 * makes the result available in {@link AboutSyncInfoGetter#getAboutInfo()}. 349 * 350 * This class has to be run on the main thread, as it accesses the ProfileSyncService. 351 */ 352 public static class AboutSyncInfoGetter implements Runnable { 353 private static final String TAG = "AboutSyncInfoGetter"; 354 final Context mContext; 355 Map<Pair<String, String>, String> mAboutInfo; 356 357 public AboutSyncInfoGetter(Context context) { 358 mContext = context.getApplicationContext(); 359 mAboutInfo = new HashMap<Pair<String, String>, String>(); 360 } 361 362 @Override 363 public void run() { 364 String info = ProfileSyncService.get(mContext).getSyncInternalsInfoForTest(); 365 try { 366 mAboutInfo = getAboutInfoStats(info); 367 } catch (JSONException e) { 368 Log.w(TAG, "Unable to parse JSON message: " + info, e); 369 } 370 } 371 372 public Map<Pair<String, String>, String> getAboutInfo() { 373 return mAboutInfo; 374 } 375 } 376 377 /** 378 * Helper class used to create a mock account on the device. 379 */ 380 public static class SyncTestContext extends AdvancedMockContext { 381 382 public SyncTestContext(Context context) { 383 super(context); 384 } 385 386 @Override 387 public Object getSystemService(String name) { 388 if (Context.ACCOUNT_SERVICE.equals(name)) { 389 throw new UnsupportedOperationException( 390 "Sync tests should not use system Account Manager."); 391 } 392 return super.getSystemService(name); 393 } 394 } 395 } 396