Bug 1182193 - Part 2: Copy the tests from android-sync project. r?nalexander draft
authorvivek <vivekb.balakrishnan@gmail.com>
Thu, 03 Sep 2015 21:31:14 +0300
changeset 290208 03635dd6d7d4c5bf0649a700d703e2354bdfb9ba
parent 290207 ce519f19f159a99b793734df6b6113c15e13cb7a
child 290209 c7345b022560e080cdb26ebbebaecf7202ae6ba2
push id5104
push uservivekb.balakrishnan@gmail.com
push dateThu, 03 Sep 2015 21:52:07 +0000
reviewersnalexander
bugs1182193
milestone43.0a1
Bug 1182193 - Part 2: Copy the tests from android-sync project. r?nalexander
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestAccountAuthenticatorStage.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestJPakeSetup.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncKeyVerification.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountAgeLockoutHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/prune/test/TestPrunePolicy.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/test/HealthReportStorageStub.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/MockAndroidSubmissionClient.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestObsoleteDocumentTracker.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestSubmissionPolicy.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestSubmissionsTracker.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestTrackingRequestDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/EntityTestHelper.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/TestBoundedByteArrayEntity.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/test/TestDeflation.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestASNUtils.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestDSACryptoImplementation.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestJSONWebTokenUtils.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/browserid/test/TestRSACryptoImplementation.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestSkewHandler.java.orig
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestFxAccountLoginStateMachine.java.orig
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/TestStateFactory.java.orig
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestBase32.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestCryptoInfo.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestHKDF.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestKeyBundle.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPBKDF2.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestPersistedCrypto5Keys.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/crypto/test/TestSRPConstants.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/middleware/test/TestCrypto5MiddlewareRepositorySession.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHMACAuthHeaderProvider.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestHawkAuthHeaderProvider.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestLiveHawkAuth.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/net/test/TestUserAgentHeaders.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/android/test/TestBookmarksInsertionManager.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/TestClientRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/domain/test/TestFormHistoryRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestRepositorySessionBundle.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureClusterURLStage.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestEnsureCrypto5KeysStage.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestFetchMetaGlobalStage.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/stage/test/TestStageLookup.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestExtendedJSONObject.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestInfoCollections.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/test/TestPersistedMetaGlobal.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/tokenserver/test/TestTokenServerClient.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestAccountAuthenticatorStage.java
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.net.URISyntaxException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.setup.auth.AuthenticateAccountStage;
+import org.mozilla.gecko.sync.setup.auth.AuthenticateAccountStage.AuthenticateAccountStageDelegate;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+/**
+ * Tests the authentication request stage of manual Account setup.
+ * @author liuche
+ *
+ */
+public class TestAccountAuthenticatorStage {
+  private static final int TEST_PORT      = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+  private static final String USERNAME  = "john-hashed";
+  private static final String PASSWORD  = "password";
+
+  private MockServer authServer;
+  private HTTPServerTestHelper serverHelper = new HTTPServerTestHelper();
+  private AuthenticateAccountStage authStage = new AuthenticateAccountStage();
+  private AuthenticateAccountStageDelegate testCallback;
+
+  @Before
+  public void setup() {
+    // Make mock server to check authentication header.
+    authServer = new MockServer() {
+      @Override
+      protected void handle(Request request, Response response, int code, String body) {
+        try {
+          final int c;
+          String responseAuth = request.getValue("Authorization");
+          // Trim whitespace, HttpResponse has an extra space?
+          if (expectedBasicAuthHeader.equals(responseAuth.trim())) {
+            c = 200;
+          } else {
+            c = 401;
+          }
+
+          Logger.debug(LOG_TAG, "Handling request...");
+          PrintStream bodyStream = this.handleBasicHeaders(request, response, c, "application/json");
+          bodyStream.println(body);
+          bodyStream.close();
+        } catch (IOException e) {
+          Logger.error(LOG_TAG, "Oops.", e);
+        }
+      }
+    };
+    authServer.expectedBasicAuthHeader = authStage.makeAuthHeader(USERNAME, PASSWORD);
+
+    // Authentication delegate to handle HTTP responses.
+    testCallback = new AuthenticateAccountStageDelegate() {
+      protected int numFailedTries = 0;
+
+      @Override
+      public void handleSuccess(boolean isSuccess) {
+        if (isSuccess) {
+          // Succeed on retry (after failing first attempt).
+          assertEquals(1, numFailedTries);
+          testWaiter().performNotify();
+        } else {
+          numFailedTries++;
+          // Fail only once.
+          if (numFailedTries != 1) {
+            testWaiter().performNotify(new Exception("Failed on retry."));
+            return;
+          }
+          String authHeader = authStage.makeAuthHeader(USERNAME, PASSWORD);
+          try {
+            authStage.authenticateAccount(testCallback, TEST_SERVER, authHeader);
+          } catch (URISyntaxException e) {
+            fail("Malformed URI.");
+          }
+        }
+      }
+
+      @Override
+      public void handleFailure(HttpResponse response) {
+        fail("Unexpected response " + response.getStatusLine().getStatusCode());
+      }
+
+      @Override
+      public void handleError(Exception e) {
+        fail("Unexpected error during authentication.");
+      }
+    };
+    assertTrue(testWaiter().isIdle());
+
+  }
+
+  @After
+  public void cleanup() {
+    serverHelper.stopHTTPServer();
+    assertTrue(testWaiter().isIdle());
+  }
+
+  @Test
+  public void testAuthenticationRetry() {
+    serverHelper.startHTTPServer(authServer);
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        // Try auth request with incorrect password. We want to fail the first time.
+        String authHeader = authStage.makeAuthHeader(USERNAME, "wrong-password");
+        try {
+          authStage.authenticateAccount(testCallback, TEST_SERVER, authHeader);
+        } catch (URISyntaxException e) {
+          fail("Malformed URI.");
+        }
+      }
+    });
+  }
+
+  protected static WaitHelper testWaiter() {
+    return WaitHelper.getTestWaiter();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBackoff.java
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+
+public class TestBackoff {
+  private final String TEST_USERNAME            = "johndoe";
+  private final String TEST_PASSWORD            = "password";
+  private final String TEST_SYNC_KEY            = "abcdeabcdeabcdeabcdeabcdea";
+  private final long   TEST_BACKOFF_IN_SECONDS  = 1201;
+
+  /**
+   * Test that interpretHTTPFailure calls requestBackoff if
+   * X-Weave-Backoff is present.
+   */
+  @Test
+  public void testBackoffCalledIfBackoffHeaderPresent() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback);
+
+      final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+      response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+
+      session.interpretHTTPFailure(response); // This is synchronous...
+
+      assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+      assertEquals(false, callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(true,  callback.calledRequestBackoff);
+      assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+
+  /**
+   * Test that interpretHTTPFailure does not call requestBackoff if
+   * X-Weave-Backoff is not present.
+   */
+  @Test
+  public void testBackoffNotCalledIfBackoffHeaderNotPresent() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback);
+
+      final HttpResponse response = new BasicHttpResponse(
+	new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+
+      session.interpretHTTPFailure(response); // This is synchronous...
+
+      assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+      assertEquals(false, callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(false, callback.calledRequestBackoff);
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+
+  /**
+   * Test that interpretHTTPFailure calls requestBackoff with the
+   * largest specified value if X-Weave-Backoff and Retry-After are
+   * present.
+   */
+  @Test
+  public void testBackoffCalledIfMultipleBackoffHeadersPresent() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback);
+
+      final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
+      response.addHeader("Retry-After", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+      response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS + 1)); // If we now add a second header, the larger should be returned.
+
+      session.interpretHTTPFailure(response); // This is synchronous...
+
+      assertEquals(false, callback.calledSuccess); // ... so we can test immediately.
+      assertEquals(false, callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(true,  callback.calledRequestBackoff);
+      assertEquals((TEST_BACKOFF_IN_SECONDS + 1) * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestBrowserIDAuthHeaderProvider.java
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider;
+
+import ch.boye.httpclientandroidlib.Header;
+
+public class TestBrowserIDAuthHeaderProvider {
+  @Test
+  public void testHeader() {
+    Header header = new BrowserIDAuthHeaderProvider("assertion").getAuthHeader(null, null, null);
+
+    assertEquals("authorization", header.getName().toLowerCase());
+    assertEquals("BrowserID assertion", header.getValue());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
@@ -0,0 +1,797 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.ParseException;
+import org.junit.After;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.android.sync.test.helpers.MockSyncClientsEngineStage;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.CommandHelpers;
+import org.mozilla.gecko.background.testhelpers.MockClientsDataDelegate;
+import org.mozilla.gecko.background.testhelpers.MockClientsDatabaseAccessor;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.Utils;
+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.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import ch.boye.httpclientandroidlib.HttpStatus;
+
+public class TestClientsEngineStage extends MockSyncClientsEngineStage {
+  public final static String LOG_TAG = "TestClientsEngSta";
+
+  public TestClientsEngineStage() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, ParseException, CryptoException, URISyntaxException {
+    super();
+    session = initializeSession();
+  }
+
+  // Static so we can set it during the constructor. This is so evil.
+  private static MockGlobalSessionCallback callback;
+  private static GlobalSession initializeSession() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, ParseException, CryptoException, URISyntaxException {
+    callback = new MockGlobalSessionCallback(TEST_SERVER);
+    SyncConfiguration config = new SyncConfiguration(USERNAME, new BasicAuthHeaderProvider(USERNAME, PASSWORD), new MockSharedPreferences());
+    config.syncKeyBundle = new KeyBundle(USERNAME, SYNC_KEY);
+    GlobalSession session = new MockClientsGlobalSession(config, callback);
+    session.config.setClusterURL(new URI(TEST_SERVER));
+    session.config.setCollectionKeys(CollectionKeys.generateCollectionKeys());
+    return session;
+  }
+
+  private static final int TEST_PORT      = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+  private static final String USERNAME  = "john";
+  private static final String PASSWORD  = "password";
+  private static final String SYNC_KEY  = "abcdeabcdeabcdeabcdeabcdea";
+
+  private HTTPServerTestHelper data = new HTTPServerTestHelper();
+  private int numRecordsFromGetRequest = 0;
+
+  private ArrayList<ClientRecord> expectedClients = new ArrayList<ClientRecord>();
+  private ArrayList<ClientRecord> downloadedClients = new ArrayList<ClientRecord>();
+
+  // For test purposes.
+  private ClientRecord lastComputedLocalClientRecord;
+  private ClientRecord uploadedRecord;
+  private String uploadBodyTimestamp;
+  private long uploadHeaderTimestamp;
+  private MockServer currentUploadMockServer;
+  private MockServer currentDownloadMockServer;
+
+  private boolean stubUpload = false;
+
+  protected static WaitHelper testWaiter() {
+    return WaitHelper.getTestWaiter();
+  }
+
+  @Override
+  protected ClientRecord newLocalClientRecord(ClientsDataDelegate delegate) {
+    lastComputedLocalClientRecord = super.newLocalClientRecord(delegate);
+    return lastComputedLocalClientRecord;
+  }
+
+  @After
+  public void teardown() {
+    stubUpload = false;
+    getMockDataAccessor().resetVars();
+  }
+
+  @Override
+  public synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() {
+    if (db == null) {
+      db = new MockClientsDatabaseAccessor();
+    }
+    return db;
+  }
+
+  // For test use.
+  private MockClientsDatabaseAccessor getMockDataAccessor() {
+    return (MockClientsDatabaseAccessor) getClientsDatabaseAccessor();
+  }
+
+  private synchronized boolean mockDataAccessorIsClosed() {
+    if (db == null) {
+      return true;
+    }
+    return ((MockClientsDatabaseAccessor) db).closed;
+  }
+
+  @Override
+  protected ClientDownloadDelegate makeClientDownloadDelegate() {
+    return clientDownloadDelegate;
+  }
+
+  @Override
+  protected void downloadClientRecords() {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(currentDownloadMockServer);
+    super.downloadClientRecords();
+  }
+
+  @Override
+  protected void uploadClientRecord(CryptoRecord record) {
+    BaseResource.rewriteLocalhost = false;
+    if (stubUpload) {
+      session.advance();
+      return;
+    }
+    data.startHTTPServer(currentUploadMockServer);
+    super.uploadClientRecord(record);
+  }
+
+  @Override
+  protected void uploadClientRecords(JSONArray records) {
+    BaseResource.rewriteLocalhost = false;
+    if (stubUpload) {
+      return;
+    }
+    data.startHTTPServer(currentUploadMockServer);
+    super.uploadClientRecords(records);
+  }
+
+  public static class MockClientsGlobalSession extends MockGlobalSession {
+    private ClientsDataDelegate clientsDataDelegate = new MockClientsDataDelegate();
+
+    public MockClientsGlobalSession(SyncConfiguration config,
+                                    GlobalSessionCallback callback)
+        throws SyncConfigurationException,
+               IllegalArgumentException,
+               IOException,
+               ParseException,
+               NonObjectJSONException {
+      super(config, callback);
+    }
+
+    @Override
+    public ClientsDataDelegate getClientsDelegate() {
+      return clientsDataDelegate;
+    }
+  }
+
+  public class TestSuccessClientDownloadDelegate extends TestClientDownloadDelegate {
+    public TestSuccessClientDownloadDelegate(HTTPServerTestHelper data) {
+      super(data);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      super.handleRequestFailure(response);
+      assertTrue(getMockDataAccessor().closed);
+      fail("Should not error.");
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      super.handleRequestError(ex);
+      assertTrue(getMockDataAccessor().closed);
+      fail("Should not fail.");
+    }
+
+    @Override
+    public void handleWBO(CryptoRecord record) {
+      ClientRecord r;
+      try {
+        r = (ClientRecord) factory.createRecord(record.decrypt());
+        downloadedClients.add(r);
+        numRecordsFromGetRequest++;
+      } catch (Exception e) {
+        fail("handleWBO failed.");
+      }
+    }
+  }
+
+  public class TestHandleWBODownloadDelegate extends TestClientDownloadDelegate {
+    public TestHandleWBODownloadDelegate(HTTPServerTestHelper data) {
+      super(data);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      super.handleRequestFailure(response);
+      assertTrue(getMockDataAccessor().closed);
+      fail("Should not error.");
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      super.handleRequestError(ex);
+      assertTrue(getMockDataAccessor().closed);
+      ex.printStackTrace();
+      fail("Should not fail.");
+    }
+  }
+
+  public class MockSuccessClientUploadDelegate extends MockClientUploadDelegate {
+    public MockSuccessClientUploadDelegate(HTTPServerTestHelper data) {
+      super(data);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse response) {
+      uploadHeaderTimestamp = response.normalizedWeaveTimestamp();
+      super.handleRequestSuccess(response);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      super.handleRequestFailure(response);
+      fail("Should not fail.");
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      super.handleRequestError(ex);
+      ex.printStackTrace();
+      fail("Should not error.");
+    }
+  }
+
+  public class MockFailureClientUploadDelegate extends MockClientUploadDelegate {
+    public MockFailureClientUploadDelegate(HTTPServerTestHelper data) {
+      super(data);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse response) {
+      super.handleRequestSuccess(response);
+      fail("Should not succeed.");
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      super.handleRequestError(ex);
+      fail("Should not fail.");
+    }
+  }
+
+  public class UploadMockServer extends MockServer {
+    @SuppressWarnings("unchecked")
+    private String postBodyForRecord(ClientRecord cr) {
+      final long now = cr.lastModified;
+      final BigDecimal modified = Utils.millisecondsToDecimalSeconds(now);
+
+      Logger.debug(LOG_TAG, "Now is " + now + " (" + modified + ")");
+      final JSONArray idArray = new JSONArray();
+      idArray.add(cr.guid);
+
+      final JSONObject result = new JSONObject();
+      result.put("modified", modified);
+      result.put("success", idArray);
+      result.put("failed", new JSONObject());
+
+      uploadBodyTimestamp = modified.toString();
+      return result.toJSONString();
+    }
+
+    private String putBodyForRecord(ClientRecord cr) {
+      final String modified = Utils.millisecondsToDecimalSecondsString(cr.lastModified);
+      uploadBodyTimestamp = modified;
+      return modified;
+    }
+
+    protected void handleUploadPUT(Request request, Response response) throws Exception {
+      Logger.debug(LOG_TAG, "Handling PUT: " + request.getPath());
+
+      // Save uploadedRecord to test against.
+      CryptoRecord cryptoRecord = CryptoRecord.fromJSONRecord(request.getContent());
+      cryptoRecord.keyBundle = session.keyBundleForCollection(COLLECTION_NAME);
+      uploadedRecord = (ClientRecord) factory.createRecord(cryptoRecord.decrypt());
+
+      // Note: collection is not saved in CryptoRecord.toJSONObject() upon upload.
+      // So its value is null and is set here so ClientRecord.equals() may be used.
+      uploadedRecord.collection = lastComputedLocalClientRecord.collection;
+
+      // Create response body containing current timestamp.
+      long now = System.currentTimeMillis();
+      PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/json", now);
+      uploadedRecord.lastModified = now;
+
+      bodyStream.println(putBodyForRecord(uploadedRecord));
+      bodyStream.close();
+    }
+
+    protected void handleUploadPOST(Request request, Response response) throws Exception {
+      Logger.debug(LOG_TAG, "Handling POST: " + request.getPath());
+      String content = request.getContent();
+      Logger.debug(LOG_TAG, "Content is " + content);
+      JSONArray array = ExtendedJSONObject.parseJSONArray(content);
+
+      Logger.debug(LOG_TAG, "Content is " + array);
+
+      KeyBundle keyBundle = session.keyBundleForCollection(COLLECTION_NAME);
+      if (array.size() != 1) {
+        Logger.debug(LOG_TAG, "Expecting only one record! Fail!");
+        PrintStream bodyStream = this.handleBasicHeaders(request, response, 400, "text/plain");
+        bodyStream.println("Expecting only one record! Fail!");
+        bodyStream.close();
+        return;
+      }
+
+      CryptoRecord r = CryptoRecord.fromJSONRecord(new ExtendedJSONObject((JSONObject) array.get(0)));
+      r.keyBundle = keyBundle;
+      ClientRecord cr = (ClientRecord) factory.createRecord(r.decrypt());
+      cr.collection = lastComputedLocalClientRecord.collection;
+      uploadedRecord = cr;
+
+      Logger.debug(LOG_TAG, "Record is " + cr);
+      long now = System.currentTimeMillis();
+      PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/json", now);
+      cr.lastModified = now;
+      final String responseBody = postBodyForRecord(cr);
+      Logger.debug(LOG_TAG, "Response is " + responseBody);
+      bodyStream.println(responseBody);
+      bodyStream.close();
+    }
+
+    @Override
+    public void handle(Request request, Response response) {
+      try {
+        String method = request.getMethod();
+        Logger.debug(LOG_TAG, "Handling " + method);
+        if (method.equalsIgnoreCase("post")) {
+          handleUploadPOST(request, response);
+        } else if (method.equalsIgnoreCase("put")) {
+          handleUploadPUT(request, response);
+        } else {
+          PrintStream bodyStream = this.handleBasicHeaders(request, response, 404, "text/plain");
+          bodyStream.close();
+        }
+      } catch (Exception e) {
+        fail("Error handling uploaded client record in UploadMockServer.");
+      }
+    }
+  }
+
+  public class DownloadMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      try {
+        PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+        for (int i = 0; i < 5; i++) {
+          ClientRecord record = new ClientRecord();
+          if (i != 2) {   // So we test null version.
+            record.version = Integer.toString(28 + i);
+          }
+          expectedClients.add(record);
+          CryptoRecord cryptoRecord = cryptoFromClient(record);
+          bodyStream.print(cryptoRecord.toJSONString() + "\n");
+        }
+        bodyStream.close();
+      } catch (IOException e) {
+        fail("Error handling downloaded client records in DownloadMockServer.");
+      }
+    }
+  }
+
+  public class DownloadLocalRecordMockServer extends MockServer {
+    @SuppressWarnings("unchecked")
+    @Override
+    public void handle(Request request, Response response) {
+      try {
+        PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+        ClientRecord record = new ClientRecord(session.getClientsDelegate().getAccountGUID());
+
+        // Timestamp on server is 10 seconds after local timestamp
+        // (would trigger 412 if upload was attempted).
+        CryptoRecord cryptoRecord = cryptoFromClient(record);
+        JSONObject object = cryptoRecord.toJSONObject();
+        final long modified = (setRecentClientRecordTimestamp() + 10000) / 1000;
+        Logger.debug(LOG_TAG, "Setting modified to " + modified);
+        object.put("modified", modified);
+        bodyStream.print(object.toJSONString() + "\n");
+        bodyStream.close();
+      } catch (IOException e) {
+        fail("Error handling downloaded client records in DownloadLocalRecordMockServer.");
+      }
+    }
+  }
+
+  private CryptoRecord cryptoFromClient(ClientRecord record) {
+    CryptoRecord cryptoRecord = record.getEnvelope();
+    cryptoRecord.keyBundle = clientDownloadDelegate.keyBundle();
+    try {
+      cryptoRecord.encrypt();
+    } catch (Exception e) {
+      fail("Cannot encrypt client record.");
+    }
+    return cryptoRecord;
+  }
+
+  private long setRecentClientRecordTimestamp() {
+    long timestamp = System.currentTimeMillis() - (CLIENTS_TTL_REFRESH - 1000);
+    session.config.persistServerClientRecordTimestamp(timestamp);
+    return timestamp;
+  }
+
+  private void performFailingUpload() {
+    // performNotify() occurs in MockGlobalSessionCallback.
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        clientUploadDelegate = new MockFailureClientUploadDelegate(data);
+        checkAndUpload();
+      }
+    });
+  }
+
+  @SuppressWarnings("unchecked")
+  @Test
+  public void testShouldUploadNoCommandsToProcess() throws NullCursorException {
+    // shouldUpload() returns true.
+    assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+    assertFalse(shouldUploadLocalRecord);
+    assertTrue(shouldUpload());
+
+    // Set the timestamp to be a little earlier than refresh time,
+    // so shouldUpload() returns false.
+    setRecentClientRecordTimestamp();
+    assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+    assertFalse(shouldUploadLocalRecord);
+    assertFalse(shouldUpload());
+
+    // Now simulate observing a client record with the incorrect version.
+
+    ClientRecord outdatedRecord = new ClientRecord("dontmatter12", "clients", System.currentTimeMillis(), false);
+
+    outdatedRecord.version = getLocalClientVersion();
+    outdatedRecord.protocols = getLocalClientProtocols();
+    handleDownloadedLocalRecord(outdatedRecord);
+
+    assertEquals(outdatedRecord.lastModified, session.config.getPersistedServerClientRecordTimestamp());
+    assertFalse(shouldUploadLocalRecord);
+    assertFalse(shouldUpload());
+
+    outdatedRecord.version = outdatedRecord.version + "a1";
+    handleDownloadedLocalRecord(outdatedRecord);
+
+    // Now we think we need to upload because the version is outdated.
+    assertTrue(shouldUploadLocalRecord);
+    assertTrue(shouldUpload());
+
+    shouldUploadLocalRecord = false;
+    assertFalse(shouldUpload());
+
+    // If the protocol list is missing or wrong, we should reupload.
+    outdatedRecord.protocols = new JSONArray();
+    handleDownloadedLocalRecord(outdatedRecord);
+    assertTrue(shouldUpload());
+
+    shouldUploadLocalRecord = false;
+    assertFalse(shouldUpload());
+
+    outdatedRecord.protocols.add("1.0");
+    handleDownloadedLocalRecord(outdatedRecord);
+    assertTrue(shouldUpload());
+  }
+
+  @SuppressWarnings("unchecked")
+  @Test
+  public void testShouldUploadProcessCommands() throws NullCursorException {
+    // shouldUpload() returns false since array is size 0 and
+    // it has not been long enough yet to require an upload.
+    processCommands(new JSONArray());
+    setRecentClientRecordTimestamp();
+    assertFalse(shouldUploadLocalRecord);
+    assertFalse(shouldUpload());
+
+    // shouldUpload() returns true since array is size 1 even though
+    // it has not been long enough yet to require an upload.
+    JSONArray commands = new JSONArray();
+    commands.add(new JSONObject());
+    processCommands(commands);
+    setRecentClientRecordTimestamp();
+    assertEquals(1, commands.size());
+    assertTrue(shouldUploadLocalRecord);
+    assertTrue(shouldUpload());
+  }
+
+  @Test
+  public void testWipeAndStoreShouldNotWipe() {
+    assertFalse(shouldWipe);
+    wipeAndStore(new ClientRecord());
+    assertFalse(shouldWipe);
+    assertFalse(getMockDataAccessor().clientsTableWiped);
+    assertTrue(getMockDataAccessor().storedRecord);
+  }
+
+  @Test
+  public void testWipeAndStoreShouldWipe() {
+    assertFalse(shouldWipe);
+    shouldWipe = true;
+    wipeAndStore(new ClientRecord());
+    assertFalse(shouldWipe);
+    assertTrue(getMockDataAccessor().clientsTableWiped);
+    assertTrue(getMockDataAccessor().storedRecord);
+  }
+
+  @Test
+  public void testDownloadClientRecord() {
+    // Make sure no upload occurs after a download so we can
+    // test download in isolation.
+    stubUpload = true;
+
+    currentDownloadMockServer = new DownloadMockServer();
+    // performNotify() occurs in MockGlobalSessionCallback.
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        clientDownloadDelegate = new TestSuccessClientDownloadDelegate(data);
+        downloadClientRecords();
+      }
+    });
+
+    assertEquals(expectedClients.size(), numRecordsFromGetRequest);
+    for (int i = 0; i < downloadedClients.size(); i++) {
+      final ClientRecord downloaded = downloadedClients.get(i);
+      final ClientRecord expected = expectedClients.get(i);
+      assertTrue(expected.guid.equals(downloaded.guid));
+      assertEquals(expected.version, downloaded.version);
+    }
+    assertTrue(mockDataAccessorIsClosed());
+  }
+
+  @Test
+  public void testCheckAndUploadClientRecord() {
+    uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+    assertFalse(shouldUploadLocalRecord);
+    assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+    currentUploadMockServer = new UploadMockServer();
+    // performNotify() occurs in MockGlobalSessionCallback.
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        clientUploadDelegate = new MockSuccessClientUploadDelegate(data);
+        checkAndUpload();
+      }
+    });
+
+    // Test ClientUploadDelegate.handleRequestSuccess().
+    Logger.debug(LOG_TAG, "Last computed local client record: " + lastComputedLocalClientRecord.guid);
+    Logger.debug(LOG_TAG, "Uploaded client record: " + uploadedRecord.guid);
+    assertTrue(lastComputedLocalClientRecord.equalPayloads(uploadedRecord));
+    assertEquals(0, uploadAttemptsCount.get());
+    assertTrue(callback.calledSuccess);
+
+    assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+
+    // Body and header are the same.
+    assertEquals(Utils.decimalSecondsToMilliseconds(uploadBodyTimestamp),
+                 session.config.getPersistedServerClientsTimestamp());
+    assertEquals(uploadedRecord.lastModified,
+                 session.config.getPersistedServerClientRecordTimestamp());
+    assertEquals(uploadHeaderTimestamp, session.config.getPersistedServerClientsTimestamp());
+  }
+
+  @Test
+  public void testDownloadHasOurRecord() {
+    // Make sure no upload occurs after a download so we can
+    // test download in isolation.
+    stubUpload = true;
+
+    // We've uploaded our local record recently.
+    long initialTimestamp = setRecentClientRecordTimestamp();
+
+    currentDownloadMockServer = new DownloadLocalRecordMockServer();
+    // performNotify() occurs in MockGlobalSessionCallback.
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        clientDownloadDelegate = new TestHandleWBODownloadDelegate(data);
+        downloadClientRecords();
+      }
+    });
+
+    // Timestamp got updated (but not reset) since we downloaded our record
+    assertFalse(0 == session.config.getPersistedServerClientRecordTimestamp());
+    assertTrue(initialTimestamp < session.config.getPersistedServerClientRecordTimestamp());
+    assertTrue(mockDataAccessorIsClosed());
+  }
+
+  @Test
+  public void testResetTimestampOnDownload() {
+    // Make sure no upload occurs after a download so we can
+    // test download in isolation.
+    stubUpload = true;
+
+    currentDownloadMockServer = new DownloadMockServer();
+    // performNotify() occurs in MockGlobalSessionCallback.
+    testWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        clientDownloadDelegate = new TestHandleWBODownloadDelegate(data);
+        downloadClientRecords();
+      }
+    });
+
+    // Timestamp got reset since our record wasn't downloaded.
+    assertEquals(0, session.config.getPersistedServerClientRecordTimestamp());
+    assertTrue(mockDataAccessorIsClosed());
+  }
+
+  /**
+   * The following 8 tests are for ClientUploadDelegate.handleRequestFailure().
+   * for the varying values of uploadAttemptsCount, shouldUploadLocalRecord,
+   * and the type of server error.
+   *
+   * The first 4 are for 412 Precondition Failures.
+   * The second 4 represent the functionality given any other type of variable.
+   */
+  @Test
+  public void testHandle412UploadFailureLowCount() {
+    assertFalse(shouldUploadLocalRecord);
+    currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+    assertEquals(0, uploadAttemptsCount.get());
+    performFailingUpload();
+    assertEquals(0, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandle412UploadFailureHighCount() {
+    assertFalse(shouldUploadLocalRecord);
+    currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+    uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+    performFailingUpload();
+    assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandle412UploadFailureLowCountWithCommand() {
+    shouldUploadLocalRecord = true;
+    currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+    assertEquals(0, uploadAttemptsCount.get());
+    performFailingUpload();
+    assertEquals(0, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandle412UploadFailureHighCountWithCommand() {
+    shouldUploadLocalRecord = true;
+    currentUploadMockServer = new MockServer(HttpStatus.SC_PRECONDITION_FAILED, null);
+    uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+    performFailingUpload();
+    assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandleMiscUploadFailureLowCount() {
+    currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+    assertFalse(shouldUploadLocalRecord);
+    assertEquals(0, uploadAttemptsCount.get());
+    performFailingUpload();
+    assertEquals(0, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandleMiscUploadFailureHighCount() {
+    currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+    assertFalse(shouldUploadLocalRecord);
+    uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+    performFailingUpload();
+    assertEquals(MAX_UPLOAD_FAILURE_COUNT, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandleMiscUploadFailureHighCountWithCommands() {
+    currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+    shouldUploadLocalRecord = true;
+    uploadAttemptsCount.set(MAX_UPLOAD_FAILURE_COUNT);
+    performFailingUpload();
+    assertEquals(MAX_UPLOAD_FAILURE_COUNT + 1, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  @Test
+  public void testHandleMiscUploadFailureMaxAttempts() {
+    currentUploadMockServer = new MockServer(HttpStatus.SC_BAD_REQUEST, null);
+    shouldUploadLocalRecord = true;
+    assertEquals(0, uploadAttemptsCount.get());
+    performFailingUpload();
+    assertEquals(MAX_UPLOAD_FAILURE_COUNT + 1, uploadAttemptsCount.get());
+    assertTrue(callback.calledError);
+  }
+
+  class TestAddCommandsMockClientsDatabaseAccessor extends MockClientsDatabaseAccessor {
+    @Override
+    public List<Command> fetchCommandsForClient(String accountGUID) throws NullCursorException {
+      List<Command> commands = new ArrayList<Command>();
+      commands.add(CommandHelpers.getCommand1());
+      commands.add(CommandHelpers.getCommand2());
+      commands.add(CommandHelpers.getCommand3());
+      commands.add(CommandHelpers.getCommand4());
+      return commands;
+    }
+  }
+
+  @Test
+  public void testAddCommandsToUnversionedClient() throws NullCursorException {
+    db = new TestAddCommandsMockClientsDatabaseAccessor();
+
+    final ClientRecord remoteRecord = new ClientRecord();
+    remoteRecord.version = null;
+    final String expectedGUID = remoteRecord.guid;
+
+    this.addCommands(remoteRecord);
+    assertEquals(1, toUpload.size());
+
+    final ClientRecord recordToUpload = toUpload.get(0);
+    assertEquals(4, recordToUpload.commands.size());
+    assertEquals(expectedGUID, recordToUpload.guid);
+    assertEquals(null, recordToUpload.version);
+  }
+
+  @Test
+  public void testAddCommandsToVersionedClient() throws NullCursorException {
+    db = new TestAddCommandsMockClientsDatabaseAccessor();
+
+    final ClientRecord remoteRecord = new ClientRecord();
+    remoteRecord.version = "12a1";
+    final String expectedGUID = remoteRecord.guid;
+
+    this.addCommands(remoteRecord);
+    assertEquals(1, toUpload.size());
+
+    final ClientRecord recordToUpload = toUpload.get(0);
+    assertEquals(4, recordToUpload.commands.size());
+    assertEquals(expectedGUID, recordToUpload.guid);
+    assertEquals("12a1", recordToUpload.version);
+  }
+
+  @Test
+  public void testLastModifiedTimestamp() throws NullCursorException {
+    // If we uploaded a record a moment ago, we shouldn't upload another.
+    final long now = System.currentTimeMillis() - 1;
+    session.config.persistServerClientRecordTimestamp(now);
+    assertEquals(now, session.config.getPersistedServerClientRecordTimestamp());
+    assertFalse(shouldUploadLocalRecord);
+    assertFalse(shouldUpload());
+
+    // But if we change our client data, we should upload.
+    session.getClientsDelegate().setClientName("new name", System.currentTimeMillis());
+    assertTrue(shouldUpload());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+
+import ch.boye.httpclientandroidlib.Header;
+
+/**
+ * Test the transfer of a UTF-8 string from desktop, and ensure that it results in the
+ * correct hashed Basic Auth header.
+ */
+public class TestCredentialsEndToEnd {
+
+  public static final String REAL_PASSWORD         = "pïgéons1";
+  public static final String USERNAME              = "utvm3mk6hnngiir2sp4jsxf2uvoycrv6";
+  public static final String DESKTOP_PASSWORD_JSON = "{\"password\":\"pïgéons1\"}";
+  public static final String BTOA_PASSWORD         = "cMOvZ8Opb25zMQ==";
+  public static final int    DESKTOP_ASSERTED_SIZE = 10;
+  public static final String DESKTOP_BASIC_AUTH    = "Basic dXR2bTNtazZobm5naWlyMnNwNGpzeGYydXZveWNydjY6cMOvZ8Opb25zMQ==";
+
+  private String getCreds(String password) {
+    Header authenticate = new BasicAuthHeaderProvider(USERNAME, password).getAuthHeader(null, null, null);
+    return authenticate.getValue();
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testUTF8() throws UnsupportedEncodingException {
+    final String in  = "pïgéons1";
+    final String out = "pïgéons1";
+    assertEquals(out, Utils.decodeUTF8(in));
+  }
+
+  @Test
+  public void testAuthHeaderFromPassword() throws NonObjectJSONException, IOException, ParseException {
+    final ExtendedJSONObject parsed = new ExtendedJSONObject(DESKTOP_PASSWORD_JSON);
+
+    final String password = parsed.getString("password");
+    final String decoded = Utils.decodeUTF8(password);
+
+    final byte[] expectedBytes = Utils.decodeBase64(BTOA_PASSWORD);
+    final String expected = new String(expectedBytes, "UTF-8");
+
+    assertEquals(DESKTOP_ASSERTED_SIZE, password.length());
+    assertEquals(expected, decoded);
+
+    System.out.println("Retrieved password: " + password);
+    System.out.println("Expected password:  " + expected);
+    System.out.println("Rescued password:   " + decoded);
+
+    assertEquals(getCreds(expected), getCreds(decoded));
+    assertEquals(getCreds(decoded), DESKTOP_BASIC_AUTH);
+  }
+
+  // Note that we do *not* have a test for the J-PAKE setup process
+  // (SetupSyncActivity) that actually stores credentials and requires
+  // decodeUTF8. This will have to suffice.
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestGlobalSession.java
@@ -0,0 +1,436 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import junit.framework.AssertionFailedError;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockGlobalSessionCallback;
+import org.mozilla.android.sync.test.helpers.MockResourceDelegate;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.MockAbstractNonRepositorySyncStage;
+import org.mozilla.gecko.background.testhelpers.MockGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockPrefsGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockServerSyncStage;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
+import org.mozilla.gecko.sync.stage.AndroidBrowserBookmarksServerSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.stage.NoSuchStageException;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+public class TestGlobalSession {
+  private int          TEST_PORT                = HTTPServerTestHelper.getTestPort();
+  private final String TEST_CLUSTER_URL         = "http://localhost:" + TEST_PORT;
+  private final String TEST_USERNAME            = "johndoe";
+  private final String TEST_PASSWORD            = "password";
+  private final String TEST_SYNC_KEY            = "abcdeabcdeabcdeabcdeabcdea";
+  private final long   TEST_BACKOFF_IN_SECONDS  = 2401;
+
+  public static WaitHelper getTestWaiter() {
+    return WaitHelper.getTestWaiter();
+  }
+
+  @Test
+  public void testGetSyncStagesBy() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, ParseException, CryptoException, NoSuchStageException {
+
+    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+    GlobalSession s = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+                                                 new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY),
+                                                 callback, /* context */ null, null);
+
+    assertTrue(s.getSyncStageByName(Stage.syncBookmarks) instanceof AndroidBrowserBookmarksServerSyncStage);
+
+    final Set<String> empty = new HashSet<String>();
+
+    final Set<String> bookmarksAndTabsNames = new HashSet<String>();
+    bookmarksAndTabsNames.add("bookmarks");
+    bookmarksAndTabsNames.add("tabs");
+
+    final Set<GlobalSyncStage> bookmarksAndTabsSyncStages = new HashSet<GlobalSyncStage>();
+    GlobalSyncStage bookmarksStage = s.getSyncStageByName("bookmarks");
+    GlobalSyncStage tabsStage = s.getSyncStageByName(Stage.syncTabs);
+    bookmarksAndTabsSyncStages.add(bookmarksStage);
+    bookmarksAndTabsSyncStages.add(tabsStage);
+
+    final Set<Stage> bookmarksAndTabsEnums = new HashSet<Stage>();
+    bookmarksAndTabsEnums.add(Stage.syncBookmarks);
+    bookmarksAndTabsEnums.add(Stage.syncTabs);
+
+    assertTrue(s.getSyncStagesByName(empty).isEmpty());
+    assertEquals(bookmarksAndTabsSyncStages, new HashSet<GlobalSyncStage>(s.getSyncStagesByName(bookmarksAndTabsNames)));
+    assertEquals(bookmarksAndTabsSyncStages, new HashSet<GlobalSyncStage>(s.getSyncStagesByEnum(bookmarksAndTabsEnums)));
+  }
+
+  /**
+   * Test that handleHTTPError does in fact backoff.
+   */
+  @Test
+  public void testBackoffCalledByHandleHTTPError() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback(TEST_CLUSTER_URL);
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback);
+
+      final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+      response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+
+      getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+        @Override
+        public void run() {
+          session.handleHTTPError(new SyncStorageResponse(response), "Illegal method/protocol");
+        }
+      }));
+
+      assertEquals(false, callback.calledSuccess);
+      assertEquals(true,  callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(true,  callback.calledRequestBackoff);
+      assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+
+  /**
+   * Test that a trivially successful GlobalSession does not fail or backoff.
+   */
+  @Test
+  public void testSuccessCalledAfterStages() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback(TEST_CLUSTER_URL);
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback);
+
+      getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            session.start();
+          } catch (Exception e) {
+            final AssertionFailedError error = new AssertionFailedError();
+            error.initCause(e);
+            getTestWaiter().performNotify(error);
+          }
+        }
+      }));
+
+      assertEquals(true,  callback.calledSuccess);
+      assertEquals(false, callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(false, callback.calledRequestBackoff);
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+
+  /**
+   * Test that a failing GlobalSession does in fact fail and back off.
+   */
+  @Test
+  public void testBackoffCalledInStages() {
+    try {
+      final MockGlobalSessionCallback callback = new MockGlobalSessionCallback(TEST_CLUSTER_URL);
+
+      // Stage fakes a 503 and sets X-Weave-Backoff header to the given seconds.
+      final GlobalSyncStage stage = new MockAbstractNonRepositorySyncStage() {
+        @Override
+        public void execute() {
+          final HttpResponse response = new BasicHttpResponse(
+            new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+
+          response.addHeader("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS)); // Backoff given in seconds.
+          session.handleHTTPError(new SyncStorageResponse(response), "Failure fetching info/collections.");
+        }
+      };
+
+      // Session installs fake stage to fetch info/collections.
+      SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+      final GlobalSession session = new MockGlobalSession(config, callback)
+                                        .withStage(Stage.fetchInfoCollections, stage);
+
+      getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            session.start();
+          } catch (Exception e) {
+            final AssertionFailedError error = new AssertionFailedError();
+            error.initCause(e);
+            getTestWaiter().performNotify(error);
+          }
+        }
+      }));
+
+      assertEquals(false, callback.calledSuccess);
+      assertEquals(true,  callback.calledError);
+      assertEquals(false, callback.calledAborted);
+      assertEquals(true,  callback.calledRequestBackoff);
+      assertEquals(TEST_BACKOFF_IN_SECONDS * 1000, callback.weaveBackoff); // Backoff returned in milliseconds.
+    } catch (Exception e) {
+      e.printStackTrace();
+      fail("Got exception.");
+    }
+  }
+
+  private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+  @SuppressWarnings("static-method")
+  @Before
+  public void setUp() {
+    BaseResource.rewriteLocalhost = false;
+  }
+
+  public void doRequest() {
+    final WaitHelper innerWaitHelper = new WaitHelper();
+    innerWaitHelper.performWait(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          final BaseResource r = new BaseResource(TEST_CLUSTER_URL);
+          r.delegate = new MockResourceDelegate(innerWaitHelper);
+          r.get();
+        } catch (URISyntaxException e) {
+          innerWaitHelper.performNotify(e);
+        }
+      }
+    });
+  }
+
+  public MockGlobalSessionCallback doTestSuccess(final boolean stageShouldBackoff, final boolean stageShouldAdvance) throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, ParseException, CryptoException {
+    MockServer server = new MockServer() {
+      @Override
+      public void handle(Request request, Response response) {
+        if (stageShouldBackoff) {
+          response.set("X-Weave-Backoff", Long.toString(TEST_BACKOFF_IN_SECONDS));
+        }
+        super.handle(request, response);
+      }
+    };
+
+    final MockServerSyncStage stage = new MockServerSyncStage() {
+      @Override
+      public void execute() {
+        // We should have installed our HTTP response observer before starting the sync.
+        assertTrue(BaseResource.isHttpResponseObserver(session));
+
+        doRequest();
+        if (stageShouldAdvance) {
+          session.advance();
+          return;
+        }
+        session.abort(null,  "Stage intentionally failed.");
+      }
+    };
+
+    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback(TEST_CLUSTER_URL);
+    SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), new MockSharedPreferences(), new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY));
+    final GlobalSession session = new MockGlobalSession(config, callback)
+                                      .withStage(Stage.syncBookmarks, stage);
+
+    data.startHTTPServer(server);
+    WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          session.start();
+        } catch (Exception e) {
+          final AssertionFailedError error = new AssertionFailedError();
+          error.initCause(e);
+          WaitHelper.getTestWaiter().performNotify(error);
+        }
+      }
+    }));
+    data.stopHTTPServer();
+
+    // We should have uninstalled our HTTP response observer when the session is terminated.
+    assertFalse(BaseResource.isHttpResponseObserver(session));
+
+    return callback;
+  }
+
+  @Test
+  public void testOnSuccessBackoffAdvanced() throws SyncConfigurationException,
+      IllegalArgumentException, NonObjectJSONException, IOException,
+      ParseException, CryptoException {
+    MockGlobalSessionCallback callback = doTestSuccess(true, true);
+
+    assertTrue(callback.calledError); // TODO: this should be calledAborted.
+    assertTrue(callback.calledRequestBackoff);
+    assertEquals(1000 * TEST_BACKOFF_IN_SECONDS, callback.weaveBackoff);
+  }
+
+  @Test
+  public void testOnSuccessBackoffAborted() throws SyncConfigurationException,
+      IllegalArgumentException, NonObjectJSONException, IOException,
+      ParseException, CryptoException {
+    MockGlobalSessionCallback callback = doTestSuccess(true, false);
+
+    assertTrue(callback.calledError); // TODO: this should be calledAborted.
+    assertTrue(callback.calledRequestBackoff);
+    assertEquals(1000 * TEST_BACKOFF_IN_SECONDS, callback.weaveBackoff);
+  }
+
+  @Test
+  public void testOnSuccessNoBackoffAdvanced() throws SyncConfigurationException,
+      IllegalArgumentException, NonObjectJSONException, IOException,
+      ParseException, CryptoException {
+    MockGlobalSessionCallback callback = doTestSuccess(false, true);
+
+    assertTrue(callback.calledSuccess);
+    assertFalse(callback.calledRequestBackoff);
+  }
+
+  @Test
+  public void testOnSuccessNoBackoffAborted() throws SyncConfigurationException,
+      IllegalArgumentException, NonObjectJSONException, IOException,
+      ParseException, CryptoException {
+    MockGlobalSessionCallback callback = doTestSuccess(false, false);
+
+    assertTrue(callback.calledError); // TODO: this should be calledAborted.
+    assertFalse(callback.calledRequestBackoff);
+  }
+
+  @Test
+  public void testGenerateNewMetaGlobalNonePersisted() throws Exception {
+    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+    final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+        new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+
+    // Verify we fill in all of our known engines when none are persisted.
+    session.config.enabledEngineNames = null;
+    MetaGlobal mg = session.generateNewMetaGlobal();
+    assertEquals(Long.valueOf(GlobalSession.STORAGE_VERSION), mg.getStorageVersion());
+    assertEquals(VersionConstants.BOOKMARKS_ENGINE_VERSION, mg.getEngines().getObject("bookmarks").getIntegerSafely("version").intValue());
+    assertEquals(VersionConstants.CLIENTS_ENGINE_VERSION, mg.getEngines().getObject("clients").getIntegerSafely("version").intValue());
+
+    List<String> namesList = new ArrayList<String>(mg.getEnabledEngineNames());
+    Collections.sort(namesList);
+    String[] names = namesList.toArray(new String[namesList.size()]);
+    String[] expected = new String[] { "bookmarks", "clients", "forms", "history", "passwords", "tabs" };
+    assertArrayEquals(expected, names);
+  }
+
+  @Test
+  public void testGenerateNewMetaGlobalSomePersisted() throws Exception {
+    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+    final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+        new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+
+    // Verify we preserve engines with version 0 if some are persisted.
+    session.config.enabledEngineNames = new HashSet<String>();
+    session.config.enabledEngineNames.add("bookmarks");
+    session.config.enabledEngineNames.add("clients");
+    session.config.enabledEngineNames.add("addons");
+    session.config.enabledEngineNames.add("prefs");
+
+    MetaGlobal mg = session.generateNewMetaGlobal();
+    assertEquals(Long.valueOf(GlobalSession.STORAGE_VERSION), mg.getStorageVersion());
+    assertEquals(VersionConstants.BOOKMARKS_ENGINE_VERSION, mg.getEngines().getObject("bookmarks").getIntegerSafely("version").intValue());
+    assertEquals(VersionConstants.CLIENTS_ENGINE_VERSION, mg.getEngines().getObject("clients").getIntegerSafely("version").intValue());
+    assertEquals(0, mg.getEngines().getObject("addons").getIntegerSafely("version").intValue());
+    assertEquals(0, mg.getEngines().getObject("prefs").getIntegerSafely("version").intValue());
+
+    List<String> namesList = new ArrayList<String>(mg.getEnabledEngineNames());
+    Collections.sort(namesList);
+    String[] names = namesList.toArray(new String[namesList.size()]);
+    String[] expected = new String[] { "addons", "bookmarks", "clients", "prefs" };
+    assertArrayEquals(expected, names);
+  }
+
+  @Test
+  public void testUploadUpdatedMetaGlobal() throws Exception {
+    // Set up session with meta/global.
+    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
+    final GlobalSession session = MockPrefsGlobalSession.getSession(TEST_USERNAME, TEST_PASSWORD,
+        new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY), callback, null, null);
+    session.config.metaGlobal = session.generateNewMetaGlobal();
+    session.enginesToUpdate.clear();
+
+    // Set enabledEngines in meta/global, including a "new engine."
+    String[] origEngines = new String[] { "bookmarks", "clients", "forms", "history", "tabs", "new-engine" };
+
+    ExtendedJSONObject origEnginesJSONObject = new ExtendedJSONObject();
+    for (String engineName : origEngines) {
+      EngineSettings mockEngineSettings = new EngineSettings(Utils.generateGuid(), Integer.valueOf(0));
+      origEnginesJSONObject.put(engineName, mockEngineSettings);
+    }
+    session.config.metaGlobal.setEngines(origEnginesJSONObject);
+
+    // Engines to remove.
+    String[] toRemove = new String[] { "bookmarks", "tabs" };
+    for (String name : toRemove) {
+      session.removeEngineFromMetaGlobal(name);
+    }
+
+    // Engines to add.
+    String[] toAdd = new String[] { "passwords" };
+    for (String name : toAdd) {
+      String syncId = Utils.generateGuid();
+      session.recordForMetaGlobalUpdate(name, new EngineSettings(syncId, Integer.valueOf(1)));
+    }
+
+    // Update engines.
+    session.uploadUpdatedMetaGlobal();
+
+    // Check resulting enabledEngines.
+    Set<String> expected = new HashSet<String>();
+    for (String name : origEngines) {
+      expected.add(name);
+    }
+    for (String name : toRemove) {
+      expected.remove(name);
+    }
+    for (String name : toAdd) {
+      expected.add(name);
+    }
+    assertEquals(expected, session.config.metaGlobal.getEnabledEngineNames());
+  }
+
+  public void testStageAdvance() {
+    assertEquals(GlobalSession.nextStage(Stage.idle), Stage.checkPreconditions);
+    assertEquals(GlobalSession.nextStage(Stage.completed), Stage.idle);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestHeaderParsing.java
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.mozilla.gecko.sync.Utils;
+
+public class TestHeaderParsing {
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testDecimalSecondsToMilliseconds() {
+    assertEquals(Utils.decimalSecondsToMilliseconds(""),         -1);
+    assertEquals(Utils.decimalSecondsToMilliseconds("1234.1.1"), -1);
+    assertEquals(Utils.decimalSecondsToMilliseconds("1234"),     1234000);
+    assertEquals(Utils.decimalSecondsToMilliseconds("1234.123"), 1234123);
+    assertEquals(Utils.decimalSecondsToMilliseconds("1234.12"),  1234120);
+
+    assertEquals("1234.000", Utils.millisecondsToDecimalSecondsString(1234000));
+    assertEquals("1234.123", Utils.millisecondsToDecimalSecondsString(1234123));
+    assertEquals("1234.120", Utils.millisecondsToDecimalSecondsString(1234120));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestLineByLineHandling.java
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
+import org.mozilla.gecko.sync.net.SyncStorageCollectionRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+public class TestLineByLineHandling {
+  private static final int     TEST_PORT   = HTTPServerTestHelper.getTestPort();
+  private static final String  TEST_SERVER = "http://localhost:" + TEST_PORT;
+  private static final String  LOG_TAG     = "TestLineByLineHandling";
+  static String                STORAGE_URL = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/lines";
+  private HTTPServerTestHelper data        = new HTTPServerTestHelper();
+
+  public ArrayList<String>     lines       = new ArrayList<String>();
+
+  public class LineByLineMockServer extends MockServer {
+    public void handle(Request request, Response response) {
+      try {
+        System.out.println("Handling line-by-line request...");
+        PrintStream bodyStream = this.handleBasicHeaders(request, response, 200, "application/newlines");
+
+        bodyStream.print("First line.\n");
+        bodyStream.print("Second line.\n");
+        bodyStream.print("Third line.\n");
+        bodyStream.print("Fourth line.\n");
+        bodyStream.close();
+      } catch (IOException e) {
+        System.err.println("Oops.");
+      }
+    }
+  }
+
+  public class BaseLineByLineDelegate extends
+      SyncStorageCollectionRequestDelegate {
+
+    @Override
+    public void handleRequestProgress(String progress) {
+      lines.add(progress);
+    }
+
+    @Override
+    public AuthHeaderProvider getAuthHeaderProvider() {
+      return null;
+    }
+
+    @Override
+    public String ifUnmodifiedSince() {
+      return null;
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse res) {
+      Logger.info(LOG_TAG, "Request success.");
+      assertTrue(res.wasSuccessful());
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+
+      assertEquals(lines.size(), 4);
+      assertEquals(lines.get(0), "First line.");
+      assertEquals(lines.get(1), "Second line.");
+      assertEquals(lines.get(2), "Third line.");
+      assertEquals(lines.get(3), "Fourth line.");
+      data.stopHTTPServer();
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      Logger.info(LOG_TAG, "Got request failure: " + response);
+      BaseResource.consumeEntity(response);
+      fail("Should not be called.");
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      Logger.error(LOG_TAG, "Got request error: ", ex);
+      fail("Should not be called.");
+    }
+  }
+
+  @Test
+  public void testLineByLine() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+
+    data.startHTTPServer(new LineByLineMockServer());
+    Logger.info(LOG_TAG, "Server started.");
+    SyncStorageCollectionRequest r = new SyncStorageCollectionRequest(new URI(STORAGE_URL));
+    SyncStorageCollectionRequestDelegate delegate = new BaseLineByLineDelegate();
+    r.delegate = delegate;
+    r.get();
+    // Server is stopped in the callback.
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestMetaGlobal.java
@@ -0,0 +1,346 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.MetaGlobal;
+import org.mozilla.gecko.sync.delegates.MetaGlobalDelegate;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+public class TestMetaGlobal {
+  public static Object monitor = new Object();
+
+  private static final int    TEST_PORT    = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER  = "http://localhost:" + TEST_PORT;
+  private static final String TEST_SYNC_ID = "foobar";
+
+  public static final String USER_PASS = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd:password";
+  public static final String META_URL  = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/meta/global";
+  private HTTPServerTestHelper data    = new HTTPServerTestHelper();
+
+
+  public static final String TEST_DECLINED_META_GLOBAL_RESPONSE =
+          "{\"id\":\"global\"," +
+          "\"payload\":" +
+          "\"{\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\"," +
+          "\\\"declined\\\":[\\\"bookmarks\\\"]," +
+          "\\\"storageVersion\\\":5," +
+          "\\\"engines\\\":{" +
+          "\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"}," +
+          "\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"}," +
+          "\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"}," +
+          "\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"}," +
+          "\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"}," +
+          "\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\"," +
+          "\"username\":\"5817483\"," +
+          "\"modified\":1.32046073744E9}";
+
+  public static final String TEST_META_GLOBAL_RESPONSE =
+          "{\"id\":\"global\"," +
+          "\"payload\":" +
+          "\"{\\\"syncID\\\":\\\"zPSQTm7WBVWB\\\"," +
+          "\\\"storageVersion\\\":5," +
+          "\\\"engines\\\":{" +
+          "\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"fDg0MS5bDtV7\\\"}," +
+          "\\\"bookmarks\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"NNaQr6_F-9dm\\\"}," +
+          "\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"GXF29AFprnvc\\\"}," +
+          "\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"av75g4vm-_rp\\\"}," +
+          "\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"LT_ACGpuKZ6a\\\"}," +
+          "\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"-3nsksP9wSAs\\\"}," +
+          "\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"W4H5lOMChkYA\\\"}}}\"," +
+          "\"username\":\"5817483\"," +
+          "\"modified\":1.32046073744E9}";
+  public static final String TEST_META_GLOBAL_NO_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+      "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+  public static final String TEST_META_GLOBAL_MALFORMED_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+      "\"payload\":\"{!!!}\"," +
+      "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+  public static final String TEST_META_GLOBAL_EMPTY_PAYLOAD_RESPONSE = "{\"id\":\"global\"," +
+      "\"payload\":\"{}\"," +
+      "\"username\":\"5817483\",\"modified\":1.32046073744E9}";
+
+  public MetaGlobal global;
+
+  @SuppressWarnings("static-method")
+  @Before
+  public void setUp() {
+    BaseResource.rewriteLocalhost = false;
+    global = new MetaGlobal(META_URL, new BasicAuthHeaderProvider(USER_PASS));
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testSyncID() {
+    global.setSyncID("foobar");
+    assertEquals(global.getSyncID(), "foobar");
+  }
+
+  public class MockMetaGlobalFetchDelegate implements MetaGlobalDelegate {
+    boolean             successCalled   = false;
+    MetaGlobal          successGlobal   = null;
+    SyncStorageResponse successResponse = null;
+    boolean             failureCalled   = false;
+    SyncStorageResponse failureResponse = null;
+    boolean             errorCalled     = false;
+    Exception           errorException  = null;
+    boolean             missingCalled   = false;
+    MetaGlobal          missingGlobal   = null;
+    SyncStorageResponse missingResponse = null;
+
+    public void handleSuccess(MetaGlobal global, SyncStorageResponse response) {
+      successCalled = true;
+      successGlobal = global;
+      successResponse = response;
+      WaitHelper.getTestWaiter().performNotify();
+    }
+
+    public void handleFailure(SyncStorageResponse response) {
+      failureCalled = true;
+      failureResponse = response;
+      WaitHelper.getTestWaiter().performNotify();
+    }
+
+    public void handleError(Exception e) {
+      errorCalled = true;
+      errorException = e;
+      WaitHelper.getTestWaiter().performNotify();
+    }
+
+    public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
+      missingCalled = true;
+      missingGlobal = global;
+      missingResponse = response;
+      WaitHelper.getTestWaiter().performNotify();
+    }
+  }
+
+  public MockMetaGlobalFetchDelegate doFetch(final MetaGlobal global) {
+    final MockMetaGlobalFetchDelegate delegate = new MockMetaGlobalFetchDelegate();
+    WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+      @Override
+      public void run() {
+        global.fetch(delegate);
+      }
+    }));
+
+    return delegate;
+  }
+
+  @Test
+  public void testFetchMissing() {
+    MockServer missingMetaGlobalServer = new MockServer(404, "{}");
+    global.setSyncID(TEST_SYNC_ID);
+    assertEquals(TEST_SYNC_ID, global.getSyncID());
+
+    data.startHTTPServer(missingMetaGlobalServer);
+    final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.missingCalled);
+    assertEquals(404, delegate.missingResponse.getStatusCode());
+    assertEquals(TEST_SYNC_ID, delegate.missingGlobal.getSyncID());
+  }
+
+  @Test
+  public void testFetchExisting() {
+    MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_RESPONSE);
+    assertNull(global.getSyncID());
+    assertNull(global.getEngines());
+    assertNull(global.getStorageVersion());
+
+    data.startHTTPServer(existingMetaGlobalServer);
+    final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.successCalled);
+    assertEquals(200, delegate.successResponse.getStatusCode());
+    assertEquals("zPSQTm7WBVWB", global.getSyncID());
+    assertTrue(global.getEngines() instanceof ExtendedJSONObject);
+    assertEquals(Long.valueOf(5), global.getStorageVersion());
+  }
+
+  /**
+   * A record that is valid JSON but invalid as a meta/global record will be
+   * downloaded successfully, but will fail later.
+   */
+  @Test
+  public void testFetchNoPayload() {
+    MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_NO_PAYLOAD_RESPONSE);
+
+    data.startHTTPServer(existingMetaGlobalServer);
+    final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.successCalled);
+  }
+
+  @Test
+  public void testFetchEmptyPayload() {
+    MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_EMPTY_PAYLOAD_RESPONSE);
+
+    data.startHTTPServer(existingMetaGlobalServer);
+    final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.successCalled);
+  }
+
+  /**
+   * A record that is invalid JSON will fail to download at all.
+   */
+  @Test
+  public void testFetchMalformedPayload() {
+    MockServer existingMetaGlobalServer = new MockServer(200, TEST_META_GLOBAL_MALFORMED_PAYLOAD_RESPONSE);
+
+    data.startHTTPServer(existingMetaGlobalServer);
+    final MockMetaGlobalFetchDelegate delegate = doFetch(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.errorCalled);
+    assertNotNull(delegate.errorException);
+    assertEquals(ParseException.class, delegate.errorException.getClass());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testSetFromRecord() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+    assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+    assertTrue(mg.getEngines() instanceof ExtendedJSONObject);
+    assertEquals(Long.valueOf(5), mg.getStorageVersion());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testAsCryptoRecord() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+    CryptoRecord rec = mg.asCryptoRecord();
+    assertEquals("global", rec.guid);
+    mg.setFromRecord(rec);
+    assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+    assertTrue(mg.getEngines() instanceof ExtendedJSONObject);
+    assertEquals(Long.valueOf(5), mg.getStorageVersion());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testGetEnabledEngineNames() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+    assertEquals("zPSQTm7WBVWB", mg.getSyncID());
+    final Set<String> actual = mg.getEnabledEngineNames();
+    final Set<String> expected = new HashSet<String>();
+    for (String name : new String[] { "bookmarks", "clients", "forms", "history", "passwords", "prefs", "tabs" }) {
+      expected.add(name);
+    }
+    assertEquals(expected, actual);
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testGetEmptyDeclinedEngineNames() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_META_GLOBAL_RESPONSE));
+    assertEquals(0, mg.getDeclinedEngineNames().size());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testGetDeclinedEngineNames() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_DECLINED_META_GLOBAL_RESPONSE));
+    assertEquals(1, mg.getDeclinedEngineNames().size());
+    assertEquals("bookmarks", mg.getDeclinedEngineNames().iterator().next());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testRoundtripDeclinedEngineNames() throws Exception {
+    MetaGlobal mg = new MetaGlobal(null, null);
+    mg.setFromRecord(CryptoRecord.fromJSONRecord(TEST_DECLINED_META_GLOBAL_RESPONSE));
+    assertEquals("bookmarks", mg.getDeclinedEngineNames().iterator().next());
+    assertEquals("bookmarks", mg.asCryptoRecord().payload.getArray("declined").get(0));
+    MetaGlobal again = new MetaGlobal(null, null);
+    again.setFromRecord(mg.asCryptoRecord());
+    assertEquals("bookmarks", again.getDeclinedEngineNames().iterator().next());
+  }
+
+
+  public MockMetaGlobalFetchDelegate doUpload(final MetaGlobal global) {
+    final MockMetaGlobalFetchDelegate delegate = new MockMetaGlobalFetchDelegate();
+    WaitHelper.getTestWaiter().performWait(WaitHelper.onThreadRunnable(new Runnable() {
+      @Override
+      public void run() {
+        global.upload(delegate);
+      }
+    }));
+
+    return delegate;
+  }
+
+  @Test
+  public void testUpload() {
+    long TEST_STORAGE_VERSION = 111;
+    String TEST_SYNC_ID = "testSyncID";
+    global.setSyncID(TEST_SYNC_ID);
+    global.setStorageVersion(Long.valueOf(TEST_STORAGE_VERSION));
+
+    final AtomicBoolean mgUploaded = new AtomicBoolean(false);
+    final MetaGlobal uploadedMg = new MetaGlobal(null, null);
+
+    MockServer server = new MockServer() {
+      public void handle(Request request, Response response) {
+        if (request.getMethod().equals("PUT")) {
+          try {
+            ExtendedJSONObject body = ExtendedJSONObject.parseJSONObject(request.getContent());
+            System.out.println(body.toJSONString());
+            assertTrue(body.containsKey("payload"));
+            assertFalse(body.containsKey("default"));
+
+            CryptoRecord rec = CryptoRecord.fromJSONRecord(body);
+            uploadedMg.setFromRecord(rec);
+            mgUploaded.set(true);
+          } catch (Exception e) {
+            throw new RuntimeException(e);
+          }
+          this.handle(request, response, 200, "success");
+          return;
+        }
+        this.handle(request, response, 404, "missing");
+      }
+    };
+
+    data.startHTTPServer(server);
+    final MockMetaGlobalFetchDelegate delegate = doUpload(global);
+    data.stopHTTPServer();
+
+    assertTrue(delegate.successCalled);
+    assertTrue(mgUploaded.get());
+    assertEquals(TEST_SYNC_ID, uploadedMg.getSyncID());
+    assertEquals(TEST_STORAGE_VERSION, uploadedMg.getStorageVersion().longValue());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestResource.java
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URISyntaxException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockResourceDelegate;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.HttpResponseObserver;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
+
+public class TestResource {
+  private static final int    TEST_PORT   = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+  private HTTPServerTestHelper data     = new HTTPServerTestHelper();
+
+  @SuppressWarnings("static-method")
+  @Before
+  public void setUp() {
+    BaseResource.rewriteLocalhost = false;
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testLocalhostRewriting() throws URISyntaxException {
+    BaseResource r = new BaseResource("http://localhost:5000/foo/bar", true);
+    assertEquals("http://10.0.2.2:5000/foo/bar", r.getURI().toASCIIString());
+  }
+
+  @SuppressWarnings("static-method")
+  public MockResourceDelegate doGet() throws URISyntaxException {
+    final BaseResource r = new BaseResource(TEST_SERVER + "/foo/bar");
+    MockResourceDelegate delegate = new MockResourceDelegate();
+    r.delegate = delegate;
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        r.get();
+      }
+    });
+    return delegate;
+  }
+
+  @Test
+  public void testTrivialFetch() throws URISyntaxException {
+    MockServer server = data.startHTTPServer();
+    server.expectedBasicAuthHeader = MockResourceDelegate.EXPECT_BASIC;
+    MockResourceDelegate delegate = doGet();
+    assertTrue(delegate.handledHttpResponse);
+    data.stopHTTPServer();
+  }
+
+  public static class MockHttpResponseObserver implements HttpResponseObserver {
+    public HttpResponse response = null;
+
+    @Override
+    public void observeHttpResponse(HttpUriRequest request, HttpResponse response) {
+      this.response = response;
+    }
+  }
+
+  @Test
+  public void testObservers() throws URISyntaxException {
+    data.startHTTPServer();
+    // Check that null observer doesn't fail.
+    BaseResource.addHttpResponseObserver(null);
+    doGet(); // HTTP server stopped in callback.
+
+    // Check that multiple non-null observers gets called with reasonable HttpResponse.
+    MockHttpResponseObserver observers[] = { new MockHttpResponseObserver(), new MockHttpResponseObserver() };
+    for (MockHttpResponseObserver observer : observers) {
+      BaseResource.addHttpResponseObserver(observer);
+      assertTrue(BaseResource.isHttpResponseObserver(observer));
+      assertNull(observer.response);
+    }
+
+    doGet(); // HTTP server stopped in callback.
+
+    for (MockHttpResponseObserver observer : observers) {
+      assertNotNull(observer.response);
+      assertEquals(200, observer.response.getStatusLine().getStatusCode());
+    }
+
+    data.stopHTTPServer();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestRetryAfter.java
@@ -0,0 +1,85 @@
+package org.mozilla.android.sync.net.test;
+
+import java.util.Date;
+
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.mozilla.gecko.sync.net.SyncResponse;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.ProtocolVersion;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
+import ch.boye.httpclientandroidlib.message.BasicStatusLine;
+
+public class TestRetryAfter {
+  private int TEST_SECONDS = 120;
+
+  @Test
+  public void testRetryAfterParsesSeconds() {
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+    response.addHeader("Retry-After", Long.toString(TEST_SECONDS)); // Retry-After given in seconds.
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertEquals(TEST_SECONDS, syncResponse.retryAfterInSeconds());
+  }
+
+  @Test
+  public void testRetryAfterParsesHTTPDate() {
+    Date future = new Date(System.currentTimeMillis() + TEST_SECONDS * 1000);
+
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+    response.addHeader("Retry-After", DateUtils.formatDate(future));
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertTrue(syncResponse.retryAfterInSeconds() > TEST_SECONDS - 15);
+    assertTrue(syncResponse.retryAfterInSeconds() < TEST_SECONDS + 15);
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testRetryAfterParsesMalformed() {
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+    response.addHeader("Retry-After", "10X");
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertEquals(-1, syncResponse.retryAfterInSeconds());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testRetryAfterParsesNeither() {
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertEquals(-1, syncResponse.retryAfterInSeconds());
+  }
+
+  @Test
+  public void testRetryAfterParsesLargerRetryAfter() {
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+    response.addHeader("Retry-After", Long.toString(TEST_SECONDS + 1));
+    response.addHeader("X-Weave-Backoff", Long.toString(TEST_SECONDS));
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertEquals(1000 * (TEST_SECONDS + 1), syncResponse.totalBackoffInMilliseconds());
+  }
+
+  @Test
+  public void testRetryAfterParsesLargerXWeaveBackoff() {
+    final HttpResponse response = new BasicHttpResponse(
+        new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 503, "Illegal method/protocol"));
+    response.addHeader("Retry-After", Long.toString(TEST_SECONDS));
+    response.addHeader("X-Weave-Backoff", Long.toString(TEST_SECONDS + 1));
+
+    final SyncResponse syncResponse = new SyncResponse(response);
+    assertEquals(1000 * (TEST_SECONDS + 1), syncResponse.totalBackoffInMilliseconds());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+
+public class TestServer11Repository {
+
+  private static final String COLLECTION = "bookmarks";
+  private static final String COLLECTION_URL = "http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage";
+
+  protected final InfoCollections infoCollections = new InfoCollections();
+
+  public static void assertQueryEquals(String expected, URI u) {
+    Assert.assertEquals(expected, u.getRawQuery());
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testCollectionURIFull() throws URISyntaxException {
+    Server11Repository r = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections);
+    assertQueryEquals("full=1&newer=5000.000",              r.collectionURI(true,  5000000L, -1,    null, null));
+    assertQueryEquals("newer=1230.000",                     r.collectionURI(false, 1230000L, -1,    null, null));
+    assertQueryEquals("newer=5000.000&limit=10",            r.collectionURI(false, 5000000L, 10,    null, null));
+    assertQueryEquals("full=1&newer=5000.000&sort=index",   r.collectionURI(true,  5000000L,  0, "index", null));
+    assertQueryEquals("full=1&ids=123,abc",                 r.collectionURI(true,       -1L, -1,    null, "123,abc"));
+  }
+
+  @Test
+  public void testCollectionURI() throws URISyntaxException {
+    Server11Repository noTrailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections);
+    Server11Repository trailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL + "/", null, infoCollections);
+    Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", noTrailingSlash.collectionURI().toASCIIString());
+    Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", trailingSlash.collectionURI().toASCIIString());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestSyncStorageRequest.java
@@ -0,0 +1,265 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.net.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.json.simple.JSONObject;
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.BaseTestStorageRequestDelegate;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+public class TestSyncStorageRequest {
+  private static final int    TEST_PORT   = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER = "http://localhost:" + TEST_PORT;
+
+  private static final String LOCAL_META_URL  = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/meta/global";
+  private static final String LOCAL_BAD_REQUEST_URL  = TEST_SERVER + "/1.1/c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd/storage/bad";
+
+  private static final String EXPECTED_ERROR_CODE = "12";
+  private static final String EXPECTED_RETRY_AFTER_ERROR_MESSAGE = "{error:'informative error message'}";
+
+  // Corresponds to rnewman+testandroid@mozilla.com.
+  private static final String TEST_USERNAME    = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
+  private static final String TEST_PASSWORD    = "password";
+  private final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+
+  private HTTPServerTestHelper data = new HTTPServerTestHelper();
+
+  public class TestSyncStorageRequestDelegate extends
+      BaseTestStorageRequestDelegate {
+    public TestSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+      super(authHeaderProvider);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse res) {
+      assertTrue(res.wasSuccessful());
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+
+      // Make sure we consume the rest of the body, so we can reuse the
+      // connection. Even test code has to be correct in this regard!
+      try {
+        System.out.println("Success body: " + res.body());
+      } catch (Exception e) {
+        e.printStackTrace();
+      }
+      BaseResource.consumeEntity(res);
+      data.stopHTTPServer();
+    }
+  }
+
+  public class TestBadSyncStorageRequestDelegate extends
+      BaseTestStorageRequestDelegate {
+
+    public TestBadSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+      super(authHeaderProvider);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse res) {
+      assertTrue(!res.wasSuccessful());
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+      try {
+        String responseMessage = res.getErrorMessage();
+        String expectedMessage = SyncStorageResponse.SERVER_ERROR_MESSAGES.get(EXPECTED_ERROR_CODE);
+        assertEquals(expectedMessage, responseMessage);
+      } catch (Exception e) {
+        fail("Got exception fetching error message.");
+      }
+      BaseResource.consumeEntity(res);
+      data.stopHTTPServer();
+    }
+  }
+
+
+  @Test
+  public void testSyncStorageRequest() throws URISyntaxException, IOException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer();
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL));
+    TestSyncStorageRequestDelegate delegate = new TestSyncStorageRequestDelegate(authHeaderProvider);
+    r.delegate = delegate;
+    r.get();
+    // Server is stopped in the callback.
+  }
+
+  public class ErrorMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      super.handle(request, response, 400, EXPECTED_ERROR_CODE);
+    }
+  }
+
+  @Test
+  public void testErrorResponse() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new ErrorMockServer());
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_BAD_REQUEST_URL));
+    TestBadSyncStorageRequestDelegate delegate = new TestBadSyncStorageRequestDelegate(authHeaderProvider);
+    r.delegate = delegate;
+    r.post(new JSONObject());
+    // Server is stopped in the callback.
+  }
+
+  // Test that the Retry-After header is correctly parsed and that handleRequestFailure
+  // is being called.
+  public class TestRetryAfterSyncStorageRequestDelegate extends BaseTestStorageRequestDelegate {
+
+    public TestRetryAfterSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+      super(authHeaderProvider);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse res) {
+      assertTrue(!res.wasSuccessful());
+      assertTrue(res.httpResponse().containsHeader("Retry-After"));
+      assertEquals(res.retryAfterInSeconds(), 3001);
+      try {
+        String responseMessage = res.getErrorMessage();
+        String expectedMessage = EXPECTED_RETRY_AFTER_ERROR_MESSAGE;
+        assertEquals(expectedMessage, responseMessage);
+      } catch (Exception e) {
+        fail("Got exception fetching error message.");
+      }
+      BaseResource.consumeEntity(res);
+      data.stopHTTPServer();
+    }
+  }
+
+  public class RetryAfterMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      String errorBody = EXPECTED_RETRY_AFTER_ERROR_MESSAGE;
+      response.set("Retry-After", "3001");
+      super.handle(request, response, 503, errorBody);
+    }
+  }
+
+  @Test
+  public void testRetryAfterResponse() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new RetryAfterMockServer());
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_BAD_REQUEST_URL)); // URL not used -- we 503 every response
+    TestRetryAfterSyncStorageRequestDelegate delegate = new TestRetryAfterSyncStorageRequestDelegate(authHeaderProvider);
+    r.delegate = delegate;
+    r.post(new JSONObject());
+    // Server is stopped in the callback.
+  }
+  
+  // Test that the X-Weave-Backoff header is correctly parsed and that handleRequestSuccess
+  // is still being called.
+  public class TestWeaveBackoffSyncStorageRequestDelegate extends
+  TestSyncStorageRequestDelegate {
+
+    public TestWeaveBackoffSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+      super(authHeaderProvider);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse res) {
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Backoff"));
+      assertEquals(res.weaveBackoffInSeconds(), 1801);
+      super.handleRequestSuccess(res);
+    }
+  }
+  
+  public class WeaveBackoffMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      response.set("X-Weave-Backoff", "1801");
+      super.handle(request, response);
+    }
+  }
+
+  @Test
+  public void testWeaveBackoffResponse() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new WeaveBackoffMockServer());
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+    TestWeaveBackoffSyncStorageRequestDelegate delegate = new TestWeaveBackoffSyncStorageRequestDelegate(new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD));
+    r.delegate = delegate;
+    r.post(new JSONObject());
+    // Server is stopped in the callback.
+  }
+
+  // Test that the X-Weave-{Quota-Remaining, Alert, Records} headers are correctly parsed and
+  // that handleRequestSuccess is still being called.
+  public class TestHeadersSyncStorageRequestDelegate extends
+  TestSyncStorageRequestDelegate {
+
+    public TestHeadersSyncStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+      super(authHeaderProvider);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse res) {
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Quota-Remaining"));
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Alert"));
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Records"));
+      assertEquals(65536, res.weaveQuotaRemaining());
+      assertEquals("First weave alert string", res.weaveAlert());
+      assertEquals(50, res.weaveRecords());
+
+      super.handleRequestSuccess(res);
+    }
+  }
+
+  public class HeadersMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      response.set("X-Weave-Quota-Remaining", "65536");
+      response.set("X-Weave-Alert", "First weave alert string");
+      response.add("X-Weave-Alert", "Second weave alert string");
+      response.set("X-Weave-Records", "50");
+
+      super.handle(request, response);
+    }
+  }
+
+  @Test
+  public void testHeadersResponse() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new HeadersMockServer());
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+    TestHeadersSyncStorageRequestDelegate delegate = new TestHeadersSyncStorageRequestDelegate(authHeaderProvider);
+    r.delegate = delegate;
+    r.post(new JSONObject());
+    // Server is stopped in the callback.
+  }
+
+  public class DeleteMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      assertTrue(request.contains("x-confirm-delete"));
+      assertEquals("1", request.getValue("x-confirm-delete"));
+      super.handle(request, response);
+    }
+  }
+
+  @Test
+  public void testDelete() throws URISyntaxException {
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new DeleteMockServer());
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(new URI(LOCAL_META_URL)); // URL re-used -- we need any successful response
+    TestSyncStorageRequestDelegate delegate = new TestSyncStorageRequestDelegate(authHeaderProvider);
+    r.delegate = delegate;
+    r.delete();
+    // Server is stopped in the callback.
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/SynchronizerHelpers.java
@@ -0,0 +1,283 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+import android.content.Context;
+
+public class SynchronizerHelpers {
+  public static final String FAIL_SENTINEL = "Fail";
+
+  /**
+   * Store one at a time, failing if the guid contains FAIL_SENTINEL.
+   */
+  public static class FailFetchWBORepository extends WBORepository {
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new WBORepositorySession(this) {
+        @Override
+        public void fetchSince(long timestamp,
+                               final RepositorySessionFetchRecordsDelegate delegate) {
+          super.fetchSince(timestamp, new RepositorySessionFetchRecordsDelegate() {
+            @Override
+            public void onFetchedRecord(Record record) {
+              if (record.guid.contains(FAIL_SENTINEL)) {
+                delegate.onFetchFailed(new FetchFailedException(), record);
+              } else {
+                delegate.onFetchedRecord(record);
+              }
+            }
+
+            @Override
+            public void onFetchFailed(Exception ex, Record record) {
+              delegate.onFetchFailed(ex, record);
+            }
+
+            @Override
+            public void onFetchCompleted(long fetchEnd) {
+              delegate.onFetchCompleted(fetchEnd);
+            }
+
+            @Override
+            public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+              return this;
+            }
+          });
+        }
+      });
+    }
+  }
+
+  /**
+   * Store one at a time, failing if the guid contains FAIL_SENTINEL.
+   */
+  public static class SerialFailStoreWBORepository extends WBORepository {
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new WBORepositorySession(this) {
+        @Override
+        public void store(final Record record) throws NoStoreDelegateException {
+          if (delegate == null) {
+            throw new NoStoreDelegateException();
+          }
+          if (record.guid.contains(FAIL_SENTINEL)) {
+            delegate.onRecordStoreFailed(new StoreFailedException(), record.guid);
+          } else {
+            super.store(record);
+          }
+        }
+      });
+    }
+  }
+
+  /**
+   * Store in batches, failing if any of the batch guids contains "Fail".
+   * <p>
+   * This will drop the final batch.
+   */
+  public static class BatchFailStoreWBORepository extends WBORepository {
+    public final int batchSize;
+    public ArrayList<Record> batch = new ArrayList<Record>();
+    public boolean batchShouldFail = false;
+
+    public class BatchFailStoreWBORepositorySession extends WBORepositorySession {
+      public BatchFailStoreWBORepositorySession(WBORepository repository) {
+        super(repository);
+      }
+
+      public void superStore(final Record record) throws NoStoreDelegateException {
+        super.store(record);
+      }
+
+      @Override
+      public void store(final Record record) throws NoStoreDelegateException {
+        if (delegate == null) {
+          throw new NoStoreDelegateException();
+        }
+        synchronized (batch) {
+          batch.add(record);
+          if (record.guid.contains("Fail")) {
+            batchShouldFail = true;
+          }
+
+          if (batch.size() >= batchSize) {
+            flush();
+          }
+        }
+      }
+
+      public void flush() {
+        final ArrayList<Record> thisBatch = new ArrayList<Record>(batch);
+        final boolean thisBatchShouldFail = batchShouldFail;
+        batchShouldFail = false;
+        batch.clear();
+        storeWorkQueue.execute(new Runnable() {
+          @Override
+          public void run() {
+            Logger.trace("XXX", "Notifying about batch.  Failure? " + thisBatchShouldFail);
+            for (Record batchRecord : thisBatch) {
+              if (thisBatchShouldFail) {
+                delegate.onRecordStoreFailed(new StoreFailedException(), batchRecord.guid);
+              } else {
+                try {
+                  superStore(batchRecord);
+                } catch (NoStoreDelegateException e) {
+                  delegate.onRecordStoreFailed(e, batchRecord.guid);
+                }
+              }
+            }
+          }
+        });
+      }
+
+      @Override
+      public void storeDone() {
+        synchronized (batch) {
+          flush();
+          // Do this in a Runnable so that the timestamp is grabbed after any upload.
+          final Runnable r = new Runnable() {
+            @Override
+            public void run() {
+              synchronized (batch) {
+                Logger.trace("XXX", "Calling storeDone.");
+                storeDone(now());
+              }
+            }
+          };
+          storeWorkQueue.execute(r);
+        }
+      }
+    }
+    public BatchFailStoreWBORepository(int batchSize) {
+      super();
+      this.batchSize = batchSize;
+    }
+
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new BatchFailStoreWBORepositorySession(this));
+    }
+  }
+
+  public static class TrackingWBORepository extends WBORepository {
+    @Override
+    public synchronized boolean shouldTrack() {
+      return true;
+    }
+  }
+
+  public static class BeginFailedException extends Exception {
+    private static final long serialVersionUID = -2349459755976915096L;
+  }
+
+  public static class FinishFailedException extends Exception {
+    private static final long serialVersionUID = -4644528423867070934L;
+  }
+
+  public static class BeginErrorWBORepository extends TrackingWBORepository {
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new BeginErrorWBORepositorySession(this));
+    }
+
+    public class BeginErrorWBORepositorySession extends WBORepositorySession {
+      public BeginErrorWBORepositorySession(WBORepository repository) {
+        super(repository);
+      }
+
+      @Override
+      public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
+        delegate.onBeginFailed(new BeginFailedException());
+      }
+    }
+  }
+
+  public static class FinishErrorWBORepository extends TrackingWBORepository {
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new FinishErrorWBORepositorySession(this));
+    }
+
+    public class FinishErrorWBORepositorySession extends WBORepositorySession {
+      public FinishErrorWBORepositorySession(WBORepository repository) {
+        super(repository);
+      }
+
+      @Override
+      public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
+        delegate.onFinishFailed(new FinishFailedException());
+      }
+    }
+  }
+
+  public static class DataAvailableWBORepository extends TrackingWBORepository {
+    public boolean dataAvailable = true;
+
+    public DataAvailableWBORepository(boolean dataAvailable) {
+      this.dataAvailable = dataAvailable;
+    }
+
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new DataAvailableWBORepositorySession(this));
+    }
+
+    public class DataAvailableWBORepositorySession extends WBORepositorySession {
+      public DataAvailableWBORepositorySession(WBORepository repository) {
+        super(repository);
+      }
+
+      @Override
+      public boolean dataAvailable() {
+        return dataAvailable;
+      }
+    }
+  }
+
+  public static class ShouldSkipWBORepository extends TrackingWBORepository {
+    public boolean shouldSkip = true;
+
+    public ShouldSkipWBORepository(boolean shouldSkip) {
+      this.shouldSkip = shouldSkip;
+    }
+
+    @Override
+    public void createSession(RepositorySessionCreationDelegate delegate,
+                              Context context) {
+      delegate.deferredCreationDelegate().onSessionCreated(new ShouldSkipWBORepositorySession(this));
+    }
+
+    public class ShouldSkipWBORepositorySession extends WBORepositorySession {
+      public ShouldSkipWBORepositorySession(WBORepository repository) {
+        super(repository);
+      }
+
+      @Override
+      public boolean shouldSkip() {
+        return shouldSkip;
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCollectionKeys.java
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Set;
+
+import org.json.simple.JSONArray;
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.CollectionKeys;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.NoCollectionKeysSetException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+
+public class TestCollectionKeys {
+
+  @Test
+  public void testDefaultKeys() throws CryptoException, NoCollectionKeysSetException {
+    CollectionKeys ck = new CollectionKeys();
+    try {
+      ck.defaultKeyBundle();
+      fail("defaultKeys should throw.");
+    } catch (NoCollectionKeysSetException ex) {
+      // Good.
+    }
+    KeyBundle testKeys = KeyBundle.withRandomKeys();
+    ck.setDefaultKeyBundle(testKeys);
+    assertEquals(testKeys, ck.defaultKeyBundle());
+  }
+
+  @Test
+  public void testKeyForCollection() throws CryptoException, NoCollectionKeysSetException {
+    CollectionKeys ck = new CollectionKeys();
+    try {
+      ck.keyBundleForCollection("test");
+      fail("keyForCollection should throw.");
+    } catch (NoCollectionKeysSetException ex) {
+      // Good.
+    }
+    KeyBundle testKeys  = KeyBundle.withRandomKeys();
+    KeyBundle otherKeys = KeyBundle.withRandomKeys();
+
+    ck.setDefaultKeyBundle(testKeys);
+    assertEquals(testKeys, ck.defaultKeyBundle());
+    assertEquals(testKeys, ck.keyBundleForCollection("test"));  // Returns default.
+
+    ck.setKeyBundleForCollection("test", otherKeys);
+    assertEquals(otherKeys, ck.keyBundleForCollection("test"));  // Returns default.
+
+  }
+
+  public static void assertSame(byte[] arrayOne, byte[] arrayTwo) {
+    assertTrue(Arrays.equals(arrayOne, arrayTwo));
+  }
+
+
+  @Test
+  public void testSetKeysFromWBO() throws IOException, ParseException, NonObjectJSONException, CryptoException, NoCollectionKeysSetException {
+    String json = "{\"default\":[\"3fI6k1exImMgAKjilmMaAWxGqEIzFX/9K5EjEgH99vc=\",\"/AMaoCX4hzic28WY94XtokNi7N4T0nv+moS1y5wlbug=\"],\"collections\":{},\"collection\":\"crypto\",\"id\":\"keys\"}";
+    CryptoRecord rec = new CryptoRecord(json);
+
+    KeyBundle syncKeyBundle = new KeyBundle("slyjcrjednxd6rf4cr63vqilmkus6zbe", "6m8mv8ex2brqnrmsb9fjuvfg7y");
+    rec.keyBundle = syncKeyBundle;
+
+    rec.encrypt();
+    CollectionKeys ck = new CollectionKeys();
+    ck.setKeyPairsFromWBO(rec, syncKeyBundle);
+    byte[] input = "3fI6k1exImMgAKjilmMaAWxGqEIzFX/9K5EjEgH99vc=".getBytes("UTF-8");
+    byte[] expected = Base64.decodeBase64(input);
+    assertSame(expected, ck.defaultKeyBundle().getEncryptionKey());
+  }
+
+  @Test
+  public void testCryptoRecordFromCollectionKeys() throws CryptoException, NoCollectionKeysSetException, IOException, ParseException, NonObjectJSONException {
+    CollectionKeys ck1 = CollectionKeys.generateCollectionKeys();
+    assertNotNull(ck1.defaultKeyBundle());
+    assertEquals(ck1.keyBundleForCollection("foobar"), ck1.defaultKeyBundle());
+    CryptoRecord rec = ck1.asCryptoRecord();
+    assertEquals(rec.collection, "crypto");
+    assertEquals(rec.guid, "keys");
+    JSONArray defaultKey = (JSONArray) rec.payload.get("default");
+
+    assertSame(Base64.decodeBase64((String) (defaultKey.get(0))), ck1.defaultKeyBundle().getEncryptionKey());
+    CollectionKeys ck2 = new CollectionKeys();
+    ck2.setKeyPairsFromWBO(rec, null);
+    assertSame(ck1.defaultKeyBundle().getEncryptionKey(), ck2.defaultKeyBundle().getEncryptionKey());
+  }
+
+  @Test
+  public void testCreateKeysBundle() throws CryptoException, NonObjectJSONException, IOException, ParseException, NoCollectionKeysSetException {
+    String username =                       "b6evr62dptbxz7fvebek7btljyu322wp";
+    String friendlyBase32SyncKey =          "basuxv2426eqj7frhvpcwkavdi";
+
+    KeyBundle syncKeyBundle = new KeyBundle(username, friendlyBase32SyncKey);
+
+    CollectionKeys ck = CollectionKeys.generateCollectionKeys();
+    CryptoRecord unencrypted = ck.asCryptoRecord();
+    unencrypted.keyBundle = syncKeyBundle;
+    CryptoRecord encrypted = unencrypted.encrypt();
+
+    CollectionKeys ckDecrypted = new CollectionKeys();
+    ckDecrypted.setKeyPairsFromWBO(encrypted, syncKeyBundle);
+
+    // Compare decrypted keys to the keys that were set upon creation
+    assertArrayEquals(ck.defaultKeyBundle().getEncryptionKey(), ckDecrypted.defaultKeyBundle().getEncryptionKey());
+    assertArrayEquals(ck.defaultKeyBundle().getHMACKey(), ckDecrypted.defaultKeyBundle().getHMACKey());
+  }
+
+  @Test
+  public void testDifferences() throws Exception {
+    KeyBundle kb1 = KeyBundle.withRandomKeys();
+    KeyBundle kb2 = KeyBundle.withRandomKeys();
+    KeyBundle kb3 = KeyBundle.withRandomKeys();
+    CollectionKeys a = CollectionKeys.generateCollectionKeys();
+    CollectionKeys b = CollectionKeys.generateCollectionKeys();
+    Set<String> diffs;
+
+    a.setKeyBundleForCollection("1", kb1);
+    b.setKeyBundleForCollection("1", kb1);
+    diffs = CollectionKeys.differences(a, b);
+    assertTrue(diffs.isEmpty());
+
+    a.setKeyBundleForCollection("2", kb2);
+    diffs = CollectionKeys.differences(a, b);
+    assertArrayEquals(new String[] { "2" }, diffs.toArray(new String[diffs.size()]));
+
+    b.setKeyBundleForCollection("3", kb3);
+    diffs = CollectionKeys.differences(a, b);
+    assertEquals(2, diffs.size());
+    assertTrue(diffs.contains("2"));
+    assertTrue(diffs.contains("3"));
+
+    b.setKeyBundleForCollection("1", KeyBundle.withRandomKeys());
+    diffs = CollectionKeys.differences(a, b);
+    assertEquals(3, diffs.size());
+
+    // This tests that explicitly setting a default key works.
+    a = CollectionKeys.generateCollectionKeys();
+    b = CollectionKeys.generateCollectionKeys();
+    b.setDefaultKeyBundle(a.defaultKeyBundle());
+    a.setKeyBundleForCollection("a", a.defaultKeyBundle());
+    b.setKeyBundleForCollection("b", b.defaultKeyBundle());
+    assertTrue(CollectionKeys.differences(a, b).isEmpty());
+    assertTrue(CollectionKeys.differences(b, a).isEmpty());
+  }
+
+  @Test
+  public void testEquals() throws Exception {
+    KeyBundle kb1 = KeyBundle.withRandomKeys();
+    KeyBundle kb2 = KeyBundle.withRandomKeys();
+    CollectionKeys a = CollectionKeys.generateCollectionKeys();
+    CollectionKeys b = CollectionKeys.generateCollectionKeys();
+
+    // Random keys are different.
+    assertFalse(a.equals(b));
+    assertFalse(b.equals(a));
+
+    // keys with unset default key bundles are different.
+    b.setDefaultKeyBundle(null);
+    assertFalse(a.equals(b));
+
+    // keys with equal default key bundles and no other collections are the same.
+    b.setDefaultKeyBundle(a.defaultKeyBundle());
+    assertTrue(a.equals(b));
+
+    // keys with equal defaults and equal collections are the same.
+    a.setKeyBundleForCollection("1", kb1);
+    b.setKeyBundleForCollection("1", kb1);
+    assertTrue(a.equals(b));
+
+    // keys with equal defaults but some collection missing are different.
+    a.setKeyBundleForCollection("2", kb2);
+    assertFalse(a.equals(b));
+    assertFalse(b.equals(a));
+
+    // keys with equal defaults and some collection set to the default are the same.
+    a.setKeyBundleForCollection("2", a.defaultKeyBundle());
+    b.setKeyBundleForCollection("3", b.defaultKeyBundle());
+    assertTrue(a.equals(b));
+    assertTrue(b.equals(a));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCommandProcessor.java
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.CommandRunner;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+
+public class TestCommandProcessor extends CommandProcessor {
+
+  public static final String commandType = "displayURI";
+  public static final String commandWithNoArgs = "{\"command\":\"displayURI\"}";
+  public static final String commandWithNoType = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",\"PKsljsuqYbGg\"]}";
+  public static final String wellFormedCommand = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",\"PKsljsuqYbGg\"],\"command\":\"displayURI\"}";
+  public static final String wellFormedCommandWithNullArgs = "{\"args\":[\"https://bugzilla.mozilla.org/show_bug.cgi?id=731341\",null,\"PKsljsuqYbGg\",null],\"command\":\"displayURI\"}";
+
+  private boolean commandExecuted;
+
+  // Session is not used in these tests.
+  protected final GlobalSession session = null;
+
+  public class MockCommandRunner extends CommandRunner {
+    public MockCommandRunner(int argCount) {
+      super(argCount);
+    }
+
+    @Override
+    public void executeCommand(final GlobalSession session, List<String> args) {
+      commandExecuted = true;
+    }
+  }
+
+  @Test
+  public void testRegisterCommand() throws NonObjectJSONException, IOException, ParseException {
+    assertNull(commands.get(commandType));
+    this.registerCommand(commandType, new MockCommandRunner(1));
+    assertNotNull(commands.get(commandType));
+  }
+
+  @Test
+  public void testProcessRegisteredCommand() throws NonObjectJSONException, IOException, ParseException {
+    commandExecuted = false;
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+    this.registerCommand(commandType, new MockCommandRunner(1));
+    this.processCommand(session, unparsedCommand);
+    assertTrue(commandExecuted);
+  }
+
+  @Test
+  public void testProcessUnregisteredCommand() throws NonObjectJSONException, IOException, ParseException {
+    commandExecuted = false;
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+    this.processCommand(session, unparsedCommand);
+    assertFalse(commandExecuted);
+  }
+
+  @Test
+  public void testProcessInvalidCommand() throws NonObjectJSONException, IOException, ParseException {
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoType);
+    this.registerCommand(commandType, new MockCommandRunner(1));
+    this.processCommand(session, unparsedCommand);
+    assertFalse(commandExecuted);
+  }
+
+  @Test
+  public void testParseCommandNoType() throws NonObjectJSONException, IOException, ParseException {
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoType);
+    assertNull(CommandProcessor.parseCommand(unparsedCommand));
+  }
+
+  @Test
+  public void testParseCommandNoArgs() throws NonObjectJSONException, IOException, ParseException {
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(commandWithNoArgs);
+    assertNull(CommandProcessor.parseCommand(unparsedCommand));
+  }
+
+  @Test
+  public void testParseWellFormedCommand() throws NonObjectJSONException, IOException, ParseException {
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommand);
+    Command parsedCommand = CommandProcessor.parseCommand(unparsedCommand);
+    assertNotNull(parsedCommand);
+    assertEquals(2, parsedCommand.args.size());
+    assertEquals(commandType, parsedCommand.commandType);
+  }
+
+  @Test
+  public void testParseCommandNullArg() throws NonObjectJSONException, IOException, ParseException {
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(wellFormedCommandWithNullArgs);
+    Command parsedCommand = CommandProcessor.parseCommand(unparsedCommand);
+    assertNotNull(parsedCommand);
+    assertEquals(4, parsedCommand.args.size());
+    assertEquals(commandType, parsedCommand.commandType);
+    final List<String> expectedArgs = new ArrayList<String>();
+    expectedArgs.add("https://bugzilla.mozilla.org/show_bug.cgi?id=731341");
+    expectedArgs.add(null);
+    expectedArgs.add("PKsljsuqYbGg");
+    expectedArgs.add(null);
+    assertEquals(expectedArgs, parsedCommand.getArgsList());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestCryptoRecord.java
@@ -0,0 +1,294 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class TestCryptoRecord {
+  String base64EncryptionKey = "9K/wLdXdw+nrTtXo4ZpECyHFNr4d7aYHqeg3KW9+m6Q=";
+  String base64HmacKey = "MMntEfutgLTc8FlTLQFms8/xMPmCldqPlq/QQXEjx70=";
+
+  @Test
+  public void testBaseCryptoRecordEncrypt() throws IOException, ParseException, NonObjectJSONException, CryptoException {
+    ExtendedJSONObject clearPayload = ExtendedJSONObject.parseJSONObject("{\"id\":\"5qRsgXWRJZXr\",\"title\":\"Index of file:///Users/jason/Library/Application Support/Firefox/Profiles/ksgd7wpk.LocalSyncServer/weave/logs/\",\"histUri\":\"file:///Users/jason/Library/Application%20Support/Firefox/Profiles/ksgd7wpk.LocalSyncServer/weave/logs/\",\"visits\":[{\"type\":1,\"date\":1319149012372425}]}");
+
+    CryptoRecord record = new CryptoRecord();
+    record.payload = clearPayload;
+    String expectedGUID = "5qRsgXWRJZXr";
+    record.guid = expectedGUID;
+    record.keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+    record.encrypt();
+    assertTrue(record.payload.get("title") == null);
+    assertTrue(record.payload.get("ciphertext") != null);
+    assertEquals(expectedGUID, record.guid);
+    assertEquals(expectedGUID, record.toJSONObject().get("id"));
+    record.decrypt();
+    assertEquals(expectedGUID, record.toJSONObject().get("id"));
+  }
+
+  @Test
+  public void testEntireRecord() throws Exception {
+    // Check a raw JSON blob from a real Sync account.
+    String inputString = "{\"sortindex\": 131, \"payload\": \"{\\\"ciphertext\\\":\\\"YJB4dr0vZEIWPirfU2FCJvfzeSLiOP5QWasol2R6ILUxdHsJWuUuvTZVhxYQfTVNou6hVV67jfAvi5Cs+bqhhQsv7icZTiZhPTiTdVGt+uuMotxauVA5OryNGVEZgCCTvT3upzhDFdDbJzVd9O3/gU/b7r/CmAHykX8bTlthlbWeZ8oz6gwHJB5tPRU15nM/m/qW1vyKIw5pw/ZwtAy630AieRehGIGDk+33PWqsfyuT4EUFY9/Ly+8JlnqzxfiBCunIfuXGdLuqTjJOxgrK8mI4wccRFEdFEnmHvh5x7fjl1ID52qumFNQl8zkB75C8XK25alXqwvRR6/AQSP+BgQ==\\\",\\\"IV\\\":\\\"v/0BFgicqYQsd70T39rraA==\\\",\\\"hmac\\\":\\\"59605ed696f6e0e6e062a03510cff742bf6b50d695c042e8372a93f4c2d37dac\\\"}\", \"id\": \"0-P9fabp9vJD\", \"modified\": 1326254123.65}";
+    CryptoRecord record = CryptoRecord.fromJSONRecord(inputString);
+    assertEquals("0-P9fabp9vJD", record.guid);
+    assertEquals(1326254123650L, record.lastModified);
+    assertEquals(131,            record.sortIndex);
+
+    String b64E = "0A7mU5SZ/tu7ZqwXW1og4qHVHN+zgEi4Xwfwjw+vEJw=";
+    String b64H = "11GN34O9QWXkjR06g8t0gWE1sGgQeWL0qxxWwl8Dmxs=";
+    record.keyBundle = KeyBundle.fromBase64EncodedKeys(b64E, b64H);
+    record.decrypt();
+
+    assertEquals("0-P9fabp9vJD", record.guid);
+    assertEquals(1326254123650L, record.lastModified);
+    assertEquals(131,            record.sortIndex);
+
+    assertEquals("Customize Firefox", record.payload.get("title"));
+    assertEquals("0-P9fabp9vJD",      record.payload.get("id"));
+    assertTrue(record.payload.get("tags") instanceof JSONArray);
+  }
+
+  @Test
+  public void testBaseCryptoRecordDecrypt() throws Exception {
+    String base64CipherText =
+          "NMsdnRulLwQsVcwxKW9XwaUe7ouJk5Wn"
+        + "80QhbD80l0HEcZGCynh45qIbeYBik0lg"
+        + "cHbKmlIxTJNwU+OeqipN+/j7MqhjKOGI"
+        + "lvbpiPQQLC6/ffF2vbzL0nzMUuSyvaQz"
+        + "yGGkSYM2xUFt06aNivoQTvU2GgGmUK6M"
+        + "vadoY38hhW2LCMkoZcNfgCqJ26lO1O0s"
+        + "EO6zHsk3IVz6vsKiJ2Hq6VCo7hu123wN"
+        + "egmujHWQSGyf8JeudZjKzfi0OFRRvvm4"
+        + "QAKyBWf0MgrW1F8SFDnVfkq8amCB7Nhd"
+        + "whgLWbN+21NitNwWYknoEWe1m6hmGZDg"
+        + "DT32uxzWxCV8QqqrpH/ZggViEr9uMgoy"
+        + "4lYaWqP7G5WKvvechc62aqnsNEYhH26A"
+        + "5QgzmlNyvB+KPFvPsYzxDnSCjOoRSLx7"
+        + "GG86wT59QZw=";
+    String base64IV = "GX8L37AAb2FZJMzIoXlX8w==";
+    String base16Hmac = 
+          "b1e6c18ac30deb70236bc0d65a46f7a4"
+        + "dce3b8b0e02cf92182b914e3afa5eebc";
+
+    ExtendedJSONObject body    = new ExtendedJSONObject();
+    ExtendedJSONObject payload = new ExtendedJSONObject();
+    payload.put("ciphertext", base64CipherText);
+    payload.put("IV", base64IV);
+    payload.put("hmac", base16Hmac);
+    body.put("payload", payload.toJSONString());
+    CryptoRecord record = CryptoRecord.fromJSONRecord(body);
+    byte[] decodedKey  = Base64.decodeBase64(base64EncryptionKey.getBytes("UTF-8"));
+    byte[] decodedHMAC = Base64.decodeBase64(base64HmacKey.getBytes("UTF-8")); 
+    record.keyBundle = new KeyBundle(decodedKey, decodedHMAC);
+
+    record.decrypt();
+    String id = (String) record.payload.get("id");
+    assertTrue(id.equals("5qRsgXWRJZXr"));
+  }
+
+  @Test
+  public void testBaseCryptoRecordSyncKeyBundle() throws UnsupportedEncodingException, CryptoException {
+    // These values pulled straight out of Firefox.
+    String key  = "6m8mv8ex2brqnrmsb9fjuvfg7y";
+    String user = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
+    
+    // Check our friendly base32 decoding.
+    assertTrue(Arrays.equals(Utils.decodeFriendlyBase32(key), Base64.decodeBase64("8xbKrJfQYwbFkguKmlSm/g==".getBytes("UTF-8"))));
+    KeyBundle bundle = new KeyBundle(user, key);
+    String expectedEncryptKeyBase64 = "/8RzbFT396htpZu5rwgIg2WKfyARgm7dLzsF5pwrVz8=";
+    String expectedHMACKeyBase64    = "NChGjrqoXYyw8vIYP2334cvmMtsjAMUZNqFwV2LGNkM=";
+    byte[] computedEncryptKey       = bundle.getEncryptionKey();
+    byte[] computedHMACKey          = bundle.getHMACKey();
+    assertTrue(Arrays.equals(computedEncryptKey, Base64.decodeBase64(expectedEncryptKeyBase64.getBytes("UTF-8"))));
+    assertTrue(Arrays.equals(computedHMACKey,    Base64.decodeBase64(expectedHMACKeyBase64.getBytes("UTF-8"))));
+  }
+
+  @Test
+  public void testDecrypt() throws Exception {
+    String jsonInput =              "{\"sortindex\": 90, \"payload\":" +
+                                    "\"{\\\"ciphertext\\\":\\\"F4ukf0" +
+                                    "LM+vhffiKyjaANXeUhfmOPPmQYX1XBoG" +
+                                    "Rh1LiHeKHB5rqjhzd7yAoxqgmFnkIgQF" +
+                                    "YPSqRAoCxWiAeGULTX+KM4MU5drbNyR/" +
+                                    "690JBWSyE1vQSiMGwNIbTKnOLGHKkQVY" +
+                                    "HDpajg5BNFfvHNQ5Jx7uM9uJcmuEjCI6" +
+                                    "GRMDKyKjhsTqCd99MONkY5rISutaWQ0e" +
+                                    "EXFgpA9RZPv4jgWlQhe+YrVnpcrTi20b" +
+                                    "NgKp3IfIeqEelrZ5FJd2WGZOA021d3e7" +
+                                    "P3Z4qptefH4Q9/hySrWsELWngBaydyn/" +
+                                    "IjsheZuKra3kJSST/4SvRZ7qXn\\\",\\" +
+                                    "\"IV\\\":\\\"GadPajeXhpk75K2YH+L" +
+                                    "y4w==\\\",\\\"hmac\\\":\\\"71442" +
+                                    "d946502e3ca475c70a633d3d37f4b4e9" +
+                                    "313a6d1041d0c0550cd354e7605\\\"}" +
+                                    "\", \"id\": \"hkZYpC-BH4Xi\", \"" +
+                                    "modified\": 1320183464.21}";
+    String base64EncryptionKey =    "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+                                    "N/G3bz0Bx1M=";
+    String base64HmacKey =          "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+                                    "yUhx+OztVgM=";
+    String expectedDecryptedText =  "{\"id\":\"hkZYpC-BH4Xi\",\"histU" +
+                                    "ri\":\"http://hathology.com/2008" +
+                                    "/06/how-to-edit-your-path-enviro" +
+                                    "nment-variables-on-mac-os-x/\",\"" +
+                                    "title\":\"How To Edit Your PATH " +
+                                    "Environment Variables On Mac OS " +
+                                    "X\",\"visits\":[{\"date\":131898" +
+                                    "2074310889,\"type\":1}]}";
+
+    KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+
+    CryptoRecord encrypted = CryptoRecord.fromJSONRecord(jsonInput);
+    encrypted.keyBundle = keyBundle;
+    CryptoRecord decrypted = encrypted.decrypt();
+
+    // We don't necessarily produce exactly the same JSON but we do have the same values.
+    ExtendedJSONObject expectedJson = new ExtendedJSONObject(expectedDecryptedText);
+    assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+    assertEquals(expectedJson.get("title"), decrypted.payload.get("title"));
+    assertEquals(expectedJson.get("histUri"), decrypted.payload.get("histUri"));
+  }
+
+  @Test
+  public void testEncryptDecrypt() throws Exception {
+      String originalText =           "{\"id\":\"hkZYpC-BH4Xi\",\"histU" +
+                                      "ri\":\"http://hathology.com/2008" +
+                                      "/06/how-to-edit-your-path-enviro" +
+                                      "nment-variables-on-mac-os-x/\",\"" +
+                                      "title\":\"How To Edit Your PATH " +
+                                      "Environment Variables On Mac OS " +
+                                      "X\",\"visits\":[{\"date\":131898" +
+                                      "2074310889,\"type\":1}]}";
+      String base64EncryptionKey =    "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+                                      "N/G3bz0Bx1M=";
+      String base64HmacKey =          "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+                                      "yUhx+OztVgM=";
+
+      KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys(base64EncryptionKey, base64HmacKey);
+
+      // Encrypt.
+      CryptoRecord unencrypted = new CryptoRecord(originalText);
+      unencrypted.keyBundle = keyBundle;
+      CryptoRecord encrypted = unencrypted.encrypt();
+
+      // Decrypt after round-trip through JSON.
+      CryptoRecord undecrypted = CryptoRecord.fromJSONRecord(encrypted.toJSONString());
+      undecrypted.keyBundle = keyBundle;
+      CryptoRecord decrypted = undecrypted.decrypt();
+
+      // We don't necessarily produce exactly the same JSON but we do have the same values.
+      ExtendedJSONObject expectedJson = new ExtendedJSONObject(originalText);
+      assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+      assertEquals(expectedJson.get("title"), decrypted.payload.get("title"));
+      assertEquals(expectedJson.get("histUri"), decrypted.payload.get("histUri"));
+  }
+
+  @Test
+  public void testDecryptKeysBundle() throws Exception {
+    String jsonInput =                      "{\"payload\": \"{\\\"ciphertext\\" +
+                                            "\":\\\"L1yRyZBkVYKXC1cTpeUqqfmKg" +
+                                            "CinYV9YntGiG0PfYZSTLQ2s86WPI0VBb" +
+                                            "QbLZfx7udk6sf6CFE4w5EgiPx0XP3Fbj" +
+                                            "L7r4qIT0vjbAOrLKedZwA3cgiquc+PXM" +
+                                            "Etml8B4Dfm0crJK0iROlRkb+lePAYkzI" +
+                                            "iQn5Ba8mSWQEFoLy3zAcfCYXumA7E0Fj" +
+                                            "XYD+TqTG5bqYJY4zvPaB9mn9y3WHw==\\" +
+                                            "\",\\\"IV\\\":\\\"Jjb2oVI5uvvFfm" +
+                                            "ZYRY4GaA==\\\",\\\"hmac\\\":\\\"" +
+                                            "0b59731cb1aaedc85f54917b7058f361" +
+                                            "60826b70050b0d70cd42b0b609b1d717" +
+                                            "\\\"}\", \"id\": \"keys\", \"mod" +
+                                            "ified\": 1320183463.91}";
+    String username =                       "b6evr62dptbxz7fvebek7btljyu322wp";
+    String friendlyBase32SyncKey =          "basuxv2426eqj7frhvpcwkavdi";
+    String expectedDecryptedText =          "{\"default\":[\"K8fV6PHG8RgugfHe" +
+                                            "xGesbzTeOs2o12crN/G3bz0Bx1M=\",\"" +
+                                            "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+                                            "yUhx+OztVgM=\"],\"collections\":" +
+                                            "{},\"collection\":\"crypto\",\"i" +
+                                            "d\":\"keys\"}";
+    String expectedBase64EncryptionKey =    "K8fV6PHG8RgugfHexGesbzTeOs2o12cr" +
+                                            "N/G3bz0Bx1M=";
+    String expectedBase64HmacKey =          "nbceuI6w1RJbBzh+iCJHEs8p4lElsOma" +
+                                            "yUhx+OztVgM=";
+
+    KeyBundle syncKeyBundle = new KeyBundle(username, friendlyBase32SyncKey);
+
+    ExtendedJSONObject json = new ExtendedJSONObject(jsonInput);
+    assertEquals("keys", json.get("id"));
+
+    CryptoRecord encrypted = CryptoRecord.fromJSONRecord(jsonInput);
+    encrypted.keyBundle = syncKeyBundle;
+    CryptoRecord decrypted = encrypted.decrypt();
+
+    // We don't necessarily produce exactly the same JSON but we do have the same values.
+    ExtendedJSONObject expectedJson = new ExtendedJSONObject(expectedDecryptedText);
+    assertEquals(expectedJson.get("id"), decrypted.payload.get("id"));
+    assertEquals(expectedJson.get("default"), decrypted.payload.get("default"));
+    assertEquals(expectedJson.get("collection"), decrypted.payload.get("collection"));
+    assertEquals(expectedJson.get("collections"), decrypted.payload.get("collections"));
+
+    // Check that the extracted keys were as expected.
+    JSONArray keys = ExtendedJSONObject.parseJSONObject(decrypted.payload.toJSONString()).getArray("default");
+    KeyBundle keyBundle = KeyBundle.fromBase64EncodedKeys((String)keys.get(0), (String)keys.get(1));
+
+    assertArrayEquals(Base64.decodeBase64(expectedBase64EncryptionKey.getBytes("UTF-8")), keyBundle.getEncryptionKey());
+    assertArrayEquals(Base64.decodeBase64(expectedBase64HmacKey.getBytes("UTF-8")), keyBundle.getHMACKey());
+  }
+
+  @Test
+  public void testTTL() throws UnsupportedEncodingException, CryptoException {
+    Record historyRecord = new HistoryRecord();
+    CryptoRecord cryptoRecord = historyRecord.getEnvelope();
+    assertEquals(historyRecord.ttl, cryptoRecord.ttl);
+
+    // Very important that ttls are set in outbound envelopes.
+    JSONObject o = cryptoRecord.toJSONObject();
+    assertEquals(cryptoRecord.ttl, o.get("ttl"));
+
+    // Most important of all, outbound encrypted record envelopes.
+    KeyBundle keyBundle = KeyBundle.withRandomKeys();
+    cryptoRecord.keyBundle = keyBundle;
+    cryptoRecord.encrypt();
+    assertEquals(historyRecord.ttl, cryptoRecord.ttl); // Should be preserved.
+    o = cryptoRecord.toJSONObject();
+    assertEquals(cryptoRecord.ttl, o.get("ttl"));
+
+    // But we should ignore negative ttls.
+    Record clientRecord = new ClientRecord();
+    clientRecord.ttl = -1; // Don't ttl this record.
+    o = clientRecord.getEnvelope().toJSONObject();
+    assertNull(o.get("ttl"));
+
+    // But we should ignore negative ttls in outbound encrypted record envelopes.
+    cryptoRecord = clientRecord.getEnvelope();
+    cryptoRecord.keyBundle = keyBundle;
+    cryptoRecord.encrypt();
+    o = cryptoRecord.toJSONObject();
+    assertNull(o.get("ttl"));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestJPakeSetup.java
@@ -0,0 +1,259 @@
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.math.BigInteger;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.jpake.Gx3OrGx4IsZeroOrOneException;
+import org.mozilla.gecko.sync.jpake.IncorrectZkpException;
+import org.mozilla.gecko.sync.jpake.JPakeClient;
+import org.mozilla.gecko.sync.jpake.JPakeCrypto;
+import org.mozilla.gecko.sync.jpake.JPakeNumGenerator;
+import org.mozilla.gecko.sync.jpake.JPakeNumGeneratorRandom;
+import org.mozilla.gecko.sync.jpake.JPakeParty;
+import org.mozilla.gecko.sync.jpake.stage.ComputeKeyVerificationStage;
+import org.mozilla.gecko.sync.jpake.stage.VerifyPairingStage;
+import org.mozilla.gecko.sync.setup.Constants;
+
+public class TestJPakeSetup {
+  // Note: will throw NullPointerException if aborts. Only use stateless public
+  // methods.
+
+  @Test
+  public void testGx3OrGx4ZeroOrOneThrowsException()
+      throws UnsupportedEncodingException
+  {
+    JPakeNumGeneratorRandom gen = new JPakeNumGeneratorRandom();
+    JPakeParty p = new JPakeParty("foobar");
+    BigInteger secret = JPakeClient.secretAsBigInteger("secret");
+
+    p.gx4 = new BigInteger("2");
+    p.gx3 = new BigInteger("0");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+      fail("round2 should fail if gx3 == 0");
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      // Hurrah.
+    } catch (Exception e) {
+      fail("Unexpected exception " + e);
+    }
+
+    p.gx3 = new BigInteger("1");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+      fail("round2 should fail if gx3 == 1");
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      // Hurrah.
+    } catch (Exception e) {
+      fail("Unexpected exception " + e);
+    }
+
+    p.gx3 = new BigInteger("3");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      fail("Unexpected exception " + e);
+    } catch (Exception e) {
+      // There are plenty of other reasons this should fail.
+    }
+
+    p.gx3 = new BigInteger("2");
+    p.gx4 = new BigInteger("0");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+      fail("round2 should fail if gx4 == 0");
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      // Hurrah.
+    } catch (Exception e) {
+      fail("Unexpected exception " + e);
+    }
+
+    p.gx4 = new BigInteger("1");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+      fail("round2 should fail if gx4 == 1");
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      // Hurrah.
+    } catch (Exception e) {
+      fail("Unexpected exception " + e);
+    }
+
+    p.gx4 = new BigInteger("3");
+    try {
+      JPakeCrypto.round2(secret, p, gen);
+    } catch (Gx3OrGx4IsZeroOrOneException e) {
+      fail("Unexpected exception " + e);
+    } catch (Exception e) {
+      // There are plenty of other reasons this should fail.
+    }
+  }
+
+  /*
+   * Tests encryption key and hmac generation from a derived key, using values
+   * taken from a successful run of J-PAKE.
+   */
+  @Test
+  public void testKeyDerivation() throws UnsupportedEncodingException {
+    String keyChars16 = "811565455b22c857a3e303d1f48ff72ae9ef42d9c3fe3740ce7772cb5bfe23491dd5b7ee5af4828ab9b7d5844866f378b4cf0156810aff0504ef2947402e8e40be1179cf7f37b231bc0db9e4e1bb239c849aa5c12ed2b0b4413017599270aae71ee993dd755ee8c045c5fe03d713894692bf72158d9835ad905442edfd8235e1d0c915053debfc49d8248e4dae16608743aef5dab061f49fd6edd0b93ecdf9feafcbe47eb7e6c3678356d96e9bcd87814b13b9eb1a791fd446d69cb040ec7d7194031267e26f266ee3decbc1a85c5203427361997adf9823fbffe16af9946f1347c5354956356732e436ef5f8307e96554cf69a54e4e8a78552e3f506e9310a1c4438d3ddce44a37482270533e47fc40dc84abfe39c1f95328d0d2540074f6301d4f121c2f0eac49c47a2c430614234ca26dede2a429e2fdb6d282a85174886c3a68c3cf5edc87ccb82af4ae4a9a26fffadc7f4d8ded4ff47b3d2d171f374b230e52e6b45963d3a0a6b20cbe6a440fd4a932279d52a6fd7694b4cbc0cb67ff3c";
+    String expectedEncKey64 = "3TXwVlWf6YbuIPcg8m/2U4UXYV4a8RNu6pE2GOVkJJo=";
+    String expectedHmac64 = "L49fnEPAD31G5uEKy5e4bGZ6IF3G/62qW6Ua/1NvBeQ=";
+
+    byte[] encKeyBytes = new byte[32];
+    byte[] hmacBytes = new byte[32];
+
+    try {
+      JPakeCrypto.generateKeyAndHmac(new BigInteger(keyChars16, 16), encKeyBytes, hmacBytes);
+    } catch (Exception e) {
+      fail("Unexpected exception " + e);
+    }
+    String encKey64 = new String(Base64.encodeBase64(encKeyBytes));
+    String hmac64 = new String(Base64.encodeBase64(hmacBytes));
+
+    assertTrue(expectedEncKey64.equals(encKey64));
+    assertTrue(expectedHmac64.equals(hmac64));
+  }
+
+  /*
+   * Test correct key derivation when both parties share a secret.
+   */
+  @Test
+  public void testJPakeCorrectSecret() throws Gx3OrGx4IsZeroOrOneException,
+      IncorrectZkpException, IOException, ParseException,
+      NonObjectJSONException, CryptoException, NoSuchAlgorithmException, InvalidKeyException {
+    BigInteger secret = JPakeClient.secretAsBigInteger("byubd7u75qmq");
+    JPakeNumGenerator gen = new JPakeNumGeneratorRandom();
+    // Keys derived should be the same.
+    assertTrue(jPakeDeriveSameKey(gen, gen, secret, secret));
+  }
+
+  /*
+   * Test incorrect key derivation when parties do not share the same secret.
+   */
+  @Test
+  public void testJPakeIncorrectSecret() throws Gx3OrGx4IsZeroOrOneException,
+      IncorrectZkpException, IOException, ParseException,
+      NonObjectJSONException, CryptoException, NoSuchAlgorithmException, InvalidKeyException {
+    BigInteger secret1 = JPakeClient.secretAsBigInteger("shareSecret1");
+    BigInteger secret2 = JPakeClient.secretAsBigInteger("shareSecret2");
+    JPakeNumGenerator gen = new JPakeNumGeneratorRandom();
+    // Unsuccessful key derivation.
+    assertFalse(jPakeDeriveSameKey(gen, gen, secret1, secret2));
+  }
+
+  /*
+   * Helper simulation of a J-PAKE key derivation between two parties, with
+   * secret1 and secret2. Both parties are assumed to be communicating on the
+   * same channel; otherwise, J-PAKE would have failed immediately.
+   */
+  public boolean jPakeDeriveSameKey(JPakeNumGenerator gen1,
+      JPakeNumGenerator gen2, BigInteger secret1, BigInteger secret2)
+      throws IncorrectZkpException, Gx3OrGx4IsZeroOrOneException, IOException,
+      ParseException, NonObjectJSONException, CryptoException, NoSuchAlgorithmException, InvalidKeyException {
+
+    // Communicating parties.
+    JPakeParty party1 = new JPakeParty("party1");
+    JPakeParty party2 = new JPakeParty("party2");
+
+    JPakeCrypto.round1(party1, gen1);
+    // After party1 round 1, these values should no longer be null.
+    assertNotNull(party1.signerId);
+    assertNotNull(party1.x2);
+    assertNotNull(party1.gx1);
+    assertNotNull(party1.gx2);
+    assertNotNull(party1.zkp1);
+    assertNotNull(party1.zkp2);
+    assertNotNull(party1.zkp1.b);
+    assertNotNull(party1.zkp1.gr);
+    assertNotNull(party1.zkp1.id);
+    assertNotNull(party1.zkp2.b);
+    assertNotNull(party1.zkp2.gr);
+    assertNotNull(party1.zkp2.id);
+
+    // party2 receives the following values from party1.
+    party2.gx3 = party1.gx1;
+    party2.gx4 = party1.gx2;
+    party2.zkp3 = party1.zkp1;
+    party2.zkp4 = party1.zkp2;
+    // TODO Run JPakeClient checks.
+
+    JPakeCrypto.round1(party2, gen2);
+    // After party2 round 1, these values should no longer be null.
+    assertNotNull(party2.signerId);
+    assertNotNull(party2.x2);
+    assertNotNull(party2.gx1);
+    assertNotNull(party2.gx2);
+    assertNotNull(party2.zkp1);
+    assertNotNull(party2.zkp2);
+    assertNotNull(party2.zkp1.b);
+    assertNotNull(party2.zkp1.gr);
+    assertNotNull(party2.zkp1.id);
+    assertNotNull(party2.zkp2.b);
+    assertNotNull(party2.zkp2.gr);
+    assertNotNull(party2.zkp2.id);
+
+    // Pass relevant values to party1.
+    party1.gx3 = party2.gx1;
+    party1.gx4 = party2.gx2;
+    party1.zkp3 = party2.zkp1;
+    party1.zkp4 = party2.zkp2;
+    // TODO Run JPakeClient checks.
+
+    JPakeCrypto.round2(secret1, party1, gen1);
+    // After party1 round 2, these values should no longer be null.
+    assertNotNull(party1.thisA);
+    assertNotNull(party1.thisZkpA);
+    assertNotNull(party1.thisZkpA.b);
+    assertNotNull(party1.thisZkpA.gr);
+    assertNotNull(party1.thisZkpA.id);
+
+    // Pass relevant values to party2.
+    party2.otherA = party1.thisA;
+    party2.otherZkpA = party1.thisZkpA;
+
+    JPakeCrypto.round2(secret2, party2, gen2);
+    // Check for nulls.
+    assertNotNull(party2.thisA);
+    assertNotNull(party2.thisZkpA);
+    assertNotNull(party2.thisZkpA.b);
+    assertNotNull(party2.thisZkpA.gr);
+    assertNotNull(party2.thisZkpA.id);
+
+    // Pass values to party1.
+    party1.otherA = party2.thisA;
+    party1.otherZkpA = party2.thisZkpA;
+
+    KeyBundle keyBundle1 = JPakeCrypto.finalRound(secret1, party1);
+    assertNotNull(keyBundle1);
+
+    // party1 computes the shared key, generates an encrypted message to party2.
+    ExtendedJSONObject verificationMsg = new ComputeKeyVerificationStage()
+        .computeKeyVerification(keyBundle1, party1.signerId);
+    ExtendedJSONObject payload = verificationMsg
+        .getObject(Constants.JSON_KEY_PAYLOAD);
+    String ciphertext1 = (String) payload.get(Constants.JSON_KEY_CIPHERTEXT);
+    String iv1 = (String) payload.get(Constants.JSON_KEY_IV);
+
+    // party2 computes the key as well, using its copy of the secret.
+    KeyBundle keyBundle2 = JPakeCrypto.finalRound(secret2, party2);
+    // party2 fetches the encrypted message and verifies the pairing against its
+    // own derived key.
+
+    boolean isSuccess = new VerifyPairingStage().verifyCiphertext(ciphertext1, iv1,
+        keyBundle2);
+    return isSuccess;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecord.java
@@ -0,0 +1,328 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.ParseException;
+import org.junit.Test;
+import org.mozilla.gecko.background.db.Tab;
+import org.mozilla.gecko.sync.CryptoRecord;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.repositories.domain.RecordParseException;
+import org.mozilla.gecko.sync.repositories.domain.TabsRecord;
+
+public class TestRecord {
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testQueryRecord() throws NonObjectJSONException, IOException, ParseException {
+    final String expectedGUID = "Bl3n3gpKag3s";
+    final String testRecord =
+        "{\"id\":\"" + expectedGUID + "\"," +
+        " \"type\":\"query\"," +
+        " \"title\":\"Downloads\"," +
+        " \"parentName\":\"\"," +
+        " \"bmkUri\":\"place:transition=7&sort=4\"," +
+        " \"tags\":[]," +
+        " \"keyword\":null," +
+        " \"description\":null," +
+        " \"loadInSidebar\":false," +
+        " \"parentid\":\"BxfRgGiNeITG\"}";
+
+    final ExtendedJSONObject o = new ExtendedJSONObject(testRecord);
+    final CryptoRecord cr = new CryptoRecord(o);
+    cr.guid = expectedGUID;
+    cr.lastModified = System.currentTimeMillis();
+    cr.collection = "bookmarks";
+
+    final BookmarkRecord r = new BookmarkRecord("Bl3n3gpKag3s", "bookmarks");
+    r.initFromEnvelope(cr);
+    assertEquals(expectedGUID, r.guid);
+    assertEquals("query", r.type);
+    assertEquals("places:uri=place%3Atransition%3D7%26sort%3D4", r.bookmarkURI);
+
+    // Check that we get the same bookmark URI out the other end,
+    // once we've parsed it into a CryptoRecord, a BookmarkRecord, then
+    // back into a CryptoRecord.
+    assertEquals("place:transition=7&sort=4", r.getEnvelope().payload.getString("bmkUri"));
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testRecordGUIDs() {
+    for (int i = 0; i < 50; ++i) {
+      CryptoRecord cryptoRecord = new HistoryRecord().getEnvelope();
+      assertEquals(12, cryptoRecord.guid.length());
+    }
+  }
+
+  @Test
+  public void testRecordEquality() {
+    long now = System.currentTimeMillis();
+    BookmarkRecord bOne = new BookmarkRecord("abcdefghijkl", "bookmarks", now , false);
+    BookmarkRecord bTwo = new BookmarkRecord("abcdefghijkl", "bookmarks", now , false);
+    HistoryRecord hOne = new HistoryRecord("mbcdefghijkm", "history", now , false);
+    HistoryRecord hTwo = new HistoryRecord("mbcdefghijkm", "history", now , false);
+
+    // Identical records.
+    assertFalse(bOne == bTwo);
+    assertTrue(bOne.equals(bTwo));
+    assertTrue(bOne.equalPayloads(bTwo));
+    assertTrue(bOne.congruentWith(bTwo));
+    assertTrue(bTwo.equals(bOne));
+    assertTrue(bTwo.equalPayloads(bOne));
+    assertTrue(bTwo.congruentWith(bOne));
+
+    // Null checking.
+    assertFalse(bOne.equals(null));
+    assertFalse(bOne.equalPayloads(null));
+    assertFalse(bOne.congruentWith(null));
+
+    // Different types.
+    hOne.guid = bOne.guid;
+    assertFalse(bOne.equals(hOne));
+    assertFalse(bOne.equalPayloads(hOne));
+    assertFalse(bOne.congruentWith(hOne));
+    hOne.guid = hTwo.guid;
+
+    // Congruent androidID.
+    bOne.androidID = 1;
+    assertFalse(bOne.equals(bTwo));
+    assertTrue(bOne.equalPayloads(bTwo));
+    assertTrue(bOne.congruentWith(bTwo));
+    assertFalse(bTwo.equals(bOne));
+    assertTrue(bTwo.equalPayloads(bOne));
+    assertTrue(bTwo.congruentWith(bOne));
+
+    // Non-congruent androidID.
+    bTwo.androidID = 2;
+    assertFalse(bOne.equals(bTwo));
+    assertTrue(bOne.equalPayloads(bTwo));
+    assertFalse(bOne.congruentWith(bTwo));
+    assertFalse(bTwo.equals(bOne));
+    assertTrue(bTwo.equalPayloads(bOne));
+    assertFalse(bTwo.congruentWith(bOne));
+
+    // Identical androidID.
+    bOne.androidID = 2;
+    assertTrue(bOne.equals(bTwo));
+    assertTrue(bOne.equalPayloads(bTwo));
+    assertTrue(bOne.congruentWith(bTwo));
+    assertTrue(bTwo.equals(bOne));
+    assertTrue(bTwo.equalPayloads(bOne));
+    assertTrue(bTwo.congruentWith(bOne));
+
+    // Different times.
+    bTwo.lastModified += 1000;
+    assertFalse(bOne.equals(bTwo));
+    assertTrue(bOne.equalPayloads(bTwo));
+    assertTrue(bOne.congruentWith(bTwo));
+    assertFalse(bTwo.equals(bOne));
+    assertTrue(bTwo.equalPayloads(bOne));
+    assertTrue(bTwo.congruentWith(bOne));
+
+    // Add some visits.
+    JSONObject v1 = fakeVisit(now - 1000);
+    JSONObject v2 = fakeVisit(now - 500);
+
+    hOne.fennecDateVisited = now + 2000;
+    hOne.fennecVisitCount  = 1;
+    assertFalse(hOne.equals(hTwo));
+    assertTrue(hOne.equalPayloads(hTwo));
+    assertTrue(hOne.congruentWith(hTwo));
+    addVisit(hOne, v1);
+    assertFalse(hOne.equals(hTwo));
+    assertFalse(hOne.equalPayloads(hTwo));
+    assertTrue(hOne.congruentWith(hTwo));
+    addVisit(hTwo, v2);
+    assertFalse(hOne.equals(hTwo));
+    assertFalse(hOne.equalPayloads(hTwo));
+    assertTrue(hOne.congruentWith(hTwo));
+
+    // Now merge the visits.
+    addVisit(hTwo, v1);
+    addVisit(hOne, v2);
+    assertFalse(hOne.equals(hTwo));
+    assertTrue(hOne.equalPayloads(hTwo));
+    assertTrue(hOne.congruentWith(hTwo));
+    hTwo.fennecDateVisited = hOne.fennecDateVisited;
+    hTwo.fennecVisitCount = hOne.fennecVisitCount = 2;
+    assertTrue(hOne.equals(hTwo));
+    assertTrue(hOne.equalPayloads(hTwo));
+    assertTrue(hOne.congruentWith(hTwo));
+  }
+
+  @SuppressWarnings("unchecked")
+  private void addVisit(HistoryRecord r, JSONObject visit) {
+    if (r.visits == null) {
+      r.visits = new JSONArray();
+    }
+    r.visits.add(visit);
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject fakeVisit(long time) {
+    JSONObject object = new JSONObject();
+    object.put("type", 1L);
+    object.put("date", time * 1000);
+    return object;
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testTabParsing() throws Exception {
+    String json = "{\"title\":\"mozilla-central mozilla/browser/base/content/syncSetup.js\"," +
+                  " \"urlHistory\":[\"http://mxr.mozilla.org/mozilla-central/source/browser/base/content/syncSetup.js#72\"]," +
+                  " \"icon\":\"http://mxr.mozilla.org/mxr.png\"," +
+                  " \"lastUsed\":\"1306374531\"}";
+    Tab tab = TabsRecord.tabFromJSONObject(ExtendedJSONObject.parseJSONObject(json).object);
+
+    assertEquals("mozilla-central mozilla/browser/base/content/syncSetup.js", tab.title);
+    assertEquals("http://mxr.mozilla.org/mxr.png", tab.icon);
+    assertEquals("http://mxr.mozilla.org/mozilla-central/source/browser/base/content/syncSetup.js#72", tab.history.get(0));
+    assertEquals(1306374531000L, tab.lastUsed);
+
+    String zeroJSON = "{\"title\":\"a\"," +
+        " \"urlHistory\":[\"http://example.com\"]," +
+        " \"icon\":\"\"," +
+        " \"lastUsed\":0}";
+    Tab zero = TabsRecord.tabFromJSONObject(ExtendedJSONObject.parseJSONObject(zeroJSON).object);
+
+    assertEquals("a", zero.title);
+    assertEquals("", zero.icon);
+    assertEquals("http://example.com", zero.history.get(0));
+    assertEquals(0L, zero.lastUsed);
+  }
+
+  @SuppressWarnings({ "unchecked", "static-method" })
+  @Test
+  public void testTabsRecordCreation() throws Exception {
+    final TabsRecord record = new TabsRecord("testGuid");
+    record.clientName = "test client name";
+
+    final JSONArray history1 = new JSONArray();
+    history1.add("http://test.com/test1.html");
+    final Tab tab1 = new Tab("test title 1", "http://test.com/test1.png", history1, 1000);
+
+    final JSONArray history2 = new JSONArray();
+    history2.add("http://test.com/test2.html#1");
+    history2.add("http://test.com/test2.html#2");
+    history2.add("http://test.com/test2.html#3");
+    final Tab tab2 = new Tab("test title 2", "http://test.com/test2.png", history2, 2000);
+
+    record.tabs = new ArrayList<Tab>();
+    record.tabs.add(tab1);
+    record.tabs.add(tab2);
+
+    final TabsRecord parsed = new TabsRecord();
+    parsed.initFromEnvelope(CryptoRecord.fromJSONRecord(record.getEnvelope().toJSONString()));
+
+    assertEquals(record.guid, parsed.guid);
+    assertEquals(record.clientName, parsed.clientName);
+    assertEquals(record.tabs, parsed.tabs);
+
+    // Verify that equality test doesn't always return true.
+    parsed.tabs.get(0).history.add("http://test.com/different.html");
+    assertFalse(record.tabs.equals(parsed.tabs));
+  }
+
+  public static class URITestBookmarkRecord extends BookmarkRecord {
+    public static void doTest() {
+      assertEquals("places:uri=abc%26def+baz&p1=123&p2=bar+baz",
+                   encodeUnsupportedTypeURI("abc&def baz", "p1", "123", "p2", "bar baz"));
+      assertEquals("places:uri=abc%26def+baz&p1=123",
+                   encodeUnsupportedTypeURI("abc&def baz", "p1", "123", null, "bar baz"));
+      assertEquals("places:p1=123",
+                   encodeUnsupportedTypeURI(null, "p1", "123", "p2", null));
+    }
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testEncodeURI() {
+    URITestBookmarkRecord.doTest();
+  }
+
+  private static final String payload =
+     "{\"id\":\"M5bwUKK8hPyF\"," +
+      "\"type\":\"livemark\"," +
+      "\"siteUri\":\"http://www.bbc.co.uk/go/rss/int/news/-/news/\"," +
+      "\"feedUri\":\"http://fxfeeds.mozilla.com/en-US/firefox/headlines.xml\"," +
+      "\"parentName\":\"Bookmarks Toolbar\"," +
+      "\"parentid\":\"toolbar\"," +
+      "\"title\":\"Latest Headlines\"," +
+      "\"description\":\"\"," +
+      "\"children\":" +
+        "[\"7oBdEZB-8BMO\", \"SUd1wktMNCTB\", \"eZe4QWzo1BcY\", \"YNBhGwhVnQsN\"," +
+         "\"mNTdpgoRZMbW\", \"-L8Vci6CbkJY\", \"bVzudKSQERc1\", \"Gxl9lb4DXsmL\"," +
+         "\"3Qr13GucOtEh\"]}";
+
+  public class PayloadBookmarkRecord extends BookmarkRecord {
+    public PayloadBookmarkRecord() {
+      super("abcdefghijkl", "bookmarks", 1234, false);
+    }
+
+    public void doTest() throws NonObjectJSONException, IOException, ParseException {
+      this.initFromPayload(new ExtendedJSONObject(payload));
+      assertEquals("abcdefghijkl",      this.guid);              // Ignores payload.
+      assertEquals("livemark",          this.type);
+      assertEquals("Bookmarks Toolbar", this.parentName);
+      assertEquals("toolbar",           this.parentID);
+      assertEquals("",                  this.description);
+      assertEquals(null,                this.children);
+
+      final String encodedSite = "http%3A%2F%2Fwww.bbc.co.uk%2Fgo%2Frss%2Fint%2Fnews%2F-%2Fnews%2F";
+      final String encodedFeed = "http%3A%2F%2Ffxfeeds.mozilla.com%2Fen-US%2Ffirefox%2Fheadlines.xml";
+      final String expectedURI = "places:siteUri=" + encodedSite + "&feedUri=" + encodedFeed;
+      assertEquals(expectedURI, this.bookmarkURI);
+    }
+  }
+
+  @Test
+  public void testUnusualBookmarkRecords() throws NonObjectJSONException, IOException, ParseException {
+    PayloadBookmarkRecord record = new PayloadBookmarkRecord();
+    record.doTest();
+  }
+
+  @SuppressWarnings("static-method")
+  @Test
+  public void testTTL() {
+    Record record = new HistoryRecord();
+    assertEquals(HistoryRecord.HISTORY_TTL, record.ttl);
+
+    // ClientRecords are transient, HistoryRecords are not.
+    Record clientRecord = new ClientRecord();
+    assertTrue(clientRecord.ttl < record.ttl);
+
+    CryptoRecord cryptoRecord = record.getEnvelope();
+    assertEquals(record.ttl, cryptoRecord.ttl);
+  }
+
+  @Test
+  public void testStringModified() throws Exception {
+    // modified member is a string, expected a floating point number with 2
+    // decimal digits.
+    String badJson = "{\"sortindex\":\"0\",\"payload\":\"{\\\"syncID\\\":\\\"ZJOqMBjhBthH\\\",\\\"storageVersion\\\":5,\\\"engines\\\":{\\\"clients\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"4oTBXG20rJH5\\\"},\\\"bookmarks\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"JiMJXy8xI3fr\\\"},\\\"forms\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"J17vSloroXBU\\\"},\\\"history\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"y1HgpbSc3LJT\\\"},\\\"passwords\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"v3y-RidcCuT5\\\"},\\\"prefs\\\":{\\\"version\\\":2,\\\"syncID\\\":\\\"LvfqmT7cUUm4\\\"},\\\"tabs\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"MKMRlBah2d9D\\\"},\\\"addons\\\":{\\\"version\\\":1,\\\"syncID\\\":\\\"Ih2hhRrcGjh4\\\"}}}\",\"id\":\"global\",\"modified\":\"1370689360.28\"}";
+    try {
+      CryptoRecord.fromJSONRecord(badJson);
+      fail("Expected exception.");
+    } catch (Exception e) {
+      assertTrue(e instanceof RecordParseException);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestRecordsChannel.java
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.Test;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FailFetchWBORepository;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionCreationDelegate;
+import org.mozilla.android.sync.test.helpers.ExpectSuccessRepositorySessionFinishDelegate;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.InactiveSessionException;
+import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
+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.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.RecordsChannel;
+import org.mozilla.gecko.sync.synchronizer.RecordsChannelDelegate;
+
+public class TestRecordsChannel {
+
+  protected WBORepository remote;
+  protected WBORepository local;
+
+  protected RepositorySession source;
+  protected RepositorySession sink;
+  protected RecordsChannelDelegate rcDelegate;
+
+  protected AtomicInteger numFlowFetchFailed;
+  protected AtomicInteger numFlowStoreFailed;
+  protected AtomicInteger numFlowCompleted;
+  protected AtomicBoolean flowBeginFailed;
+  protected AtomicBoolean flowFinishFailed;
+
+  public void doFlow(final Repository remote, final Repository local) throws Exception {
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        remote.createSession(new ExpectSuccessRepositorySessionCreationDelegate(WaitHelper.getTestWaiter()) {
+          @Override
+          public void onSessionCreated(RepositorySession session) {
+            source = session;
+            local.createSession(new ExpectSuccessRepositorySessionCreationDelegate(WaitHelper.getTestWaiter()) {
+              @Override
+              public void onSessionCreated(RepositorySession session) {
+                sink = session;
+                WaitHelper.getTestWaiter().performNotify();
+              }
+            }, null);
+          }
+        }, null);
+      }
+    });
+
+    assertNotNull(source);
+    assertNotNull(sink);
+
+    numFlowFetchFailed = new AtomicInteger(0);
+    numFlowStoreFailed = new AtomicInteger(0);
+    numFlowCompleted = new AtomicInteger(0);
+    flowBeginFailed = new AtomicBoolean(false);
+    flowFinishFailed = new AtomicBoolean(false);
+
+    rcDelegate = new RecordsChannelDelegate() {
+      @Override
+      public void onFlowFetchFailed(RecordsChannel recordsChannel, Exception ex) {
+        numFlowFetchFailed.incrementAndGet();
+      }
+
+      @Override
+      public void onFlowStoreFailed(RecordsChannel recordsChannel, Exception ex, String recordGuid) {
+        numFlowStoreFailed.incrementAndGet();
+      }
+
+      @Override
+      public void onFlowFinishFailed(RecordsChannel recordsChannel, Exception ex) {
+        flowFinishFailed.set(true);
+        WaitHelper.getTestWaiter().performNotify();
+      }
+
+      @Override
+      public void onFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
+        numFlowCompleted.incrementAndGet();
+        try {
+          sink.finish(new ExpectSuccessRepositorySessionFinishDelegate(WaitHelper.getTestWaiter()) {
+            @Override
+            public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+              try {
+                source.finish(new ExpectSuccessRepositorySessionFinishDelegate(WaitHelper.getTestWaiter()) {
+                  @Override
+                  public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+                    performNotify();
+                  }
+                });
+              } catch (InactiveSessionException e) {
+                WaitHelper.getTestWaiter().performNotify(e);
+              }
+            }
+          });
+        } catch (InactiveSessionException e) {
+          WaitHelper.getTestWaiter().performNotify(e);
+        }
+      }
+
+      @Override
+      public void onFlowBeginFailed(RecordsChannel recordsChannel, Exception ex) {
+        flowBeginFailed.set(true);
+        WaitHelper.getTestWaiter().performNotify();
+      }
+    };
+
+    final RecordsChannel rc = new RecordsChannel(source,  sink, rcDelegate);
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          rc.beginAndFlow();
+        } catch (InvalidSessionTransitionException e) {
+          WaitHelper.getTestWaiter().performNotify(e);
+        }
+      }
+    });
+  }
+
+  public static final BookmarkRecord[] inbounds = new BookmarkRecord[] {
+    new BookmarkRecord("inboundSucc1", "bookmarks", 1, false),
+    new BookmarkRecord("inboundSucc2", "bookmarks", 1, false),
+    new BookmarkRecord("inboundFail1", "bookmarks", 1, false),
+    new BookmarkRecord("inboundSucc3", "bookmarks", 1, false),
+    new BookmarkRecord("inboundSucc4", "bookmarks", 1, false),
+    new BookmarkRecord("inboundFail2", "bookmarks", 1, false),
+  };
+  public static final BookmarkRecord[] outbounds = new BookmarkRecord[] {
+      new BookmarkRecord("outboundSucc1", "bookmarks", 1, false),
+      new BookmarkRecord("outboundSucc2", "bookmarks", 1, false),
+      new BookmarkRecord("outboundSucc3", "bookmarks", 1, false),
+      new BookmarkRecord("outboundSucc4", "bookmarks", 1, false),
+      new BookmarkRecord("outboundSucc5", "bookmarks", 1, false),
+      new BookmarkRecord("outboundFail6", "bookmarks", 1, false),
+  };
+
+  protected WBORepository empty() {
+    WBORepository repo = new SynchronizerHelpers.TrackingWBORepository();
+    return repo;
+  }
+
+  protected WBORepository full() {
+    WBORepository repo = new SynchronizerHelpers.TrackingWBORepository();
+    for (BookmarkRecord outbound : outbounds) {
+      repo.wbos.put(outbound.guid, outbound);
+    }
+    return repo;
+  }
+
+  protected WBORepository failingFetch() {
+    WBORepository repo = new FailFetchWBORepository();
+    for (BookmarkRecord outbound : outbounds) {
+      repo.wbos.put(outbound.guid, outbound);
+    }
+    return repo;
+  }
+
+  @Test
+  public void testSuccess() throws Exception {
+    WBORepository source = full();
+    WBORepository sink = empty();
+    doFlow(source, sink);
+    assertEquals(1, numFlowCompleted.get());
+    assertEquals(0, numFlowFetchFailed.get());
+    assertEquals(0, numFlowStoreFailed.get());
+    assertEquals(source.wbos, sink.wbos);
+  }
+
+  @Test
+  public void testFetchFail() throws Exception {
+    WBORepository source = failingFetch();
+    WBORepository sink = empty();
+    doFlow(source, sink);
+    assertEquals(1, numFlowCompleted.get());
+    assertTrue(numFlowFetchFailed.get() > 0);
+    assertEquals(0, numFlowStoreFailed.get());
+    assertTrue(sink.wbos.size() < 6);
+  }
+
+  @Test
+  public void testStoreSerialFail() throws Exception {
+    WBORepository source = full();
+    WBORepository sink = new SynchronizerHelpers.SerialFailStoreWBORepository();
+    doFlow(source, sink);
+    assertEquals(1, numFlowCompleted.get());
+    assertEquals(0, numFlowFetchFailed.get());
+    assertEquals(1, numFlowStoreFailed.get());
+    assertEquals(5, sink.wbos.size());
+  }
+
+  @Test
+  public void testStoreBatchesFail() throws Exception {
+    WBORepository source = full();
+    WBORepository sink = new SynchronizerHelpers.BatchFailStoreWBORepository(3);
+    doFlow(source, sink);
+    assertEquals(1, numFlowCompleted.get());
+    assertEquals(0, numFlowFetchFailed.get());
+    assertEquals(3, numFlowStoreFailed.get()); // One batch fails.
+    assertEquals(3, sink.wbos.size()); // One batch succeeds.
+  }
+
+
+  @Test
+  public void testStoreOneBigBatchFail() throws Exception {
+    WBORepository source = full();
+    WBORepository sink = new SynchronizerHelpers.BatchFailStoreWBORepository(50);
+    doFlow(source, sink);
+    assertEquals(1, numFlowCompleted.get());
+    assertEquals(0, numFlowFetchFailed.get());
+    assertEquals(6, numFlowStoreFailed.get()); // One (big) batch fails.
+    assertEquals(0, sink.wbos.size()); // No batches succeed.
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestResetCommands.java
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.HashMap;
+
+import org.json.simple.parser.ParseException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.background.testhelpers.DefaultGlobalSessionCallback;
+import org.mozilla.gecko.background.testhelpers.MockPrefsGlobalSession;
+import org.mozilla.gecko.background.testhelpers.MockServerSyncStage;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.EngineSettings;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.MetaGlobalException;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConfigurationException;
+import org.mozilla.gecko.sync.crypto.CryptoException;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+import android.content.SharedPreferences;
+
+/**
+ * Test that reset commands properly invoke the reset methods on the correct stage.
+ */
+public class TestResetCommands {
+  private static final String TEST_USERNAME    = "johndoe";
+  private static final String TEST_PASSWORD    = "password";
+  private static final String TEST_SYNC_KEY    = "abcdeabcdeabcdeabcdeabcdea";
+
+  public static void performNotify() {
+    WaitHelper.getTestWaiter().performNotify();
+  }
+
+  public static void performNotify(Throwable e) {
+    WaitHelper.getTestWaiter().performNotify(e);
+  }
+
+  public static void performWait(Runnable runnable) {
+    WaitHelper.getTestWaiter().performWait(runnable);
+  }
+
+  @Before
+  public void setUp() {
+    assertTrue(WaitHelper.getTestWaiter().isIdle());
+  }
+
+  @Test
+  public void testHandleResetCommand() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, ParseException, CryptoException {
+    // Create a global session.
+    // Set up stage mappings for a real stage name (because they're looked up by name
+    // in an enumeration) pointing to our fake stage.
+    // Send a reset command.
+    // Verify that reset is called on our stage.
+
+    class Result {
+      public boolean called = false;
+    }
+
+    final Result yes = new Result();
+    final Result no  = new Result();
+    final GlobalSessionCallback callback = createGlobalSessionCallback();
+
+    // So we can poke at stages separately.
+    final HashMap<Stage, GlobalSyncStage> stagesToRun = new HashMap<Stage, GlobalSyncStage>();
+
+    // Side-effect: modifies global command processor.
+    final SharedPreferences prefs = new MockSharedPreferences();
+    final SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD), prefs);
+    config.syncKeyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
+    final GlobalSession session = new MockPrefsGlobalSession(config, callback, null, null) {
+      @Override
+      public boolean isEngineRemotelyEnabled(String engineName,
+                                     EngineSettings engineSettings)
+        throws MetaGlobalException {
+        return true;
+      }
+
+      @Override
+      public void advance() {
+        // So we don't proceed and run other stages.
+      }
+
+      @Override
+      public void prepareStages() {
+        this.stages = stagesToRun;
+      }
+    };
+
+    final MockServerSyncStage stageGetsReset = new MockServerSyncStage() {
+      @Override
+      public void resetLocal() {
+        yes.called = true;
+      }
+    };
+
+    final MockServerSyncStage stageNotReset = new MockServerSyncStage() {
+      @Override
+      public void resetLocal() {
+        no.called = true;
+      }
+    };
+
+    stagesToRun.put(Stage.syncBookmarks, stageGetsReset);
+    stagesToRun.put(Stage.syncHistory,   stageNotReset);
+
+    final String resetBookmarks = "{\"args\":[\"bookmarks\"],\"command\":\"resetEngine\"}";
+    ExtendedJSONObject unparsedCommand = new ExtendedJSONObject(resetBookmarks);
+    CommandProcessor processor = CommandProcessor.getProcessor();
+    processor.processCommand(session, unparsedCommand);
+
+    assertTrue(yes.called);
+    assertFalse(no.called);
+  }
+
+  public void testHandleWipeCommand() {
+    // TODO
+  }
+
+  private static GlobalSessionCallback createGlobalSessionCallback() {
+    return new DefaultGlobalSessionCallback() {
+
+      @Override
+      public void handleAborted(GlobalSession globalSession, String reason) {
+        performNotify(new Exception("Aborted"));
+      }
+
+      @Override
+      public void handleError(GlobalSession globalSession, Exception ex) {
+        performNotify(ex);
+      }
+
+      @Override
+      public void handleSuccess(GlobalSession globalSession) {
+      }
+    };
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
@@ -0,0 +1,272 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.Test;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.android.sync.test.helpers.BaseTestStorageRequestDelegate;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.gecko.background.testhelpers.MockRecord;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.InfoCollections;
+import org.mozilla.gecko.sync.JSONRecordFetcher;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.middleware.Crypto5MiddlewareRepository;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRecordRequest;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.Repository;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.Server11Repository;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecordFactory;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.stage.SafeConstrainedServer11Repository;
+import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.simpleframework.http.ContentType;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+
+public class TestServer11RepositorySession {
+
+  public class POSTMockServer extends MockServer {
+    @Override
+    public void handle(Request request, Response response) {
+      try {
+        String content = request.getContent();
+        System.out.println("Content:" + content);
+      } catch (IOException e) {
+        e.printStackTrace();
+      }
+      ContentType contentType = request.getContentType();
+      System.out.println("Content-Type:" + contentType);
+      super.handle(request, response, 200, "{success:[]}");
+    }
+  }
+
+  private static final int    TEST_PORT   = HTTPServerTestHelper.getTestPort();
+  private static final String TEST_SERVER = "http://localhost:" + TEST_PORT + "/";
+  static final String LOCAL_BASE_URL      = TEST_SERVER + "1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/";
+  static final String LOCAL_REQUEST_URL   = LOCAL_BASE_URL + "storage/bookmarks";
+  static final String LOCAL_INFO_BASE_URL = LOCAL_BASE_URL + "info/";
+  static final String LOCAL_COUNTS_URL    = LOCAL_INFO_BASE_URL + "collection_counts";
+
+  // Corresponds to rnewman+atest1@mozilla.com, local.
+  static final String TEST_USERNAME          = "n6ec3u5bee3tixzp2asys7bs6fve4jfw";
+  static final String TEST_PASSWORD          = "passowrd";
+  static final String SYNC_KEY          = "eh7ppnb82iwr5kt3z3uyi5vr44";
+
+  public final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+  protected final InfoCollections infoCollections = new InfoCollections();
+
+  // Few-second timeout so that our longer operations don't time out and cause spurious error-handling results.
+  private static final int SHORT_TIMEOUT = 10000;
+
+  public AuthHeaderProvider getAuthHeaderProvider() {
+    return new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
+  }
+
+  private HTTPServerTestHelper data     = new HTTPServerTestHelper();
+
+  public class MockServer11RepositorySession extends Server11RepositorySession {
+    public MockServer11RepositorySession(Repository repository) {
+      super(repository);
+    }
+
+    public RecordUploadRunnable getRecordUploadRunnable() {
+      // TODO: implement upload delegate in the class, too!
+      return new RecordUploadRunnable(null, recordsBuffer, recordGuidsBuffer, byteCount);
+    }
+
+    public void enqueueRecord(Record r) {
+      super.enqueue(r);
+    }
+
+    public HttpEntity getEntity() {
+      return this.getRecordUploadRunnable().getBodyEntity();
+    }
+  }
+
+  public class TestSyncStorageRequestDelegate extends
+  BaseTestStorageRequestDelegate {
+    public TestSyncStorageRequestDelegate(String username, String password) {
+      super(username, password);
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse res) {
+      assertTrue(res.wasSuccessful());
+      assertTrue(res.httpResponse().containsHeader("X-Weave-Timestamp"));
+      BaseResource.consumeEntity(res);
+      data.stopHTTPServer();
+    }
+  }
+
+  @Test
+  public void test() throws URISyntaxException {
+
+    BaseResource.rewriteLocalhost = false;
+    data.startHTTPServer(new POSTMockServer());
+
+    MockServer11RepositorySession session = new MockServer11RepositorySession(
+        null);
+    session.enqueueRecord(new MockRecord(Utils.generateGuid(), null, 0, false));
+    session.enqueueRecord(new MockRecord(Utils.generateGuid(), null, 0, false));
+
+    URI uri = new URI(LOCAL_REQUEST_URL);
+    SyncStorageRecordRequest r = new SyncStorageRecordRequest(uri);
+    TestSyncStorageRequestDelegate delegate = new TestSyncStorageRequestDelegate(TEST_USERNAME, TEST_PASSWORD);
+    r.delegate = delegate;
+    r.post(session.getEntity());
+  }
+
+  @SuppressWarnings("static-method")
+  protected TrackingWBORepository getLocal(int numRecords) {
+    final TrackingWBORepository local = new TrackingWBORepository();
+    for (int i = 0; i < numRecords; i++) {
+      BookmarkRecord outbound = new BookmarkRecord("outboundFail" + i, "bookmarks", 1, false);
+      local.wbos.put(outbound.guid, outbound);
+    }
+    return local;
+  }
+
+  protected Exception doSynchronize(MockServer server) throws Exception {
+    final String COLLECTION = "test";
+
+    final TrackingWBORepository local = getLocal(100);
+    final Server11Repository remote = new Server11Repository(COLLECTION, getCollectionURL(COLLECTION), authHeaderProvider, infoCollections);
+    KeyBundle collectionKey = new KeyBundle(TEST_USERNAME, SYNC_KEY);
+    Crypto5MiddlewareRepository cryptoRepo = new Crypto5MiddlewareRepository(remote, collectionKey);
+    cryptoRepo.recordFactory = new BookmarkRecordFactory();
+
+    final Synchronizer synchronizer = new ServerLocalSynchronizer();
+    synchronizer.repositoryA = cryptoRepo;
+    synchronizer.repositoryB = local;
+
+    data.startHTTPServer(server);
+    try {
+      Exception e = TestServerLocalSynchronizer.doSynchronize(synchronizer);
+      return e;
+    } finally {
+      data.stopHTTPServer();
+    }
+  }
+
+  protected String getCollectionURL(String collection) {
+    return LOCAL_BASE_URL + "/storage/" + collection;
+  }
+
+  @Test
+  public void testFetchFailure() throws Exception {
+    MockServer server = new MockServer(404, "error");
+    Exception e = doSynchronize(server);
+    assertNotNull(e);
+    assertEquals(FetchFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testStorePostSuccessWithFailingRecords() throws Exception {
+    MockServer server = new MockServer(200, "{ modified: \" + " + Utils.millisecondsToDecimalSeconds(System.currentTimeMillis()) + ", " +
+        "success: []," +
+        "failed: { outboundFail2: [] } }");
+    Exception e = doSynchronize(server);
+    assertNotNull(e);
+    assertEquals(StoreFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testStorePostFailure() throws Exception {
+    MockServer server = new MockServer() {
+      @Override
+      public void handle(Request request, Response response) {
+        if (request.getMethod().equals("POST")) {
+          this.handle(request, response, 404, "missing");
+        }
+        this.handle(request, response, 200, "success");
+        return;
+      }
+    };
+
+    Exception e = doSynchronize(server);
+    assertNotNull(e);
+    assertEquals(StoreFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testConstraints() throws Exception {
+    MockServer server = new MockServer() {
+      @Override
+      public void handle(Request request, Response response) {
+        if (request.getMethod().equals("GET")) {
+          if (request.getPath().getPath().endsWith("/info/collection_counts")) {
+            this.handle(request, response, 200, "{\"bookmarks\": 5001}");
+          }
+        }
+        this.handle(request, response, 400, "NOOOO");
+      }
+    };
+    final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(LOCAL_COUNTS_URL, getAuthHeaderProvider());
+    String collection = "bookmarks";
+    final SafeConstrainedServer11Repository remote = new SafeConstrainedServer11Repository(collection,
+        getCollectionURL(collection),
+        getAuthHeaderProvider(),
+        infoCollections,
+        5000, "sortindex", countsFetcher);
+
+    data.startHTTPServer(server);
+    final AtomicBoolean out = new AtomicBoolean(false);
+
+    // Verify that shouldSkip returns true due to a fetch of too large counts,
+    // rather than due to a timeout failure waiting to fetch counts.
+    try {
+      WaitHelper.getTestWaiter().performWait(
+          SHORT_TIMEOUT,
+          new Runnable() {
+            @Override
+            public void run() {
+              remote.createSession(new RepositorySessionCreationDelegate() {
+                @Override
+                public void onSessionCreated(RepositorySession session) {
+                  out.set(session.shouldSkip());
+                  WaitHelper.getTestWaiter().performNotify();
+                }
+
+                @Override
+                public void onSessionCreateFailed(Exception ex) {
+                  WaitHelper.getTestWaiter().performNotify(ex);
+                }
+
+                @Override
+                public RepositorySessionCreationDelegate deferredCreationDelegate() {
+                  return this;
+                }
+              }, null);
+            }
+          });
+      assertTrue(out.get());
+    } finally {
+      data.stopHTTPServer();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServerLocalSynchronizer.java
@@ -0,0 +1,234 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.util.ArrayList;
+
+import org.junit.Test;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BatchFailStoreWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BeginErrorWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.BeginFailedException;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FailFetchWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FinishErrorWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.FinishFailedException;
+import org.mozilla.android.sync.test.SynchronizerHelpers.SerialFailStoreWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
+
+public class TestServerLocalSynchronizer {
+  public static final String LOG_TAG = "TestServLocSync";
+
+  protected Synchronizer getSynchronizer(WBORepository remote, WBORepository local) {
+    BookmarkRecord[] inbounds = new BookmarkRecord[] {
+        new BookmarkRecord("inboundSucc1", "bookmarks", 1, false),
+        new BookmarkRecord("inboundSucc2", "bookmarks", 1, false),
+        new BookmarkRecord("inboundFail1", "bookmarks", 1, false),
+        new BookmarkRecord("inboundSucc3", "bookmarks", 1, false),
+        new BookmarkRecord("inboundFail2", "bookmarks", 1, false),
+        new BookmarkRecord("inboundFail3", "bookmarks", 1, false),
+    };
+    BookmarkRecord[] outbounds = new BookmarkRecord[] {
+        new BookmarkRecord("outboundFail1", "bookmarks", 1, false),
+        new BookmarkRecord("outboundFail2", "bookmarks", 1, false),
+        new BookmarkRecord("outboundFail3", "bookmarks", 1, false),
+        new BookmarkRecord("outboundFail4", "bookmarks", 1, false),
+        new BookmarkRecord("outboundFail5", "bookmarks", 1, false),
+        new BookmarkRecord("outboundFail6", "bookmarks", 1, false),
+    };
+    for (BookmarkRecord inbound : inbounds) {
+      remote.wbos.put(inbound.guid, inbound);
+    }
+    for (BookmarkRecord outbound : outbounds) {
+      local.wbos.put(outbound.guid, outbound);
+    }
+
+    final Synchronizer synchronizer = new ServerLocalSynchronizer();
+    synchronizer.repositoryA = remote;
+    synchronizer.repositoryB = local;
+    return synchronizer;
+  }
+
+  protected static Exception doSynchronize(final Synchronizer synchronizer) {
+    final ArrayList<Exception> a = new ArrayList<Exception>();
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        synchronizer.synchronize(null, new SynchronizerDelegate() {
+          @Override
+          public void onSynchronized(Synchronizer synchronizer) {
+            Logger.trace(LOG_TAG, "Got onSynchronized.");
+            a.add(null);
+            WaitHelper.getTestWaiter().performNotify();
+          }
+
+          @Override
+          public void onSynchronizeFailed(Synchronizer synchronizer, Exception lastException, String reason) {
+            Logger.trace(LOG_TAG, "Got onSynchronizedFailed.");
+            a.add(lastException);
+            WaitHelper.getTestWaiter().performNotify();
+          }
+        });
+      }
+    });
+
+    assertEquals(1, a.size()); // Should not be called multiple times!
+    return a.get(0);
+  }
+
+  @Test
+  public void testNoErrors() {
+    WBORepository remote = new TrackingWBORepository();
+    WBORepository local  = new TrackingWBORepository();
+
+    Synchronizer synchronizer = getSynchronizer(remote, local);
+    assertNull(doSynchronize(synchronizer));
+
+    assertEquals(12, local.wbos.size());
+    assertEquals(12, remote.wbos.size());
+  }
+
+  @Test
+  public void testLocalFetchErrors() {
+    WBORepository remote = new TrackingWBORepository();
+    WBORepository local  = new FailFetchWBORepository();
+
+    Synchronizer synchronizer = getSynchronizer(remote, local);
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(FetchFailedException.class, e.getClass());
+
+    // Neither session gets finished successfully, so all records are dropped.
+    assertEquals(6, local.wbos.size());
+    assertEquals(6, remote.wbos.size());
+  }
+
+  @Test
+  public void testRemoteFetchErrors() {
+    WBORepository remote = new FailFetchWBORepository();
+    WBORepository local  = new TrackingWBORepository();
+
+    Synchronizer synchronizer = getSynchronizer(remote, local);
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(FetchFailedException.class, e.getClass());
+
+    // Neither session gets finished successfully, so all records are dropped.
+    assertEquals(6, local.wbos.size());
+    assertEquals(6, remote.wbos.size());
+  }
+
+  @Test
+  public void testLocalSerialStoreErrorsAreIgnored() {
+    WBORepository remote = new TrackingWBORepository();
+    WBORepository local  = new SerialFailStoreWBORepository();
+
+    Synchronizer synchronizer = getSynchronizer(remote, local);
+    assertNull(doSynchronize(synchronizer));
+
+    assertEquals(9,  local.wbos.size());
+    assertEquals(12, remote.wbos.size());
+  }
+
+  @Test
+  public void testLocalBatchStoreErrorsAreIgnored() {
+    final int BATCH_SIZE = 3;
+
+    Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new BatchFailStoreWBORepository(BATCH_SIZE));
+
+    Exception e = doSynchronize(synchronizer);
+    assertNull(e);
+  }
+
+  @Test
+  public void testRemoteSerialStoreErrorsAreNotIgnored() throws Exception {
+    Synchronizer synchronizer = getSynchronizer(new SerialFailStoreWBORepository(), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(StoreFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testRemoteBatchStoreErrorsAreNotIgnoredManyBatches() throws Exception {
+    final int BATCH_SIZE = 3;
+
+    Synchronizer synchronizer = getSynchronizer(new BatchFailStoreWBORepository(BATCH_SIZE), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(StoreFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testRemoteBatchStoreErrorsAreNotIgnoredOneBigBatch() throws Exception {
+    final int BATCH_SIZE = 20;
+
+    Synchronizer synchronizer = getSynchronizer(new BatchFailStoreWBORepository(BATCH_SIZE), new TrackingWBORepository()); // Tracking so we don't send incoming records back.
+
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(StoreFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionRemoteBeginError() {
+    Synchronizer synchronizer = getSynchronizer(new BeginErrorWBORepository(), new TrackingWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(BeginFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionLocalBeginError() {
+    Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new BeginErrorWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(BeginFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionRemoteFinishError() {
+    Synchronizer synchronizer = getSynchronizer(new FinishErrorWBORepository(), new TrackingWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(FinishFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionLocalFinishError() {
+    Synchronizer synchronizer = getSynchronizer(new TrackingWBORepository(), new FinishErrorWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(FinishFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionBothBeginError() {
+    Synchronizer synchronizer = getSynchronizer(new BeginErrorWBORepository(), new BeginErrorWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(BeginFailedException.class, e.getClass());
+  }
+
+  @Test
+  public void testSessionBothFinishError() {
+    Synchronizer synchronizer = getSynchronizer(new FinishErrorWBORepository(), new FinishErrorWBORepository());
+    Exception e = doSynchronize(synchronizer);
+    assertNotNull(e);
+    assertEquals(FinishFailedException.class, e.getClass());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncConfiguration.java
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import java.net.URI;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.sync.Sync11Configuration;
+import org.mozilla.gecko.sync.SyncConfiguration;
+
+public class TestSyncConfiguration {
+  @Test
+  public void testURLs() throws Exception {
+    final MockSharedPreferences prefs = new MockSharedPreferences();
+
+    // N.B., the username isn't used in the cluster path.
+    SyncConfiguration fxaConfig = new SyncConfiguration("username", null, prefs);
+    fxaConfig.clusterURL = new URI("http://db1.oldsync.dev.lcip.org/1.1/174");
+    Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/info/collections", fxaConfig.infoCollectionsURL());
+    Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/info/collection_counts", fxaConfig.infoCollectionCountsURL());
+    Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage/meta/global", fxaConfig.metaURL());
+    Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage", fxaConfig.storageURL());
+    Assert.assertEquals("http://db1.oldsync.dev.lcip.org/1.1/174/storage/collection", fxaConfig.collectionURI("collection").toASCIIString());
+
+    SyncConfiguration oldConfig = new Sync11Configuration("username", null, prefs);
+    oldConfig.clusterURL = new URI("https://db.com/internal/");
+    Assert.assertEquals("https://db.com/internal/1.1/username/info/collections", oldConfig.infoCollectionsURL());
+    Assert.assertEquals("https://db.com/internal/1.1/username/info/collection_counts", oldConfig.infoCollectionCountsURL());
+    Assert.assertEquals("https://db.com/internal/1.1/username/storage/meta/global", oldConfig.metaURL());
+    Assert.assertEquals("https://db.com/internal/1.1/username/storage", oldConfig.storageURL());
+    Assert.assertEquals("https://db.com/internal/1.1/username/storage/collection", oldConfig.collectionURI("collection").toASCIIString());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSyncKeyVerification.java
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.fail;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.sync.setup.InvalidSyncKeyException;
+import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
+
+public class TestSyncKeyVerification {
+
+  private int[] mutateIndices;
+  private final String validBasicKey = "abcdefghijkmnpqrstuvwxyz23"; // 26 char, valid characters.
+  char[] invalidChars = new char[] { '1', 'l', 'o', '0' };
+
+  @Before
+  public void setUp() {
+    // Generate indicies to mutate.
+    mutateIndices = generateMutationArray();
+  }
+
+  @Test
+  public void testValidKey() {
+    try {
+      ActivityUtils.validateSyncKey(validBasicKey);
+    } catch (InvalidSyncKeyException e) {
+      fail("Threw unexpected InvalidSyncKeyException.");
+    }
+  }
+
+  @Test
+  public void testHyphenationSuccess() {
+    StringBuilder sb = new StringBuilder();
+    int prev = 0;
+    for (int i : mutateIndices) {
+      sb.append(validBasicKey.substring(prev, i));
+      sb.append("-");
+      prev = i;
+    }
+    sb.append(validBasicKey.substring(prev));
+    String hString = sb.toString();
+    try {
+      ActivityUtils.validateSyncKey(hString);
+    } catch (InvalidSyncKeyException e) {
+      fail("Failed validation with hypenation.");
+    }
+  }
+
+  @Test
+  public void testCapitalizationSuccess() {
+
+    char[] mutatedKey = validBasicKey.toCharArray();
+    for (int i : mutateIndices) {
+      mutatedKey[i] = Character.toUpperCase(validBasicKey.charAt(i));
+    }
+    String mKey = new String(mutatedKey);
+    try {
+      ActivityUtils.validateSyncKey(mKey);
+    } catch (InvalidSyncKeyException e) {
+      fail("Failed validation with uppercasing.");
+    }
+  }
+
+  @Test (expected = InvalidSyncKeyException.class)
+  public void testInvalidCharFailure() throws InvalidSyncKeyException {
+    char[] mutatedKey = validBasicKey.toCharArray();
+    for (int i : mutateIndices) {
+      mutatedKey[i] = invalidChars[i % invalidChars.length];
+    }
+    ActivityUtils.validateSyncKey(mutatedKey.toString());
+  }
+
+  private int[] generateMutationArray() {
+    // Hardcoded; change if desired?
+    return new int[] { 2, 4, 5, 9, 16 };
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizer.java
@@ -0,0 +1,396 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.Date;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.SynchronizerHelpers.TrackingWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSessionDelegate;
+
+import android.content.Context;
+
+public class TestSynchronizer {
+  public static final String LOG_TAG = "TestSynchronizer";
+
+  public static void assertInRangeInclusive(long earliest, long value, long latest) {
+    assertTrue(earliest <= value);
+    assertTrue(latest   >= value);
+  }
+
+  public static void recordEquals(BookmarkRecord r, String guid, long lastModified, boolean deleted, String collection) {
+    assertEquals(r.guid,         guid);
+    assertEquals(r.lastModified, lastModified);
+    assertEquals(r.deleted,      deleted);
+    assertEquals(r.collection,   collection);
+  }
+
+  public static void recordEquals(BookmarkRecord a, BookmarkRecord b) {
+    assertEquals(a.guid,         b.guid);
+    assertEquals(a.lastModified, b.lastModified);
+    assertEquals(a.deleted,      b.deleted);
+    assertEquals(a.collection,   b.collection);
+  }
+
+  @Before
+  public void setUp() {
+    WaitHelper.resetTestWaiter();
+  }
+
+  @After
+  public void tearDown() {
+    WaitHelper.resetTestWaiter();
+  }
+
+  @Test
+  public void testSynchronizerSession() {
+    final Context context = null;
+    final WBORepository repoA = new TrackingWBORepository();
+    final WBORepository repoB = new TrackingWBORepository();
+
+    final String collection  = "bookmarks";
+    final boolean deleted    = false;
+    final String guidA       = "abcdabcdabcd";
+    final String guidB       = "ffffffffffff";
+    final String guidC       = "xxxxxxxxxxxx";
+    final long lastModifiedA = 312345;
+    final long lastModifiedB = 412340;
+    final long lastModifiedC = 412345;
+    BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+    BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+    BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+    repoA.wbos.put(guidA, bookmarkRecordA);
+    repoB.wbos.put(guidB, bookmarkRecordB);
+    repoB.wbos.put(guidC, bookmarkRecordC);
+    Synchronizer synchronizer = new Synchronizer();
+    synchronizer.repositoryA = repoA;
+    synchronizer.repositoryB = repoB;
+    final SynchronizerSession syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+
+      @Override
+      public void onInitialized(SynchronizerSession session) {
+        assertFalse(repoA.wbos.containsKey(guidB));
+        assertFalse(repoA.wbos.containsKey(guidC));
+        assertFalse(repoB.wbos.containsKey(guidA));
+        assertTrue(repoA.wbos.containsKey(guidA));
+        assertTrue(repoB.wbos.containsKey(guidB));
+        assertTrue(repoB.wbos.containsKey(guidC));
+        session.synchronize();
+      }
+
+      @Override
+      public void onSynchronized(SynchronizerSession session) {
+        try {
+          assertEquals(1, session.getInboundCount());
+          assertEquals(2, session.getOutboundCount());
+          WaitHelper.getTestWaiter().performNotify();
+        } catch (Throwable e) {
+          WaitHelper.getTestWaiter().performNotify(e);
+        }
+      }
+
+      @Override
+      public void onSynchronizeFailed(SynchronizerSession session,
+                                      Exception lastException, String reason) {
+        WaitHelper.getTestWaiter().performNotify(lastException);
+      }
+
+      @Override
+      public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+        WaitHelper.getTestWaiter().performNotify(new RuntimeException());
+      }
+    });
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        syncSession.init(context, new RepositorySessionBundle(0), new RepositorySessionBundle(0));
+      }
+    });
+
+    // Verify contents.
+    assertTrue(repoA.wbos.containsKey(guidA));
+    assertTrue(repoA.wbos.containsKey(guidB));
+    assertTrue(repoA.wbos.containsKey(guidC));
+    assertTrue(repoB.wbos.containsKey(guidA));
+    assertTrue(repoB.wbos.containsKey(guidB));
+    assertTrue(repoB.wbos.containsKey(guidC));
+    BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+    BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+    BookmarkRecord ac = (BookmarkRecord) repoA.wbos.get(guidC);
+    BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+    BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+    BookmarkRecord bc = (BookmarkRecord) repoB.wbos.get(guidC);
+    recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+    recordEquals(ab, guidB, lastModifiedB, deleted, collection);
+    recordEquals(ac, guidC, lastModifiedC, deleted, collection);
+    recordEquals(ba, guidA, lastModifiedA, deleted, collection);
+    recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+    recordEquals(bc, guidC, lastModifiedC, deleted, collection);
+    recordEquals(aa, ba);
+    recordEquals(ab, bb);
+    recordEquals(ac, bc);
+  }
+
+  public abstract class SuccessfulSynchronizerDelegate implements SynchronizerDelegate {
+    public long syncAOne = 0;
+    public long syncBOne = 0;
+
+    @Override
+    public void onSynchronizeFailed(Synchronizer synchronizer,
+                                    Exception lastException, String reason) {
+      fail("Should not fail.");
+    }
+  }
+
+  @Test
+  public void testSynchronizerPersists() {
+    final Object monitor = new Object();
+    final long earliest = new Date().getTime();
+
+    Context context = null;
+    final WBORepository repoA = new WBORepository();
+    final WBORepository repoB = new WBORepository();
+    Synchronizer synchronizer = new Synchronizer();
+    synchronizer.bundleA     = new RepositorySessionBundle(0);
+    synchronizer.bundleB     = new RepositorySessionBundle(0);
+    synchronizer.repositoryA = repoA;
+    synchronizer.repositoryB = repoB;
+
+    final SuccessfulSynchronizerDelegate delegateOne = new SuccessfulSynchronizerDelegate() {
+      @Override
+      public void onSynchronized(Synchronizer synchronizer) {
+        Logger.trace(LOG_TAG, "onSynchronized. Success!");
+        syncAOne = synchronizer.bundleA.getTimestamp();
+        syncBOne = synchronizer.bundleB.getTimestamp();
+        synchronized (monitor) {
+          monitor.notify();
+        }
+      }
+    };
+    final SuccessfulSynchronizerDelegate delegateTwo = new SuccessfulSynchronizerDelegate() {
+      @Override
+      public void onSynchronized(Synchronizer synchronizer) {
+        Logger.trace(LOG_TAG, "onSynchronized. Success!");
+        syncAOne = synchronizer.bundleA.getTimestamp();
+        syncBOne = synchronizer.bundleB.getTimestamp();
+        synchronized (monitor) {
+          monitor.notify();
+        }
+      }
+    };
+    synchronized (monitor) {
+      synchronizer.synchronize(context, delegateOne);
+      try {
+        monitor.wait();
+      } catch (InterruptedException e) {
+        fail("Interrupted.");
+      }
+    }
+    long now = new Date().getTime();
+    Logger.trace(LOG_TAG, "Earliest is " + earliest);
+    Logger.trace(LOG_TAG, "syncAOne is " + delegateOne.syncAOne);
+    Logger.trace(LOG_TAG, "syncBOne is " + delegateOne.syncBOne);
+    Logger.trace(LOG_TAG, "Now: " + now);
+    assertInRangeInclusive(earliest, delegateOne.syncAOne, now);
+    assertInRangeInclusive(earliest, delegateOne.syncBOne, now);
+    try {
+      Thread.sleep(10);
+    } catch (InterruptedException e) {
+      fail("Thread interrupted!");
+    }
+    synchronized (monitor) {
+      synchronizer.synchronize(context, delegateTwo);
+      try {
+        monitor.wait();
+      } catch (InterruptedException e) {
+        fail("Interrupted.");
+      }
+    }
+    now = new Date().getTime();
+    Logger.trace(LOG_TAG, "Earliest is " + earliest);
+    Logger.trace(LOG_TAG, "syncAOne is " + delegateTwo.syncAOne);
+    Logger.trace(LOG_TAG, "syncBOne is " + delegateTwo.syncBOne);
+    Logger.trace(LOG_TAG, "Now: " + now);
+    assertInRangeInclusive(earliest, delegateTwo.syncAOne, now);
+    assertInRangeInclusive(earliest, delegateTwo.syncBOne, now);
+    assertTrue(delegateTwo.syncAOne > delegateOne.syncAOne);
+    assertTrue(delegateTwo.syncBOne > delegateOne.syncBOne);
+    Logger.trace(LOG_TAG, "Reached end of test.");
+  }
+
+  private Synchronizer getTestSynchronizer(long tsA, long tsB) {
+    WBORepository repoA = new TrackingWBORepository();
+    WBORepository repoB = new TrackingWBORepository();
+    Synchronizer synchronizer = new Synchronizer();
+    synchronizer.bundleA      = new RepositorySessionBundle(tsA);
+    synchronizer.bundleB      = new RepositorySessionBundle(tsB);
+    synchronizer.repositoryA  = repoA;
+    synchronizer.repositoryB  = repoB;
+    return synchronizer;
+  }
+
+  /**
+   * Let's put data in two repos and synchronize them with last sync
+   * timestamps later than all of the records. Verify that no records
+   * are exchanged.
+   */
+  @Test
+  public void testSynchronizerFakeTimestamps() {
+    final Context context = null;
+
+    final String collection  = "bookmarks";
+    final boolean deleted    = false;
+    final String guidA       = "abcdabcdabcd";
+    final String guidB       = "ffffffffffff";
+    final long lastModifiedA = 312345;
+    final long lastModifiedB = 412345;
+    BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+    BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+
+    final Synchronizer synchronizer = getTestSynchronizer(lastModifiedA + 10, lastModifiedB + 10);
+    final WBORepository repoA = (WBORepository) synchronizer.repositoryA;
+    final WBORepository repoB = (WBORepository) synchronizer.repositoryB;
+
+    repoA.wbos.put(guidA, bookmarkRecordA);
+    repoB.wbos.put(guidB, bookmarkRecordB);
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        synchronizer.synchronize(context, new SynchronizerDelegate() {
+
+          @Override
+          public void onSynchronized(Synchronizer synchronizer) {
+            try {
+              // No records get sent either way.
+              final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+              assertNotNull(synchronizerSession);
+              assertEquals(0, synchronizerSession.getInboundCount());
+              assertEquals(0, synchronizerSession.getOutboundCount());
+              WaitHelper.getTestWaiter().performNotify();
+            } catch (Throwable e) {
+              WaitHelper.getTestWaiter().performNotify(e);
+            }
+          }
+
+          @Override
+          public void onSynchronizeFailed(Synchronizer synchronizer,
+              Exception lastException, String reason) {
+            WaitHelper.getTestWaiter().performNotify(lastException);
+          }
+        });
+      }
+    });
+
+    // Verify contents.
+    assertTrue(repoA.wbos.containsKey(guidA));
+    assertTrue(repoB.wbos.containsKey(guidB));
+    assertFalse(repoB.wbos.containsKey(guidA));
+    assertFalse(repoA.wbos.containsKey(guidB));
+    BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+    BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+    BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+    BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+    assertNull(ab);
+    assertNull(ba);
+    recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+    recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+  }
+
+
+  @Test
+  public void testSynchronizer() {
+    final Context context = null;
+
+    final String collection = "bookmarks";
+    final boolean deleted = false;
+    final String guidA = "abcdabcdabcd";
+    final String guidB = "ffffffffffff";
+    final String guidC = "gggggggggggg";
+    final long lastModifiedA = 312345;
+    final long lastModifiedB = 412340;
+    final long lastModifiedC = 412345;
+    BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+    BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+    BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+    final Synchronizer synchronizer = getTestSynchronizer(0, 0);
+    final WBORepository repoA = (WBORepository) synchronizer.repositoryA;
+    final WBORepository repoB = (WBORepository) synchronizer.repositoryB;
+
+    repoA.wbos.put(guidA, bookmarkRecordA);
+    repoB.wbos.put(guidB, bookmarkRecordB);
+    repoB.wbos.put(guidC, bookmarkRecordC);
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        synchronizer.synchronize(context, new SynchronizerDelegate() {
+
+          @Override
+          public void onSynchronized(Synchronizer synchronizer) {
+            try {
+              // No records get sent either way.
+              final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+              assertNotNull(synchronizerSession);
+              assertEquals(1, synchronizerSession.getInboundCount());
+              assertEquals(2, synchronizerSession.getOutboundCount());
+              WaitHelper.getTestWaiter().performNotify();
+            } catch (Throwable e) {
+              WaitHelper.getTestWaiter().performNotify(e);
+            }
+          }
+
+          @Override
+          public void onSynchronizeFailed(Synchronizer synchronizer,
+              Exception lastException, String reason) {
+            WaitHelper.getTestWaiter().performNotify(lastException);
+          }
+        });
+      }
+    });
+
+    // Verify contents.
+    assertTrue(repoA.wbos.containsKey(guidA));
+    assertTrue(repoA.wbos.containsKey(guidB));
+    assertTrue(repoA.wbos.containsKey(guidC));
+    assertTrue(repoB.wbos.containsKey(guidA));
+    assertTrue(repoB.wbos.containsKey(guidB));
+    assertTrue(repoB.wbos.containsKey(guidC));
+    BookmarkRecord aa = (BookmarkRecord) repoA.wbos.get(guidA);
+    BookmarkRecord ab = (BookmarkRecord) repoA.wbos.get(guidB);
+    BookmarkRecord ac = (BookmarkRecord) repoA.wbos.get(guidC);
+    BookmarkRecord ba = (BookmarkRecord) repoB.wbos.get(guidA);
+    BookmarkRecord bb = (BookmarkRecord) repoB.wbos.get(guidB);
+    BookmarkRecord bc = (BookmarkRecord) repoB.wbos.get(guidC);
+    recordEquals(aa, guidA, lastModifiedA, deleted, collection);
+    recordEquals(ab, guidB, lastModifiedB, deleted, collection);
+    recordEquals(ac, guidC, lastModifiedC, deleted, collection);
+    recordEquals(ba, guidA, lastModifiedA, deleted, collection);
+    recordEquals(bb, guidB, lastModifiedB, deleted, collection);
+    recordEquals(bc, guidC, lastModifiedC, deleted, collection);
+    recordEquals(aa, ba);
+    recordEquals(ab, bb);
+    recordEquals(ac, bc);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestSynchronizerSession.java
@@ -0,0 +1,304 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.android.sync.test.SynchronizerHelpers.DataAvailableWBORepository;
+import org.mozilla.android.sync.test.SynchronizerHelpers.ShouldSkipWBORepository;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WBORepository;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.SynchronizerConfiguration;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+import org.mozilla.gecko.sync.synchronizer.SynchronizerSessionDelegate;
+
+import android.content.Context;
+
+public class TestSynchronizerSession {
+  public static final String LOG_TAG = TestSynchronizerSession.class.getSimpleName();
+
+  protected static void assertFirstContainsSecond(Map<String, Record> first, Map<String, Record> second) {
+    for (Entry<String, Record> entry : second.entrySet()) {
+      assertTrue("Expected key " + entry.getKey(), first.containsKey(entry.getKey()));
+      Record record = first.get(entry.getKey());
+      assertEquals(entry.getValue(), record);
+    }
+  }
+
+  protected static void assertFirstDoesNotContainSecond(Map<String, Record> first, Map<String, Record> second) {
+    for (Entry<String, Record> entry : second.entrySet()) {
+      assertFalse("Unexpected key " + entry.getKey(), first.containsKey(entry.getKey()));
+    }
+  }
+
+  protected WBORepository repoA = null;
+  protected WBORepository repoB = null;
+  protected SynchronizerSession syncSession = null;
+  protected Map<String, Record> originalWbosA = null;
+  protected Map<String, Record> originalWbosB = null;
+
+  @Before
+  public void setUp() {
+    repoA = new DataAvailableWBORepository(false);
+    repoB = new DataAvailableWBORepository(false);
+
+    final String collection  = "bookmarks";
+    final boolean deleted    = false;
+    final String guidA       = "abcdabcdabcd";
+    final String guidB       = "ffffffffffff";
+    final String guidC       = "xxxxxxxxxxxx";
+    final long lastModifiedA = 312345;
+    final long lastModifiedB = 412340;
+    final long lastModifiedC = 412345;
+    final BookmarkRecord bookmarkRecordA = new BookmarkRecord(guidA, collection, lastModifiedA, deleted);
+    final BookmarkRecord bookmarkRecordB = new BookmarkRecord(guidB, collection, lastModifiedB, deleted);
+    final BookmarkRecord bookmarkRecordC = new BookmarkRecord(guidC, collection, lastModifiedC, deleted);
+
+    repoA.wbos.put(guidA, bookmarkRecordA);
+    repoB.wbos.put(guidB, bookmarkRecordB);
+    repoB.wbos.put(guidC, bookmarkRecordC);
+
+    originalWbosA = new HashMap<String, Record>(repoA.wbos);
+    originalWbosB = new HashMap<String, Record>(repoB.wbos);
+
+    Synchronizer synchronizer = new Synchronizer();
+    synchronizer.repositoryA = repoA;
+    synchronizer.repositoryB = repoB;
+    syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+      @Override
+      public void onInitialized(SynchronizerSession session) {
+        session.synchronize();
+      }
+
+      @Override
+      public void onSynchronized(SynchronizerSession session) {
+        WaitHelper.getTestWaiter().performNotify();
+      }
+
+      @Override
+      public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason) {
+        WaitHelper.getTestWaiter().performNotify(lastException);
+      }
+
+      @Override
+      public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+        WaitHelper.getTestWaiter().performNotify(new RuntimeException("Not expecting onSynchronizeSkipped"));
+      }
+    });
+  }
+
+  protected void logStats() {
+    // Uncomment this line to print stats to console:
+    // Logger.startLoggingTo(new PrintLogWriter(new PrintWriter(System.out, true)));
+
+    Logger.debug(LOG_TAG, "Repo A fetch done: " + repoA.stats.fetchCompleted);
+    Logger.debug(LOG_TAG, "Repo B store done: " + repoB.stats.storeCompleted);
+    Logger.debug(LOG_TAG, "Repo B fetch done: " + repoB.stats.fetchCompleted);
+    Logger.debug(LOG_TAG, "Repo A store done: " + repoA.stats.storeCompleted);
+
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    Logger.debug(LOG_TAG, "Repo A timestamp: " + sc.remoteBundle.getTimestamp());
+    Logger.debug(LOG_TAG, "Repo B timestamp: " + sc.localBundle.getTimestamp());
+  }
+
+  protected void doTest(boolean remoteDataAvailable, boolean localDataAvailable) {
+    ((DataAvailableWBORepository) repoA).dataAvailable = remoteDataAvailable;
+    ((DataAvailableWBORepository) repoB).dataAvailable = localDataAvailable;
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        final Context context = null;
+        syncSession.init(context,
+            new RepositorySessionBundle(0),
+            new RepositorySessionBundle(0));
+      }
+    });
+
+    logStats();
+  }
+
+  @Test
+  public void testSynchronizerSessionBothHaveData() {
+    long before = System.currentTimeMillis();
+    boolean remoteDataAvailable = true;
+    boolean localDataAvailable = true;
+    doTest(remoteDataAvailable, localDataAvailable);
+    long after = System.currentTimeMillis();
+
+    assertEquals(1, syncSession.getInboundCount());
+    assertEquals(2, syncSession.getOutboundCount());
+
+    // Didn't lose any records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosA);
+    assertFirstContainsSecond(repoB.wbos, originalWbosB);
+    // Got new records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosB);
+    assertFirstContainsSecond(repoB.wbos, originalWbosA);
+
+    // Timestamps updated.
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+    TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+  }
+
+  @Test
+  public void testSynchronizerSessionOnlyLocalHasData() {
+    long before = System.currentTimeMillis();
+    boolean remoteDataAvailable = false;
+    boolean localDataAvailable = true;
+    doTest(remoteDataAvailable, localDataAvailable);
+    long after = System.currentTimeMillis();
+
+    // Record counts updated.
+    assertEquals(0, syncSession.getInboundCount());
+    assertEquals(2, syncSession.getOutboundCount());
+
+    // Didn't lose any records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosA);
+    assertFirstContainsSecond(repoB.wbos, originalWbosB);
+    // Got new records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosB);
+    // Didn't get records we shouldn't have fetched.
+    assertFirstDoesNotContainSecond(repoB.wbos, originalWbosA);
+
+    // Timestamps updated.
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+    TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+  }
+
+  @Test
+  public void testSynchronizerSessionOnlyRemoteHasData() {
+    long before = System.currentTimeMillis();
+    boolean remoteDataAvailable = true;
+    boolean localDataAvailable = false;
+    doTest(remoteDataAvailable, localDataAvailable);
+    long after = System.currentTimeMillis();
+
+    // Record counts updated.
+    assertEquals(1, syncSession.getInboundCount());
+    assertEquals(0, syncSession.getOutboundCount());
+
+    // Didn't lose any records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosA);
+    assertFirstContainsSecond(repoB.wbos, originalWbosB);
+    // Got new records.
+    assertFirstContainsSecond(repoB.wbos, originalWbosA);
+    // Didn't get records we shouldn't have fetched.
+    assertFirstDoesNotContainSecond(repoA.wbos, originalWbosB);
+
+    // Timestamps updated.
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+    TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+  }
+
+  @Test
+  public void testSynchronizerSessionNeitherHaveData() {
+    long before = System.currentTimeMillis();
+    boolean remoteDataAvailable = false;
+    boolean localDataAvailable = false;
+    doTest(remoteDataAvailable, localDataAvailable);
+    long after = System.currentTimeMillis();
+
+    // Record counts updated.
+    assertEquals(0, syncSession.getInboundCount());
+    assertEquals(0, syncSession.getOutboundCount());
+
+    // Didn't lose any records.
+    assertFirstContainsSecond(repoA.wbos, originalWbosA);
+    assertFirstContainsSecond(repoB.wbos, originalWbosB);
+    // Didn't get records we shouldn't have fetched.
+    assertFirstDoesNotContainSecond(repoA.wbos, originalWbosB);
+    assertFirstDoesNotContainSecond(repoB.wbos, originalWbosA);
+
+    // Timestamps updated.
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    TestSynchronizer.assertInRangeInclusive(before, sc.localBundle.getTimestamp(), after);
+    TestSynchronizer.assertInRangeInclusive(before, sc.remoteBundle.getTimestamp(), after);
+  }
+
+  protected void doSkipTest(boolean remoteShouldSkip, boolean localShouldSkip) {
+    repoA = new ShouldSkipWBORepository(remoteShouldSkip);
+    repoB = new ShouldSkipWBORepository(localShouldSkip);
+
+    Synchronizer synchronizer = new Synchronizer();
+    synchronizer.repositoryA = repoA;
+    synchronizer.repositoryB = repoB;
+
+    syncSession = new SynchronizerSession(synchronizer, new SynchronizerSessionDelegate() {
+      @Override
+      public void onInitialized(SynchronizerSession session) {
+        session.synchronize();
+      }
+
+      @Override
+      public void onSynchronized(SynchronizerSession session) {
+        WaitHelper.getTestWaiter().performNotify(new RuntimeException("Not expecting onSynchronized"));
+      }
+
+      @Override
+      public void onSynchronizeFailed(SynchronizerSession session, Exception lastException, String reason) {
+        WaitHelper.getTestWaiter().performNotify(lastException);
+      }
+
+      @Override
+      public void onSynchronizeSkipped(SynchronizerSession synchronizerSession) {
+        WaitHelper.getTestWaiter().performNotify();
+      }
+    });
+
+    WaitHelper.getTestWaiter().performWait(new Runnable() {
+      @Override
+      public void run() {
+        final Context context = null;
+        syncSession.init(context,
+            new RepositorySessionBundle(100),
+            new RepositorySessionBundle(200));
+      }
+    });
+
+    // If we skip, we don't update timestamps or even un-bundle.
+    SynchronizerConfiguration sc = syncSession.getSynchronizer().save();
+    assertNotNull(sc);
+    assertNull(sc.localBundle);
+    assertNull(sc.remoteBundle);
+    assertEquals(-1, syncSession.getInboundCount());
+    assertEquals(-1, syncSession.getOutboundCount());
+  }
+
+  @Test
+  public void testSynchronizerSessionShouldSkip() {
+    // These combinations should all skip.
+    doSkipTest(true, false);
+
+    doSkipTest(false, true);
+    doSkipTest(true, true);
+
+    try {
+      doSkipTest(false, false);
+      fail("Expected exception.");
+    } catch (WaitHelper.InnerError e) {
+      assertTrue(e.innerError instanceof RuntimeException);
+      assertEquals("Not expecting onSynchronized", e.innerError.getMessage());
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestUtils.java
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mozilla.gecko.sync.SyncConstants;
+import org.mozilla.gecko.sync.Utils;
+
+public class TestUtils extends Utils {
+
+  @Test
+  public void testGenerateGUID() {
+    for (int i = 0; i < 1000; ++i) {
+      assertEquals(12, Utils.generateGuid().length());
+    }
+  }
+
+  public static final byte[][] BYTE_ARRS = {
+    new byte[] {'	'}, // Tab.
+    new byte[] {'0'},
+    new byte[] {'A'},
+    new byte[] {'a'},
+    new byte[] {'I', 'U'},
+    new byte[] {'`', 'h', 'g', ' ', 's', '`'},
+    new byte[] {}
+  };
+  // Indices correspond with the above array.
+  public static final String[] STRING_ARR = {
+    "09",
+    "30",
+    "41",
+    "61",
+    "4955",
+    "606867207360",
+    ""
+  };
+
+  @Test
+  public void testByte2Hex() throws Exception {
+    for (int i = 0; i < BYTE_ARRS.length; ++i) {
+      final byte[] b = BYTE_ARRS[i];
+      final String expected = STRING_ARR[i];
+      assertEquals(expected, Utils.byte2Hex(b));
+    }
+  }
+
+  @Test
+  public void testHex2Byte() throws Exception {
+    for (int i = 0; i < STRING_ARR.length; ++i) {
+      final String s = STRING_ARR[i];
+      final byte[] expected = BYTE_ARRS[i];
+      assertTrue(Arrays.equals(expected, Utils.hex2Byte(s)));
+    }
+  }
+
+  @Test
+  public void testByte2Hex2ByteAndViceVersa() throws Exception { // There and back again!
+    for (int i = 0; i < BYTE_ARRS.length; ++i) {
+      // byte2Hex2Byte
+      final byte[] b = BYTE_ARRS[i];
+      final String s = Utils.byte2Hex(b);
+      assertTrue(Arrays.equals(b, Utils.hex2Byte(s)));
+    }
+
+    // hex2Byte2Hex
+    for (int i = 0; i < STRING_ARR.length; ++i) {
+      final String s = STRING_ARR[i];
+      final byte[] b = Utils.hex2Byte(s);
+      assertEquals(s, Utils.byte2Hex(b));
+    }
+  }
+
+  @Test
+  public void testByte2HexLength() throws Exception {
+    for (int i = 0; i < BYTE_ARRS.length; ++i) {
+      final byte[] b = BYTE_ARRS[i];
+      final String expected = STRING_ARR[i];
+      assertEquals(expected, Utils.byte2Hex(b, b.length));
+      assertEquals("0" + expected, Utils.byte2Hex(b, 2 * b.length + 1));
+      assertEquals("00" + expected, Utils.byte2Hex(b, 2 * b.length + 2));
+    }
+  }
+
+  @Test
+  public void testHex2ByteLength() throws Exception {
+    for (int i = 0; i < STRING_ARR.length; ++i) {
+      final String s = STRING_ARR[i];
+      final byte[] expected = BYTE_ARRS[i];
+      assertTrue(Arrays.equals(expected, Utils.hex2Byte(s)));
+      final byte[] expected1 = new byte[expected.length + 1];
+      System.arraycopy(expected, 0, expected1, 1, expected.length);
+      assertTrue(Arrays.equals(expected1, Utils.hex2Byte("00" + s)));
+      final byte[] expected2 = new byte[expected.length + 2];
+      System.arraycopy(expected, 0, expected2, 2, expected.length);
+      assertTrue(Arrays.equals(expected2, Utils.hex2Byte("0000" + s)));
+    }
+  }
+
+  @Test
+  public void testToCommaSeparatedString() {
+    ArrayList<String> xs = new ArrayList<String>();
+    assertEquals("", Utils.toCommaSeparatedString(null));
+    assertEquals("", Utils.toCommaSeparatedString(xs));
+    xs.add("test1");
+    assertEquals("test1", Utils.toCommaSeparatedString(xs));
+    xs.add("test2");
+    assertEquals("test1, test2", Utils.toCommaSeparatedString(xs));
+    xs.add("test3");
+    assertEquals("test1, test2, test3", Utils.toCommaSeparatedString(xs));
+  }
+
+  @Test
+  public void testUsernameFromAccount() throws NoSuchAlgorithmException, UnsupportedEncodingException {
+    assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.sha1Base32("foobar@baz.com"));
+    assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("foobar@baz.com"));
+    assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("FooBar@Baz.com"));
+    assertEquals("xee7ffonluzpdp66l6xgpyh2v2w6ojkc", Utils.usernameFromAccount("xee7ffonluzpdp66l6xgpyh2v2w6ojkc"));
+    assertEquals("foobar",                           Utils.usernameFromAccount("foobar"));
+    assertEquals("foobar",                           Utils.usernameFromAccount("FOOBAr"));
+  }
+
+  @Test
+  public void testGetPrefsPath() throws NoSuchAlgorithmException, UnsupportedEncodingException {
+    assertEquals("ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.sha1Base32("test.url.com:xee7ffonluzpdp66l6xgpyh2v2w6ojkc"));
+
+    assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("product", "foobar@baz.com", "test.url.com", "default", 0));
+    assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("org.mozilla.firefox_beta", "FooBar@Baz.com", "test.url.com", "default", 0));
+    assertEquals("sync.prefs.ore7dlrwqi6xr7honxdtpvmh6tly4r7k", Utils.getPrefsPath("org.mozilla.firefox", "xee7ffonluzpdp66l6xgpyh2v2w6ojkc", "test.url.com", "profile", 0));
+
+    assertEquals("sync.prefs.product.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.1", Utils.getPrefsPath("product", "foobar@baz.com", "test.url.com", "default", 1));
+    assertEquals("sync.prefs.with!spaces_underbars!periods.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.1", Utils.getPrefsPath("with spaces_underbars.periods", "foobar@baz.com", "test.url.com", "default", 1));
+    assertEquals("sync.prefs.org!mozilla!firefox_beta.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.default.2", Utils.getPrefsPath("org.mozilla.firefox_beta", "FooBar@Baz.com", "test.url.com", "default", 2));
+    assertEquals("sync.prefs.org!mozilla!firefox.ore7dlrwqi6xr7honxdtpvmh6tly4r7k.profile.3", Utils.getPrefsPath("org.mozilla.firefox", "xee7ffonluzpdp66l6xgpyh2v2w6ojkc", "test.url.com", "profile", 3));
+  }
+
+  @Test
+  public void testObfuscateEmail() {
+    assertEquals("XXX@XXX.XXX", Utils.obfuscateEmail("foo@bar.com"));
+    assertEquals("XXXX@XXX.XXXX.XX", Utils.obfuscateEmail("foot@bar.test.ca"));
+  }
+
+  @Test
+  public void testNodeWeaveURL() throws Exception {
+    Assert.assertEquals("http://userapi.com/endpoint/user/1.0/username/node/weave", Utils.nodeWeaveURL("http://userapi.com/endpoint", "username"));
+    Assert.assertEquals("http://userapi.com/endpoint/user/1.0/username/node/weave", Utils.nodeWeaveURL("http://userapi.com/endpoint/", "username"));
+    Assert.assertEquals(SyncConstants.DEFAULT_AUTH_SERVER + "user/1.0/username/node/weave", Utils.nodeWeaveURL(null, "username"));
+    Assert.assertEquals(SyncConstants.DEFAULT_AUTH_SERVER + "user/1.0/username2/node/weave", Utils.nodeWeaveURL(null, "username2"));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/BaseTestStorageRequestDelegate.java
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+
+public class BaseTestStorageRequestDelegate implements
+    SyncStorageRequestDelegate {
+
+  protected final AuthHeaderProvider authHeaderProvider;
+
+  public BaseTestStorageRequestDelegate(AuthHeaderProvider authHeaderProvider) {
+    this.authHeaderProvider = authHeaderProvider;
+  }
+
+  public BaseTestStorageRequestDelegate(String username, String password) {
+    this(new BasicAuthHeaderProvider(username, password));
+  }
+
+  @Override
+  public AuthHeaderProvider getAuthHeaderProvider() {
+    return authHeaderProvider;
+  }
+
+  @Override
+  public String ifUnmodifiedSince() {
+    return null;
+  }
+
+  @Override
+  public void handleRequestSuccess(SyncStorageResponse response) {
+    BaseResource.consumeEntity(response);
+    fail("Should not be called.");
+  }
+
+  @Override
+  public void handleRequestFailure(SyncStorageResponse response) {
+    System.out.println("Response: " + response.httpResponse().getStatusLine().getStatusCode());
+    BaseResource.consumeEntity(response);
+    fail("Should not be called.");
+  }
+
+  @Override
+  public void handleRequestError(Exception e) {
+    if (e instanceof IOException) {
+      System.out.println("WARNING: TEST FAILURE IGNORED!");
+      // Assume that this is because Jenkins doesn't have network access.
+      return;
+    }
+    fail("Should not error.");
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessDelegate.java
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+
+public class ExpectSuccessDelegate {
+  public WaitHelper waitHelper;
+
+  public ExpectSuccessDelegate(WaitHelper waitHelper) {
+    this.waitHelper = waitHelper;
+  }
+
+  public void performNotify() {
+    this.waitHelper.performNotify();
+  }
+
+  public void performNotify(Throwable e) {
+    this.waitHelper.performNotify(e);
+  }
+
+  public String logTag() {
+    return this.getClass().getSimpleName();
+  }
+
+  public void log(String message) {
+    Logger.info(logTag(), message);
+  }
+
+  public void log(String message, Throwable throwable) {
+    Logger.warn(logTag(), message, throwable);
+  }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionBeginDelegate.java
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.concurrent.ExecutorService;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
+
+public class ExpectSuccessRepositorySessionBeginDelegate
+extends ExpectSuccessDelegate
+implements RepositorySessionBeginDelegate {
+
+  public ExpectSuccessRepositorySessionBeginDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onBeginFailed(Exception ex) {
+    log("Session begin failed.", ex);
+    performNotify(new AssertionFailedError("Session begin failed: " + ex.getMessage()));
+  }
+
+  @Override
+  public void onBeginSucceeded(RepositorySession session) {
+    log("Session begin succeeded.");
+    performNotify();
+  }
+
+  @Override
+  public RepositorySessionBeginDelegate deferredBeginDelegate(ExecutorService executor) {
+    log("Session begin delegate deferred.");
+    return this;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionCreationDelegate.java
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
+
+public class ExpectSuccessRepositorySessionCreationDelegate extends
+    ExpectSuccessDelegate implements RepositorySessionCreationDelegate {
+
+  public ExpectSuccessRepositorySessionCreationDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onSessionCreateFailed(Exception ex) {
+    log("Session creation failed.", ex);
+    performNotify(new AssertionFailedError("onSessionCreateFailed: session creation should not have failed."));
+  }
+
+  @Override
+  public void onSessionCreated(RepositorySession session) {
+    log("Session creation succeeded.");
+    performNotify();
+  }
+
+  @Override
+  public RepositorySessionCreationDelegate deferredCreationDelegate() {
+    log("Session creation deferred.");
+    return this;
+  }
+
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFetchRecordsDelegate.java
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
+import org.mozilla.gecko.sync.repositories.domain.Record;
+
+public class ExpectSuccessRepositorySessionFetchRecordsDelegate extends
+    ExpectSuccessDelegate implements RepositorySessionFetchRecordsDelegate {
+  public ArrayList<Record> fetchedRecords = new ArrayList<Record>();
+
+  public ExpectSuccessRepositorySessionFetchRecordsDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onFetchFailed(Exception ex, Record record) {
+    log("Fetch failed.", ex);
+    performNotify(new AssertionFailedError("onFetchFailed: fetch should not have failed."));
+  }
+
+  @Override
+  public void onFetchedRecord(Record record) {
+    fetchedRecords.add(record);
+    log("Fetched record with guid '" + record.guid + "'.");
+  }
+
+  @Override
+  public void onFetchCompleted(long end) {
+    log("Fetch completed.");
+    performNotify();
+  }
+
+  @Override
+  public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
+    return this;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionFinishDelegate.java
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.concurrent.ExecutorService;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySessionBundle;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
+
+public class ExpectSuccessRepositorySessionFinishDelegate extends
+    ExpectSuccessDelegate implements RepositorySessionFinishDelegate {
+
+  public ExpectSuccessRepositorySessionFinishDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onFinishFailed(Exception ex) {
+    log("Finish failed.", ex);
+    performNotify(new AssertionFailedError("onFinishFailed: finish should not have failed."));
+  }
+
+  @Override
+  public void onFinishSucceeded(RepositorySession session, RepositorySessionBundle bundle) {
+    log("Finish succeeded.");
+    performNotify();
+  }
+
+  @Override
+  public RepositorySessionFinishDelegate deferredFinishDelegate(ExecutorService executor) {
+    return this;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositorySessionStoreDelegate.java
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.concurrent.ExecutorService;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
+
+public class ExpectSuccessRepositorySessionStoreDelegate extends
+    ExpectSuccessDelegate implements RepositorySessionStoreDelegate {
+
+  public ExpectSuccessRepositorySessionStoreDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onRecordStoreFailed(Exception ex, String guid) {
+    log("Record store failed.", ex);
+    performNotify(new AssertionFailedError("onRecordStoreFailed: record store should not have failed."));
+  }
+
+  @Override
+  public void onRecordStoreSucceeded(String guid) {
+    log("Record store succeeded.");
+  }
+
+  @Override
+  public void onStoreCompleted(long storeEnd) {
+    log("Record store completed at " + storeEnd);
+  }
+
+  @Override
+  public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor) {
+    return this;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/ExpectSuccessRepositoryWipeDelegate.java
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.concurrent.ExecutorService;
+
+import junit.framework.AssertionFailedError;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
+
+public class ExpectSuccessRepositoryWipeDelegate extends ExpectSuccessDelegate
+    implements RepositorySessionWipeDelegate {
+
+  public ExpectSuccessRepositoryWipeDelegate(WaitHelper waitHelper) {
+    super(waitHelper);
+  }
+
+  @Override
+  public void onWipeSucceeded() {
+    log("Wipe succeeded.");
+    performNotify();
+  }
+
+  @Override
+  public void onWipeFailed(Exception ex) {
+    log("Wipe failed.", ex);
+    performNotify(new AssertionFailedError("onWipeFailed: wipe should not have failed."));
+  }
+
+  @Override
+  public RepositorySessionWipeDelegate deferredWipeDelegate(ExecutorService executor) {
+    log("Wipe deferred.");
+    return this;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/HTTPServerTestHelper.java
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.simpleframework.transport.connect.Connection;
+import org.simpleframework.transport.connect.SocketConnection;
+
+/**
+ * Test helper code to bind <code>MockServer</code> instances to ports.
+ * <p>
+ * Maintains a collection of running servers and (by default) throws helpful
+ * errors if two servers are started "on top" of each other. The
+ * <b>unchecked</b> exception thrown contains a stack trace pointing to where
+ * the new server is being created and where the pre-existing server was
+ * created.
+ * <p>
+ * Parses a system property to determine current test port, which is fixed for
+ * the duration of a test execution.
+ */
+public class HTTPServerTestHelper {
+  private static final String LOG_TAG = "HTTPServerTestHelper";
+
+  /**
+   * Port to run HTTP servers on during this test execution.
+   * <p>
+   * Lazily initialized on first call to {@link #getTestPort}.
+   */
+  public static Integer testPort = null;
+
+  public static final String LOCAL_HTTP_PORT_PROPERTY = "android.sync.local.http.port";
+  public static final int    LOCAL_HTTP_PORT_DEFAULT = 15125;
+
+  public final int port;
+
+  public Connection connection;
+  public MockServer server;
+
+  /**
+   * Create a helper to bind <code>MockServer</code> instances.
+   * <p>
+   * Use {@link #getTestPort} to determine the port this helper will bind to.
+   */
+  public HTTPServerTestHelper() {
+    this.port = getTestPort();
+  }
+
+  // For testing only.
+  protected HTTPServerTestHelper(int port) {
+    this.port = port;
+  }
+
+  /**
+   * Lazily initialize test port for this test execution.
+   * <p>
+   * Only called from {@link #getTestPort}.
+   * <p>
+   * If the test port has not been determined, we try to parse it from a system
+   * property; if that fails, we return the default test port.
+   */
+  protected synchronized static void ensureTestPort() {
+    if (testPort != null) {
+      return;
+    }
+
+    String value = System.getProperty(LOCAL_HTTP_PORT_PROPERTY);
+    if (value != null) {
+      try {
+        testPort = Integer.valueOf(value);
+      } catch (NumberFormatException e) {
+        Logger.warn(LOG_TAG, "Got exception parsing local test port; ignoring. ", e);
+      }
+    }
+
+    if (testPort == null) {
+      testPort = Integer.valueOf(LOCAL_HTTP_PORT_DEFAULT);
+    }
+  }
+
+  /**
+   * The port to which all HTTP servers will be found for the duration of this
+   * test execution.
+   * <p>
+   * We try to parse the port from a system property; if that fails, we return
+   * the default test port.
+   *
+   * @return port number.
+   */
+  public synchronized static int getTestPort() {
+    if (testPort == null) {
+      ensureTestPort();
+    }
+
+    return testPort.intValue();
+  }
+
+  /**
+   * Used to maintain a stack trace pointing to where a server was started.
+   */
+  public static class HTTPServerStartedError extends Error {
+    private static final long serialVersionUID = -6778447718799087274L;
+
+    public final HTTPServerTestHelper httpServer;
+
+    public HTTPServerStartedError(HTTPServerTestHelper httpServer) {
+      this.httpServer = httpServer;
+    }
+  }
+
+  /**
+   * Thrown when a server is started "on top" of another server. The cause error
+   * will be an <code>HTTPServerStartedError</code> with a stack trace pointing
+   * to where the pre-existing server was started.
+   */
+  public static class HTTPServerAlreadyRunningError extends Error {
+    private static final long serialVersionUID = -6778447718799087275L;
+
+    public HTTPServerAlreadyRunningError(Throwable e) {
+      super(e);
+    }
+  }
+
+  /**
+   * Maintain a hash of running servers. Each value is an error with a stack
+   * traces pointing to where that server was started.
+   * <p>
+   * We don't key on the server itself because each server is a <it>helper</it>
+   * that may be started many times with different <code>MockServer</code>
+   * instances.
+   * <p>
+   * Synchronize access on the class.
+   */
+  protected static Map<Connection, HTTPServerStartedError> runningServers =
+      new IdentityHashMap<Connection, HTTPServerStartedError>();
+
+  protected synchronized static void throwIfServerAlreadyRunning() {
+    for (HTTPServerStartedError value : runningServers.values()) {
+      throw new HTTPServerAlreadyRunningError(value);
+    }
+  }
+
+  protected synchronized static void registerServerAsRunning(HTTPServerTestHelper httpServer) {
+    if (httpServer == null || httpServer.connection == null) {
+      throw new IllegalArgumentException("HTTPServerTestHelper or connection was null; perhaps server has not been started?");
+    }
+
+    HTTPServerStartedError old = runningServers.put(httpServer.connection, new HTTPServerStartedError(httpServer));
+    if (old != null) {
+      // Should never happen.
+      throw old;
+    }
+  }
+
+  protected synchronized static void unregisterServerAsRunning(HTTPServerTestHelper httpServer) {
+    if (httpServer == null || httpServer.connection == null) {
+      throw new IllegalArgumentException("HTTPServerTestHelper or connection was null; perhaps server has not been started?");
+    }
+
+    runningServers.remove(httpServer.connection);
+  }
+
+  public MockServer startHTTPServer(MockServer server, boolean allowMultipleServers) {
+    BaseResource.rewriteLocalhost = false; // No sense rewriting when we're running the unit tests.
+    BaseResourceDelegate.connectionTimeoutInMillis = 1000; // No sense waiting a long time for a local connection.
+
+    if (!allowMultipleServers) {
+      throwIfServerAlreadyRunning();
+    }
+
+    try {
+      this.server = server;
+      connection = new SocketConnection(server);
+      SocketAddress address = new InetSocketAddress(port);
+      connection.connect(address);
+
+      registerServerAsRunning(this);
+
+      Logger.info(LOG_TAG, "Started HTTP server on port " + port + ".");
+    } catch (IOException ex) {
+      Logger.error(LOG_TAG, "Error starting HTTP server on port " + port + ".", ex);
+      fail(ex.toString());
+    }
+
+    return server;
+  }
+
+  public MockServer startHTTPServer(MockServer server) {
+    return startHTTPServer(server, false);
+  }
+
+  public MockServer startHTTPServer() {
+    return startHTTPServer(new MockServer());
+  }
+
+  public void stopHTTPServer() {
+    try {
+      if (connection != null) {
+        unregisterServerAsRunning(this);
+
+        connection.close();
+      }
+      server = null;
+      connection = null;
+
+      Logger.info(LOG_TAG, "Stopped HTTP server on port " + port + ".");
+
+      Logger.debug(LOG_TAG, "Closing connection pool...");
+      BaseResource.shutdownConnectionManager();
+    } catch (IOException ex) {
+      Logger.error(LOG_TAG, "Error stopping HTTP server on port " + port + ".", ex);
+      fail(ex.toString());
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.assertEquals;
+
+import java.net.URI;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
+import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+
+/**
+ * A callback for use with a GlobalSession that records what happens for later
+ * inspection.
+ *
+ * This callback is expected to be used from within the friendly confines of a
+ * WaitHelper performWait.
+ */
+public class MockGlobalSessionCallback implements GlobalSessionCallback {
+  public final String nodeWeaveURL;
+
+  public MockGlobalSessionCallback(String nodeWeaveURL) {
+    this.nodeWeaveURL = nodeWeaveURL;
+  }
+
+  public MockGlobalSessionCallback() {
+    this(null);
+  }
+
+  protected WaitHelper testWaiter() {
+    return WaitHelper.getTestWaiter();
+  }
+
+  public int stageCounter = Stage.values().length - 1; // Exclude starting state.
+  public boolean calledSuccess = false;
+  public boolean calledError = false;
+  public Exception calledErrorException = null;
+  public boolean calledAborted = false;
+  public boolean calledRequestBackoff = false;
+  public boolean calledInformNodeAuthenticationFailed = false;
+  public boolean calledInformNodeAssigned = false;
+  public boolean calledInformUnauthorizedResponse = false;
+  public boolean calledInformUpgradeRequiredResponse = false;
+  public boolean calledInformMigrated = false;
+  public URI calledInformNodeAuthenticationFailedClusterURL = null;
+  public URI calledInformNodeAssignedOldClusterURL = null;
+  public URI calledInformNodeAssignedNewClusterURL = null;
+  public URI calledInformUnauthorizedResponseClusterURL = null;
+  public long weaveBackoff = -1;
+
+  @Override
+  public void handleSuccess(GlobalSession globalSession) {
+    this.calledSuccess = true;
+    assertEquals(0, this.stageCounter);
+    this.testWaiter().performNotify();
+  }
+
+  @Override
+  public void handleAborted(GlobalSession globalSession, String reason) {
+    this.calledAborted = true;
+    this.testWaiter().performNotify();
+  }
+
+  @Override
+  public void handleError(GlobalSession globalSession, Exception ex) {
+    this.calledError = true;
+    this.calledErrorException = ex;
+    this.testWaiter().performNotify();
+  }
+
+  @Override
+  public void handleStageCompleted(Stage currentState,
+           GlobalSession globalSession) {
+    stageCounter--;
+  }
+
+  @Override
+  public void requestBackoff(long backoff) {
+    this.calledRequestBackoff = true;
+    this.weaveBackoff = backoff;
+  }
+
+  @Override
+  public void informNodeAuthenticationFailed(GlobalSession session, URI clusterURL) {
+    this.calledInformNodeAuthenticationFailed = true;
+    this.calledInformNodeAuthenticationFailedClusterURL = clusterURL;
+  }
+
+  @Override
+  public void informNodeAssigned(GlobalSession session, URI oldClusterURL, URI newClusterURL) {
+    this.calledInformNodeAssigned = true;
+    this.calledInformNodeAssignedOldClusterURL = oldClusterURL;
+    this.calledInformNodeAssignedNewClusterURL = newClusterURL;
+  }
+
+  @Override
+  public void informUnauthorizedResponse(GlobalSession session, URI clusterURL) {
+    this.calledInformUnauthorizedResponse = true;
+    this.calledInformUnauthorizedResponseClusterURL = clusterURL;
+  }
+
+  @Override
+  public void informUpgradeRequiredResponse(GlobalSession session) {
+    this.calledInformUpgradeRequiredResponse = true;
+  }
+
+  @Override
+  public void informMigrated(GlobalSession session) {
+    this.calledInformMigrated = true;
+  }
+
+  @Override
+  public boolean shouldBackOffStorage() {
+    return false;
+  }
+
+  @Override
+  public boolean wantNodeAssignment() {
+    return false;
+  }
+
+  @Override
+  public String nodeWeaveURL() {
+    return nodeWeaveURL;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockResourceDelegate.java
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import org.mozilla.gecko.background.testhelpers.WaitHelper;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.net.ResourceDelegate;
+
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+
+public class MockResourceDelegate implements ResourceDelegate {
+  public WaitHelper waitHelper = null;
+  public static String USER_PASS    = "john:password";
+  public static String EXPECT_BASIC = "Basic am9objpwYXNzd29yZA==";
+
+  public boolean handledHttpResponse = false;
+  public HttpResponse httpResponse = null;
+
+  public MockResourceDelegate(WaitHelper waitHelper) {
+    this.waitHelper = waitHelper;
+  }
+
+  public MockResourceDelegate() {
+    this.waitHelper = WaitHelper.getTestWaiter();
+  }
+
+  @Override
+  public String getUserAgent() {
+    return null;
+  }
+
+  @Override
+  public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+  }
+
+  @Override
+  public int connectionTimeout() {
+    return 0;
+  }
+
+  @Override
+  public int socketTimeout() {
+    return 0;
+  }
+
+  @Override
+  public AuthHeaderProvider getAuthHeaderProvider() {
+    return new BasicAuthHeaderProvider(USER_PASS);
+  }
+
+  @Override
+  public void handleHttpProtocolException(ClientProtocolException e) {
+    waitHelper.performNotify(e);
+  }
+
+  @Override
+  public void handleHttpIOException(IOException e) {
+    waitHelper.performNotify(e);
+  }
+
+  @Override
+  public void handleTransportException(GeneralSecurityException e) {
+    waitHelper.performNotify(e);
+  }
+
+  @Override
+  public void handleHttpResponse(HttpResponse response) {
+    handledHttpResponse = true;
+    httpResponse = response;
+
+    assertEquals(response.getStatusLine().getStatusCode(), 200);
+    BaseResource.consumeEntity(response);
+    waitHelper.performNotify();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockServer.java
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.io.PrintStream;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.Utils;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+import org.simpleframework.http.core.Container;
+
+public class MockServer implements Container {
+  public static final String LOG_TAG = "MockServer";
+
+  public int statusCode = 200;
+  public String body = "Hello World";
+
+  public MockServer() {
+  }
+
+  public MockServer(int statusCode, String body) {
+    this.statusCode = statusCode;
+    this.body = body;
+  }
+
+  public String expectedBasicAuthHeader;
+
+  protected PrintStream handleBasicHeaders(Request request, Response response, int code, String contentType) throws IOException {
+    return this.handleBasicHeaders(request, response, code, contentType, System.currentTimeMillis());
+  }
+
+  protected PrintStream handleBasicHeaders(Request request, Response response, int code, String contentType, long time) throws IOException {
+    Logger.debug(LOG_TAG, "< Auth header: " + request.getValue("Authorization"));
+
+    PrintStream bodyStream = response.getPrintStream();
+    response.setCode(code);
+    response.set("Content-Type", contentType);
+    response.set("Server", "HelloWorld/1.0 (Simple 4.0)");
+    response.setDate("Date", time);
+    response.setDate("Last-Modified", time);
+
+    final String timestampHeader = Utils.millisecondsToDecimalSecondsString(time);
+    response.set("X-Weave-Timestamp", timestampHeader);
+    Logger.debug(LOG_TAG, "> X-Weave-Timestamp header: " + timestampHeader);
+    return bodyStream;
+  }
+
+  protected void handle(Request request, Response response, int code, String body) {
+    try {
+      Logger.debug(LOG_TAG, "Handling request...");
+      PrintStream bodyStream = this.handleBasicHeaders(request, response, code, "application/json");
+
+      if (expectedBasicAuthHeader != null) {
+        Logger.debug(LOG_TAG, "Expecting auth header " + expectedBasicAuthHeader);
+        assertEquals(request.getValue("Authorization"), expectedBasicAuthHeader);
+      }
+
+      bodyStream.println(body);
+      bodyStream.close();
+    } catch (IOException e) {
+      Logger.error(LOG_TAG, "Oops.");
+    }
+  }
+  public void handle(Request request, Response response) {
+    this.handle(request, response, statusCode, body);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockSyncClientsEngineStage.java
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers;
+
+import static org.junit.Assert.assertTrue;
+
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
+
+public class MockSyncClientsEngineStage extends SyncClientsEngineStage {
+  public class MockClientUploadDelegate extends ClientUploadDelegate {
+    HTTPServerTestHelper data;
+
+    public MockClientUploadDelegate(HTTPServerTestHelper data) {
+      this.data = data;
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse response) {
+      assertTrue(response.wasSuccessful());
+      data.stopHTTPServer();
+      super.handleRequestSuccess(response);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      BaseResource.consumeEntity(response);
+      data.stopHTTPServer();
+      super.handleRequestFailure(response);
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      ex.printStackTrace();
+      data.stopHTTPServer();
+      super.handleRequestError(ex);
+    }
+  }
+
+  public class TestClientDownloadDelegate extends ClientDownloadDelegate {
+    HTTPServerTestHelper data;
+
+    public TestClientDownloadDelegate(HTTPServerTestHelper data) {
+      this.data = data;
+    }
+
+    @Override
+    public void handleRequestSuccess(SyncStorageResponse response) {
+      assertTrue(response.wasSuccessful());
+      BaseResource.consumeEntity(response);
+      data.stopHTTPServer();
+      super.handleRequestSuccess(response);
+    }
+
+    @Override
+    public void handleRequestFailure(SyncStorageResponse response) {
+      BaseResource.consumeEntity(response);
+      super.handleRequestFailure(response);
+      data.stopHTTPServer();
+    }
+
+    @Override
+    public void handleRequestError(Exception ex) {
+      ex.printStackTrace();
+      super.handleRequestError(ex);
+      data.stopHTTPServer();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockWBOServer.java
@@ -0,0 +1,28 @@
+package org.mozilla.android.sync.test.helpers;
+
+import java.util.HashMap;
+
+import org.simpleframework.http.Path;
+import org.simpleframework.http.Request;
+import org.simpleframework.http.Response;
+
+/**
+ * A trivial server that collects and returns WBOs.
+ *
+ * @author rnewman
+ *
+ */
+public class MockWBOServer extends MockServer {
+  public HashMap<String, HashMap<String, String> > collections;
+
+  public MockWBOServer() {
+    collections = new HashMap<String, HashMap<String, String> >();
+  }
+
+  @Override
+  public void handle(Request request, Response response) {
+    Path path = request.getPath();
+    path.getPath(0);
+    // TODO
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/test/TestHTTPServerTestHelper.java
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.android.sync.test.helpers.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import org.junit.Test;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
+import org.mozilla.android.sync.test.helpers.MockServer;
+import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper.HTTPServerAlreadyRunningError;
+
+public class TestHTTPServerTestHelper {
+  public static final int TEST_PORT = HTTPServerTestHelper.getTestPort();
+
+  protected MockServer mockServer = new MockServer();
+
+  @Test
+  public void testStartStop() {
+    // Need to be able to start and stop multiple times.
+    for (int i = 0; i < 2; i++) {
+      HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+
+      assertNull(httpServer.connection);
+      httpServer.startHTTPServer(mockServer);
+
+      assertNotNull(httpServer.connection);
+      httpServer.stopHTTPServer();
+    }
+  }
+
+  public void startAgain() {
+    HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+    httpServer.startHTTPServer(mockServer);
+  }
+
+  @Test
+  public void testStartTwice() {
+    HTTPServerTestHelper httpServer = new HTTPServerTestHelper();
+
+    httpServer.startHTTPServer(mockServer);
+    assertNotNull(httpServer.connection);
+
+    // Should not be able to start multiple times.
+    try {
+      try {
+        startAgain();
+
+        fail("Expected exception.");
+      } catch (Throwable e) {
+        assertEquals(HTTPServerAlreadyRunningError.class, e.getClass());
+
+        StringWriter sw = new StringWriter();
+        e.printStackTrace(new PrintWriter(sw));
+        String s = sw.toString();
+
+        // Ensure we get a useful stack trace.
+        // We should have the method trying to start the server the second time...
+        assertTrue(s.contains("startAgain"));
+        // ... as well as the the method that started the server the first time.
+        assertTrue(s.contains("testStartTwice"));
+      }
+    } finally {
+      httpServer.stopHTTPServer();
+    }
+  }
+
+  protected static class LeakyHTTPServerTestHelper extends HTTPServerTestHelper {
+    // Make this constructor public, just for this test.
+    public LeakyHTTPServerTestHelper(int port) {
+      super(port);
+    }
+  }
+
+  @Test
+  public void testForceStartTwice() {
+    HTTPServerTestHelper httpServer1 = new HTTPServerTestHelper();
+    HTTPServerTestHelper httpServer2 = new LeakyHTTPServerTestHelper(httpServer1.port + 1);
+
+    // Should be able to start multiple times if we specify it.
+    try {
+      httpServer1.startHTTPServer(mockServer);
+      assertNotNull(httpServer1.connection);
+
+      httpServer2.startHTTPServer(mockServer, true);
+      assertNotNull(httpServer2.connection);
+    } finally {
+      httpServer1.stopHTTPServer();
+      httpServer2.stopHTTPServer();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/common/log/writers/test/TestLogWriters.java
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.common.log.writers.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.common.log.writers.LevelFilteringLogWriter;
+import org.mozilla.gecko.background.common.log.writers.LogWriter;
+import org.mozilla.gecko.background.common.log.writers.PrintLogWriter;
+import org.mozilla.gecko.background.common.log.writers.SimpleTagLogWriter;
+import org.mozilla.gecko.background.common.log.writers.StringLogWriter;
+import org.mozilla.gecko.background.common.log.writers.ThreadLocalTagLogWriter;
+
+import android.util.Log;
+
+public class TestLogWriters {
+
+  public static final String TEST_LOG_TAG_1 = "TestLogTag1";
+  public static final String TEST_LOG_TAG_2 = "TestLogTag2";
+
+  public static final String TEST_MESSAGE_1  = "LOG TEST MESSAGE one";
+  public static final String TEST_MESSAGE_2  = "LOG TEST MESSAGE two";
+  public static final String TEST_MESSAGE_3  = "LOG TEST MESSAGE three";
+
+  @Before
+  public void setUp() {
+    Logger.stopLoggingToAll();
+  }
+
+  @After
+  public void tearDown() {
+    Logger.stopLoggingToAll();
+  }
+
+  @Test
+  public void testStringLogWriter() {
+    StringLogWriter lw = new StringLogWriter();
+
+    Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_1, new RuntimeException());
+    Logger.startLoggingTo(lw);
+    Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.warn(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.info(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.debug(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.trace(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.stopLoggingTo(lw);
+    Logger.error(TEST_LOG_TAG_2, TEST_MESSAGE_3, new RuntimeException());
+
+    String s = lw.toString();
+    assertFalse(s.contains("RuntimeException"));
+    assertFalse(s.contains(".java"));
+    assertTrue(s.contains(TEST_LOG_TAG_1));
+    assertFalse(s.contains(TEST_LOG_TAG_2));
+    assertFalse(s.contains(TEST_MESSAGE_1));
+    assertTrue(s.contains(TEST_MESSAGE_2));
+    assertFalse(s.contains(TEST_MESSAGE_3));
+  }
+
+  @Test
+  public void testSingleTagLogWriter() {
+    final String SINGLE_TAG = "XXX";
+    StringLogWriter lw = new StringLogWriter();
+
+    Logger.startLoggingTo(new SimpleTagLogWriter(SINGLE_TAG, lw));
+    Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_1);
+    Logger.warn(TEST_LOG_TAG_2, TEST_MESSAGE_2);
+
+    String s = lw.toString();
+    for (String line : s.split("\n")) {
+      assertTrue(line.startsWith(SINGLE_TAG));
+    }
+    assertTrue(s.startsWith(SINGLE_TAG + " :: E :: " + TEST_LOG_TAG_1));
+  }
+
+  @Test
+  public void testLevelFilteringLogWriter() {
+    StringLogWriter lw = new StringLogWriter();
+
+    assertFalse(new LevelFilteringLogWriter(Log.WARN, lw).shouldLogVerbose(TEST_LOG_TAG_1));
+    assertTrue(new LevelFilteringLogWriter(Log.VERBOSE, lw).shouldLogVerbose(TEST_LOG_TAG_1));
+
+    Logger.startLoggingTo(new LevelFilteringLogWriter(Log.WARN, lw));
+    Logger.error(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.warn(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.info(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.debug(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+    Logger.trace(TEST_LOG_TAG_1, TEST_MESSAGE_2);
+
+    String s = lw.toString();
+    assertTrue(s.contains(PrintLogWriter.ERROR));
+    assertTrue(s.contains(PrintLogWriter.WARN));
+    assertFalse(s.contains(PrintLogWriter.INFO));
+    assertFalse(s.contains(PrintLogWriter.DEBUG));
+    assertFalse(s.contains(PrintLogWriter.VERBOSE));
+  }
+
+  @Test
+  public void testThreadLocalLogWriter() throws InterruptedException {
+    final InheritableThreadLocal<String> logTag = new InheritableThreadLocal<String>() {
+      @Override
+      protected String initialValue() {
+        return "PARENT";
+      }
+    };
+
+    final StringLogWriter stringLogWriter = new StringLogWriter();
+    final LogWriter logWriter = new ThreadLocalTagLogWriter(logTag, stringLogWriter);
+
+    try {
+      Logger.startLoggingTo(logWriter);
+
+      Logger.info("parent tag before", "parent message before");
+
+      int threads = 3;
+      final CountDownLatch latch = new CountDownLatch(threads);
+
+      for (int thread = 0; thread < threads; thread++) {
+        final int threadNumber = thread;
+
+        new Thread(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              logTag.set("CHILD" + threadNumber);
+              Logger.info("child tag " + threadNumber, "child message " + threadNumber);
+            } finally {
+              latch.countDown();
+            }
+          }
+        }).start();
+      }
+
+      latch.await();
+
+      Logger.info("parent tag after", "parent message after");
+
+      String s = stringLogWriter.toString();
+      List<String> lines = Arrays.asList(s.split("\n"));
+
+      // Because tests are run in a multi-threaded environment, we get
+      // additional logs that are not generated by this test. So we test that we
+      // get all the messages in a reasonable order.
+      try {
+        int parent1 = lines.indexOf("PARENT :: I :: parent tag before :: parent message before");
+        int parent2 = lines.indexOf("PARENT :: I :: parent tag after :: parent message after");
+
+        assertTrue(parent1 >= 0);
+        assertTrue(parent2 >= 0);
+        assertTrue(parent1 < parent2);
+
+        for (int thread = 0; thread < threads; thread++) {
+          int child = lines.indexOf("CHILD" + thread + " :: I :: child tag " + thread + " :: child message " + thread);
+          assertTrue(child >= 0);
+          assertTrue(parent1 < child);
+          assertTrue(child < parent2);
+        }
+      } catch (Throwable e) {
+        // Shouldn't happen.  Let's dump to aid debugging.
+        e.printStackTrace();
+        assertEquals("\0", s);
+      }
+    } finally {
+      Logger.stopLoggingTo(logWriter);
+    }
+  }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountAgeLockoutHelper.java
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.test;
+
+import java.util.Calendar;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mozilla.gecko.background.fxa.FxAccountAgeLockoutHelper;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+
+public class TestFxAccountAgeLockoutHelper {
+  @Test
+  public void testPassesAgeCheck() {
+    Calendar today = Calendar.getInstance();
+    int birthMonthIndex = today.get(Calendar.MONTH);
+    int birthDate = today.get(Calendar.DATE);
+    int birthYear = today.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertTrue("Minimum age as of today",
+        FxAccountAgeLockoutHelper.passesAgeCheck(birthDate, birthMonthIndex, birthYear));
+
+    Calendar yesterday = Calendar.getInstance();
+    yesterday.add(Calendar.DATE, -1);
+    birthMonthIndex = yesterday.get(Calendar.MONTH);
+    birthDate = yesterday.get(Calendar.DATE);
+    birthYear = yesterday.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertTrue("Minimum age more than by a day",
+        FxAccountAgeLockoutHelper.passesAgeCheck(birthDate, birthMonthIndex, birthYear));
+
+    Calendar tomorrow = Calendar.getInstance();
+    tomorrow.add(Calendar.DATE, 1);
+    birthMonthIndex = tomorrow.get(Calendar.MONTH);
+    birthDate = tomorrow.get(Calendar.DATE);
+    birthYear = tomorrow.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertFalse("Minimum age fails by a day",
+        FxAccountAgeLockoutHelper.passesAgeCheck(birthDate, birthMonthIndex, birthYear));
+
+    Calendar monthBefore = Calendar.getInstance();
+    monthBefore.add(Calendar.MONTH, -1);
+    birthMonthIndex = monthBefore.get(Calendar.MONTH);
+    birthDate = monthBefore.get(Calendar.DATE);
+    birthYear = monthBefore.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertTrue("Minimum age more than by a month",
+        FxAccountAgeLockoutHelper.passesAgeCheck(birthDate, birthMonthIndex, birthYear));
+
+    Calendar monthAfter = Calendar.getInstance();
+    monthAfter.add(Calendar.MONTH, 1);
+    birthMonthIndex = monthAfter.get(Calendar.MONTH);
+    birthDate = monthAfter.get(Calendar.DATE);
+    birthYear = monthAfter.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertFalse("Minimum age fails by a month",
+        FxAccountAgeLockoutHelper.passesAgeCheck(birthDate, birthMonthIndex, birthYear));
+  }
+
+  @Test
+  public void testIsMagicYear() {
+    final Calendar today = Calendar.getInstance();
+    int magicYear = today.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT;
+    Assert.assertTrue("Passes magic year check: year is magic year",
+        FxAccountAgeLockoutHelper.isMagicYear(magicYear));
+
+    int beforeMagicYear = today.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT - 1;
+    Assert.assertFalse("Fails magic year check: year before magic year",
+        FxAccountAgeLockoutHelper.isMagicYear(beforeMagicYear));
+
+    int afterMagicYear = today.get(Calendar.YEAR) - FxAccountConstants.MINIMUM_AGE_TO_CREATE_AN_ACCOUNT + 1;
+    Assert.assertFalse("Fails magic year: year after magic year",
+        FxAccountAgeLockoutHelper.isMagicYear(afterMagicYear));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountClient20.java
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.test;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+public class TestFxAccountClient20 {
+  protected static class MockFxAccountClient20 extends FxAccountClient20 {
+    public MockFxAccountClient20(String serverURI, Executor executor) {
+      super(serverURI, executor);
+    }
+
+    // Public for testing.
+    @Override
+    public BaseResource getBaseResource(final String path, final String... queryParameters) throws UnsupportedEncodingException, URISyntaxException {
+      return super.getBaseResource(path, queryParameters);
+    }
+  }
+
+  @Test
+  public void testGetCreateAccountURI() throws Exception {
+    final String TEST_SERVER = "https://test.com:4430/inner/v1/";
+    final MockFxAccountClient20 client = new MockFxAccountClient20(TEST_SERVER, Executors.newSingleThreadExecutor());
+    Assert.assertEquals(TEST_SERVER + "account/create", client.getBaseResource("account/create").getURIString());
+    Assert.assertEquals(TEST_SERVER + "account/create?service=sync&keys=true", client.getBaseResource("account/create", "service", "sync", "keys", "true").getURIString());
+    Assert.assertEquals(TEST_SERVER + "account/create?service=two+words", client.getBaseResource("account/create", "service", "two words").getURIString());
+    Assert.assertEquals(TEST_SERVER + "account/create?service=symbols%2F%3A%3F%2B", client.getBaseResource("account/create", "service", "symbols/:?+").getURIString());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.test;
+
+import java.math.BigInteger;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.crypto.KeyBundle;
+import org.mozilla.gecko.sync.net.SRPConstants;
+
+/**
+ * Test vectors from
+ * <a href="https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#stretch-KDF">https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#stretch-KDF</a>
+ * and
+ * <a href="https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol/5a9bc81e499306d769ca19b40b50fa60123df15d">https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol/5a9bc81e499306d769ca19b40b50fa60123df15d</a>.
+ */
+public class TestFxAccountUtils {
+  protected static void assertEncoding(String base16String, String utf8String) throws Exception {
+    Assert.assertEquals(base16String, FxAccountUtils.bytes(utf8String));
+  }
+
+  @Test
+  public void testUTF8Encoding() throws Exception {
+    assertEncoding("616e6472c3a9406578616d706c652e6f7267", "andré@example.org");
+    assertEncoding("70c3a4737377c3b67264", "pässwörd");
+  }
+
+  @Test
+  public void testHexModN() {
+    BigInteger N = BigInteger.valueOf(14);
+    Assert.assertEquals(4, N.bitLength());
+    Assert.assertEquals(1, (N.bitLength() + 7)/8);
+    Assert.assertEquals("00", FxAccountUtils.hexModN(BigInteger.valueOf(0), N));
+    Assert.assertEquals("05", FxAccountUtils.hexModN(BigInteger.valueOf(5), N));
+    Assert.assertEquals("0b", FxAccountUtils.hexModN(BigInteger.valueOf(11), N));
+    Assert.assertEquals("00", FxAccountUtils.hexModN(BigInteger.valueOf(14), N));
+    Assert.assertEquals("01", FxAccountUtils.hexModN(BigInteger.valueOf(15), N));
+    Assert.assertEquals("02", FxAccountUtils.hexModN(BigInteger.valueOf(16), N));
+    Assert.assertEquals("02", FxAccountUtils.hexModN(BigInteger.valueOf(30), N));
+
+    N = BigInteger.valueOf(260);
+    Assert.assertEquals("00ff", FxAccountUtils.hexModN(BigInteger.valueOf(255), N));
+    Assert.assertEquals("0100", FxAccountUtils.hexModN(BigInteger.valueOf(256), N));
+    Assert.assertEquals("0101", FxAccountUtils.hexModN(BigInteger.valueOf(257), N));
+    Assert.assertEquals("0001", FxAccountUtils.hexModN(BigInteger.valueOf(261), N));
+  }
+
+  @Test
+  public void testSRPVerifierFunctions() throws Exception {
+    byte[] emailUTF8Bytes = Utils.hex2Byte("616e6472c3a9406578616d706c652e6f7267");
+    byte[] srpPWBytes = Utils.hex2Byte("00f9b71800ab5337d51177d8fbc682a3653fa6dae5b87628eeec43a18af59a9d", 32);
+    byte[] srpSaltBytes = Utils.hex2Byte("00f1000000000000000000000000000000000000000000000000000000000179", 32);
+
+    String expectedX = "81925186909189958012481408070938147619474993903899664126296984459627523279550";
+    BigInteger x = FxAccountUtils.srpVerifierLowercaseX(emailUTF8Bytes, srpPWBytes, srpSaltBytes);
+    Assert.assertEquals(expectedX, x.toString(10));
+
+    String expectedV = "11464957230405843056840989945621595830717843959177257412217395741657995431613430369165714029818141919887853709633756255809680435884948698492811770122091692817955078535761033207000504846365974552196983218225819721112680718485091921646083608065626264424771606096544316730881455897489989950697705196721477608178869100211706638584538751009854562396937282582855620488967259498367841284829152987988548996842770025110751388952323221706639434861071834212055174768483159061566055471366772641252573641352721966728239512914666806496255304380341487975080159076396759492553066357163103546373216130193328802116982288883318596822";
+    BigInteger v = FxAccountUtils.srpVerifierLowercaseV(emailUTF8Bytes, srpPWBytes, srpSaltBytes, SRPConstants._2048.g, SRPConstants._2048.N);
+    Assert.assertEquals(expectedV, v.toString(10));
+
+    String expectedVHex = "00173ffa0263e63ccfd6791b8ee2a40f048ec94cd95aa8a3125726f9805e0c8283c658dc0b607fbb25db68e68e93f2658483049c68af7e8214c49fde2712a775b63e545160d64b00189a86708c69657da7a1678eda0cd79f86b8560ebdb1ffc221db360eab901d643a75bf1205070a5791230ae56466b8c3c1eb656e19b794f1ea0d2a077b3a755350208ea0118fec8c4b2ec344a05c66ae1449b32609ca7189451c259d65bd15b34d8729afdb5faff8af1f3437bbdc0c3d0b069a8ab2a959c90c5a43d42082c77490f3afcc10ef5648625c0605cdaace6c6fdc9e9a7e6635d619f50af7734522470502cab26a52a198f5b00a279858916507b0b4e9ef9524d6";
+    Assert.assertEquals(expectedVHex, FxAccountUtils.hexModN(v, SRPConstants._2048.N));
+  }
+
+  @Test
+  public void testGenerateSyncKeyBundle() throws Exception {
+    byte[] kB = Utils.hex2Byte("d02d8fe39f28b601159c543f2deeb8f72bdf2043e8279aa08496fbd9ebaea361");
+    KeyBundle bundle = FxAccountUtils.generateSyncKeyBundle(kB);
+    Assert.assertEquals("rsLwECkgPYeGbYl92e23FskfIbgld9TgeifEaB9ZwTI=", Base64.encodeBase64String(bundle.getEncryptionKey()));
+    Assert.assertEquals("fs75EseCD/VOLodlIGmwNabBjhTYBHFCe7CGIf0t8Tw=", Base64.encodeBase64String(bundle.getHMACKey()));
+  }
+
+  @Test
+  public void testGeneration() throws Exception {
+    byte[] quickStretchedPW = FxAccountUtils.generateQuickStretchedPW(
+        Utils.hex2Byte("616e6472c3a9406578616d706c652e6f7267"),
+        Utils.hex2Byte("70c3a4737377c3b67264"));
+    Assert.assertEquals("e4e8889bd8bd61ad6de6b95c059d56e7b50dacdaf62bd84644af7e2add84345d",
+        Utils.byte2Hex(quickStretchedPW));
+    Assert.assertEquals("247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375",
+        Utils.byte2Hex(FxAccountUtils.generateAuthPW(quickStretchedPW)));
+    byte[] unwrapkB = FxAccountUtils.generateUnwrapBKey(quickStretchedPW);
+    Assert.assertEquals("de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28",
+        Utils.byte2Hex(unwrapkB));
+    byte[] wrapkB = Utils.hex2Byte("7effe354abecbcb234a8dfc2d7644b4ad339b525589738f2d27341bb8622ecd8");
+    Assert.assertEquals("a095c51c1c6e384e8d5777d97e3c487a4fc2128a00ab395a73d57fedf41631f0",
+        Utils.byte2Hex(FxAccountUtils.unwrapkB(unwrapkB, wrapkB)));
+  }
+
+  @Test
+  public void testClientState() throws Exception {
+    final String hexKB = "fd5c747806c07ce0b9d69dcfea144663e630b65ec4963596a22f24910d7dd15d";
+    final byte[] byteKB = Utils.hex2Byte(hexKB);
+    final String clientState = FxAccountUtils.computeClientState(byteKB);
+    final String expected = "6ae94683571c7a7c54dab4700aa3995f";
+    Assert.assertEquals(expected, clientState);
+  }
+
+  @Test
+  public void testGetAudienceForURL() throws Exception {
+    // Sub-domains and path components.
+    Assert.assertEquals("http://sub.test.com", FxAccountUtils.getAudienceForURL("http://sub.test.com"));
+    Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/"));
+    Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/path/component"));
+    Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com/path/component/"));
+
+    // No port and default port.
+    Assert.assertEquals("http://test.com", FxAccountUtils.getAudienceForURL("http://test.com"));
+    Assert.assertEquals("http://test.com:80", FxAccountUtils.getAudienceForURL("http://test.com:80"));
+
+    Assert.assertEquals("https://test.com", FxAccountUtils.getAudienceForURL("https://test.com"));
+    Assert.assertEquals("https://test.com:443", FxAccountUtils.getAudienceForURL("https://test.com:443"));
+
+    // Ports that are the default ports for a different scheme.
+    Assert.assertEquals("https://test.com:80", FxAccountUtils.getAudienceForURL("https://test.com:80"));
+    Assert.assertEquals("http://test.com:443", FxAccountUtils.getAudienceForURL("http://test.com:443"));
+
+    // Arbitrary ports.
+    Assert.assertEquals("http://test.com:8080", FxAccountUtils.getAudienceForURL("http://test.com:8080"));
+    Assert.assertEquals("https://test.com:4430", FxAccountUtils.getAudienceForURL("https://test.com:4430"));
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/prune/test/TestPrunePolicy.java
@@ -0,0 +1,324 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.healthreport.prune.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.prune.PrunePolicy;
+import org.mozilla.gecko.background.healthreport.prune.PrunePolicyStorage;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+
+import android.content.SharedPreferences;
+
+public class TestPrunePolicy {
+  public static class MockPrunePolicy extends PrunePolicy {
+    public MockPrunePolicy(final PrunePolicyStorage storage, final SharedPreferences sharedPrefs) {
+      super(storage, sharedPrefs);
+    }
+
+    @Override
+    public boolean attemptPruneBySize(final long time) {
+      return super.attemptPruneBySize(time);
+    }
+
+    @Override
+    public boolean attemptExpiration(final long time) {
+      return super.attemptExpiration(time);
+    }
+
+    @Override
+    protected boolean attemptStorageCleanup(final long time) {
+      return super.attemptStorageCleanup(time);
+    }
+  }
+
+  public static class MockPrunePolicyStorage implements PrunePolicyStorage {
+    public int eventCount = -1;
+    public int environmentCount = -1;
+
+    // TODO: Spies - should we be using a framework?
+    // TODO: Each method was called with what args?
+    public boolean wasPruneEventsCalled = false;
+    public boolean wasPruneEnvironmentsCalled = false;
+    public boolean wasDeleteDataBeforeCalled = false;
+    public boolean wasCleanupCalled = false;
+
+    public MockPrunePolicyStorage() { }
+
+    public void pruneEvents(final int maxNumToPrune) {
+      wasPruneEventsCalled = true;
+    }
+
+    public void pruneEnvironments(final int numToPrune) {
+      wasPruneEnvironmentsCalled = true;
+    }
+
+    public int deleteDataBefore(final long time) {
+      wasDeleteDataBeforeCalled = true;
+      return -1;
+    }
+
+    public void cleanup() {
+      wasCleanupCalled = true;
+    }
+
+    public int getEventCount() { return eventCount; }
+    public int getEnvironmentCount() { return environmentCount; }
+
+    public void close() { /* Nothing to cleanup. */ }
+  }
+
+  // An arbitrary value so that each test doesn't need to specify its own time.
+  public static final long START_TIME = 1000;
+
+  public MockPrunePolicy policy;
+  public MockPrunePolicyStorage storage;
+  public SharedPreferences sharedPrefs;
+
+  @Before
+  public void setUp() throws Exception {
+    sharedPrefs = new MockSharedPreferences();
+    storage = new MockPrunePolicyStorage();
+    policy = new MockPrunePolicy(storage, sharedPrefs);
+  }
+
+  public boolean attemptPruneBySize(final long time) {
+    final boolean retval = policy.attemptPruneBySize(time);
+    // This commit may be deferred over multiple methods so we ensure it runs.
+    sharedPrefs.edit().commit();
+    return retval;
+  }
+
+  public boolean attemptExpiration(final long time) {
+    final boolean retval = policy.attemptExpiration(time);
+    // This commit may be deferred over multiple methods so we ensure it runs.
+    sharedPrefs.edit().commit();
+    return retval;
+  }
+
+  public boolean attemptStorageCleanup(final long time) {
+    final boolean retval = policy.attemptStorageCleanup(time);
+    // This commit may be deferred over multiple methods so we ensure it runs.
+    sharedPrefs.edit().commit();
+    return retval;
+  }
+
+  @Test
+  public void testAttemptPruneBySizeInit() throws Exception {
+    assertFalse(containsNextPruneBySizeTime());
+    attemptPruneBySize(START_TIME);
+
+    // Next time should be initialized.
+    assertTrue(containsNextPruneBySizeTime());
+    assertTrue(getNextPruneBySizeTime() > 0);
+    assertFalse(storage.wasPruneEventsCalled);
+    assertFalse(storage.wasPruneEnvironmentsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeEarly() throws Exception {
+    final long nextTime = START_TIME + 1;
+    setNextPruneBySizeTime(nextTime);
+    attemptPruneBySize(START_TIME);
+
+    // We didn't prune so next time remains the same.
+    assertEquals(nextTime, getNextPruneBySizeTime());
+    assertFalse(storage.wasPruneEventsCalled);
+    assertFalse(storage.wasPruneEnvironmentsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeSkewed() throws Exception {
+    setNextPruneBySizeTime(START_TIME + getMinimumTimeBetweenPruneBySizeChecks() + 1);
+    attemptPruneBySize(START_TIME);
+
+    // Skewed so the next time is reset.
+    assertEquals(START_TIME + getMinimumTimeBetweenPruneBySizeChecks(), getNextPruneBySizeTime());
+    assertFalse(storage.wasPruneEventsCalled);
+    assertFalse(storage.wasPruneEnvironmentsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeTooFewEnvironments() throws Exception {
+    setNextPruneBySizeTime(START_TIME - 1);
+    storage.environmentCount = getMaximumEnvironmentCount(); // Too few to prune.
+    attemptPruneBySize(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenPruneBySizeChecks(), getNextPruneBySizeTime());
+    assertFalse(storage.wasPruneEnvironmentsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeEnvironmentsSuccess() throws Exception {
+    setNextPruneBySizeTime(START_TIME - 1);
+    storage.environmentCount = getMaximumEnvironmentCount() + 1;
+    attemptPruneBySize(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenPruneBySizeChecks(), getNextPruneBySizeTime());
+    assertTrue(storage.wasPruneEnvironmentsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeTooFewEvents() throws Exception {
+    setNextPruneBySizeTime(START_TIME - 1);
+    storage.eventCount = getMaximumEventCount(); // Too few to prune.
+    attemptPruneBySize(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenPruneBySizeChecks(), getNextPruneBySizeTime());
+    assertFalse(storage.wasPruneEventsCalled);
+  }
+
+  @Test
+  public void testAttemptPruneBySizeEventsSuccess() throws Exception {
+    setNextPruneBySizeTime(START_TIME - 1);
+    storage.eventCount = getMaximumEventCount() + 1;
+    attemptPruneBySize(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenPruneBySizeChecks(), getNextPruneBySizeTime());
+    assertTrue(storage.wasPruneEventsCalled);
+  }
+
+  @Test
+  public void testAttemptExpirationInit() throws Exception {
+    assertFalse(containsNextExpirationTime());
+    attemptExpiration(START_TIME);
+
+    // Next time should be initialized.
+    assertTrue(containsNextExpirationTime());
+    assertTrue(getNextExpirationTime() > 0);
+    assertFalse(storage.wasDeleteDataBeforeCalled);
+  }
+
+  @Test
+  public void testAttemptExpirationEarly() throws Exception {
+    final long nextTime = START_TIME + 1;
+    setNextExpirationTime(nextTime);
+    attemptExpiration(START_TIME);
+
+    // We didn't prune so next time remains the same.
+    assertEquals(nextTime, getNextExpirationTime());
+    assertFalse(storage.wasDeleteDataBeforeCalled);
+  }
+
+  @Test
+  public void testAttemptExpirationSkewed() throws Exception {
+    setNextExpirationTime(START_TIME + getMinimumTimeBetweenExpirationChecks() + 1);
+    attemptExpiration(START_TIME);
+
+    // Skewed so the next time is reset.
+    assertEquals(START_TIME + getMinimumTimeBetweenExpirationChecks(), getNextExpirationTime());
+    assertFalse(storage.wasDeleteDataBeforeCalled);
+  }
+
+  @Test
+  public void testAttemptExpirationSuccess() throws Exception {
+    setNextExpirationTime(START_TIME - 1);
+    attemptExpiration(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenExpirationChecks(), getNextExpirationTime());
+    assertTrue(storage.wasDeleteDataBeforeCalled);
+  }
+
+  @Test
+  public void testAttemptCleanupInit() throws Exception {
+    assertFalse(containsNextCleanupTime());
+    attemptStorageCleanup(START_TIME);
+
+    // Next time should be initialized.
+    assertTrue(containsNextCleanupTime());
+    assertTrue(getNextCleanupTime() > 0);
+    assertFalse(storage.wasCleanupCalled);
+  }
+
+  @Test
+  public void testAttemptCleanupEarly() throws Exception {
+    final long nextTime = START_TIME + 1;
+    setNextCleanupTime(nextTime);
+    attemptStorageCleanup(START_TIME);
+
+    // We didn't prune so next time remains the same.
+    assertEquals(nextTime, getNextCleanupTime());
+    assertFalse(storage.wasCleanupCalled);
+  }
+
+  @Test
+  public void testAttemptCleanupSkewed() throws Exception {
+    setNextCleanupTime(START_TIME + getMinimumTimeBetweenCleanupChecks() + 1);
+    attemptStorageCleanup(START_TIME);
+
+    // Skewed so the next time is reset.
+    assertEquals(START_TIME + getMinimumTimeBetweenCleanupChecks(), getNextCleanupTime());
+    assertFalse(storage.wasCleanupCalled);
+  }
+
+  @Test
+  public void testAttemptCleanupSuccess() throws Exception {
+    setNextCleanupTime(START_TIME - 1);
+    attemptStorageCleanup(START_TIME);
+
+    assertEquals(START_TIME + getMinimumTimeBetweenCleanupChecks(), getNextCleanupTime());
+    assertTrue(storage.wasCleanupCalled);
+  }
+
+  public int getMaximumEnvironmentCount() {
+    return HealthReportConstants.MAX_ENVIRONMENT_COUNT;
+  }
+
+  public int getMaximumEventCount() {
+    return HealthReportConstants.MAX_EVENT_COUNT;
+  }
+
+  public long getMinimumTimeBetweenPruneBySizeChecks() {
+    return HealthReportConstants.MINIMUM_TIME_BETWEEN_PRUNE_BY_SIZE_CHECKS_MILLIS;
+  }
+
+  public long getNextPruneBySizeTime() {
+    return sharedPrefs.getLong(HealthReportConstants.PREF_PRUNE_BY_SIZE_TIME, -1);
+  }
+
+  public void setNextPruneBySizeTime(final long time) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_PRUNE_BY_SIZE_TIME, time).commit();
+  }
+
+  public boolean containsNextPruneBySizeTime() {
+    return sharedPrefs.contains(HealthReportConstants.PREF_PRUNE_BY_SIZE_TIME);
+  }
+
+  public long getMinimumTimeBetweenExpirationChecks() {
+    return HealthReportConstants.MINIMUM_TIME_BETWEEN_EXPIRATION_CHECKS_MILLIS;
+  }
+
+  public long getNextExpirationTime() {
+    return sharedPrefs.getLong(HealthReportConstants.PREF_EXPIRATION_TIME, -1);
+  }
+
+  public void setNextExpirationTime(final long time) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_EXPIRATION_TIME, time).commit();
+  }
+
+  public boolean containsNextExpirationTime() {
+    return sharedPrefs.contains(HealthReportConstants.PREF_EXPIRATION_TIME);
+  }
+
+  public long getMinimumTimeBetweenCleanupChecks() {
+    return HealthReportConstants.MINIMUM_TIME_BETWEEN_CLEANUP_CHECKS_MILLIS;
+  }
+
+  public long getNextCleanupTime() {
+    return sharedPrefs.getLong(HealthReportConstants.PREF_CLEANUP_TIME, -1);
+  }
+
+  public void setNextCleanupTime(final long time) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_CLEANUP_TIME, time).commit();
+  }
+
+  public boolean containsNextCleanupTime() {
+    return sharedPrefs.contains(HealthReportConstants.PREF_CLEANUP_TIME);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/test/HealthReportStorageStub.java
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.healthreport.test;
+
+import org.json.JSONObject;
+
+import android.database.Cursor;
+import android.util.SparseArray;
+
+import org.mozilla.gecko.background.healthreport.Environment;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage;
+
+public class HealthReportStorageStub implements HealthReportStorage {
+  public void close() { throw new UnsupportedOperationException(); }
+
+  public int getDay(long time) { throw new UnsupportedOperationException(); }
+  public int getDay() { throw new UnsupportedOperationException(); }
+
+  public Environment getEnvironment() { throw new UnsupportedOperationException(); }
+  public SparseArray<String> getEnvironmentHashesByID() { throw new UnsupportedOperationException(); }
+  public SparseArray<Environment> getEnvironmentRecordsByID() { throw new UnsupportedOperationException(); }
+  public Cursor getEnvironmentRecordForID(int id) { throw new UnsupportedOperationException(); }
+
+  public Field getField(String measurement, int measurementVersion, String fieldName) {
+    throw new UnsupportedOperationException();
+  }
+  public SparseArray<Field> getFieldsByID() { throw new UnsupportedOperationException(); }
+
+  public void recordDailyLast(int env, int day, int field, JSONObject value) { throw new UnsupportedOperationException(); }
+  public void recordDailyLast(int env, int day, int field, String value) { throw new UnsupportedOperationException(); }
+  public void recordDailyLast(int env, int day, int field, int value) { throw new UnsupportedOperationException(); }
+  public void recordDailyDiscrete(int env, int day, int field, JSONObject value) { throw new UnsupportedOperationException(); }
+  public void recordDailyDiscrete(int env, int day, int field, String value) { throw new UnsupportedOperationException(); }
+  public void recordDailyDiscrete(int env, int day, int field, int value) { throw new UnsupportedOperationException(); }
+  public void incrementDailyCount(int env, int day, int field, int by) { throw new UnsupportedOperationException(); }
+  public void incrementDailyCount(int env, int day, int field) { throw new UnsupportedOperationException(); }
+
+  public boolean hasEventSince(long time) { throw new UnsupportedOperationException(); }
+
+  public Cursor getRawEventsSince(long time) { throw new UnsupportedOperationException(); }
+
+  public Cursor getEventsSince(long time) { throw new UnsupportedOperationException(); }
+
+  public void ensureMeasurementInitialized(String measurement, int version, MeasurementFields fields) {
+    throw new UnsupportedOperationException();
+  }
+  public Cursor getMeasurementVersions() { throw new UnsupportedOperationException(); }
+  public Cursor getFieldVersions() { throw new UnsupportedOperationException(); }
+  public Cursor getFieldVersions(String measurement, int measurementVersion) { throw new UnsupportedOperationException(); }
+
+  public void deleteEverything() { throw new UnsupportedOperationException(); }
+  public void deleteEnvironments() { throw new UnsupportedOperationException(); }
+  public void deleteMeasurements() { throw new UnsupportedOperationException(); }
+  public int deleteDataBefore(final long time, final int curEnv) { throw new UnsupportedOperationException(); }
+
+  public int getEventCount() { throw new UnsupportedOperationException(); }
+  public int getEnvironmentCount() { throw new UnsupportedOperationException(); }
+
+  public void pruneEvents(final int num) { throw new UnsupportedOperationException(); }
+  public void pruneEnvironments(final int num) { throw new UnsupportedOperationException(); }
+
+  public void enqueueOperation(Runnable runnable) { throw new UnsupportedOperationException(); }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/MockAndroidSubmissionClient.java
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.healthreport.upload.test;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.mozilla.gecko.background.healthreport.Environment;
+import org.mozilla.gecko.background.healthreport.Environment.UIType;
+import org.mozilla.gecko.background.healthreport.EnvironmentBuilder.ConfigurationProvider;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage;
+import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
+import org.mozilla.gecko.background.healthreport.test.HealthReportStorageStub;
+import org.mozilla.gecko.background.healthreport.upload.AndroidSubmissionClient;
+
+public class MockAndroidSubmissionClient extends AndroidSubmissionClient {
+  public MockAndroidSubmissionClient(final Context context, final SharedPreferences prefs,
+      final String profilePath) {
+    super(context, prefs, profilePath, new MockConfigurationProvider());
+  }
+
+  public static class MockConfigurationProvider implements ConfigurationProvider {
+    @Override
+    public boolean hasHardwareKeyboard() {
+      return false;
+    }
+
+    @Override
+    public UIType getUIType() {
+      return UIType.DEFAULT;
+    }
+
+    @Override
+    public int getUIModeType() {
+      return 0;
+    }
+
+    @Override
+    public int getScreenLayoutSize() {
+      return 0;
+    }
+
+    @Override
+    public int getScreenXInMM() {
+      return 100;
+    }
+
+    @Override
+    public int getScreenYInMM() {
+      return 140;
+    }
+
+  }
+
+  public class MockSubmissionsTracker extends SubmissionsTracker {
+    public MockSubmissionsTracker(final HealthReportStorage storage, final long localTime,
+        final boolean hasUploadBeenRequested) {
+      super(storage, localTime, hasUploadBeenRequested);
+    }
+
+    // Override so we don't touch storage to register the current env.
+    // The returned id value does not matter much.
+    @Override
+    public int registerCurrentEnvironment() {
+      return 0;
+    }
+
+    // Override so we don't touch storage to get cache. Only getCurrentEnviroment uses the cache,
+    // which we override, so we're free to return null.
+    @Override
+    public ProfileInformationCache getProfileInformationCache() {
+      return null;
+    }
+
+    @Override
+    public TrackingGenerator getGenerator() {
+      return new MockTrackingGenerator();
+    }
+
+    public class MockTrackingGenerator extends TrackingGenerator {
+      // Override so it doesn't fail in the constructor when touching the storage stub (below).
+      @Override
+      protected Environment getCurrentEnvironment() {
+        return new Environment() {
+          @Override
+          public int register() {
+            return 0;
+          }
+        };
+      }
+    }
+  }
+
+  /**
+   * Mocked HealthReportStorage class for use within the MockAndroidSubmissionClient and its inner
+   * classes, to prevent access to real storage.
+   */
+  public static class MockHealthReportStorage extends HealthReportStorageStub {
+    // Ensures a unique Field ID for SubmissionsFieldName.getID().
+    @Override
+    public Field getField(String mName, int mVersion, String fieldName) {
+      return new Field(mName, mVersion, fieldName, 0) {
+        @Override
+        public int getID() throws IllegalStateException {
+          return fieldName.hashCode();
+        }
+      };
+    }
+
+    // Called in the SubmissionsTracker constructor.
+    @Override
+    public int getDay(final long millis) {
+      return 0;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestObsoleteDocumentTracker.java
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.healthreport.upload.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.upload.ObsoleteDocumentTracker;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import android.content.SharedPreferences;
+
+public class TestObsoleteDocumentTracker {
+  public static class MockObsoleteDocumentTracker extends ObsoleteDocumentTracker {
+    public MockObsoleteDocumentTracker(SharedPreferences sharedPrefs) {
+      super(sharedPrefs);
+    }
+
+    @Override
+    public ExtendedJSONObject getObsoleteIds() {
+      return super.getObsoleteIds();
+    }
+
+    @Override
+    public void setObsoleteIds(ExtendedJSONObject ids) {
+      super.setObsoleteIds(ids);
+    }
+};
+  public MockObsoleteDocumentTracker tracker;
+  public MockSharedPreferences sharedPrefs;
+
+  @Before
+  public void setUp() {
+    sharedPrefs = new MockSharedPreferences();
+    tracker = new MockObsoleteDocumentTracker(sharedPrefs);
+  }
+
+  @Test
+  public void testDecrementObsoleteIdAttempts() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    ids.put("id1", HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+    ids.put("id2", HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+    tracker.setObsoleteIds(ids);
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    tracker.decrementObsoleteIdAttempts("id1");
+    ids.put("id1", HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID - 1);
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    for (int i = 0; i < HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID; i++) {
+      tracker.decrementObsoleteIdAttempts("id1");
+    }
+    ids.remove("id1");
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    tracker.removeObsoleteId("id2");
+    ids.remove("id2");
+    assertEquals(ids, tracker.getObsoleteIds());
+  }
+
+  @Test
+  public void testAddObsoleteId() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    ids.put("id1", HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+    tracker.addObsoleteId("id1");
+    assertEquals(ids, tracker.getObsoleteIds());
+  }
+
+  @Test
+  public void testDecrementObsoleteIdAttemptsSet() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    ids.put("id1", 5L);
+    ids.put("id2", 1L);
+    ids.put("id3", -1L); // This should never happen, but just in case.
+    tracker.setObsoleteIds(ids);
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    HashSet<String> oldIds = new HashSet<String>();
+    oldIds.add("id1");
+    oldIds.add("id2");
+    tracker.decrementObsoleteIdAttempts(oldIds);
+    ids.put("id1", 4L);
+    ids.remove("id2");
+    assertEquals(ids, tracker.getObsoleteIds());
+  }
+
+  @Test
+  public void testMaximumObsoleteIds() {
+    for (int i = 1; i < HealthReportConstants.MAXIMUM_STORED_OBSOLETE_DOCUMENT_IDS + 10; i++) {
+      tracker.addObsoleteId("id" + i);
+      assertTrue(tracker.getObsoleteIds().size() <= HealthReportConstants.MAXIMUM_STORED_OBSOLETE_DOCUMENT_IDS);
+    }
+  }
+
+  @Test
+  public void testMigration() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    sharedPrefs.edit()
+      .putString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, "id")
+      .commit();
+
+    ids.put("id", HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+    assertEquals(ids, tracker.getObsoleteIds());
+
+    assertTrue(sharedPrefs.contains(HealthReportConstants.PREF_OBSOLETE_DOCUMENT_IDS_TO_DELETION_ATTEMPTS_REMAINING));
+  }
+
+  @Test
+  public void testGetBatchOfObsoleteIds() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    for (int i = 0; i < 2 * HealthReportConstants.MAXIMUM_DELETIONS_PER_POST + 10; i++) {
+      ids.put("id" + (100 - i), Long.valueOf(100 - i));
+    }
+    tracker.setObsoleteIds(ids);
+
+    Set<String> expected = new HashSet<String>();
+    for (int i = 0; i < HealthReportConstants.MAXIMUM_DELETIONS_PER_POST; i++) {
+      expected.add("id" + (100 - i));
+    }
+    assertEquals(expected, new HashSet<String>(tracker.getBatchOfObsoleteIds()));
+  }
+
+  @Test
+  public void testPairComparator() {
+    // Make sure that malformed entries get sorted first.
+    ArrayList<Entry<String, Object>> list = new ArrayList<Entry<String,Object>>();
+    list.add(new SimpleImmutableEntry<String, Object>("a", null));
+    list.add(new SimpleImmutableEntry<String, Object>("d", Long.valueOf(5)));
+    list.add(new SimpleImmutableEntry<String, Object>("e", Long.valueOf(1)));
+    list.add(new SimpleImmutableEntry<String, Object>("c", Long.valueOf(10)));
+    list.add(new SimpleImmutableEntry<String, Object>("b", "test"));
+    Collections.sort(list, new ObsoleteDocumentTracker.PairComparator());
+
+    ArrayList<String> got = new ArrayList<String>();
+    for (Entry<String, Object> pair : list) {
+      got.add(pair.getKey());
+    }
+    List<String> exp = Arrays.asList(new String[] { "a", "b", "c", "d", "e" });
+    assertEquals(exp, got);
+  }
+
+  @Test
+  public void testLimitObsoleteIds() {
+    ExtendedJSONObject ids = new ExtendedJSONObject();
+    for (long i = -HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID; i < HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID; i++) {
+      long j = 1 + HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID + i;
+      ids.put("id" + j, j);
+    }
+    tracker.setObsoleteIds(ids);
+    tracker.limitObsoleteIds();
+
+    assertEquals(ids.keySet(), tracker.getObsoleteIds().keySet());
+    ExtendedJSONObject got = tracker.getObsoleteIds();
+    for (String id : ids.keySet()) {
+      assertTrue(got.getLong(id).longValue() <= HealthReportConstants.DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/healthreport/upload/test/TestSubmissionPolicy.java
@@ -0,0 +1,435 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.healthreport.upload.test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.HashSet;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.upload.SubmissionClient;
+import org.mozilla.gecko.background.healthreport.upload.SubmissionPolicy;
+import org.mozilla.gecko.background.healthreport.upload.test.TestObsoleteDocumentTracker.MockObsoleteDocumentTracker;
+import org.mozilla.gecko.background.healthreport.upload.test.TestSubmissionPolicy.MockSubmissionClient.Response;
+import org.mozilla.gecko.background.testhelpers.MockSharedPreferences;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import android.content.SharedPreferences;
+
+public class TestSubmissionPolicy {
+  public static class MockSubmissionClient implements SubmissionClient {
+    public String lastId = null;
+    public Collection<String> lastOldIds = null;
+
+    public enum Response { SUCCESS, SOFT_FAILURE, HARD_FAILURE };
+    public Response upload = Response.SUCCESS;
+    public Response delete = Response.SUCCESS;
+    public Exception exception = null;
+
+    protected void response(long localTime, String id, Delegate delegate, Response response) {
+      lastId = id;
+      switch (response) {
+      case SOFT_FAILURE:
+        delegate.onSoftFailure(localTime, id, "Soft failure.", exception);
+        break;
+      case HARD_FAILURE:
+        delegate.onHardFailure(localTime, id, "Hard failure.", exception);
+        break;
+      default:
+        delegate.onSuccess(localTime, id);
+      }
+    }
+
+    @Override
+    public void upload(long localTime, String id, Collection<String> oldIds, Delegate delegate) {
+      lastOldIds = oldIds;
+      response(localTime, id, delegate, upload);
+    }
+
+    @Override
+    public void delete(long localTime, String id, Delegate delegate) {
+      response(localTime, id, delegate, delete);
+    }
+  }
+
+  public MockSubmissionClient client;
+  public SubmissionPolicy policy;
+  public SharedPreferences sharedPrefs;
+  public MockObsoleteDocumentTracker tracker;
+
+
+  public void setMinimumTimeBetweenUploads(long time) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_UPLOADS, time).commit();
+  }
+
+  public void setMinimumTimeBeforeFirstSubmission(long time) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION, time).commit();
+  }
+
+  public void setCurrentDayFailureCount(long count) {
+    sharedPrefs.edit().putLong(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, count).commit();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    sharedPrefs = new MockSharedPreferences();
+    client = new MockSubmissionClient();
+    tracker = new MockObsoleteDocumentTracker(sharedPrefs);
+    policy = new SubmissionPolicy(sharedPrefs, client, tracker, true);
+    setMinimumTimeBeforeFirstSubmission(0);
+  }
+
+  @Test
+  public void testNoMinimumTimeBeforeFirstSubmission() {
+    assertTrue(policy.tick(0));
+  }
+
+  @Test
+  public void testMinimumTimeBeforeFirstSubmission() {
+    setMinimumTimeBeforeFirstSubmission(10);
+    assertFalse(policy.tick(0));
+    assertEquals(policy.getMinimumTimeBeforeFirstSubmission(), policy.getNextSubmission());
+    assertFalse(policy.tick(policy.getMinimumTimeBeforeFirstSubmission() - 1));
+    assertTrue(policy.tick(policy.getMinimumTimeBeforeFirstSubmission()));
+  }
+
+  @Test
+  public void testNextUpload() {
+    assertTrue(policy.tick(0));
+    assertEquals(policy.getMinimumTimeBetweenUploads(), policy.getNextSubmission());
+    assertFalse(policy.tick(policy.getMinimumTimeBetweenUploads() - 1));
+    assertTrue(policy.tick(policy.getMinimumTimeBetweenUploads()));
+  }
+
+  @Test
+  public void testLastUploadRequested() {
+    assertTrue(policy.tick(0));
+    assertEquals(0, policy.getLastUploadRequested());
+    assertFalse(policy.tick(1));
+    assertEquals(0, policy.getLastUploadRequested());
+    assertTrue(policy.tick(2*policy.getMinimumTimeBetweenUploads()));
+    assertEquals(2*policy.getMinimumTimeBetweenUploads(), policy.getLastUploadRequested());
+  }
+
+  @Test
+  public void testUploadSuccess() throws Exception {
+    assertTrue(policy.tick(0));
+    setCurrentDayFailureCount(1);
+    client.upload = Response.SUCCESS;
+    assertTrue(policy.tick(2*policy.getMinimumTimeBetweenUploads()));
+    assertEquals(2*policy.getMinimumTimeBetweenUploads(), policy.getLastUploadRequested());
+    assertEquals(2*policy.getMinimumTimeBetweenUploads(), policy.getLastUploadSucceeded());
+    assertTrue(policy.getNextSubmission() > policy.getLastUploadSucceeded());
+    assertEquals(0, policy.getCurrentDayFailureCount());
+    assertNotNull(client.lastId);
+    assertTrue(tracker.getObsoleteIds().containsKey(client.lastId));
+  }
+
+  @Test
+  public void testUploadSoftFailure() throws Exception {
+    final long initialUploadTime = 0;
+    assertTrue(policy.tick(initialUploadTime));
+    client.upload = Response.SOFT_FAILURE;
+
+    final long secondUploadTime = initialUploadTime + policy.getMinimumTimeBetweenUploads();
+    assertTrue(policy.tick(secondUploadTime));
+    assertEquals(secondUploadTime, policy.getLastUploadRequested());
+    assertEquals(secondUploadTime, policy.getLastUploadFailed());
+    assertEquals(1, policy.getCurrentDayFailureCount());
+    assertEquals(policy.getLastUploadFailed() + policy.getMinimumTimeAfterFailure(), policy.getNextSubmission());
+    assertNotNull(client.lastId);
+    assertTrue(tracker.getObsoleteIds().containsKey(client.lastId));
+    client.lastId = null;
+
+    final long thirdUploadTime = secondUploadTime + policy.getMinimumTimeAfterFailure();
+    assertTrue(policy.tick(thirdUploadTime));
+    assertEquals(thirdUploadTime, policy.getLastUploadRequested());
+    assertEquals(thirdUploadTime, policy.getLastUploadFailed());
+    assertEquals(2, policy.getCurrentDayFailureCount());
+    assertEquals(policy.getLastUploadFailed() + policy.getMinimumTimeAfterFailure(), policy.getNextSubmission());
+    assertNotNull(client.lastId);
+    assertTrue(tracker.getObsoleteIds().containsKey(client.lastId));
+    client.lastId = null;
+
+    final long fourthUploadTime = thirdUploadTime + policy.getMinimumTimeAfterFailure();
+    assertTrue(policy.tick(fourthUploadTime));
+    assertEquals(fourthUploadTime, policy.getLastUploadRequested());
+    assertEquals(