michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.sync; michael@0: michael@0: import java.io.IOException; michael@0: import java.net.URI; michael@0: import java.net.URISyntaxException; michael@0: import java.util.ArrayList; michael@0: import java.util.Collection; michael@0: import java.util.Collections; michael@0: import java.util.HashMap; michael@0: import java.util.HashSet; michael@0: import java.util.List; michael@0: import java.util.Map; michael@0: import java.util.Map.Entry; michael@0: import java.util.Set; michael@0: import java.util.concurrent.atomic.AtomicLong; michael@0: michael@0: import org.json.simple.JSONArray; michael@0: import org.json.simple.parser.ParseException; michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.sync.crypto.CryptoException; michael@0: import org.mozilla.gecko.sync.crypto.KeyBundle; michael@0: import org.mozilla.gecko.sync.delegates.BaseGlobalSessionCallback; michael@0: import org.mozilla.gecko.sync.delegates.ClientsDataDelegate; michael@0: import org.mozilla.gecko.sync.delegates.FreshStartDelegate; michael@0: import org.mozilla.gecko.sync.delegates.JSONRecordFetchDelegate; michael@0: import org.mozilla.gecko.sync.delegates.KeyUploadDelegate; michael@0: import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate; michael@0: import org.mozilla.gecko.sync.delegates.NodeAssignmentCallback; michael@0: import org.mozilla.gecko.sync.delegates.WipeServerDelegate; michael@0: import org.mozilla.gecko.sync.net.AuthHeaderProvider; michael@0: import org.mozilla.gecko.sync.net.BaseResource; michael@0: import org.mozilla.gecko.sync.net.HttpResponseObserver; michael@0: import org.mozilla.gecko.sync.net.SyncResponse; michael@0: import org.mozilla.gecko.sync.net.SyncStorageRecordRequest; michael@0: import org.mozilla.gecko.sync.net.SyncStorageRequest; michael@0: import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate; michael@0: import org.mozilla.gecko.sync.net.SyncStorageResponse; michael@0: import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage; michael@0: import org.mozilla.gecko.sync.stage.AndroidBrowserHistoryServerSyncStage; michael@0: import org.mozilla.gecko.sync.stage.CheckPreconditionsStage; michael@0: import org.mozilla.gecko.sync.stage.CompletedStage; michael@0: import org.mozilla.gecko.sync.stage.EnsureClusterURLStage; michael@0: import org.mozilla.gecko.sync.stage.EnsureCrypto5KeysStage; michael@0: import org.mozilla.gecko.sync.stage.FennecTabsServerSyncStage; michael@0: import org.mozilla.gecko.sync.stage.FetchInfoCollectionsStage; michael@0: import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage; michael@0: import org.mozilla.gecko.sync.stage.FormHistoryServerSyncStage; michael@0: import org.mozilla.gecko.sync.stage.GlobalSyncStage; michael@0: import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage; michael@0: import org.mozilla.gecko.sync.stage.NoSuchStageException; michael@0: import org.mozilla.gecko.sync.stage.PasswordsServerSyncStage; michael@0: import org.mozilla.gecko.sync.stage.SyncClientsEngineStage; michael@0: import org.mozilla.gecko.sync.stage.UploadMetaGlobalStage; michael@0: michael@0: import android.content.Context; michael@0: import ch.boye.httpclientandroidlib.HttpResponse; michael@0: michael@0: public class GlobalSession implements HttpResponseObserver { michael@0: private static final String LOG_TAG = "GlobalSession"; michael@0: michael@0: public static final long STORAGE_VERSION = 5; michael@0: michael@0: public SyncConfiguration config = null; michael@0: michael@0: protected Map stages; michael@0: public Stage currentState = Stage.idle; michael@0: michael@0: public final BaseGlobalSessionCallback callback; michael@0: protected final Context context; michael@0: protected final ClientsDataDelegate clientsDelegate; michael@0: protected final NodeAssignmentCallback nodeAssignmentCallback; michael@0: michael@0: /** michael@0: * Map from engine name to new settings for an updated meta/global record. michael@0: * Engines to remove will have null EngineSettings. michael@0: */ michael@0: public final Map enginesToUpdate = new HashMap(); michael@0: michael@0: /* michael@0: * Key accessors. michael@0: */ michael@0: public KeyBundle keyBundleForCollection(String collection) throws NoCollectionKeysSetException { michael@0: return config.getCollectionKeys().keyBundleForCollection(collection); michael@0: } michael@0: michael@0: /* michael@0: * Config passthrough for convenience. michael@0: */ michael@0: public AuthHeaderProvider getAuthHeaderProvider() { michael@0: return config.getAuthHeaderProvider(); michael@0: } michael@0: michael@0: public URI wboURI(String collection, String id) throws URISyntaxException { michael@0: return config.wboURI(collection, id); michael@0: } michael@0: michael@0: public GlobalSession(SyncConfiguration config, michael@0: BaseGlobalSessionCallback callback, michael@0: Context context, michael@0: ClientsDataDelegate clientsDelegate, NodeAssignmentCallback nodeAssignmentCallback) michael@0: throws SyncConfigurationException, IllegalArgumentException, IOException, ParseException, NonObjectJSONException { michael@0: michael@0: if (callback == null) { michael@0: throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor."); michael@0: } michael@0: michael@0: this.callback = callback; michael@0: this.context = context; michael@0: this.clientsDelegate = clientsDelegate; michael@0: this.nodeAssignmentCallback = nodeAssignmentCallback; michael@0: michael@0: this.config = config; michael@0: registerCommands(); michael@0: prepareStages(); michael@0: michael@0: if (config.stagesToSync == null) { michael@0: Logger.info(LOG_TAG, "No stages to sync specified; defaulting to all valid engine names."); michael@0: config.stagesToSync = Collections.unmodifiableCollection(SyncConfiguration.validEngineNames()); michael@0: } michael@0: michael@0: // TODO: data-driven plan for the sync, referring to prepareStages. michael@0: } michael@0: michael@0: /** michael@0: * Register commands this global session knows how to process. michael@0: *

michael@0: * Re-registering a command overwrites any existing registration. michael@0: */ michael@0: protected static void registerCommands() { michael@0: final CommandProcessor processor = CommandProcessor.getProcessor(); michael@0: michael@0: processor.registerCommand("resetEngine", new CommandRunner(1) { michael@0: @Override michael@0: public void executeCommand(final GlobalSession session, List args) { michael@0: HashSet names = new HashSet(); michael@0: names.add(args.get(0)); michael@0: session.resetStagesByName(names); michael@0: } michael@0: }); michael@0: michael@0: processor.registerCommand("resetAll", new CommandRunner(0) { michael@0: @Override michael@0: public void executeCommand(final GlobalSession session, List args) { michael@0: session.resetAllStages(); michael@0: } michael@0: }); michael@0: michael@0: processor.registerCommand("wipeEngine", new CommandRunner(1) { michael@0: @Override michael@0: public void executeCommand(final GlobalSession session, List args) { michael@0: HashSet names = new HashSet(); michael@0: names.add(args.get(0)); michael@0: session.wipeStagesByName(names); michael@0: } michael@0: }); michael@0: michael@0: processor.registerCommand("wipeAll", new CommandRunner(0) { michael@0: @Override michael@0: public void executeCommand(final GlobalSession session, List args) { michael@0: session.wipeAllStages(); michael@0: } michael@0: }); michael@0: michael@0: processor.registerCommand("displayURI", new CommandRunner(3) { michael@0: @Override michael@0: public void executeCommand(final GlobalSession session, List args) { michael@0: CommandProcessor.displayURI(args, session.getContext()); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: protected void prepareStages() { michael@0: HashMap stages = new HashMap(); michael@0: michael@0: stages.put(Stage.checkPreconditions, new CheckPreconditionsStage()); michael@0: stages.put(Stage.ensureClusterURL, new EnsureClusterURLStage(nodeAssignmentCallback)); michael@0: stages.put(Stage.fetchInfoCollections, new FetchInfoCollectionsStage()); michael@0: stages.put(Stage.fetchMetaGlobal, new FetchMetaGlobalStage()); michael@0: stages.put(Stage.ensureKeysStage, new EnsureCrypto5KeysStage()); michael@0: stages.put(Stage.syncClientsEngine, new SyncClientsEngineStage()); michael@0: michael@0: stages.put(Stage.syncTabs, new FennecTabsServerSyncStage()); michael@0: stages.put(Stage.syncPasswords, new PasswordsServerSyncStage()); michael@0: stages.put(Stage.syncBookmarks, new AndroidBrowserBookmarksServerSyncStage()); michael@0: stages.put(Stage.syncHistory, new AndroidBrowserHistoryServerSyncStage()); michael@0: stages.put(Stage.syncFormHistory, new FormHistoryServerSyncStage()); michael@0: michael@0: stages.put(Stage.uploadMetaGlobal, new UploadMetaGlobalStage()); michael@0: stages.put(Stage.completed, new CompletedStage()); michael@0: michael@0: this.stages = Collections.unmodifiableMap(stages); michael@0: } michael@0: michael@0: public GlobalSyncStage getSyncStageByName(String name) throws NoSuchStageException { michael@0: return getSyncStageByName(Stage.byName(name)); michael@0: } michael@0: michael@0: public GlobalSyncStage getSyncStageByName(Stage next) throws NoSuchStageException { michael@0: GlobalSyncStage stage = stages.get(next); michael@0: if (stage == null) { michael@0: throw new NoSuchStageException(next); michael@0: } michael@0: return stage; michael@0: } michael@0: michael@0: public Collection getSyncStagesByEnum(Collection enums) { michael@0: ArrayList out = new ArrayList(); michael@0: for (Stage name : enums) { michael@0: try { michael@0: GlobalSyncStage stage = this.getSyncStageByName(name); michael@0: out.add(stage); michael@0: } catch (NoSuchStageException e) { michael@0: Logger.warn(LOG_TAG, "Unable to find stage with name " + name); michael@0: } michael@0: } michael@0: return out; michael@0: } michael@0: michael@0: public Collection getSyncStagesByName(Collection names) { michael@0: ArrayList out = new ArrayList(); michael@0: for (String name : names) { michael@0: try { michael@0: GlobalSyncStage stage = this.getSyncStageByName(name); michael@0: out.add(stage); michael@0: } catch (NoSuchStageException e) { michael@0: Logger.warn(LOG_TAG, "Unable to find stage with name " + name); michael@0: } michael@0: } michael@0: return out; michael@0: } michael@0: michael@0: /** michael@0: * Advance and loop around the stages of a sync. michael@0: * @param current michael@0: * @return michael@0: * The next stage to execute. michael@0: */ michael@0: public static Stage nextStage(Stage current) { michael@0: int index = current.ordinal() + 1; michael@0: int max = Stage.completed.ordinal() + 1; michael@0: return Stage.values()[index % max]; michael@0: } michael@0: michael@0: /** michael@0: * Move to the next stage in the syncing process. michael@0: */ michael@0: public void advance() { michael@0: // If we have a backoff, request a backoff and don't advance to next stage. michael@0: long existingBackoff = largestBackoffObserved.get(); michael@0: if (existingBackoff > 0) { michael@0: this.abort(null, "Aborting sync because of backoff of " + existingBackoff + " milliseconds."); michael@0: return; michael@0: } michael@0: michael@0: this.callback.handleStageCompleted(this.currentState, this); michael@0: Stage next = nextStage(this.currentState); michael@0: GlobalSyncStage nextStage; michael@0: try { michael@0: nextStage = this.getSyncStageByName(next); michael@0: } catch (NoSuchStageException e) { michael@0: this.abort(e, "No such stage " + next); michael@0: return; michael@0: } michael@0: this.currentState = next; michael@0: Logger.info(LOG_TAG, "Running next stage " + next + " (" + nextStage + ")..."); michael@0: try { michael@0: nextStage.execute(this); michael@0: } catch (Exception ex) { michael@0: Logger.warn(LOG_TAG, "Caught exception " + ex + " running stage " + next); michael@0: this.abort(ex, "Uncaught exception in stage."); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: public Context getContext() { michael@0: return this.context; michael@0: } michael@0: michael@0: /** michael@0: * Begin a sync. michael@0: *

michael@0: * The caller is responsible for: michael@0: *

    michael@0: *
  • Verifying that any backoffs/minimum next sync requests are respected.
  • michael@0: *
  • Ensuring that the device is online.
  • michael@0: *
  • Ensuring that dependencies are ready.
  • michael@0: *
michael@0: * michael@0: * @throws AlreadySyncingException michael@0: */ michael@0: public void start() throws AlreadySyncingException { michael@0: if (this.currentState != GlobalSyncStage.Stage.idle) { michael@0: throw new AlreadySyncingException(this.currentState); michael@0: } michael@0: installAsHttpResponseObserver(); // Uninstalled by completeSync or abort. michael@0: this.advance(); michael@0: } michael@0: michael@0: /** michael@0: * Stop this sync and start again. michael@0: * @throws AlreadySyncingException michael@0: */ michael@0: protected void restart() throws AlreadySyncingException { michael@0: this.currentState = GlobalSyncStage.Stage.idle; michael@0: if (callback.shouldBackOffStorage()) { michael@0: this.callback.handleAborted(this, "Told to back off."); michael@0: return; michael@0: } michael@0: this.start(); michael@0: } michael@0: michael@0: /** michael@0: * We're finished (aborted or succeeded): release resources. michael@0: */ michael@0: protected void cleanUp() { michael@0: uninstallAsHttpResponseObserver(); michael@0: this.stages = null; michael@0: } michael@0: michael@0: public void completeSync() { michael@0: cleanUp(); michael@0: this.currentState = GlobalSyncStage.Stage.idle; michael@0: this.callback.handleSuccess(this); michael@0: } michael@0: michael@0: /** michael@0: * Record that an updated meta/global record should be uploaded with the given michael@0: * settings for the given engine. michael@0: * michael@0: * @param engineName engine to update. michael@0: * @param engineSettings new syncID and version. michael@0: */ michael@0: public void recordForMetaGlobalUpdate(String engineName, EngineSettings engineSettings) { michael@0: enginesToUpdate.put(engineName, engineSettings); michael@0: } michael@0: michael@0: /** michael@0: * Record that an updated meta/global record should be uploaded without the michael@0: * given engine name. michael@0: * michael@0: * @param engineName michael@0: * engine to remove. michael@0: */ michael@0: public void removeEngineFromMetaGlobal(String engineName) { michael@0: enginesToUpdate.put(engineName, null); michael@0: } michael@0: michael@0: public boolean hasUpdatedMetaGlobal() { michael@0: if (enginesToUpdate.isEmpty()) { michael@0: Logger.info(LOG_TAG, "Not uploading updated meta/global record since there are no engines requesting upload."); michael@0: return false; michael@0: } michael@0: michael@0: if (Logger.shouldLogVerbose(LOG_TAG)) { michael@0: Logger.trace(LOG_TAG, "Uploading updated meta/global record since there are engine changes to meta/global."); michael@0: Logger.trace(LOG_TAG, "Engines requesting update [" + Utils.toCommaSeparatedString(enginesToUpdate.keySet()) + "]"); michael@0: } michael@0: michael@0: return true; michael@0: } michael@0: michael@0: public void updateMetaGlobalInPlace() { michael@0: config.metaGlobal.declined = this.declinedEngineNames(); michael@0: ExtendedJSONObject engines = config.metaGlobal.getEngines(); michael@0: for (Entry pair : enginesToUpdate.entrySet()) { michael@0: if (pair.getValue() == null) { michael@0: engines.remove(pair.getKey()); michael@0: } else { michael@0: engines.put(pair.getKey(), pair.getValue().toJSONObject()); michael@0: } michael@0: } michael@0: michael@0: enginesToUpdate.clear(); michael@0: } michael@0: michael@0: /** michael@0: * Synchronously upload an updated meta/global. michael@0: *

michael@0: * All problems are logged and ignored. michael@0: */ michael@0: public void uploadUpdatedMetaGlobal() { michael@0: updateMetaGlobalInPlace(); michael@0: michael@0: Logger.debug(LOG_TAG, "Uploading updated meta/global record."); michael@0: final Object monitor = new Object(); michael@0: michael@0: Runnable doUpload = new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: config.metaGlobal.upload(new MetaGlobalDelegate() { michael@0: @Override michael@0: public void handleSuccess(MetaGlobal global, SyncStorageResponse response) { michael@0: Logger.info(LOG_TAG, "Successfully uploaded updated meta/global record."); michael@0: // Engine changes are stored as diffs, so update enabled engines in config to match uploaded meta/global. michael@0: config.enabledEngineNames = config.metaGlobal.getEnabledEngineNames(); michael@0: // Clear userSelectedEngines because they are updated in config and meta/global. michael@0: config.userSelectedEngines = null; michael@0: michael@0: synchronized (monitor) { michael@0: monitor.notify(); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void handleMissing(MetaGlobal global, SyncStorageResponse response) { michael@0: Logger.warn(LOG_TAG, "Got 404 missing uploading updated meta/global record; shouldn't happen. Ignoring."); michael@0: synchronized (monitor) { michael@0: monitor.notify(); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void handleFailure(SyncStorageResponse response) { michael@0: Logger.warn(LOG_TAG, "Failed to upload updated meta/global record; ignoring."); michael@0: synchronized (monitor) { michael@0: monitor.notify(); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void handleError(Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception trying to upload updated meta/global record; ignoring.", e); michael@0: synchronized (monitor) { michael@0: monitor.notify(); michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: }; michael@0: michael@0: final Thread upload = new Thread(doUpload); michael@0: synchronized (monitor) { michael@0: try { michael@0: upload.start(); michael@0: monitor.wait(); michael@0: Logger.debug(LOG_TAG, "Uploaded updated meta/global record."); michael@0: } catch (InterruptedException e) { michael@0: Logger.error(LOG_TAG, "Uploading updated meta/global interrupted; continuing."); michael@0: } michael@0: } michael@0: } michael@0: michael@0: michael@0: public void abort(Exception e, String reason) { michael@0: Logger.warn(LOG_TAG, "Aborting sync: " + reason, e); michael@0: cleanUp(); michael@0: long existingBackoff = largestBackoffObserved.get(); michael@0: if (existingBackoff > 0) { michael@0: callback.requestBackoff(existingBackoff); michael@0: } michael@0: if (!(e instanceof HTTPFailureException)) { michael@0: // e is null, or we aborted for a non-HTTP reason; okay to upload new meta/global record. michael@0: if (this.hasUpdatedMetaGlobal()) { michael@0: this.uploadUpdatedMetaGlobal(); // Only logs errors; does not call abort. michael@0: } michael@0: } michael@0: this.callback.handleError(this, e); michael@0: } michael@0: michael@0: public void handleHTTPError(SyncStorageResponse response, String reason) { michael@0: // TODO: handling of 50x (backoff), 401 (node reassignment or auth error). michael@0: // Fall back to aborting. michael@0: Logger.warn(LOG_TAG, "Aborting sync due to HTTP " + response.getStatusCode()); michael@0: this.interpretHTTPFailure(response.httpResponse()); michael@0: this.abort(new HTTPFailureException(response), reason); michael@0: } michael@0: michael@0: /** michael@0: * Perform appropriate backoff etc. extraction. michael@0: */ michael@0: public void interpretHTTPFailure(HttpResponse response) { michael@0: // TODO: handle permanent rejection. michael@0: long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); michael@0: if (responseBackoff > 0) { michael@0: callback.requestBackoff(responseBackoff); michael@0: } michael@0: michael@0: if (response.getStatusLine() != null) { michael@0: final int statusCode = response.getStatusLine().getStatusCode(); michael@0: switch(statusCode) { michael@0: michael@0: case 400: michael@0: SyncStorageResponse storageResponse = new SyncStorageResponse(response); michael@0: this.interpretHTTPBadRequestBody(storageResponse); michael@0: break; michael@0: michael@0: case 401: michael@0: /* michael@0: * Alert our callback we have a 401 on a cluster URL. This GlobalSession michael@0: * will fail, but the next one will fetch a new cluster URL and will michael@0: * distinguish between "node reassignment" and "user password changed". michael@0: */ michael@0: callback.informUnauthorizedResponse(this, config.getClusterURL()); michael@0: break; michael@0: } michael@0: } michael@0: } michael@0: michael@0: protected void interpretHTTPBadRequestBody(final SyncStorageResponse storageResponse) { michael@0: try { michael@0: final String body = storageResponse.body(); michael@0: if (body == null) { michael@0: return; michael@0: } michael@0: if (SyncStorageResponse.RESPONSE_CLIENT_UPGRADE_REQUIRED.equals(body)) { michael@0: callback.informUpgradeRequiredResponse(this); michael@0: return; michael@0: } michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Exception parsing HTTP 400 body.", e); michael@0: } michael@0: } michael@0: michael@0: public void fetchInfoCollections(JSONRecordFetchDelegate callback) throws URISyntaxException { michael@0: final JSONRecordFetcher fetcher = new JSONRecordFetcher(config.infoCollectionsURL(), getAuthHeaderProvider()); michael@0: fetcher.fetch(callback); michael@0: } michael@0: michael@0: /** michael@0: * Upload new crypto/keys. michael@0: * michael@0: * @param keys michael@0: * new keys. michael@0: * @param keyUploadDelegate michael@0: * a delegate. michael@0: */ michael@0: public void uploadKeys(final CollectionKeys keys, michael@0: final KeyUploadDelegate keyUploadDelegate) { michael@0: SyncStorageRecordRequest request; michael@0: try { michael@0: request = new SyncStorageRecordRequest(this.config.keysURI()); michael@0: } catch (URISyntaxException e) { michael@0: keyUploadDelegate.onKeyUploadFailed(e); michael@0: return; michael@0: } michael@0: michael@0: request.delegate = new SyncStorageRequestDelegate() { michael@0: michael@0: @Override michael@0: public String ifUnmodifiedSince() { michael@0: return null; michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestSuccess(SyncStorageResponse response) { michael@0: Logger.debug(LOG_TAG, "Keys uploaded."); michael@0: BaseResource.consumeEntity(response); // We don't need the response at all. michael@0: keyUploadDelegate.onKeysUploaded(); michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestFailure(SyncStorageResponse response) { michael@0: Logger.debug(LOG_TAG, "Failed to upload keys."); michael@0: GlobalSession.this.interpretHTTPFailure(response.httpResponse()); michael@0: BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response. michael@0: keyUploadDelegate.onKeyUploadFailed(new HTTPFailureException(response)); michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestError(Exception ex) { michael@0: Logger.warn(LOG_TAG, "Got exception trying to upload keys", ex); michael@0: keyUploadDelegate.onKeyUploadFailed(ex); michael@0: } michael@0: michael@0: @Override michael@0: public AuthHeaderProvider getAuthHeaderProvider() { michael@0: return GlobalSession.this.getAuthHeaderProvider(); michael@0: } michael@0: }; michael@0: michael@0: // Convert keys to an encrypted crypto record. michael@0: CryptoRecord keysRecord; michael@0: try { michael@0: keysRecord = keys.asCryptoRecord(); michael@0: keysRecord.setKeyBundle(config.syncKeyBundle); michael@0: keysRecord.encrypt(); michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception trying creating crypto record from keys", e); michael@0: keyUploadDelegate.onKeyUploadFailed(e); michael@0: return; michael@0: } michael@0: michael@0: request.put(keysRecord); michael@0: } michael@0: michael@0: /* michael@0: * meta/global callbacks. michael@0: */ michael@0: public void processMetaGlobal(MetaGlobal global) { michael@0: config.metaGlobal = global; michael@0: michael@0: Long storageVersion = global.getStorageVersion(); michael@0: if (storageVersion == null) { michael@0: Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote storage version."); michael@0: freshStart(); michael@0: return; michael@0: } michael@0: if (storageVersion < STORAGE_VERSION) { michael@0: Logger.warn(LOG_TAG, "Outdated server: reported " + michael@0: "remote storage version " + storageVersion + " < " + michael@0: "local storage version " + STORAGE_VERSION); michael@0: freshStart(); michael@0: return; michael@0: } michael@0: if (storageVersion > STORAGE_VERSION) { michael@0: Logger.warn(LOG_TAG, "Outdated client: reported " + michael@0: "remote storage version " + storageVersion + " > " + michael@0: "local storage version " + STORAGE_VERSION); michael@0: requiresUpgrade(); michael@0: return; michael@0: } michael@0: String remoteSyncID = global.getSyncID(); michael@0: if (remoteSyncID == null) { michael@0: Logger.warn(LOG_TAG, "Malformed remote meta/global: could not retrieve remote syncID."); michael@0: freshStart(); michael@0: return; michael@0: } michael@0: String localSyncID = config.syncID; michael@0: if (!remoteSyncID.equals(localSyncID)) { michael@0: Logger.warn(LOG_TAG, "Remote syncID different from local syncID: resetting client and assuming remote syncID."); michael@0: resetAllStages(); michael@0: config.purgeCryptoKeys(); michael@0: config.syncID = remoteSyncID; michael@0: } michael@0: // Compare lastModified timestamps for remote/local engine selection times. michael@0: Logger.debug(LOG_TAG, "Comparing local engine selection timestamp [" + config.userSelectedEnginesTimestamp + "] to server meta/global timestamp [" + config.persistedMetaGlobal().lastModified() + "]."); michael@0: if (config.userSelectedEnginesTimestamp < config.persistedMetaGlobal().lastModified()) { michael@0: // Remote has later meta/global timestamp. Don't upload engine changes. michael@0: config.userSelectedEngines = null; michael@0: } michael@0: // Persist enabled engine names. michael@0: config.enabledEngineNames = global.getEnabledEngineNames(); michael@0: if (config.enabledEngineNames == null) { michael@0: Logger.warn(LOG_TAG, "meta/global reported no enabled engine names!"); michael@0: } else { michael@0: if (Logger.shouldLogVerbose(LOG_TAG)) { michael@0: Logger.trace(LOG_TAG, "Persisting enabled engine names '" + michael@0: Utils.toCommaSeparatedString(config.enabledEngineNames) + "' from meta/global."); michael@0: } michael@0: } michael@0: michael@0: // Persist declined. michael@0: // Our declined engines at any point are: michael@0: // Whatever they were remotely, plus whatever they were locally, less any michael@0: // engines that were just enabled locally or remotely. michael@0: // If remote just 'won', our recently enabled list just got cleared. michael@0: final HashSet allDeclined = new HashSet(); michael@0: michael@0: final Set newRemoteDeclined = global.getDeclinedEngineNames(); michael@0: final Set oldLocalDeclined = config.declinedEngineNames; michael@0: michael@0: allDeclined.addAll(newRemoteDeclined); michael@0: allDeclined.addAll(oldLocalDeclined); michael@0: michael@0: if (config.userSelectedEngines != null) { michael@0: for (Entry selection : config.userSelectedEngines.entrySet()) { michael@0: if (selection.getValue()) { michael@0: allDeclined.remove(selection.getKey()); michael@0: } michael@0: } michael@0: } michael@0: michael@0: config.declinedEngineNames = allDeclined; michael@0: if (config.declinedEngineNames.isEmpty()) { michael@0: Logger.debug(LOG_TAG, "meta/global reported no declined engine names, and we have none declined locally."); michael@0: } else { michael@0: if (Logger.shouldLogVerbose(LOG_TAG)) { michael@0: Logger.trace(LOG_TAG, "Persisting declined engine names '" + michael@0: Utils.toCommaSeparatedString(config.declinedEngineNames) + "' from meta/global."); michael@0: } michael@0: } michael@0: michael@0: config.persistToPrefs(); michael@0: advance(); michael@0: } michael@0: michael@0: public void processMissingMetaGlobal(MetaGlobal global) { michael@0: freshStart(); michael@0: } michael@0: michael@0: /** michael@0: * Do a fresh start then quietly finish the sync, starting another. michael@0: */ michael@0: public void freshStart() { michael@0: final GlobalSession globalSession = this; michael@0: freshStart(this, new FreshStartDelegate() { michael@0: michael@0: @Override michael@0: public void onFreshStartFailed(Exception e) { michael@0: globalSession.abort(e, "Fresh start failed."); michael@0: } michael@0: michael@0: @Override michael@0: public void onFreshStart() { michael@0: try { michael@0: Logger.warn(LOG_TAG, "Fresh start succeeded; restarting global session."); michael@0: globalSession.config.persistToPrefs(); michael@0: globalSession.restart(); michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception when restarting sync after freshStart.", e); michael@0: globalSession.abort(e, "Got exception after freshStart."); michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Clean the server, aborting the current sync. michael@0: *

michael@0: *

    michael@0: *
  1. Wipe the server storage.
  2. michael@0: *
  3. Reset all stages and purge cached state: (meta/global and crypto/keys records).
  4. michael@0: *
  5. Upload fresh meta/global record.
  6. michael@0: *
  7. Upload fresh crypto/keys record.
  8. michael@0: *
  9. Restart the sync entirely in order to re-download meta/global and crypto/keys record.
  10. michael@0: *
michael@0: * @param session the current session. michael@0: * @param freshStartDelegate delegate to notify on fresh start or failure. michael@0: */ michael@0: protected static void freshStart(final GlobalSession session, final FreshStartDelegate freshStartDelegate) { michael@0: Logger.debug(LOG_TAG, "Fresh starting."); michael@0: michael@0: final MetaGlobal mg = session.generateNewMetaGlobal(); michael@0: michael@0: session.wipeServer(session.getAuthHeaderProvider(), new WipeServerDelegate() { michael@0: michael@0: @Override michael@0: public void onWiped(long timestamp) { michael@0: Logger.debug(LOG_TAG, "Successfully wiped server. Resetting all stages and purging cached meta/global and crypto/keys records."); michael@0: michael@0: session.resetAllStages(); michael@0: session.config.purgeMetaGlobal(); michael@0: session.config.purgeCryptoKeys(); michael@0: session.config.persistToPrefs(); michael@0: michael@0: Logger.info(LOG_TAG, "Uploading new meta/global with sync ID " + mg.syncID + "."); michael@0: michael@0: // It would be good to set the X-If-Unmodified-Since header to `timestamp` michael@0: // for this PUT to ensure at least some level of transactionality. michael@0: // Unfortunately, the servers don't support it after a wipe right now michael@0: // (bug 693893), so we're going to defer this until bug 692700. michael@0: mg.upload(new MetaGlobalDelegate() { michael@0: @Override michael@0: public void handleSuccess(MetaGlobal uploadedGlobal, SyncStorageResponse uploadResponse) { michael@0: Logger.info(LOG_TAG, "Uploaded new meta/global with sync ID " + uploadedGlobal.syncID + "."); michael@0: michael@0: // Generate new keys. michael@0: CollectionKeys keys = null; michael@0: try { michael@0: keys = session.generateNewCryptoKeys(); michael@0: } catch (CryptoException e) { michael@0: Logger.warn(LOG_TAG, "Got exception generating new keys; failing fresh start.", e); michael@0: freshStartDelegate.onFreshStartFailed(e); michael@0: } michael@0: if (keys == null) { michael@0: Logger.warn(LOG_TAG, "Got null keys from generateNewKeys; failing fresh start."); michael@0: freshStartDelegate.onFreshStartFailed(null); michael@0: } michael@0: michael@0: // Upload new keys. michael@0: Logger.info(LOG_TAG, "Uploading new crypto/keys."); michael@0: session.uploadKeys(keys, new KeyUploadDelegate() { michael@0: @Override michael@0: public void onKeysUploaded() { michael@0: Logger.info(LOG_TAG, "Uploaded new crypto/keys."); michael@0: freshStartDelegate.onFreshStart(); michael@0: } michael@0: michael@0: @Override michael@0: public void onKeyUploadFailed(Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception uploading new keys.", e); michael@0: freshStartDelegate.onFreshStartFailed(e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: @Override michael@0: public void handleMissing(MetaGlobal global, SyncStorageResponse response) { michael@0: // Shouldn't happen on upload. michael@0: Logger.warn(LOG_TAG, "Got 'missing' response uploading new meta/global."); michael@0: freshStartDelegate.onFreshStartFailed(new Exception("meta/global missing while uploading.")); michael@0: } michael@0: michael@0: @Override michael@0: public void handleFailure(SyncStorageResponse response) { michael@0: Logger.warn(LOG_TAG, "Got failure " + response.getStatusCode() + " uploading new meta/global."); michael@0: session.interpretHTTPFailure(response.httpResponse()); michael@0: freshStartDelegate.onFreshStartFailed(new HTTPFailureException(response)); michael@0: } michael@0: michael@0: @Override michael@0: public void handleError(Exception e) { michael@0: Logger.warn(LOG_TAG, "Got error uploading new meta/global.", e); michael@0: freshStartDelegate.onFreshStartFailed(e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: @Override michael@0: public void onWipeFailed(Exception e) { michael@0: Logger.warn(LOG_TAG, "Wipe failed."); michael@0: freshStartDelegate.onFreshStartFailed(e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: // Note that we do not yet implement wipeRemote: it's only necessary for michael@0: // first sync options. michael@0: // -- reset local stages, wipe server for each stage *except* clients michael@0: // (stages only, not whole server!), send wipeEngine commands to each client. michael@0: // michael@0: // Similarly for startOver (because we don't receive that notification). michael@0: // -- remove client data from server, reset local stages, clear keys, reset michael@0: // backoff, clear all prefs, discard credentials. michael@0: // michael@0: // Change passphrase: wipe entire server, reset client to force upload, sync. michael@0: // michael@0: // When an engine is disabled: wipe its collections on the server, reupload michael@0: // meta/global. michael@0: // michael@0: // On syncing each stage: if server has engine version 0 or old, wipe server, michael@0: // reset client to prompt reupload. michael@0: // If sync ID mismatch: take that syncID and reset client. michael@0: michael@0: protected void wipeServer(final AuthHeaderProvider authHeaderProvider, final WipeServerDelegate wipeDelegate) { michael@0: SyncStorageRequest request; michael@0: final GlobalSession self = this; michael@0: michael@0: try { michael@0: request = new SyncStorageRequest(config.storageURL()); michael@0: } catch (URISyntaxException ex) { michael@0: Logger.warn(LOG_TAG, "Invalid URI in wipeServer."); michael@0: wipeDelegate.onWipeFailed(ex); michael@0: return; michael@0: } michael@0: michael@0: request.delegate = new SyncStorageRequestDelegate() { michael@0: michael@0: @Override michael@0: public String ifUnmodifiedSince() { michael@0: return null; michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestSuccess(SyncStorageResponse response) { michael@0: BaseResource.consumeEntity(response); michael@0: wipeDelegate.onWiped(response.normalizedWeaveTimestamp()); michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestFailure(SyncStorageResponse response) { michael@0: Logger.warn(LOG_TAG, "Got request failure " + response.getStatusCode() + " in wipeServer."); michael@0: // Process HTTP failures here to pick up backoffs, etc. michael@0: self.interpretHTTPFailure(response.httpResponse()); michael@0: BaseResource.consumeEntity(response); // The exception thrown should not need the body of the response. michael@0: wipeDelegate.onWipeFailed(new HTTPFailureException(response)); michael@0: } michael@0: michael@0: @Override michael@0: public void handleRequestError(Exception ex) { michael@0: Logger.warn(LOG_TAG, "Got exception in wipeServer.", ex); michael@0: wipeDelegate.onWipeFailed(ex); michael@0: } michael@0: michael@0: @Override michael@0: public AuthHeaderProvider getAuthHeaderProvider() { michael@0: return GlobalSession.this.getAuthHeaderProvider(); michael@0: } michael@0: }; michael@0: request.delete(); michael@0: } michael@0: michael@0: public void wipeAllStages() { michael@0: Logger.info(LOG_TAG, "Wiping all stages."); michael@0: // Includes "clients". michael@0: this.wipeStagesByEnum(Stage.getNamedStages()); michael@0: } michael@0: michael@0: public void wipeStages(Collection stages) { michael@0: for (GlobalSyncStage stage : stages) { michael@0: try { michael@0: Logger.info(LOG_TAG, "Wiping " + stage); michael@0: stage.wipeLocal(this); michael@0: } catch (Exception e) { michael@0: Logger.error(LOG_TAG, "Ignoring wipe failure for stage " + stage, e); michael@0: } michael@0: } michael@0: } michael@0: michael@0: public void wipeStagesByEnum(Collection stages) { michael@0: wipeStages(this.getSyncStagesByEnum(stages)); michael@0: } michael@0: michael@0: public void wipeStagesByName(Collection names) { michael@0: wipeStages(this.getSyncStagesByName(names)); michael@0: } michael@0: michael@0: public void resetAllStages() { michael@0: Logger.info(LOG_TAG, "Resetting all stages."); michael@0: // Includes "clients". michael@0: this.resetStagesByEnum(Stage.getNamedStages()); michael@0: } michael@0: michael@0: public void resetStages(Collection stages) { michael@0: for (GlobalSyncStage stage : stages) { michael@0: try { michael@0: Logger.info(LOG_TAG, "Resetting " + stage); michael@0: stage.resetLocal(this); michael@0: } catch (Exception e) { michael@0: Logger.error(LOG_TAG, "Ignoring reset failure for stage " + stage, e); michael@0: } michael@0: } michael@0: } michael@0: michael@0: public void resetStagesByEnum(Collection stages) { michael@0: resetStages(this.getSyncStagesByEnum(stages)); michael@0: } michael@0: michael@0: public void resetStagesByName(Collection names) { michael@0: resetStages(this.getSyncStagesByName(names)); michael@0: } michael@0: michael@0: /** michael@0: * Engines to explicitly mark as declined in a fresh meta/global record. michael@0: *

michael@0: * Returns an empty array if the user hasn't elected to customize data types, michael@0: * or an array of engines that the user un-checked during customization. michael@0: *

michael@0: * Engines that Android Sync doesn't recognize are not included in michael@0: * the returned array. michael@0: * michael@0: * @return a new JSONArray of engine names. michael@0: */ michael@0: @SuppressWarnings("unchecked") michael@0: protected JSONArray declinedEngineNames() { michael@0: final JSONArray declined = new JSONArray(); michael@0: for (String engine : config.declinedEngineNames) { michael@0: declined.add(engine); michael@0: }; michael@0: michael@0: return declined; michael@0: } michael@0: michael@0: /** michael@0: * Engines to include in a fresh meta/global record. michael@0: *

michael@0: * Returns either the persisted engine names (perhaps we have been node michael@0: * re-assigned and are initializing a clean server: we want to upload the michael@0: * persisted engine names so that we don't accidentally disable engines that michael@0: * Android Sync doesn't recognize), or the set of engines names that Android michael@0: * Sync implements. michael@0: * michael@0: * @return set of engine names. michael@0: */ michael@0: protected Set enabledEngineNames() { michael@0: if (config.enabledEngineNames != null) { michael@0: return config.enabledEngineNames; michael@0: } michael@0: michael@0: // These are the default set of engine names. michael@0: Set validEngineNames = SyncConfiguration.validEngineNames(); michael@0: michael@0: // If the user hasn't set any selected engines, that's okay -- default to michael@0: // everything. michael@0: if (config.userSelectedEngines == null) { michael@0: return validEngineNames; michael@0: } michael@0: michael@0: // userSelectedEngines has keys that are engine names, and boolean values michael@0: // corresponding to whether the user asked for the engine to sync or not. If michael@0: // an engine is not present, that means the user didn't change its sync michael@0: // setting. Since we default to everything on, that means the user didn't michael@0: // turn it off; therefore, it's included in the set of engines to sync. michael@0: Set validAndSelectedEngineNames = new HashSet(); michael@0: for (String engineName : validEngineNames) { michael@0: if (config.userSelectedEngines.containsKey(engineName) && michael@0: !config.userSelectedEngines.get(engineName)) { michael@0: continue; michael@0: } michael@0: validAndSelectedEngineNames.add(engineName); michael@0: } michael@0: return validAndSelectedEngineNames; michael@0: } michael@0: michael@0: /** michael@0: * Generate fresh crypto/keys collection. michael@0: * @return crypto/keys collection. michael@0: * @throws CryptoException michael@0: */ michael@0: @SuppressWarnings("static-method") michael@0: public CollectionKeys generateNewCryptoKeys() throws CryptoException { michael@0: return CollectionKeys.generateCollectionKeys(); michael@0: } michael@0: michael@0: /** michael@0: * Generate a fresh meta/global record. michael@0: * @return meta/global record. michael@0: */ michael@0: public MetaGlobal generateNewMetaGlobal() { michael@0: final String newSyncID = Utils.generateGuid(); michael@0: final String metaURL = this.config.metaURL(); michael@0: michael@0: ExtendedJSONObject engines = new ExtendedJSONObject(); michael@0: for (String engineName : enabledEngineNames()) { michael@0: EngineSettings engineSettings = null; michael@0: try { michael@0: GlobalSyncStage globalStage = this.getSyncStageByName(engineName); michael@0: Integer version = globalStage.getStorageVersion(); michael@0: if (version == null) { michael@0: continue; // Don't want this stage to be included in meta/global. michael@0: } michael@0: engineSettings = new EngineSettings(Utils.generateGuid(), version.intValue()); michael@0: } catch (NoSuchStageException e) { michael@0: // No trouble; Android Sync might not recognize this engine yet. michael@0: // By default, version 0. Other clients will see the 0 version and reset/wipe accordingly. michael@0: engineSettings = new EngineSettings(Utils.generateGuid(), 0); michael@0: } michael@0: engines.put(engineName, engineSettings.toJSONObject()); michael@0: } michael@0: michael@0: MetaGlobal metaGlobal = new MetaGlobal(metaURL, this.getAuthHeaderProvider()); michael@0: metaGlobal.setSyncID(newSyncID); michael@0: metaGlobal.setStorageVersion(STORAGE_VERSION); michael@0: metaGlobal.setEngines(engines); michael@0: michael@0: // We assume that the config's declined engines have been updated michael@0: // according to the user's selections. michael@0: metaGlobal.setDeclinedEngineNames(this.declinedEngineNames()); michael@0: michael@0: return metaGlobal; michael@0: } michael@0: michael@0: /** michael@0: * Suggest that your Sync client needs to be upgraded to work michael@0: * with this server. michael@0: */ michael@0: public void requiresUpgrade() { michael@0: Logger.info(LOG_TAG, "Client outdated storage version; requires update."); michael@0: // TODO: notify UI. michael@0: this.abort(null, "Requires upgrade"); michael@0: } michael@0: michael@0: /** michael@0: * If meta/global is missing or malformed, throws a MetaGlobalException. michael@0: * Otherwise, returns true if there is an entry for this engine in the michael@0: * meta/global "engines" object. michael@0: *

michael@0: * This is a global/permanent setting, not a local/temporary setting. For the michael@0: * latter, see {@link GlobalSession#isEngineLocallyEnabled(String)}. michael@0: * michael@0: * @param engineName the name to check (e.g., "bookmarks"). michael@0: * @param engineSettings michael@0: * if non-null, verify that the server engine settings are congruent michael@0: * with this, throwing the appropriate MetaGlobalException if not. michael@0: * @return michael@0: * true if the engine with the provided name is present in the michael@0: * meta/global "engines" object, and verification passed. michael@0: * michael@0: * @throws MetaGlobalException michael@0: */ michael@0: public boolean isEngineRemotelyEnabled(String engineName, EngineSettings engineSettings) throws MetaGlobalException { michael@0: if (this.config.metaGlobal == null) { michael@0: throw new MetaGlobalNotSetException(); michael@0: } michael@0: michael@0: // This should not occur. michael@0: if (this.config.enabledEngineNames == null) { michael@0: Logger.error(LOG_TAG, "No enabled engines in config. Giving up."); michael@0: throw new MetaGlobalMissingEnginesException(); michael@0: } michael@0: michael@0: if (!(this.config.enabledEngineNames.contains(engineName))) { michael@0: Logger.debug(LOG_TAG, "Engine " + engineName + " not enabled: no meta/global entry."); michael@0: return false; michael@0: } michael@0: michael@0: // If we have a meta/global, check that it's safe for us to sync. michael@0: // (If we don't, we'll create one later, which is why we return `true` above.) michael@0: if (engineSettings != null) { michael@0: // Throws if there's a problem. michael@0: this.config.metaGlobal.verifyEngineSettings(engineName, engineSettings); michael@0: } michael@0: michael@0: return true; michael@0: } michael@0: michael@0: michael@0: /** michael@0: * Return true if the named stage should be synced this session. michael@0: *

michael@0: * This is a local/temporary setting, in contrast to the meta/global record, michael@0: * which is a global/permanent setting. For the latter, see michael@0: * {@link GlobalSession#isEngineRemotelyEnabled(String, EngineSettings)}. michael@0: * michael@0: * @param stageName michael@0: * to query. michael@0: * @return true if named stage is enabled for this sync. michael@0: */ michael@0: public boolean isEngineLocallyEnabled(String stageName) { michael@0: if (config.stagesToSync == null) { michael@0: return true; michael@0: } michael@0: return config.stagesToSync.contains(stageName); michael@0: } michael@0: michael@0: public ClientsDataDelegate getClientsDelegate() { michael@0: return this.clientsDelegate; michael@0: } michael@0: michael@0: /** michael@0: * The longest backoff observed to date; -1 means no backoff observed. michael@0: */ michael@0: protected final AtomicLong largestBackoffObserved = new AtomicLong(-1); michael@0: michael@0: /** michael@0: * Reset any observed backoff and start observing HTTP responses for backoff michael@0: * requests. michael@0: */ michael@0: protected void installAsHttpResponseObserver() { michael@0: Logger.debug(LOG_TAG, "Installing " + this + " as BaseResource HttpResponseObserver."); michael@0: BaseResource.setHttpResponseObserver(this); michael@0: largestBackoffObserved.set(-1); michael@0: } michael@0: michael@0: /** michael@0: * Stop observing HttpResponses for backoff requests. michael@0: */ michael@0: protected void uninstallAsHttpResponseObserver() { michael@0: Logger.debug(LOG_TAG, "Uninstalling " + this + " as BaseResource HttpResponseObserver."); michael@0: BaseResource.setHttpResponseObserver(null); michael@0: } michael@0: michael@0: /** michael@0: * Observe all HTTP response for backoff requests on all status codes, not just errors. michael@0: */ michael@0: @Override michael@0: public void observeHttpResponse(HttpResponse response) { michael@0: long responseBackoff = (new SyncResponse(response)).totalBackoffInMilliseconds(); // TODO: don't allocate object? michael@0: if (responseBackoff <= 0) { michael@0: return; michael@0: } michael@0: michael@0: Logger.debug(LOG_TAG, "Observed " + responseBackoff + " millisecond backoff request."); michael@0: while (true) { michael@0: long existingBackoff = largestBackoffObserved.get(); michael@0: if (existingBackoff >= responseBackoff) { michael@0: return; michael@0: } michael@0: if (largestBackoffObserved.compareAndSet(existingBackoff, responseBackoff)) { michael@0: return; michael@0: } michael@0: } michael@0: } michael@0: }