summaryrefslogtreecommitdiffstats
path: root/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java')
-rw-r--r--mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java1167
1 files changed, 1167 insertions, 0 deletions
diff --git a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
new file mode 100644
index 000000000..e28bbe4cc
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
@@ -0,0 +1,1167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.sync;
+
+import android.content.Context;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.delegates.FreshStartDelegate;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate;
+import org.mozilla.gecko.sync.delegates.KeyUploadDelegate;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.delegates.WipeServerDelegate;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.HttpResponseObserver;
+import org.mozilla.gecko.sync.net.SyncResponse;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequest;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage;
+import org.mozilla.gecko.sync.stage.AndroidBrowserHistoryServerSyncStage;
+import org.mozilla.gecko.sync.stage.CheckPreconditionsStage;
+import org.mozilla.gecko.sync.stage.CompletedStage;
+import org.mozilla.gecko.sync.stage.EnsureCrypto5KeysStage;
+import org.mozilla.gecko.sync.stage.FennecTabsServerSyncStage;
+import org.mozilla.gecko.sync.stage.FetchInfoCollectionsStage;
+import org.mozilla.gecko.sync.stage.FetchInfoConfigurationStage;
+import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage;
+import org.mozilla.gecko.sync.stage.FormHistoryServerSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.stage.NoSuchStageException;
+import org.mozilla.gecko.sync.stage.PasswordsServerSyncStage;
+import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
+import org.mozilla.gecko.sync.stage.UploadMetaGlobalStage;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+
+public class GlobalSession implements HttpResponseObserver {
+ private static final String LOG_TAG = "GlobalSession";
+
+ public static final long STORAGE_VERSION = 5;
+
+ public SyncConfiguration config = null;
+
+ protected Map<Stage, GlobalSyncStage> stages;
+ public Stage currentState = Stage.idle;
+
+ public final GlobalSessionCallback callback;
+ protected final Context context;
+ protected final ClientsDataDelegate clientsDelegate;
+
+ /**
+ * Map from engine name to new settings for an updated meta/global record.
+ * Engines to remove will have <code>null</code> EngineSettings.
+ */
+ public final Map<String, EngineSettings> enginesToUpdate = new HashMap<String, EngineSettings>();
+
+ /*
+ * Key accessors.
+ */
+ public KeyBundle keyBundleForCollection(String collection) throws NoCollectionKeysSetException {
+ return config.getCollectionKeys().keyBundleForCollection(collection);
+ }
+
+ /*
+ * Config passthrough for convenience.
+ */
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return config.getAuthHeaderProvider();
+ }
+
+ public URI wboURI(String collection, String id) throws URISyntaxException {
+ return config.wboURI(collection, id);
+ }
+
+ public GlobalSession(SyncConfiguration config,
+ GlobalSessionCallback callback,
+ Context context,
+ ClientsDataDelegate clientsDelegate)
+ throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
+
+ if (callback == null) {
+ throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor.");
+ }
+
+ this.callback = callback;
+ this.context = context;
+ this.clientsDelegate = clientsDelegate;
+
+ this.config = config;
+ registerCommands();
+ prepareStages();
+
+ if (config.stagesToSync == null) {
+ Logger.info(LOG_TAG, "No stages to sync specified; defaulting to all valid engine names.");
+ config.stagesToSync = Collections.unmodifiableCollection(SyncConfiguration.validEngineNames());
+ }
+
+ // TODO: data-driven plan for the sync, referring to prepareStages.
+ }
+
+ /**
+ * Register commands this global session knows how to process.
+ * <p>
+ * Re-registering a command overwrites any existing registration.
+ */
+ protected static void registerCommands() {
+ final CommandProcessor processor = CommandProcessor.getProcessor();
+
+ processor.registerCommand("resetEngine", new CommandRunner(1) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ HashSet<String> names = new HashSet<String>();
+ names.add(args.get(0));
+ session.resetStagesByName(names);
+ }
+ });
+
+ processor.registerCommand("resetAll", new CommandRunner(0) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ session.resetAllStages();
+ }
+ });
+
+ processor.registerCommand("wipeEngine", new CommandRunner(1) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ HashSet<String> names = new HashSet<String>();
+ names.add(args.get(0));
+ session.wipeStagesByName(names);
+ }
+ });
+
+ processor.registerCommand("wipeAll", new CommandRunner(0) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ session.wipeAllStages();
+ }
+ });
+
+ processor.registerCommand("displayURI", new CommandRunner(3) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ CommandProcessor.displayURI(args, session.getContext());
+ }
+ });
+ }
+
+ protected void prepareStages() {
+ Map<Stage, GlobalSyncStage> stages = new EnumMap<Stage, GlobalSyncStage>(Stage.class);
+
+ stages.put(Stage.checkPreconditions, new CheckPreconditionsStage());
+ stages.put(Stage.fetchInfoCollections, new FetchInfoCollectionsStage());
+ stages.put(Stage.fetchMetaGlobal, new FetchMetaGlobalStage());
+ stages.put(Stage.fetchInfoConfiguration, new FetchInfoConfigurationStage(
+ config.infoConfigurationURL(), getAuthHeaderProvider()));
+ stages.put(Stage.ensureKeysStage, new EnsureCrypto5KeysStage());
+
+ stages.put(Stage.syncClientsEngine, new SyncClientsEngineStage());
+
+ stages.put(Stage.syncTabs, new FennecTabsServerSyncStage());
+ stages.put(Stage.syncPasswords, new PasswordsServerSyncStage());
+ stages.put(Stage.syncBookmarks, new AndroidBrowserBookmarksServerSyncStage());
+ stages.put(Stage.syncHistory, new AndroidBrowserHistoryServerSyncStage());
+ stages.put(Stage.syncFormHistory, new FormHistoryServerSyncStage());
+
+ stages.put(Stage.uploadMetaGlobal, new UploadMetaGlobalStage());
+ stages.put(Stage.completed, new CompletedStage());
+
+ this.stages = Collections.unmodifiableMap(stages);
+ }
+
+ public GlobalSyncStage getSyncStageByName(String name) throws NoSuchStageException {
+ return getSyncStageByName(Stage.byName(name));
+ }
+
+ public GlobalSyncStage getSyncStageByName(Stage next) throws NoSuchStageException {
+ GlobalSyncStage stage = stages.get(next);
+ if (stage == null) {
+ throw new NoSuchStageException(next);
+ }
+ return stage;
+ }
+
+ public Collection<GlobalSyncStage> getSyncStagesByEnum(Collection<Stage> enums) {
+ ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>();
+ for (Stage name : enums) {
+ try {
+ GlobalSyncStage stage = this.getSyncStageByName(name);
+ out.add(stage);
+ } catch (NoSuchStageException e) {
+ Logger.warn(LOG_TAG, "Unable to find stage with name " + name);
+ }
+ }
+ return out;
+ }
+
+ public Collection<GlobalSyncStage> getSyncStagesByName(Collection<String> names) {
+ ArrayList<GlobalSyncStage> out = new ArrayList<GlobalSyncStage>();
+ for (String name : names) {
+ try {
+ GlobalSyncStage stage = this.getSyncStageByName(name);
+ out.add(stage);
+ } catch (NoSuchStageException e) {
+ Logger.warn(LOG_TAG, "Unable to find stage with name " + name);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Advance and loop around the stages of a sync.
+ * @param current
+ * @return
+ * The next stage to execute.
+ */
+ public static Stage nextStage(Stage current) {
+ int index = current.ordinal() + 1;
+ int max = Stage.completed.ordinal() + 1;
+ return Stage.values()[index % max];
+ }
+
+ /**
+ * Move to the next stage in the syncing process.
+ */
+ public void advance() {
+ // If we have a backoff, request a backoff and don't advance to next stage.
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff > 0) {
+ this.abort(null, "Aborting sync because of backoff of " + existingBackoff + " milliseconds.");
+ return;
+ }
+
+ this.callback.handleStageCompleted(this.currentState, this);
+ Stage next = nextStage(this.currentState);
+ GlobalSyncStage nextStage;
+ try {
+ nextStage = this.getSyncStageByName(next);
+ } catch (NoSuchStageException e) {
+ this.abort(e, "No such stage " + next);
+ return;
+ }
+ this.currentState = next;
+ Logger.info(LOG_TAG, "Running next stage " + next + " (" + nextStage + ")...");
+ try {
+ nextStage.execute(this);
+ } catch (Exception ex) {
+ Logger.warn(LOG_TAG, "Caught exception " + ex + " running stage " + next);
+ this.abort(ex, "Uncaught exception in stage.");
+ return;
+ }
+ }
+
+ public Context getContext() {
+ return this.context;
+ }
+
+ /**
+ * Begin a sync.
+ * <p>
+ * The caller is responsible for:
+ * <ul>
+ * <li>Verifying that any backoffs/minimum next sync requests are respected.</li>
+ * <li>Ensuring that the device is online.</li>
+ * <li>Ensuring that dependencies are ready.</li>
+ * </ul>
+ *
+ * @throws AlreadySyncingException
+ */
+ public void start() throws AlreadySyncingException {
+ if (this.currentState != GlobalSyncStage.Stage.idle) {
+ throw new AlreadySyncingException(this.currentState);
+ }
+ installAsHttpResponseObserver(); // Uninstalled by completeSync or abort.
+ this.advance();
+ }
+
+ /**
+ * Stop this sync and start again.
+ * @throws AlreadySyncingException
+ */
+ protected void restart() throws AlreadySyncingException {
+ this.currentState = GlobalSyncStage.Stage.idle;
+ if (callback.shouldBackOffStorage()) {
+ this.callback.handleAborted(this, "Told to back off.");
+ return;
+ }
+ this.start();
+ }
+
+ /**
+ * We're finished (aborted or succeeded): release resources.
+ */
+ protected void cleanUp() {
+ uninstallAsHttpResponseObserver();
+ this.stages = null;
+ }
+
+ public void completeSync() {
+ cleanUp();
+ this.currentState = GlobalSyncStage.Stage.idle;
+ this.callback.handleSuccess(this);
+ }
+
+ /**
+ * Record that an updated meta/global record should be uploaded with the given
+ * settings for the given engine.
+ *
+ * @param engineName engine to update.
+ * @param engineSettings new syncID and version.
+ */
+ public void recordForMetaGlobalUpdate(String engineName, EngineSettings engineSettings) {
+ enginesToUpdate.put(engineName, engineSettings);
+ }
+
+ /**
+ * Record that an updated meta/global record should be uploaded without the
+ * given engine name.
+ *
+ * @param engineName
+ * engine to remove.
+ */
+ public void removeEngineFromMetaGlobal(String engineName) {
+ enginesToUpdate.put(engineName, null);
+ }
+
+ public boolean hasUpdatedMetaGlobal() {
+ if (enginesToUpdate.isEmpty()) {
+ Logger.info(LOG_TAG, "Not uploading updated meta/global record since there are no engines requesting upload.");
+ return false;
+ }
+
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Uploading updated meta/global record since there are engine changes to meta/global.");
+ Logger.trace(LOG_TAG, "Engines requesting update [" + Utils.toCommaSeparatedString(enginesToUpdate.keySet()) + "]");
+ }
+
+ return true;
+ }
+
+ public void updateMetaGlobalInPlace() {
+ config.metaGlobal.declined = this.declinedEngineNames();
+ ExtendedJSONObject engines = config.metaGlobal.getEngines();
+ for (Entry<String, EngineSettings> pair : enginesToUpdate.entrySet()) {
+ if (pair.getValue() == null) {
+ engines.remove(pair.getKey());
+ } else {
+ engines.put(pair.getKey(), pair.getValue().toJSONObject());
+ }
+ }
+
+ enginesToUpdate.clear();
+ }
+
+ /**
+ * Synchronously upload an updated meta/global.
+ * <p>
+ * All problems are logged and ignored.
+ */
+ public void uploadUpdatedMetaGlobal() {
+ updateMetaGlobalInPlace();
+
+ Logger.debug(LOG_TAG, "Uploading updated meta/global record.");
+ final Object monitor = new Object();
+
+ Runnable doUpload = new Runnable() {
+ @Override
+ public void run() {
+ config.metaGlobal.upload(new MetaGlobalDelegate() {
+ @Override
+ public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
+ Logger.info(LOG_TAG, "Successfully uploaded updated meta/global record.");
+ // Engine changes are stored as diffs, so update enabled engines in config to match uploaded meta/global.
+ config.enabledEngineNames = config.metaGlobal.getEnabledEngineNames();
+ // Clear userSelectedEngines because they are updated in config and meta/global.
+ config.userSelectedEngines = null;
+
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got 404 missing uploading updated meta/global record; shouldn't happen. Ignoring.");
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Failed to upload updated meta/global record; ignoring.");
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.warn(LOG_TAG, "Got exception trying to upload updated meta/global record; ignoring.", e);
+ synchronized (monitor) {
+ monitor.notify();
+ }
+ }
+ });
+ }
+ };
+
+ final Thread upload = new Thread(doUpload);
+ synchronized (monitor) {
+ try {
+ upload.start();
+ monitor.wait();
+ Logger.debug(LOG_TAG, "Uploaded updated meta/global record.");
+ } catch (InterruptedException e) {
+ Logger.error(LOG_TAG, "Uploading updated meta/global interrupted; continuing.");
+ }
+ }
+ }
+
+
+ public void abort(Exception e, String reason) {
+ Logger.warn(LOG_TAG, "Aborting sync: " + reason, e);
+ cleanUp();
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff > 0) {
+ callback.requestBackoff(existingBackoff);
+ }
+ if (!(e instanceof HTTPFailureException)) {
+ // e is null, or we aborted for a non-HTTP reason; okay to upload new meta/global record.
+ if (this.hasUpdatedMetaGlobal()) {
+ this.uploadUpdatedMetaGlobal(); // Only logs errors; does not call abort.
+ }
+ }
+ this.callback.handleError(this, e);
+ }
+
+ public void handleHTTPError(SyncStorageResponse response, String reason) {
+ // TODO: handling of 50x (backoff), 401 (node reassignment or auth error).
+ // Fall back to aborting.
+ Logger.warn(LOG_TAG, "Aborting sync due to HTTP " + response.getStatusCode());
+ this.interpretHTTPFailure(response.httpResponse());
+ this.abort(new HTTPFailureException(response), reason);
+ }
+
+ /**
+ * Perform appropriate backoff etc. extraction.
+ */
+ public void interpretHTTPFailure(HttpResponse response) {
+ // TODO: handle permanent rejection.
+ long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds();
+ if (responseBackoff > 0) {
+ callback.requestBackoff(responseBackoff);
+ }
+
+ if (response.getStatusLine() != null) {
+ final int statusCode = response.getStatusLine().getStatusCode();
+ switch(statusCode) {
+
+ case 400:
+ SyncStorageResponse storageResponse = new SyncStorageResponse(response);
+ this.interpretHTTPBadRequestBody(storageResponse);
+ break;
+
+ case 401:
+ /*
+ * Alert our callback we have a 401 on a cluster URL. This GlobalSession
+ * will fail, but the next one will fetch a new cluster URL and will
+ * distinguish between "node reassignment" and "user password changed".
+ */
+ callback.informUnauthorizedResponse(this, config.getClusterURL());
+ break;
+ }
+ }
+ }
+
+ protected void interpretHTTPBadRequestBody(final SyncStorageResponse storageResponse) {
+ try {
+ final String body = storageResponse.body();
+ if (body == null) {
+ return;
+ }
+ if (SyncStorageResponse.RESPONSE_CLIENT_UPGRADE_REQUIRED.equals(body)) {
+ callback.informUpgradeRequiredResponse(this);
+ return;
+ }
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Exception parsing HTTP 400 body.", e);
+ }
+ }
+
+ public void fetchInfoCollections(JSONRecordFetchDelegate callback) throws URISyntaxException {
+ final JSONRecordFetcher fetcher = new JSONRecordFetcher(config.infoCollectionsURL(), getAuthHeaderProvider());
+ fetcher.fetch(callback);
+ }
+
+ /**
+ * Upload new crypto/keys.
+ *
+ * @param keys
+ * new keys.
+ * @param keyUploadDelegate
+ * a delegate.
+ */
+ public void uploadKeys(final CollectionKeys keys,
+ final KeyUploadDelegate keyUploadDelegate) {
+ SyncStorageRecordRequest request;
+ try {
+ request = new SyncStorageRecordRequest(this.config.keysURI());
+ } catch (URISyntaxException e) {
+ keyUploadDelegate.onKeyUploadFailed(e);
+ return;
+ }
+
+ request.delegate = new SyncStorageRequestDelegate() {
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Keys uploaded.");
+ BaseResource.consumeEntity(response); // We don't need the response at all.
+ keyUploadDelegate.onKeysUploaded();
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.debug(LOG_TAG, "Failed to upload keys.");
+ GlobalSession.this.interpretHTTPFailure(response.httpResponse());
+ BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response.
+ keyUploadDelegate.onKeyUploadFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception trying to upload keys", ex);
+ keyUploadDelegate.onKeyUploadFailed(ex);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return GlobalSession.this.getAuthHeaderProvider();
+ }
+ };
+
+ // Convert keys to an encrypted crypto record.
+ CryptoRecord keysRecord;
+ try {
+ keysRecord = keys.asCryptoRecord();
+ keysRecord.setKeyBundle(config.syncKeyBundle);
+ keysRecord.encrypt();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception trying creating crypto record from keys", e);
+ keyUploadDelegate.onKeyUploadFailed(e);
+ return;
+ }
+
+ request.put(keysRecord);
+ }
+
+ /*
+ * meta/global callbacks.
+ */
+ public void processMetaGlobal(MetaGlobal global) {
+ config.metaGlobal = global;
+
+ Long storageVersion = global.getStorageVersion();
+ if (storageVersion == null) {
+ Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote storage version.");
+ freshStart();
+ return;
+ }
+ if (storageVersion < STORAGE_VERSION) {
+ Logger.warn(LOG_TAG, "Outdated server: reported " +
+ "remote storage version " + storageVersion + " < " +
+ "local storage version " + STORAGE_VERSION);
+ freshStart();
+ return;
+ }
+ if (storageVersion > STORAGE_VERSION) {
+ Logger.warn(LOG_TAG, "Outdated client: reported " +
+ "remote storage version " + storageVersion + " > " +
+ "local storage version " + STORAGE_VERSION);
+ requiresUpgrade();
+ return;
+ }
+ String remoteSyncID = global.getSyncID();
+ if (remoteSyncID == null) {
+ Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote syncID.");
+ freshStart();
+ return;
+ }
+ String localSyncID = config.syncID;
+ if (!remoteSyncID.equals(localSyncID)) {
+ Logger.warn(LOG_TAG, "Remote syncID different from local syncID: resetting client and assuming remote syncID.");
+ resetAllStages();
+ config.purgeCryptoKeys();
+ config.syncID = remoteSyncID;
+ }
+ // Compare lastModified timestamps for remote/local engine selection times.
+ Logger.debug(LOG_TAG, "Comparing local engine selection timestamp [" + config.userSelectedEnginesTimestamp + "] to server meta/global timestamp [" + config.persistedMetaGlobal().lastModified() + "].");
+ if (config.userSelectedEnginesTimestamp < config.persistedMetaGlobal().lastModified()) {
+ // Remote has later meta/global timestamp. Don't upload engine changes.
+ config.userSelectedEngines = null;
+ }
+ // Persist enabled engine names.
+ config.enabledEngineNames = global.getEnabledEngineNames();
+ if (config.enabledEngineNames == null) {
+ Logger.warn(LOG_TAG, "meta/global reported no enabled engine names!");
+ } else {
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Persisting enabled engine names '" +
+ Utils.toCommaSeparatedString(config.enabledEngineNames) + "' from meta/global.");
+ }
+ }
+
+ // Persist declined.
+ // Our declined engines at any point are:
+ // Whatever they were remotely, plus whatever they were locally, less any
+ // engines that were just enabled locally or remotely.
+ // If remote just 'won', our recently enabled list just got cleared.
+ final HashSet<String> allDeclined = new HashSet<String>();
+
+ final Set<String> newRemoteDeclined = global.getDeclinedEngineNames();
+ final Set<String> oldLocalDeclined = config.declinedEngineNames;
+
+ allDeclined.addAll(newRemoteDeclined);
+ allDeclined.addAll(oldLocalDeclined);
+
+ if (config.userSelectedEngines != null) {
+ for (Entry<String, Boolean> selection : config.userSelectedEngines.entrySet()) {
+ if (selection.getValue()) {
+ allDeclined.remove(selection.getKey());
+ }
+ }
+ }
+
+ config.declinedEngineNames = allDeclined;
+ if (config.declinedEngineNames.isEmpty()) {
+ Logger.debug(LOG_TAG, "meta/global reported no declined engine names, and we have none declined locally.");
+ } else {
+ if (Logger.shouldLogVerbose(LOG_TAG)) {
+ Logger.trace(LOG_TAG, "Persisting declined engine names '" +
+ Utils.toCommaSeparatedString(config.declinedEngineNames) + "' from meta/global.");
+ }
+ }
+
+ config.persistToPrefs();
+ advance();
+ }
+
+ public void processMissingMetaGlobal(MetaGlobal global) {
+ freshStart();
+ }
+
+ /**
+ * Do a fresh start then quietly finish the sync, starting another.
+ */
+ public void freshStart() {
+ final GlobalSession globalSession = this;
+ freshStart(this, new FreshStartDelegate() {
+
+ @Override
+ public void onFreshStartFailed(Exception e) {
+ globalSession.abort(e, "Fresh start failed.");
+ }
+
+ @Override
+ public void onFreshStart() {
+ try {
+ Logger.warn(LOG_TAG, "Fresh start succeeded; restarting global session.");
+ globalSession.config.persistToPrefs();
+ globalSession.restart();
+ } catch (Exception e) {
+ Logger.warn(LOG_TAG, "Got exception when restarting sync after freshStart.", e);
+ globalSession.abort(e, "Got exception after freshStart.");
+ }
+ }
+ });
+ }
+
+ /**
+ * Clean the server, aborting the current sync.
+ * <p>
+ * <ol>
+ * <li>Wipe the server storage.</li>
+ * <li>Reset all stages and purge cached state: (meta/global and crypto/keys records).</li>
+ * <li>Upload fresh meta/global record.</li>
+ * <li>Upload fresh crypto/keys record.</li>
+ * <li>Restart the sync entirely in order to re-download meta/global and crypto/keys record.</li>
+ * </ol>
+ * @param session the current session.
+ * @param freshStartDelegate delegate to notify on fresh start or failure.
+ */
+ protected static void freshStart(final GlobalSession session, final FreshStartDelegate freshStartDelegate) {
+ Logger.debug(LOG_TAG, "Fresh starting.");
+
+ final MetaGlobal mg = session.generateNewMetaGlobal();
+
+ session.wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() {
+
+ @Override
+ public void onWiped(long timestamp) {
+ Logger.debug(LOG_TAG, "Successfully wiped server. Resetting all stages and purging cached meta/global and crypto/keys records.");
+
+ session.resetAllStages();
+ session.config.purgeMetaGlobal();
+ session.config.purgeCryptoKeys();
+ session.config.persistToPrefs();
+
+ Logger.info(LOG_TAG, "Uploading new meta/global with sync ID " + mg.syncID + ".");
+
+ // It would be good to set the X-If-Unmodified-Since header to `timestamp`
+ // for this PUT to ensure at least some level of transactionality.
+ // Unfortunately, the servers don't support it after a wipe right now
+ // (bug 693893), so we're going to defer this until bug 692700.
+ mg.upload(new MetaGlobalDelegate() {
+ @Override
+ public void handleSuccess(MetaGlobal uploadedGlobal, SyncStorageResponse uploadResponse) {
+ Logger.info(LOG_TAG, "Uploaded new meta/global with sync ID " + uploadedGlobal.syncID + ".");
+
+ // Generate new keys.
+ CollectionKeys keys = null;
+ try {
+ keys = session.generateNewCryptoKeys();
+ } catch (CryptoException e) {
+ Logger.warn(LOG_TAG, "Got exception generating new keys; failing fresh start.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ if (keys == null) {
+ Logger.warn(LOG_TAG, "Got null keys from generateNewKeys; failing fresh start.");
+ freshStartDelegate.onFreshStartFailed(null);
+ }
+
+ // Upload new keys.
+ Logger.info(LOG_TAG, "Uploading new crypto/keys.");
+ session.uploadKeys(keys, new KeyUploadDelegate() {
+ @Override
+ public void onKeysUploaded() {
+ Logger.info(LOG_TAG, "Uploaded new crypto/keys.");
+ freshStartDelegate.onFreshStart();
+ }
+
+ @Override
+ public void onKeyUploadFailed(Exception e) {
+ Logger.warn(LOG_TAG, "Got exception uploading new keys.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ @Override
+ public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+ // Shouldn't happen on upload.
+ Logger.warn(LOG_TAG, "Got 'missing' response uploading new meta/global.");
+ freshStartDelegate.onFreshStartFailed(new Exception("meta/global missing while uploading."));
+ }
+
+ @Override
+ public void handleFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got failure " + response.getStatusCode() + " uploading new meta/global.");
+ session.interpretHTTPFailure(response.httpResponse());
+ freshStartDelegate.onFreshStartFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ Logger.warn(LOG_TAG, "Got error uploading new meta/global.", e);
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ @Override
+ public void onWipeFailed(Exception e) {
+ Logger.warn(LOG_TAG, "Wipe failed.");
+ freshStartDelegate.onFreshStartFailed(e);
+ }
+ });
+ }
+
+ // Note that we do not yet implement wipeRemote: it's only necessary for
+ // first sync options.
+ // -- reset local stages, wipe server for each stage *except* clients
+ // (stages only, not whole server!), send wipeEngine commands to each client.
+ //
+ // Similarly for startOver (because we don't receive that notification).
+ // -- remove client data from server, reset local stages, clear keys, reset
+ // backoff, clear all prefs, discard credentials.
+ //
+ // Change passphrase: wipe entire server, reset client to force upload, sync.
+ //
+ // When an engine is disabled: wipe its collections on the server, reupload
+ // meta/global.
+ //
+ // On syncing each stage: if server has engine version 0 or old, wipe server,
+ // reset client to prompt reupload.
+ // If sync ID mismatch: take that syncID and reset client.
+
+ protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) {
+ SyncStorageRequest request;
+ final GlobalSession self = this;
+
+ try {
+ request = new SyncStorageRequest(config.storageURL());
+ } catch (URISyntaxException ex) {
+ Logger.warn(LOG_TAG, "Invalid URI in wipeServer.");
+ wipeDelegate.onWipeFailed(ex);
+ return;
+ }
+
+ request.delegate = new SyncStorageRequestDelegate() {
+
+ @Override
+ public String ifUnmodifiedSince() {
+ return null;
+ }
+
+ @Override
+ public void handleRequestSuccess(SyncStorageResponse response) {
+ BaseResource.consumeEntity(response);
+ wipeDelegate.onWiped(response.normalizedWeaveTimestamp());
+ }
+
+ @Override
+ public void handleRequestFailure(SyncStorageResponse response) {
+ Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer.");
+ // Process HTTP failures here to pick up backoffs, etc.
+ self.interpretHTTPFailure(response.httpResponse());
+ BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response.
+ wipeDelegate.onWipeFailed(new HTTPFailureException(response));
+ }
+
+ @Override
+ public void handleRequestError(Exception ex) {
+ Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex);
+ wipeDelegate.onWipeFailed(ex);
+ }
+
+ @Override
+ public AuthHeaderProvider getAuthHeaderProvider() {
+ return GlobalSession.this.getAuthHeaderProvider();
+ }
+ };
+ request.delete();
+ }
+
+ public void wipeAllStages() {
+ Logger.info(LOG_TAG, "Wiping all stages.");
+ // Includes "clients".
+ this.wipeStagesByEnum(Stage.getNamedStages());
+ }
+
+ public void wipeStages(Collection<GlobalSyncStage> stages) {
+ for (GlobalSyncStage stage : stages) {
+ try {
+ Logger.info(LOG_TAG, "Wiping " + stage);
+ stage.wipeLocal(this);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Ignoring wipe failure for stage " + stage, e);
+ }
+ }
+ }
+
+ public void wipeStagesByEnum(Collection<Stage> stages) {
+ wipeStages(this.getSyncStagesByEnum(stages));
+ }
+
+ public void wipeStagesByName(Collection<String> names) {
+ wipeStages(this.getSyncStagesByName(names));
+ }
+
+ public void resetAllStages() {
+ Logger.info(LOG_TAG, "Resetting all stages.");
+ // Includes "clients".
+ this.resetStagesByEnum(Stage.getNamedStages());
+ }
+
+ public void resetStages(Collection<GlobalSyncStage> stages) {
+ for (GlobalSyncStage stage : stages) {
+ try {
+ Logger.info(LOG_TAG, "Resetting " + stage);
+ stage.resetLocal(this);
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Ignoring reset failure for stage " + stage, e);
+ }
+ }
+ }
+
+ public void resetStagesByEnum(Collection<Stage> stages) {
+ resetStages(this.getSyncStagesByEnum(stages));
+ }
+
+ public void resetStagesByName(Collection<String> names) {
+ resetStages(this.getSyncStagesByName(names));
+ }
+
+ /**
+ * Engines to explicitly mark as declined in a fresh meta/global record.
+ * <p>
+ * Returns an empty array if the user hasn't elected to customize data types,
+ * or an array of engines that the user un-checked during customization.
+ * <p>
+ * Engines that Android Sync doesn't recognize are <b>not</b> included in
+ * the returned array.
+ *
+ * @return a new JSONArray of engine names.
+ */
+ @SuppressWarnings("unchecked")
+ protected JSONArray declinedEngineNames() {
+ final JSONArray declined = new JSONArray();
+ for (String engine : config.declinedEngineNames) {
+ declined.add(engine);
+ };
+
+ return declined;
+ }
+
+ /**
+ * Engines to include in a fresh meta/global record.
+ * <p>
+ * Returns either the persisted engine names (perhaps we have been node
+ * re-assigned and are initializing a clean server: we want to upload the
+ * persisted engine names so that we don't accidentally disable engines that
+ * Android Sync doesn't recognize), or the set of engines names that Android
+ * Sync implements.
+ *
+ * @return set of engine names.
+ */
+ protected Set<String> enabledEngineNames() {
+ if (config.enabledEngineNames != null) {
+ return config.enabledEngineNames;
+ }
+
+ // These are the default set of engine names.
+ Set<String> validEngineNames = SyncConfiguration.validEngineNames();
+
+ // If the user hasn't set any selected engines, that's okay -- default to
+ // everything.
+ if (config.userSelectedEngines == null) {
+ return validEngineNames;
+ }
+
+ // userSelectedEngines has keys that are engine names, and boolean values
+ // corresponding to whether the user asked for the engine to sync or not. If
+ // an engine is not present, that means the user didn't change its sync
+ // setting. Since we default to everything on, that means the user didn't
+ // turn it off; therefore, it's included in the set of engines to sync.
+ Set<String> validAndSelectedEngineNames = new HashSet<String>();
+ for (String engineName : validEngineNames) {
+ if (config.userSelectedEngines.containsKey(engineName) &&
+ !config.userSelectedEngines.get(engineName)) {
+ continue;
+ }
+ validAndSelectedEngineNames.add(engineName);
+ }
+ return validAndSelectedEngineNames;
+ }
+
+ /**
+ * Generate fresh crypto/keys collection.
+ * @return crypto/keys collection.
+ * @throws CryptoException
+ */
+ @SuppressWarnings("static-method")
+ public CollectionKeys generateNewCryptoKeys() throws CryptoException {
+ return CollectionKeys.generateCollectionKeys();
+ }
+
+ /**
+ * Generate a fresh meta/global record.
+ * @return meta/global record.
+ */
+ public MetaGlobal generateNewMetaGlobal() {
+ final String newSyncID = Utils.generateGuid();
+ final String metaURL = this.config.metaURL();
+
+ ExtendedJSONObject engines = new ExtendedJSONObject();
+ for (String engineName : enabledEngineNames()) {
+ EngineSettings engineSettings = null;
+ try {
+ GlobalSyncStage globalStage = this.getSyncStageByName(engineName);
+ Integer version = globalStage.getStorageVersion();
+ if (version == null) {
+ continue; // Don't want this stage to be included in meta/global.
+ }
+ engineSettings = new EngineSettings(Utils.generateGuid(), version);
+ } catch (NoSuchStageException e) {
+ // No trouble; Android Sync might not recognize this engine yet.
+ // By default, version 0. Other clients will see the 0 version and reset/wipe accordingly.
+ engineSettings = new EngineSettings(Utils.generateGuid(), 0);
+ }
+ engines.put(engineName, engineSettings.toJSONObject());
+ }
+
+ MetaGlobal metaGlobal = new MetaGlobal(metaURL, this.getAuthHeaderProvider());
+ metaGlobal.setSyncID(newSyncID);
+ metaGlobal.setStorageVersion(STORAGE_VERSION);
+ metaGlobal.setEngines(engines);
+
+ // We assume that the config's declined engines have been updated
+ // according to the user's selections.
+ metaGlobal.setDeclinedEngineNames(this.declinedEngineNames());
+
+ return metaGlobal;
+ }
+
+ /**
+ * Suggest that your Sync client needs to be upgraded to work
+ * with this server.
+ */
+ public void requiresUpgrade() {
+ Logger.info(LOG_TAG, "Client outdated storage version; requires update.");
+ // TODO: notify UI.
+ this.abort(null, "Requires upgrade");
+ }
+
+ /**
+ * If meta/global is missing or malformed, throws a MetaGlobalException.
+ * Otherwise, returns true if there is an entry for this engine in the
+ * meta/global "engines" object.
+ * <p>
+ * This is a global/permanent setting, not a local/temporary setting. For the
+ * latter, see {@link GlobalSession#isEngineLocallyEnabled(String)}.
+ *
+ * @param engineName the name to check (e.g., "bookmarks").
+ * @param engineSettings
+ * if non-null, verify that the server engine settings are congruent
+ * with this, throwing the appropriate MetaGlobalException if not.
+ * @return
+ * true if the engine with the provided name is present in the
+ * meta/global "engines" object, and verification passed.
+ *
+ * @throws MetaGlobalException
+ */
+ public boolean isEngineRemotelyEnabled(String engineName, EngineSettings engineSettings) throws MetaGlobalException {
+ if (this.config.metaGlobal == null) {
+ throw new MetaGlobalNotSetException();
+ }
+
+ // This should not occur.
+ if (this.config.enabledEngineNames == null) {
+ Logger.error(LOG_TAG, "No enabled engines in config. Giving up.");
+ throw new MetaGlobalMissingEnginesException();
+ }
+
+ if (!(this.config.enabledEngineNames.contains(engineName))) {
+ Logger.debug(LOG_TAG, "Engine " + engineName + " not enabled: no meta/global entry.");
+ return false;
+ }
+
+ // If we have a meta/global, check that it's safe for us to sync.
+ // (If we don't, we'll create one later, which is why we return `true` above.)
+ if (engineSettings != null) {
+ // Throws if there's a problem.
+ this.config.metaGlobal.verifyEngineSettings(engineName, engineSettings);
+ }
+
+ return true;
+ }
+
+
+ /**
+ * Return true if the named stage should be synced this session.
+ * <p>
+ * This is a local/temporary setting, in contrast to the meta/global record,
+ * which is a global/permanent setting. For the latter, see
+ * {@link GlobalSession#isEngineRemotelyEnabled(String, EngineSettings)}.
+ *
+ * @param stageName
+ * to query.
+ * @return true if named stage is enabled for this sync.
+ */
+ public boolean isEngineLocallyEnabled(String stageName) {
+ if (config.stagesToSync == null) {
+ return true;
+ }
+ return config.stagesToSync.contains(stageName);
+ }
+
+ public ClientsDataDelegate getClientsDelegate() {
+ return this.clientsDelegate;
+ }
+
+ /**
+ * The longest backoff observed to date; -1 means no backoff observed.
+ */
+ protected final AtomicLong largestBackoffObserved = new AtomicLong(-1);
+
+ /**
+ * Reset any observed backoff and start observing HTTP responses for backoff
+ * requests.
+ */
+ protected void installAsHttpResponseObserver() {
+ Logger.debug(LOG_TAG, "Adding " + this + " as a BaseResource HttpResponseObserver.");
+ BaseResource.addHttpResponseObserver(this);
+ largestBackoffObserved.set(-1);
+ }
+
+ /**
+ * Stop observing HttpResponses for backoff requests.
+ */
+ protected void uninstallAsHttpResponseObserver() {
+ Logger.debug(LOG_TAG, "Removing " + this + " as a BaseResource HttpResponseObserver.");
+ BaseResource.removeHttpResponseObserver(this);
+ }
+
+ /**
+ * Observe all HTTP response for backoff requests on all status codes, not just errors.
+ */
+ @Override
+ public void observeHttpResponse(HttpUriRequest request, HttpResponse response) {
+ // Ignore non-Sync storage requests.
+ final URI clusterURL = config.getClusterURL();
+ if (clusterURL != null && !clusterURL.getHost().equals(request.getURI().getHost())) {
+ // It's possible to see requests without a clusterURL (in particular,
+ // during testing); allow some extra backoffs in this case.
+ return;
+ }
+
+ long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); // TODO: don't allocate object?
+ if (responseBackoff <= 0) {
+ return;
+ }
+
+ Logger.debug(LOG_TAG, "Observed " + responseBackoff + " millisecond backoff request.");
+ while (true) {
+ long existingBackoff = largestBackoffObserved.get();
+ if (existingBackoff >= responseBackoff) {
+ return;
+ }
+ if (largestBackoffObserved.compareAndSet(existingBackoff, responseBackoff)) {
+ return;
+ }
+ }
+ }
+}