/* 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.stage;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import org.json.simple.parser.ParseException;
import org.mozilla.gecko.sync.CredentialsSource;
import org.mozilla.gecko.sync.EngineSettings;
import org.mozilla.gecko.sync.GlobalSession;
import org.mozilla.gecko.sync.HTTPFailureException;
import org.mozilla.gecko.sync.Logger;
import org.mozilla.gecko.sync.MetaGlobalException;
import org.mozilla.gecko.sync.NoCollectionKeysSetException;
import org.mozilla.gecko.sync.NonObjectJSONException;
import org.mozilla.gecko.sync.SynchronizerConfiguration;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.crypto.KeyBundle;
import org.mozilla.gecko.sync.delegates.WipeServerDelegate;
import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepository;
import org.mozilla.gecko.sync.net.BaseResource;
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.repositories.InactiveSessionException;
import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
import org.mozilla.gecko.sync.repositories.RecordFactory;
import org.mozilla.gecko.sync.repositories.Repository;
import org.mozilla.gecko.sync.repositories.RepositorySession;
import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
import org.mozilla.gecko.sync.repositories.Server11Repository;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
import org.mozilla.gecko.sync.synchronizer.Synchronizer;
import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
import android.content.Context;
/**
* Fetch from a server collection into a local repository, encrypting
* and decrypting along the way.
*
* @author rnewman
*
*/
public abstract class ServerSyncStage implements
GlobalSyncStage,
SynchronizerDelegate {
protected static final String LOG_TAG = "ServerSyncStage";
protected final GlobalSession session;
protected long stageStartTimestamp = -1;
protected long stageCompleteTimestamp = -1;
public ServerSyncStage(GlobalSession session) {
if (session == null) {
throw new IllegalArgumentException("session must not be null.");
}
this.session = session;
}
/**
* Override these in your subclasses.
*
* @return true if this stage should be executed.
* @throws MetaGlobalException
*/
protected boolean isEnabled() throws MetaGlobalException {
EngineSettings engineSettings = null;
try {
engineSettings = getEngineSettings();
} catch (Exception e) {
Logger.warn(LOG_TAG, "Unable to get engine settings for " + this + ": fetching config failed.", e);
// Fall through; null engineSettings will pass below.
}
// We can be disabled by the server's meta/global record, or malformed in the server's meta/global record,
// or by the user manually in Sync Settings.
// We catch the subclasses of MetaGlobalException to trigger various resets and wipes in execute().
boolean enabledInMetaGlobal = session.engineIsEnabled(this.getEngineName(), engineSettings);
// Check for manual changes to engines by the user.
checkAndUpdateUserSelectedEngines(enabledInMetaGlobal);
// Check for changes on the server.
if (!enabledInMetaGlobal) {
Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled by server meta/global.");
return false;
}
// We can also be disabled just for this sync.
if (session.config.stagesToSync == null) {
return true;
}
boolean enabledThisSync = session.config.stagesToSync.contains(this.getEngineName()); // For ServerSyncStage, stage name == engine name.
if (!enabledThisSync) {
Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled just for this sync.");
}
return enabledThisSync;
}
/**
* Compares meta/global engine state to user selected engines from Sync
* Settings and throws an exception if they don't match and meta/global needs
* to be updated.
*
* @param enabledInMetaGlobal
* boolean of engine sync state in meta/global
* @throws MetaGlobalException
* if engine sync state has been changed in Sync Settings, with new
* engine sync state.
*/
protected void checkAndUpdateUserSelectedEngines(boolean enabledInMetaGlobal) throws MetaGlobalException {
Map<String, Boolean> selectedEngines = session.config.userSelectedEngines;
String thisEngine = this.getEngineName();
if (selectedEngines != null && selectedEngines.containsKey(thisEngine)) {
boolean enabledInSelection = selectedEngines.get(thisEngine);
if (enabledInMetaGlobal != enabledInSelection) {
// Engine enable state has been changed by the user.
Logger.debug(LOG_TAG, "Engine state has been changed by user. Throwing exception.");
throw new MetaGlobalException.MetaGlobalEngineStateChangedException(enabledInSelection);
}
}
}
protected EngineSettings getEngineSettings() throws NonObjectJSONException, IOException, ParseException {
Integer version = getStorageVersion();
if (version == null) {
Logger.warn(LOG_TAG, "null storage version for " + this + "; using version 0.");
version = Integer.valueOf(0);
}
SynchronizerConfiguration config = this.getConfig();
if (config == null) {
return new EngineSettings(null, version.intValue());
}
return new EngineSettings(config.syncID, version.intValue());
}
protected abstract String getCollection();
protected abstract String getEngineName();
protected abstract Repository getLocalRepository();
protected abstract RecordFactory getRecordFactory();
// Override this in subclasses.
protected Repository getRemoteRepository() throws URISyntaxException {
return new Server11Repository(session.config.getClusterURLString(),
session.config.username,
getCollection(),
session);
}
/**
* Return a Crypto5Middleware-wrapped Server11Repository.
*
* @throws NoCollectionKeysSetException
* @throws URISyntaxException
*/
protected Repository wrappedServerRepo() throws NoCollectionKeysSetException, URISyntaxException {
String collection = this.getCollection();
KeyBundle collectionKey = session.keyBundleForCollection(collection);
Crypto5MiddlewareRepository cryptoRepo = new Crypto5MiddlewareRepository(getRemoteRepository(), collectionKey);
cryptoRepo.recordFactory = getRecordFactory();
return cryptoRepo;
}
protected String bundlePrefix() {
return this.getCollection() + ".";
}
protected SynchronizerConfiguration getConfig() throws NonObjectJSONException, IOException, ParseException {
return new SynchronizerConfiguration(session.config.getBranch(bundlePrefix()));
}
protected void persistConfig(SynchronizerConfiguration synchronizerConfiguration) {
synchronizerConfiguration.persist(session.config.getBranch(bundlePrefix()));
}
public Synchronizer getConfiguredSynchronizer(GlobalSession session) throws NoCollectionKeysSetException, URISyntaxException, NonObjectJSONException, IOException, ParseException {
Repository remote = wrappedServerRepo();
Synchronizer synchronizer = new ServerLocalSynchronizer();
synchronizer.repositoryA = remote;
synchronizer.repositoryB = this.getLocalRepository();
synchronizer.load(getConfig());
return synchronizer;
}
/**
* Reset timestamps.
*/
@Override
public void resetLocal() {
resetLocal(null);
}
/**
* Reset timestamps and possibly set syncID.
* @param syncID if non-null, new syncID to persist.
*/
protected void resetLocal(String syncID) {
// Clear both timestamps.
SynchronizerConfiguration config;
try {
config = this.getConfig();
} catch (Exception e) {
Logger.warn(LOG_TAG, "Unable to reset " + this + ": fetching config failed.", e);
return;
}
if (syncID != null) {
config.syncID = syncID;
Logger.info(LOG_TAG, "Setting syncID for " + this + " to '" + syncID + "'.");
}
config.localBundle.setTimestamp(0L);
config.remoteBundle.setTimestamp(0L);
persistConfig(config);
Logger.info(LOG_TAG, "Reset timestamps for " + this);
}
// Not thread-safe. Use with caution.
private class WipeWaiter {
public boolean sessionSucceeded = true;
public boolean wipeSucceeded = true;
public Exception error;
public void notify(Exception e, boolean sessionSucceeded) {
this.sessionSucceeded = sessionSucceeded;
this.wipeSucceeded = false;
this.error = e;
this.notify();
}
}
/**
* Synchronously wipe this stage by instantiating a local repository session
* and wiping that.
* <p>
* Logs and re-throws an exception on failure.
*/
@Override
public void wipeLocal() throws Exception {
// Reset, then clear data.
this.resetLocal();
final WipeWaiter monitor = new WipeWaiter();
final Context context = session.getContext();
final Repository r = this.getLocalRepository();
final Runnable doWipe = new Runnable() {
@Override
public void run() {
r.createSession(new RepositorySessionCreationDelegate() {
@Override
public void onSessionCreated(final RepositorySession session) {
try {
session.begin(new RepositorySessionBeginDelegate() {
@Override
public void onBeginSucceeded(final RepositorySession session) {
session.wipe(new RepositorySessionWipeDelegate() {
@Override
public void onWipeSucceeded() {
try {
session.finish(new RepositorySessionFinishDelegate() {
@Override
public void onFinishSucceeded(RepositorySession session,
RepositorySessionBundle bundle) {
// Hurrah.
synchronized (monitor) {
monitor.notify();
}
}
@Override
public void onFinishFailed(Exception ex) {
// Assume that no finish => no wipe.
synchronized (monitor) {
monitor.notify(ex, true);
}
}
@Override
public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) {
return this;
}
});
} catch (InactiveSessionException e) {
// Cannot happen. Call for safety.
synchronized (monitor) {
monitor.notify(e, true);
}
}
}
@Override
public void onWipeFailed(Exception ex) {
session.abort();
synchronized (monitor) {
monitor.notify(ex, true);
}
}
@Override
public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor) {
return this;
}
});
}
@Override
public void onBeginFailed(Exception ex) {
session.abort();
synchronized (monitor) {
monitor.notify(ex, true);
}
}
@Override
public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
return this;
}
});
} catch (InvalidSessionTransitionException e) {
session.abort();
synchronized (monitor) {
monitor.notify(e, true);
}
}
}
@Override
public void onSessionCreateFailed(Exception ex) {
synchronized (monitor) {
monitor.notify(ex, false);
}
}
@Override
public RepositorySessionCreationDelegate deferredCreationDelegate() {
return this;
}
}, context);
}
};
final Thread wiping = new Thread(doWipe);
synchronized (monitor) {
wiping.start();
try {
monitor.wait();
} catch (InterruptedException e) {
Logger.error(LOG_TAG, "Wipe interrupted.");
}
}
if (!monitor.sessionSucceeded) {
Logger.error(LOG_TAG, "Failed to create session for wipe.");
throw monitor.error;
}
if (!monitor.wipeSucceeded) {
Logger.error(LOG_TAG, "Failed to wipe session.");
throw monitor.error;
}
Logger.info(LOG_TAG, "Wiping stage complete.");
}
/**
* Asynchronously wipe collection on server.
*/
protected void wipeServer(final CredentialsSource credentials, final WipeServerDelegate wipeDelegate) {
SyncStorageRequest request;
try {
request = new SyncStorageRequest(session.config.collectionURI(getCollection()));
} 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);
resetLocal();
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.
session.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 String credentials() {
return credentials.credentials();
}
};
request.delete();
}
/**
* Synchronously wipe the server.
* <p>
* Logs and re-throws an exception on failure.
*/
public void wipeServer() throws Exception {
final WipeWaiter monitor = new WipeWaiter();
final Runnable doWipe = new Runnable() {
@Override
public void run() {
wipeServer(session, new WipeServerDelegate() {
@Override
public void onWiped(long timestamp) {
synchronized (monitor) {
monitor.notify();
}
}
@Override
public void onWipeFailed(Exception e) {
synchronized (monitor) {
monitor.notify(e, false);
}
}
});
}
};
final Thread wiping = new Thread(doWipe);
synchronized (monitor) {
wiping.start();
try {
monitor.wait();
} catch (InterruptedException e) {
Logger.error(LOG_TAG, "Server wipe interrupted.");
}
}
if (!monitor.wipeSucceeded) {
Logger.error(LOG_TAG, "Failed to wipe server.");
throw monitor.error;
}
Logger.info(LOG_TAG, "Wiping server complete.");
}
@Override
public void execute() throws NoSuchStageException {
final String name = getEngineName();
Logger.debug(LOG_TAG, "Starting execute for " + name);
stageStartTimestamp = System.currentTimeMillis();
try {
if (!this.isEnabled()) {
Logger.info(LOG_TAG, "Skipping stage " + name + ".");
session.advance();
return;
}
} catch (MetaGlobalException.MetaGlobalMalformedSyncIDException e) {
// Bad engine syncID. This should never happen. Wipe the server.
try {
session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
Logger.info(LOG_TAG, "Wiping server because malformed engine sync ID was found in meta/global.");
wipeServer();
Logger.info(LOG_TAG, "Wiped server after malformed engine sync ID found in meta/global.");
} catch (Exception ex) {
session.abort(ex, "Failed to wipe server after malformed engine sync ID found in meta/global.");
}
} catch (MetaGlobalException.MetaGlobalMalformedVersionException e) {
// Bad engine version. This should never happen. Wipe the server.
try {
session.recordForMetaGlobalUpdate(name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
Logger.info(LOG_TAG, "Wiping server because malformed engine version was found in meta/global.");
wipeServer();
Logger.info(LOG_TAG, "Wiped server after malformed engine version found in meta/global.");
} catch (Exception ex) {
session.abort(ex, "Failed to wipe server after malformed engine version found in meta/global.");
}
} catch (MetaGlobalException.MetaGlobalStaleClientSyncIDException e) {
// Our syncID is wrong. Reset client and take the server syncID.
Logger.warn(LOG_TAG, "Remote engine syncID different from local engine syncID:" +
" resetting local engine and assuming remote engine syncID.");
this.resetLocal(e.serverSyncID);
} catch (MetaGlobalException.MetaGlobalEngineStateChangedException e) {
boolean isEnabled = e.isEnabled;
if (!isEnabled) {
// Engine has been disabled; update meta/global with engine removal for upload.
session.removeEngineFromMetaGlobal(name);
} else {
// Add engine with new syncID to meta/global for upload.
String newSyncID = Utils.generateGuid();
session.recordForMetaGlobalUpdate(name, new EngineSettings(newSyncID, this.getStorageVersion()));
// Update SynchronizerConfiguration w/ new engine syncID.
this.resetLocal(newSyncID);
}
try {
// Engine sync status has changed. Wipe server.
Logger.warn(LOG_TAG, "Wiping server because engine sync state changed.");
wipeServer();
Logger.warn(LOG_TAG, "Wiped server because engine sync state changed.");
} catch (Exception ex) {
session.abort(ex, "Failed to wipe server after engine sync state changed");
}
if (!isEnabled) {
Logger.warn(LOG_TAG, "Stage has been disabled. Advancing to next stage.");
session.advance();
return;
}
} catch (MetaGlobalException e) {
session.abort(e, "Inappropriate meta/global; refusing to execute " + name + " stage.");
return;
}
Synchronizer synchronizer;
try {
synchronizer = this.getConfiguredSynchronizer(session);
} catch (NoCollectionKeysSetException e) {
session.abort(e, "No CollectionKeys.");
return;
} catch (URISyntaxException e) {
session.abort(e, "Invalid URI syntax for server repository.");
return;
} catch (NonObjectJSONException e) {
session.abort(e, "Invalid persisted JSON for config.");
return;
} catch (IOException e) {
session.abort(e, "Invalid persisted JSON for config.");
return;
} catch (ParseException e) {
session.abort(e, "Invalid persisted JSON for config.");
return;
}
Logger.debug(LOG_TAG, "Invoking synchronizer.");
synchronizer.synchronize(session.getContext(), this);
Logger.debug(LOG_TAG, "Reached end of execute.");
}
/**
* Express the duration taken by this stage as a String, like "0.56 seconds".
*
* @return formatted string.
*/
protected String getStageDurationString() {
return Utils.formatDuration(stageStartTimestamp, stageCompleteTimestamp);
}
/**
* We synced this engine! Persist timestamps and advance the session.
*
* @param synchronizer the <code>Synchronizer</code> that succeeded.
*/
@Override
public void onSynchronized(Synchronizer synchronizer) {
stageCompleteTimestamp = System.currentTimeMillis();
Logger.debug(LOG_TAG, "onSynchronized.");
SynchronizerConfiguration newConfig = synchronizer.save();
if (newConfig != null) {
persistConfig(newConfig);
} else {
Logger.warn(LOG_TAG, "Didn't get configuration from synchronizer after success.");
}
final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
int inboundCount = synchronizerSession.getInboundCount();
int outboundCount = synchronizerSession.getOutboundCount();
Logger.info(LOG_TAG, "Received " + inboundCount + " and sent " + outboundCount +
" records in " + getStageDurationString() + ".");
Logger.info(LOG_TAG, "Advancing session.");
session.advance();
}
/**
* We failed to sync this engine! Do not persist timestamps (which means that
* the next sync will include this sync's data), but do advance the session
* (if we didn't get a Retry-After header).
*
* @param synchronizer the <code>Synchronizer</code> that failed.
*/
@Override
public void onSynchronizeFailed(Synchronizer synchronizer,
Exception lastException, String reason) {
stageCompleteTimestamp = System.currentTimeMillis();
Logger.warn(LOG_TAG, "Synchronize failed: " + reason, lastException);
// This failure could be due to a 503 or a 401 and it could have headers.
// Interrogate the headers but only abort the global session if Retry-After header is set.
if (lastException instanceof HTTPFailureException) {
SyncStorageResponse response = ((HTTPFailureException)lastException).response;
if (response.retryAfterInSeconds() > 0) {
session.handleHTTPError(response, reason); // Calls session.abort().
return;
} else {
session.interpretHTTPFailure(response.httpResponse()); // Does not call session.abort().
}
}
Logger.info(LOG_TAG, "Advancing session even though stage failed (took " + getStageDurationString() +
"). Timestamps not persisted.");
session.advance();
}
}