Bug 957894 - Update account pickling code for Firefox Accounts. r=nalexander, a=sledru
authorMichael Comella <michael.l.comella@gmail.com>
Wed, 26 Mar 2014 11:31:43 -0700
changeset 183553 3946a4eb8302756ecfdeb0007ca7ca2468b71c3e
parent 183552 0fc15cf33a3c57e387b9bdb3e9ca5baf0d98bc08
child 183554 bc5ac3067e5dba958988fa85c94f4f5ae7d3f45a
push id3397
push userryanvm@gmail.com
push dateFri, 28 Mar 2014 16:43:39 +0000
treeherdermozilla-beta@bc5ac3067e5d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, sledru
bugs957894
milestone29.0
Bug 957894 - Update account pickling code for Firefox Accounts. r=nalexander, a=sledru
mobile/android/base/android-services.mozbuild
mobile/android/base/fxa/FirefoxAccounts.java
mobile/android/base/fxa/FxAccountConstants.java.in
mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
mobile/android/base/fxa/authenticator/AccountPickler.java
mobile/android/base/fxa/authenticator/AndroidFxAccount.java
mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
mobile/android/base/fxa/receivers/FxAccountDeletedReceiver.java
mobile/android/base/fxa/receivers/FxAccountDeletedService.java
mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
mobile/android/services/manifests/FxAccountAndroidManifest_activities.xml.in
mobile/android/services/manifests/FxAccountAndroidManifest_permissions.xml.in
mobile/android/services/manifests/FxAccountAndroidManifest_services.xml.in
mobile/android/services/manifests/SyncAndroidManifest_activities.xml.in
mobile/android/services/manifests/SyncAndroidManifest_permissions.xml.in
mobile/android/tests/background/junit3/android-services-files.mk
mobile/android/tests/background/junit3/src/fxa/authenticator/TestAccountPickler.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -556,16 +556,17 @@ sync_java_files = [
     'fxa/activities/FxAccountCreateAccountNotAllowedActivity.java',
     'fxa/activities/FxAccountGetStartedActivity.java',
     'fxa/activities/FxAccountSetupTask.java',
     'fxa/activities/FxAccountSignInActivity.java',
     'fxa/activities/FxAccountStatusActivity.java',
     'fxa/activities/FxAccountStatusFragment.java',
     'fxa/activities/FxAccountUpdateCredentialsActivity.java',
     'fxa/activities/FxAccountVerifiedAccountActivity.java',
+    'fxa/authenticator/AccountPickler.java',
     'fxa/authenticator/AndroidFxAccount.java',
     'fxa/authenticator/FxAccountAuthenticator.java',
     'fxa/authenticator/FxAccountAuthenticatorService.java',
     'fxa/authenticator/FxAccountLoginDelegate.java',
     'fxa/authenticator/FxAccountLoginException.java',
     'fxa/FirefoxAccounts.java',
     'fxa/login/BaseRequestDelegate.java',
     'fxa/login/Cohabiting.java',
@@ -573,16 +574,18 @@ sync_java_files = [
     'fxa/login/Engaged.java',
     'fxa/login/FxAccountLoginStateMachine.java',
     'fxa/login/FxAccountLoginTransition.java',
     'fxa/login/Married.java',
     'fxa/login/Separated.java',
     'fxa/login/State.java',
     'fxa/login/StateFactory.java',
     'fxa/login/TokensAndKeysState.java',
+    'fxa/receivers/FxAccountDeletedReceiver.java',
+    'fxa/receivers/FxAccountDeletedService.java',
     'fxa/sync/FxAccountGlobalSession.java',
     'fxa/sync/FxAccountNotificationManager.java',
     'fxa/sync/FxAccountSchedulePolicy.java',
     'fxa/sync/FxAccountSyncAdapter.java',
     'fxa/sync/FxAccountSyncService.java',
     'fxa/sync/SchedulePolicy.java',
     'sync/AlreadySyncingException.java',
     'sync/BackoffHandler.java',
--- a/mobile/android/base/fxa/FirefoxAccounts.java
+++ b/mobile/android/base/fxa/FirefoxAccounts.java
@@ -1,46 +1,107 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.fxa;
 
+import java.io.File;
+import java.util.concurrent.CountDownLatch;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.fxa.authenticator.AccountPickler;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.ThreadPool;
+
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.content.Context;
 
 /**
  * Simple public accessors for Firefox account objects.
  */
 public class FirefoxAccounts {
+  private static final String LOG_TAG = FirefoxAccounts.class.getSimpleName();
+
   /**
-   * Return true if at least one Firefox account exists.
+   * Returns true if a FirefoxAccount exists, false otherwise.
    *
    * @param context Android context.
    * @return true if at least one Firefox account exists.
    */
   public static boolean firefoxAccountsExist(final Context context) {
     return getFirefoxAccounts(context).length > 0;
   }
 
   /**
    * Return Firefox accounts.
+   * <p>
+   * If no accounts exist in the AccountManager, one may be created
+   * via a pickled FirefoxAccount, if available, and that account
+   * will be added to the AccountManager and returned.
+   * <p>
+   * Note that this can be called from any thread.
    *
    * @param context Android context.
    * @return Firefox account objects.
    */
   public static Account[] getFirefoxAccounts(final Context context) {
-    return AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+    final Account[] accounts =
+        AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+    if (accounts.length > 0) {
+      return accounts;
+    }
+
+    final Account pickledAccount = getPickledAccount(context);
+    return (pickledAccount != null) ? new Account[] {pickledAccount} : new Account[0];
+  }
+
+  private static Account getPickledAccount(final Context context) {
+    // To avoid a StrictMode violation for disk access, we call this from a background thread.
+    // We do this every time, so the caller doesn't have to care.
+    final CountDownLatch latch = new CountDownLatch(1);
+    final Account[] accounts = new Account[1];
+    ThreadPool.run(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          final File file = context.getFileStreamPath(FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+          if (!file.exists()) {
+            accounts[0] = null;
+            return;
+          }
+
+          // There is a small race window here: if the user creates a new Firefox account
+          // between our checks, this could erroneously report that no Firefox accounts
+          // exist.
+          final AndroidFxAccount fxAccount =
+              AccountPickler.unpickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+          accounts[0] = fxAccount.getAndroidAccount();
+        } finally {
+          latch.countDown();
+        }
+      }
+    });
+
+    try {
+      latch.await(); // Wait for the background thread to return.
+    } catch (InterruptedException e) {
+      Logger.warn(LOG_TAG,
+          "Foreground thread unexpectedly interrupted while getting pickled account", e);
+      return null;
+    }
+
+    return accounts[0];
   }
 
   /**
    * @param context Android context.
    * @return the configured Firefox account if one exists, or null otherwise.
    */
   public static Account getFirefoxAccount(final Context context) {
     Account[] accounts = getFirefoxAccounts(context);
     if (accounts.length > 0) {
       return accounts[0];
     }
     return null;
   }
-}
\ No newline at end of file
+}
--- a/mobile/android/base/fxa/FxAccountConstants.java.in
+++ b/mobile/android/base/fxa/FxAccountConstants.java.in
@@ -29,9 +29,41 @@ public class FxAccountConstants {
 
   // You must be at least 14 years old to create a Firefox Account.
   public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 14;
 
   // You must wait 15 minutes after failing an age check before trying to create a different account.
   public static final long MINIMUM_TIME_TO_WAIT_AFTER_AGE_CHECK_FAILED_IN_MILLISECONDS = 15 * 60 * 1000;
 
   public static final String USER_AGENT = "Firefox-Android-FxAccounts/ (" + GlobalConstants.MOZ_APP_DISPLAYNAME + " " + GlobalConstants.MOZ_APP_VERSION + ")";
+
+  public static final String ACCOUNT_PICKLE_FILENAME = "fxa.account.json";
+
+  /**
+   * This action is broadcast when an Android Firefox Account is deleted.
+   * This allows each installed Firefox to delete any Firefox Account pickle
+   * file.
+   * <p>
+   * It is protected by signing-level permission PER_ACCOUNT_TYPE_PERMISSION and
+   * can be received only by Firefox channels sharing the same Android Firefox
+   * Account type.
+   * <p>
+   * See {@link org.mozilla.gecko.fxa.AndroidFxAccount#makeDeletedAccountIntent(android.content.Context, android.accounts.Account)}
+   * for contents of the intent.
+   *
+   * See bug 790931 for additional information in the context of Sync.
+   */
+  public static final String ACCOUNT_DELETED_ACTION = "@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.accounts.ACCOUNT_DELETED_ACTION";
+
+  /**
+   * Version number of contents of SYNC_ACCOUNT_DELETED_ACTION intent.
+   */
+  public static final long ACCOUNT_DELETED_INTENT_VERSION = 1;
+
+  public static final String ACCOUNT_DELETED_INTENT_VERSION_KEY = "account_deleted_intent_version";
+  public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_KEY = "account_deleted_intent_account";
+
+  /**
+   * This signing-level permission protects broadcast intents that should be
+   * received only by Firefox channels sharing the same Android Firefox Account type.
+   */
+  public static final String PER_ACCOUNT_TYPE_PERMISSION = "@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE";
 }
--- a/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
@@ -62,25 +62,25 @@ public class FxAccountGetStartedActivity
     super.onResume();
 
     Intent intent = null;
     if (FxAccountAgeLockoutHelper.isLockedOut(SystemClock.elapsedRealtime())) {
       intent = new Intent(this, FxAccountCreateAccountNotAllowedActivity.class);
     } else if (FirefoxAccounts.firefoxAccountsExist(this)) {
       intent = new Intent(this, FxAccountStatusActivity.class);
     }
+
     if (intent != null) {
       this.setAccountAuthenticatorResult(null);
       setResult(RESULT_CANCELED);
       // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
       // the soft keyboard not being shown for the started activity. Why, Android, why?
       intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
-      startActivity(intent);
-      finish();
-      return;
+      this.startActivity(intent);
+      this.finish();
     }
   }
 
   /**
    * We started the CreateAccount activity for a result; this returns it to the
    * authenticator.
    */
   @Override
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/fxa/authenticator/AccountPickler.java
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.authenticator;
+
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.fxa.login.State.StateLabel;
+import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.Context;
+
+/**
+ * Android deletes Account objects when the Authenticator that owns the Account
+ * disappears. This happens when an App is installed to the SD card and the SD
+ * card is un-mounted or the device is rebooted.
+ * <p>
+ * We work around this by pickling the current Firefox account data every sync
+ * and unpickling when we check if Firefox accounts exist (called from Fennec).
+ * <p>
+ * Android just doesn't support installing Apps that define long-lived Services
+ * and/or own Account types onto the SD card. The documentation says not to do
+ * it. There are hordes of developers who want to do it, and have tried to
+ * register for almost every "package installation changed" broadcast intent
+ * that Android supports. They all explicitly state that the package that has
+ * changed does *not* receive the broadcast intent, thereby preventing an App
+ * from re-establishing its state.
+ * <p>
+ * <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a>
+ * <p>
+ * <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality
+ * will not work until external storage is remounted.
+ * <p>
+ * <b>Quote</b>: Your running Service will be killed and will not be restarted
+ * when external storage is remounted. You can, however, register for the
+ * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify
+ * your application when applications installed on external storage have become
+ * available to the system again. At which time, you can restart your Service.
+ * <p>
+ * Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>!
+ * <p>
+ * See bug 768102 for more information in the context of Sync.
+ */
+public class AccountPickler {
+  public static final String LOG_TAG = AccountPickler.class.getSimpleName();
+
+  public static final long PICKLE_VERSION = 1;
+
+  private static final String KEY_PICKLE_VERSION = "pickle_version";
+  private static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp";
+
+  private static final String KEY_ACCOUNT_VERSION = "account_version";
+  private static final String KEY_ACCOUNT_TYPE = "account_type";
+  private static final String KEY_EMAIL = "email";
+  private static final String KEY_PROFILE = "profile";
+  private static final String KEY_IDP_SERVER_URI = "idpServerURI";
+  private static final String KEY_TOKEN_SERVER_URI = "tokenServerURI";
+  private static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled";
+
+  private static final String KEY_BUNDLE = "bundle";
+
+  /**
+   * Remove Firefox account persisted to disk.
+   *
+   * @param context Android context.
+   * @param filename name of persisted pickle file; must not contain path separators.
+   * @return <code>true</code> if given pickle existed and was successfully deleted.
+   */
+  public static boolean deletePickle(final Context context, final String filename) {
+    return context.deleteFile(filename);
+  }
+
+  /**
+   * Persist Firefox account to disk as a JSON object.
+   *
+   * @param AndroidFxAccount the account to persist to disk
+   * @param filename name of file to persist to; must not contain path separators.
+   */
+  public static void pickle(final AndroidFxAccount account, final String filename) {
+    final ExtendedJSONObject o = new ExtendedJSONObject();
+    o.put(KEY_PICKLE_VERSION, Long.valueOf(PICKLE_VERSION));
+    o.put(KEY_PICKLE_TIMESTAMP, Long.valueOf(System.currentTimeMillis()));
+
+    o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION);
+    o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE);
+    o.put(KEY_EMAIL, account.getEmail());
+    o.put(KEY_PROFILE, account.getProfile());
+    o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI());
+    o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI());
+    o.put(KEY_IS_SYNCING_ENABLED, account.isSyncingEnabled());
+
+    // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs.
+
+    final ExtendedJSONObject bundle = account.unbundle();
+    if (bundle == null) {
+      Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting.");
+      return;
+    }
+    o.put(KEY_BUNDLE, bundle);
+
+    writeToDisk(account.context, filename, o);
+  }
+
+  private static void writeToDisk(final Context context, final String filename,
+      final ExtendedJSONObject pickle) {
+    try {
+      final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE);
+      try {
+        final PrintStream ps = new PrintStream(fos);
+        try {
+          ps.print(pickle.toJSONString());
+          Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() +
+              " account settings to " + filename + ".");
+        } finally {
+          ps.close();
+        }
+      } finally {
+        fos.close();
+      }
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename +
+          "; ignoring.", e);
+    }
+  }
+
+  /**
+   * Create Android account from saved JSON object. Assumes that an account does not exist.
+   *
+   * @param context
+   *          Android context.
+   * @param filename
+   *          name of file to read from; must not contain path separators.
+   * @return created Android account, or null on error.
+   */
+  public static AndroidFxAccount unpickle(final Context context, final String filename) {
+    final String jsonString = Utils.readFile(context, filename);
+    if (jsonString == null) {
+      Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting.");
+      return null;
+    }
+
+    ExtendedJSONObject json = null;
+    try {
+      json = ExtendedJSONObject.parseJSONObject(jsonString);
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e);
+      return null;
+    }
+
+    final UnpickleParams params;
+    try {
+      params = UnpickleParams.fromJSON(json);
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e);
+      return null;
+    }
+
+    final AndroidFxAccount account;
+    try {
+      account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile,
+          params.idpServerURI, params.tokenServerURI, params.state, params.accountVersion,
+          params.isSyncingEnabled, true, params.bundle);
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e);
+      return null;
+    }
+
+    if (account == null) {
+      Logger.warn(LOG_TAG, "Failed to add Android Account; aborting.");
+      return null;
+    }
+
+    Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP);
+    if (timestamp == null) {
+      Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring.");
+      timestamp = Long.valueOf(-1);
+    }
+
+    Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " +
+        params.pickleVersion + ", pickled at " + timestamp + ").");
+
+    return account;
+  }
+
+  private static class UnpickleParams {
+    private Long pickleVersion;
+
+    private int accountVersion;
+    private String email;
+    private String profile;
+    private String idpServerURI;
+    private String tokenServerURI;
+    private boolean isSyncingEnabled;
+
+    private ExtendedJSONObject bundle;
+    private State state;
+
+    private UnpickleParams() {
+    }
+
+    private static UnpickleParams fromJSON(final ExtendedJSONObject json)
+        throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+      final UnpickleParams params = new UnpickleParams();
+      params.pickleVersion = json.getLong(KEY_PICKLE_VERSION);
+      if (params.pickleVersion == null) {
+        throw new IllegalStateException("Pickle version not found.");
+      }
+
+      switch (params.pickleVersion.intValue()) {
+        case 1:
+          params.unpickleV1(json);
+          break;
+
+        default:
+          throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + ".");
+      }
+
+      return params;
+    }
+
+    private void unpickleV1(final ExtendedJSONObject json)
+        throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException {
+      // Sanity check.
+      final String accountType = json.getString(KEY_ACCOUNT_TYPE);
+      if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) {
+        throw new IllegalStateException("Account type has changed from, " + accountType +
+            ", to, " + FxAccountConstants.ACCOUNT_TYPE + ".");
+      }
+
+      this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION);
+      this.email = json.getString(KEY_EMAIL);
+      this.profile = json.getString(KEY_PROFILE);
+      this.idpServerURI = json.getString(KEY_IDP_SERVER_URI);
+      this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI);
+      this.isSyncingEnabled = json.getBoolean(KEY_IS_SYNCING_ENABLED);
+
+      this.bundle = json.getObject(KEY_BUNDLE);
+      if (bundle == null) {
+        throw new IllegalStateException("Pickle bundle is null.");
+      }
+      this.state = getState(bundle);
+    }
+
+    private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException,
+            NonObjectJSONException, NoSuchAlgorithmException {
+      // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain
+      // old versions?
+      final StateLabel stateLabel = StateLabel.valueOf(
+          bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL));
+      final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE);
+      if (stateLabel == null) {
+        throw new IllegalStateException("stateLabel must not be null");
+      }
+      if (stateString == null) {
+        throw new IllegalStateException("stateString must not be null");
+      }
+
+      try {
+        return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString));
+      } catch (Exception e) {
+        throw new IllegalStateException("could not get state", e);
+      }
+    }
+  }
+}
--- a/mobile/android/base/fxa/authenticator/AndroidFxAccount.java
+++ b/mobile/android/base/fxa/authenticator/AndroidFxAccount.java
@@ -20,32 +20,34 @@ import org.mozilla.gecko.fxa.login.State
 import org.mozilla.gecko.fxa.login.StateFactory;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.content.ContentResolver;
 import android.content.Context;
+import android.content.Intent;
 import android.content.SharedPreferences;
 import android.os.Bundle;
 
 /**
  * A Firefox Account that stores its details and state as user data attached to
  * an Android Account instance.
  * <p>
  * Account user data is accessible only to the Android App(s) that own the
  * Account type. Account user data is not removed when the App's private data is
  * cleared.
  */
 public class AndroidFxAccount {
   protected static final String LOG_TAG = AndroidFxAccount.class.getSimpleName();
 
   public static final int CURRENT_PREFS_VERSION = 1;
 
+  // When updating the account, do not forget to update AccountPickler.
   public static final int CURRENT_ACCOUNT_VERSION = 3;
   public static final String ACCOUNT_KEY_ACCOUNT_VERSION = "version";
   public static final String ACCOUNT_KEY_PROFILE = "profile";
   public static final String ACCOUNT_KEY_IDP_SERVER = "idpServerURI";
 
   // The audience should always be a prefix of the token server URI.
   public static final String ACCOUNT_KEY_AUDIENCE = "audience";                 // Sync-specific.
   public static final String ACCOUNT_KEY_TOKEN_SERVER = "tokenServerURI";       // Sync-specific.
@@ -77,16 +79,28 @@ public class AndroidFxAccount {
    *          Android account to use for storage.
    */
   public AndroidFxAccount(Context applicationContext, Account account) {
     this.context = applicationContext;
     this.account = account;
     this.accountManager = AccountManager.get(this.context);
   }
 
+  /**
+   * Persist the Firefox account to disk as a JSON object. Note that this is a wrapper around
+   * {@link AccountPickler#pickle}, and is identical to calling it directly.
+   * <p>
+   * Note that pickling is different from bundling, which involves operations on a
+   * {@link android.os.Bundle Bundle} object of miscellaenous data associated with the account.
+   * See {@link #persistBundle} and {@link #unbundle} for more.
+   */
+  public void pickle(final String filename) {
+    AccountPickler.pickle(this, filename);
+  }
+
   public Account getAndroidAccount() {
     return this.account;
   }
 
   protected int getAccountVersion() {
     String v = accountManager.getUserData(account, ACCOUNT_KEY_ACCOUNT_VERSION);
     if (v == null) {
       return 0;         // Implicit.
@@ -94,20 +108,28 @@ public class AndroidFxAccount {
 
     try {
       return Integer.parseInt(v, 10);
     } catch (NumberFormatException ex) {
       return 0;
     }
   }
 
+  /**
+   * Saves the given data as the internal bundle associated with this account.
+   * @param bundle to write to account.
+   */
   protected void persistBundle(ExtendedJSONObject bundle) {
     accountManager.setUserData(account, ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
   }
 
+  /**
+   * Retrieve the internal bundle associated with this account.
+   * @return bundle associated with account.
+   */
   protected ExtendedJSONObject unbundle() {
     final int version = getAccountVersion();
     if (version < CURRENT_ACCOUNT_VERSION) {
       // Needs upgrade. For now, do nothing. We'd like to just put your account
       // into the Separated state here and have you update your credentials.
       return null;
     }
 
@@ -270,67 +292,112 @@ public class AndroidFxAccount {
   public static AndroidFxAccount addAndroidAccount(
       Context context,
       String email,
       String profile,
       String idpServerURI,
       String tokenServerURI,
       State state)
           throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
+    return addAndroidAccount(context, email, profile, idpServerURI, tokenServerURI, state,
+        CURRENT_ACCOUNT_VERSION, true, false, null);
+  }
+
+  public static AndroidFxAccount addAndroidAccount(
+      Context context,
+      String email,
+      String profile,
+      String idpServerURI,
+      String tokenServerURI,
+      State state,
+      final int accountVersion,
+      final boolean syncEnabled,
+      final boolean fromPickle,
+      ExtendedJSONObject bundle)
+          throws UnsupportedEncodingException, GeneralSecurityException, URISyntaxException {
     if (email == null) {
       throw new IllegalArgumentException("email must not be null");
     }
     if (idpServerURI == null) {
       throw new IllegalArgumentException("idpServerURI must not be null");
     }
     if (tokenServerURI == null) {
       throw new IllegalArgumentException("tokenServerURI must not be null");
     }
     if (state == null) {
       throw new IllegalArgumentException("state must not be null");
     }
 
+    // TODO: Add migration code.
+    if (accountVersion != CURRENT_ACCOUNT_VERSION) {
+      throw new IllegalStateException("Could not create account of version " + accountVersion +
+          ". Current version is " + CURRENT_ACCOUNT_VERSION + ".");
+    }
+
     // Android has internal restrictions that require all values in this
     // bundle to be strings. *sigh*
     Bundle userdata = new Bundle();
     userdata.putString(ACCOUNT_KEY_ACCOUNT_VERSION, "" + CURRENT_ACCOUNT_VERSION);
     userdata.putString(ACCOUNT_KEY_IDP_SERVER, idpServerURI);
     userdata.putString(ACCOUNT_KEY_TOKEN_SERVER, tokenServerURI);
     userdata.putString(ACCOUNT_KEY_AUDIENCE, FxAccountUtils.getAudienceForURL(tokenServerURI));
     userdata.putString(ACCOUNT_KEY_PROFILE, profile);
 
-    ExtendedJSONObject descriptor = new ExtendedJSONObject();
+    if (bundle == null) {
+      bundle = new ExtendedJSONObject();
+      // TODO: How to upgrade?
+      bundle.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION);
+    }
+    bundle.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name());
+    bundle.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
 
-    descriptor.put(BUNDLE_KEY_STATE_LABEL, state.getStateLabel().name());
-    descriptor.put(BUNDLE_KEY_STATE, state.toJSONObject().toJSONString());
-
-    descriptor.put(BUNDLE_KEY_BUNDLE_VERSION, CURRENT_BUNDLE_VERSION);
-    userdata.putString(ACCOUNT_KEY_DESCRIPTOR, descriptor.toJSONString());
+    userdata.putString(ACCOUNT_KEY_DESCRIPTOR, bundle.toJSONString());
 
     Account account = new Account(email, FxAccountConstants.ACCOUNT_TYPE);
     AccountManager accountManager = AccountManager.get(context);
     // We don't set an Android password, because we don't want to persist the
     // password (or anything else as powerful as the password). Instead, we
     // internally manage a sessionToken with a remotely owned lifecycle.
     boolean added = accountManager.addAccountExplicitly(account, null, userdata);
     if (!added) {
       return null;
     }
 
     AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
-    fxAccount.clearSyncPrefs();
-    fxAccount.enableSyncing();
+
+    if (!fromPickle) {
+      fxAccount.clearSyncPrefs();
+    }
+
+    if (syncEnabled) {
+      fxAccount.enableSyncing();
+    } else {
+      fxAccount.disableSyncing();
+    }
 
     return fxAccount;
   }
 
   public void clearSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
     getSyncPrefs().edit().clear().commit();
   }
 
+  public boolean isSyncingEnabled() {
+    // TODO: Authority will be static in PR 426.
+    final int result = ContentResolver.getIsSyncable(account, BrowserContract.AUTHORITY);
+    if (result > 0) {
+      return true;
+    } else if (result == 0) {
+      return false;
+    } else {
+      // This should not happen.
+      throw new IllegalStateException("Sync enabled state unknown.");
+    }
+  }
+
   public void enableSyncing() {
     Logger.info(LOG_TAG, "Disabling sync for account named like " + Utils.obfuscateEmail(getEmail()));
     for (String authority : new String[] { BrowserContract.AUTHORITY }) {
       ContentResolver.setSyncAutomatically(account, authority, true);
       ContentResolver.setIsSyncable(account, authority, 1);
     }
   }
 
@@ -398,9 +465,27 @@ public class AndroidFxAccount {
    * It is important to note that this is the local email address, and not
    * necessarily the normalized remote email address that the server expects.
    *
    * @return local email address.
    */
   public String getEmail() {
     return account.name;
   }
+
+  /**
+   * Create an intent announcing that a Firefox account will be deleted.
+   *
+   * @param context
+   *          Android context.
+   * @param account
+   *          Android account being removed.
+   * @return <code>Intent</code> to broadcast.
+   */
+  public static Intent makeDeletedAccountIntent(final Context context, final Account account) {
+    final Intent intent = new Intent(FxAccountConstants.ACCOUNT_DELETED_ACTION);
+
+    intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY,
+        Long.valueOf(FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION));
+    intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name);
+    return intent;
+  }
 }
--- a/mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
+++ b/mobile/android/base/fxa/authenticator/FxAccountAuthenticator.java
@@ -93,9 +93,52 @@ public class FxAccountAuthenticator exte
   @Override
   public Bundle updateCredentials(AccountAuthenticatorResponse response,
       Account account, String authTokenType, Bundle options)
           throws NetworkErrorException {
     Logger.debug(LOG_TAG, "updateCredentials");
 
     return null;
   }
+
+  /**
+   * If the account is going to be removed, broadcast an "account deleted"
+   * intent. This allows us to clean up the account.
+   * <p>
+   * It is preferable to receive Android's LOGIN_ACCOUNTS_CHANGED_ACTION broadcast
+   * than to create our own hacky broadcast here, but that doesn't include enough
+   * information about which Accounts changed to correctly identify whether a Sync
+   * account has been removed (when some Firefox channels are installed on the SD
+   * card). We can work around this by storing additional state but it's both messy
+   * and expensive because the broadcast is noisy.
+   * <p>
+   * Note that this is <b>not</b> called when an Android Account is blown away
+   * due to the SD card being unmounted.
+   */
+  @Override
+  public Bundle getAccountRemovalAllowed(final AccountAuthenticatorResponse response, Account account)
+      throws NetworkErrorException {
+    Bundle result = super.getAccountRemovalAllowed(response, account);
+
+    if (result == null ||
+        !result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) ||
+        result.containsKey(AccountManager.KEY_INTENT)) {
+      return result;
+    }
+
+    final boolean removalAllowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
+    if (!removalAllowed) {
+      return result;
+    }
+
+    // Broadcast a message to all Firefox channels sharing this Android
+    // Account type telling that this Firefox account has been deleted.
+    //
+    // Broadcast intents protected with permissions are secure, so it's okay
+    // to include private information such as a password.
+    final Intent intent = AndroidFxAccount.makeDeletedAccountIntent(context, account);
+    Logger.info(LOG_TAG, "Account named " + account.name + " being removed; " +
+        "broadcasting secure intent " + intent.getAction() + ".");
+    context.sendBroadcast(intent, FxAccountConstants.PER_ACCOUNT_TYPE_PERMISSION);
+
+    return result;
+  }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/fxa/receivers/FxAccountDeletedReceiver.java
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.receivers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class FxAccountDeletedReceiver extends BroadcastReceiver {
+  public static final String LOG_TAG = FxAccountDeletedReceiver.class.getSimpleName();
+
+  /**
+   * This receiver can be killed as soon as it returns, but we have things to do
+   * that can't be done on the main thread (network activity). Therefore we
+   * start a service to do our clean up work for us, with Android doing the
+   * heavy lifting for the service's lifecycle.
+   * <p>
+   * See <a href="http://developer.android.com/reference/android/content/BroadcastReceiver.html#ReceiverLifecycle">the Android documentation</a>
+   * for details.
+   */
+  @Override
+  public void onReceive(final Context context, Intent broadcastIntent) {
+    Logger.debug(LOG_TAG, "FxAccount deleted broadcast received.");
+
+    Intent serviceIntent = new Intent(context, FxAccountDeletedService.class);
+    serviceIntent.putExtras(broadcastIntent);
+    context.startService(serviceIntent);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/fxa/receivers/FxAccountDeletedService.java
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.receivers;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.sync.config.AccountPickler;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * A background service to clean up after a Firefox Account is deleted.
+ * <p>
+ * Note that we specifically handle deleting the pickle file using a Service and a
+ * BroadcastReceiver, rather than a background thread, to allow channels sharing a Firefox account
+ * to delete their respective pickle files (since, if one remains, the account will be restored
+ * when that channel is used).
+ */
+public class FxAccountDeletedService extends IntentService {
+  public static final String LOG_TAG = FxAccountDeletedService.class.getSimpleName();
+
+  public FxAccountDeletedService() {
+    super(LOG_TAG);
+  }
+
+  @Override
+  protected void onHandleIntent(final Intent intent) {
+    final Context context = this;
+
+    long intentVersion = intent.getLongExtra(
+        FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION_KEY, 0);
+    long expectedVersion = FxAccountConstants.ACCOUNT_DELETED_INTENT_VERSION;
+    if (intentVersion != expectedVersion) {
+      Logger.warn(LOG_TAG, "Intent malformed: version " + intentVersion + " given but " +
+          "version " + expectedVersion + "expected. Not cleaning up after deleted Account.");
+      return;
+    }
+
+    // Android Account name, not Sync encoded account name.
+    final String accountName = intent.getStringExtra(
+        FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY);
+    if (accountName == null) {
+      Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " +
+          "deleted Account.");
+      return;
+    }
+
+    Logger.info(LOG_TAG, "Firefox account named " + accountName + " being removed; " +
+        "deleting saved pickle file '" + FxAccountConstants.ACCOUNT_PICKLE_FILENAME + "'.");
+    deletePickle(context);
+  }
+
+  public static void deletePickle(final Context context) {
+    try {
+      AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+    } catch (Exception e) {
+      // This should never happen, but we really don't want to die in a background thread.
+      Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e);
+    }
+  }
+}
--- a/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java
@@ -16,30 +16,32 @@ import org.mozilla.gecko.background.fxa.
 import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.SkewHandler;
 import org.mozilla.gecko.browserid.BrowserIDKeyPair;
 import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.browserid.RSACryptoImplementation;
 import org.mozilla.gecko.browserid.verifier.BrowserIDRemoteVerifierClient;
 import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierDelegate;
 import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AccountPickler;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator;
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate;
 import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition;
 import org.mozilla.gecko.fxa.login.Married;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.StateLabel;
 import org.mozilla.gecko.sync.BackoffHandler;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.GlobalSession;
 import org.mozilla.gecko.sync.PrefsBackoffHandler;
 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.ThreadPool;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.BaseGlobalSessionCallback;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
 import org.mozilla.gecko.tokenserver.TokenServerClient;
@@ -435,16 +437,29 @@ public class FxAccountSyncAdapter extend
         " with instance " + this + ".");
 
     final Context context = getContext();
     final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
     if (FxAccountConstants.LOG_PERSONAL_INFORMATION) {
       fxAccount.dump();
     }
 
+    // Pickle in a background thread to avoid strict mode warnings.
+    ThreadPool.run(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          AccountPickler.pickle(fxAccount, FxAccountConstants.ACCOUNT_PICKLE_FILENAME);
+        } catch (Exception e) {
+          // Should never happen, but we really don't want to die in a background thread.
+          Logger.warn(LOG_TAG, "Got exception pickling current account details; ignoring.", e);
+        }
+      }
+    });
+
     final CountDownLatch latch = new CountDownLatch(1);
     final SyncDelegate syncDelegate = new SyncDelegate(context, latch, syncResult, fxAccount, notificationManager);
 
     try {
       final State state;
       try {
         state = fxAccount.getState();
       } catch (Exception e) {
--- a/mobile/android/services/manifests/FxAccountAndroidManifest_activities.xml.in
+++ b/mobile/android/services/manifests/FxAccountAndroidManifest_activities.xml.in
@@ -58,8 +58,16 @@
             android:windowSoftInputMode="adjustResize">
         </activity>
 
         <activity
             android:theme="@style/FxAccountTheme"
             android:name="org.mozilla.gecko.fxa.activities.FxAccountCreateAccountNotAllowedActivity"
             android:windowSoftInputMode="adjustResize">
         </activity>
+
+        <receiver
+            android:name="org.mozilla.gecko.fxa.receivers.FxAccountDeletedReceiver"
+            android:permission="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE">
+            <intent-filter>
+                <action android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.accounts.ACCOUNT_DELETED_ACTION"/>
+            </intent-filter>
+        </receiver>
--- a/mobile/android/services/manifests/FxAccountAndroidManifest_permissions.xml.in
+++ b/mobile/android/services/manifests/FxAccountAndroidManifest_permissions.xml.in
@@ -2,8 +2,17 @@
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
     <uses-permission android:name="android.permission.USE_CREDENTIALS" />
     <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
     <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
     <uses-permission android:name="android.permission.WRITE_SETTINGS" />
     <uses-permission android:name="android.permission.READ_SYNC_STATS" />
     <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
+
+    <!-- A signature level permission granted only to the Firefox
+         channels sharing an Android Account type. -->
+    <permission
+        android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE"
+        android:protectionLevel="signature">
+    </permission>
+
+    <uses-permission android:name="@MOZ_ANDROID_SHARED_FXACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE" />
--- a/mobile/android/services/manifests/FxAccountAndroidManifest_services.xml.in
+++ b/mobile/android/services/manifests/FxAccountAndroidManifest_services.xml.in
@@ -15,8 +15,12 @@
             <intent-filter >
                 <action android:name="android.content.SyncAdapter" />
             </intent-filter>
 
             <meta-data
                 android:name="android.content.SyncAdapter"
                 android:resource="@xml/fxaccount_syncadapter" />
         </service>
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.fxa.receivers.FxAccountDeletedService" >
+        </service>
--- a/mobile/android/services/manifests/SyncAndroidManifest_activities.xml.in
+++ b/mobile/android/services/manifests/SyncAndroidManifest_activities.xml.in
@@ -64,18 +64,16 @@
                 <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
             </intent-filter>
         </receiver>
 
         <receiver
             android:name="org.mozilla.gecko.sync.receivers.SyncAccountDeletedReceiver"
             android:permission="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE">
             <intent-filter>
-                <!-- This needs to be kept the same as
-                     GlobalConstants.SYNC_ACCOUNT_DELETED_ACTION. -->
                 <action android:name="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.accounts.SYNC_ACCOUNT_DELETED_ACTION"/>
             </intent-filter>
         </receiver>
 
         <activity
             android:theme="@style/SyncTheme"
             android:excludeFromRecents="true"
             android:icon="@drawable/icon"
--- a/mobile/android/services/manifests/SyncAndroidManifest_permissions.xml.in
+++ b/mobile/android/services/manifests/SyncAndroidManifest_permissions.xml.in
@@ -4,18 +4,17 @@
     <uses-permission android:name="android.permission.USE_CREDENTIALS" />
     <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
     <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
     <uses-permission android:name="android.permission.WRITE_SETTINGS" />
     <uses-permission android:name="android.permission.READ_SYNC_STATS" />
     <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
 
     <!-- A signature level permission granted only to the Firefox
-         versions sharing an Android Account type.  This needs to
-         agree with GlobalConstants.PER_ACCOUNT_TYPE_PERMISSION. -->
+         versions sharing an Android Account type. -->
     <permission
         android:name="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE"
         android:protectionLevel="signature">
     </permission>
 
     <uses-permission android:name="@MOZ_ANDROID_SHARED_ACCOUNT_TYPE@.permission.PER_ACCOUNT_TYPE" />
 
     <!-- A signature level permission specific to each Firefox version
--- a/mobile/android/tests/background/junit3/android-services-files.mk
+++ b/mobile/android/tests/background/junit3/android-services-files.mk
@@ -17,16 +17,17 @@ BACKGROUND_TESTS_JAVA_FILES := \
   src/db/TestBookmarks.java \
   src/db/TestCachedSQLiteOpenHelper.java \
   src/db/TestClientsDatabase.java \
   src/db/TestClientsDatabaseAccessor.java \
   src/db/TestFennecTabsRepositorySession.java \
   src/db/TestFennecTabsStorage.java \
   src/db/TestFormHistoryRepositorySession.java \
   src/db/TestPasswordsRepository.java \
+  src/fxa/authenticator/TestAccountPickler.java \
   src/fxa/TestBrowserIDKeyPairGeneration.java \
   src/healthreport/MockDatabaseEnvironment.java \
   src/healthreport/MockHealthReportDatabaseStorage.java \
   src/healthreport/MockHealthReportSQLiteOpenHelper.java \
   src/healthreport/MockProfileInformationCache.java \
   src/healthreport/prune/TestHealthReportPruneService.java \
   src/healthreport/prune/TestPrunePolicyDatabaseStorage.java \
   src/healthreport/TestEnvironmentBuilder.java \
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit3/src/fxa/authenticator/TestAccountPickler.java
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.fxa.authenticator;
+
+import org.mozilla.gecko.background.helpers.AndroidSyncTestCase;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AccountPickler;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.Separated;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.background.sync.TestSyncAccounts;
+import org.mozilla.gecko.sync.Utils;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.test.InstrumentationTestCase;
+import android.test.RenamingDelegatingContext;
+
+public class TestAccountPickler extends AndroidSyncTestCase {
+  private final static String FILENAME_PREFIX = "TestAccountPickler-";
+  private final static String PICKLE_FILENAME = "pickle";
+
+  public Account account;
+  public RenamingDelegatingContext context;
+  public AccountManager accountManager;
+
+  @Override
+  public void setUp() {
+    this.account = null;
+    // Randomize the filename prefix in case we don't clean up correctly.
+    this.context = new RenamingDelegatingContext(getApplicationContext(), FILENAME_PREFIX +
+        Math.random() * 1000001 + "-");
+    this.accountManager = AccountManager.get(context);
+  }
+
+  public void tearDown() {
+    if (this.account != null) {
+      deleteAccount(this, this.accountManager, this.account);
+      this.account = null;
+    }
+    this.context.deleteFile(PICKLE_FILENAME);
+  }
+
+  public static void deleteAccount(final InstrumentationTestCase test,
+      final AccountManager accountManager, final Account account) {
+    TestSyncAccounts.deleteAccount(test, accountManager, account);
+  }
+
+  private boolean accountsExist() {
+    // Note that we don't use FirefoxAccounts.firefoxAccountsExist because it unpickles.
+    return AccountManager.get(context).getAccountsByType(FxAccountConstants.ACCOUNT_TYPE).length > 0;
+  }
+
+  public AndroidFxAccount addDummyAccount() throws Exception {
+    final String email = "iu@fakedomain.io";
+    final State state = new Separated(email, "uid", false); // State choice is arbitrary.
+    final AndroidFxAccount account = AndroidFxAccount.addAndroidAccount(context, email,
+        "profile", "serverURI", "tokenServerURI", state);
+    assertNotNull(account);
+    assertTrue(accountsExist()); // Sanity check.
+    this.account = account.getAndroidAccount(); // To remove in tearDown() if we throw.
+    return account;
+  }
+
+  public void testPickleAndUnpickle() throws Exception {
+    final AndroidFxAccount inputAccount = addDummyAccount();
+    // Sync is enabled by default so we do a more thorough test by disabling it.
+    inputAccount.disableSyncing();
+
+    AccountPickler.pickle(inputAccount, PICKLE_FILENAME);
+
+    // unpickle adds an account to the AccountManager so delete it first.
+    deleteAccount(this, this.accountManager, inputAccount.getAndroidAccount());
+    assertFalse(accountsExist());
+
+    final AndroidFxAccount unpickledAccount =
+        AccountPickler.unpickle(context, PICKLE_FILENAME);
+    assertNotNull(unpickledAccount);
+    this.account = unpickledAccount.getAndroidAccount(); // To remove in tearDown().
+    assertAccountsEquals(inputAccount, unpickledAccount);
+  }
+
+  public void testDeletePickle() throws Exception {
+    final AndroidFxAccount account = addDummyAccount();
+    AccountPickler.pickle(account, PICKLE_FILENAME);
+
+    final String s = Utils.readFile(context, PICKLE_FILENAME);
+    assertNotNull(s);
+    assertTrue(s.length() > 0);
+
+    AccountPickler.deletePickle(context, PICKLE_FILENAME);
+    org.mozilla.gecko.background.sync.TestAccountPickler.assertFileNotPresent(
+        context, PICKLE_FILENAME);
+  }
+
+  private void assertAccountsEquals(final AndroidFxAccount expected,
+      final AndroidFxAccount actual) throws Exception {
+    // TODO: Write and use AndroidFxAccount.equals
+    // TODO: protected.
+    //assertEquals(expected.getAccountVersion(), actual.getAccountVersion());
+    assertEquals(expected.getProfile(), actual.getProfile());
+    assertEquals(expected.getAccountServerURI(), actual.getAccountServerURI());
+    assertEquals(expected.getAudience(), actual.getAudience());
+    assertEquals(expected.getTokenServerURI(), actual.getTokenServerURI());
+    assertEquals(expected.getSyncPrefsPath(), actual.getSyncPrefsPath());
+    assertEquals(expected.isSyncingEnabled(), actual.isSyncingEnabled());
+    assertEquals(expected.getEmail(), actual.getEmail());
+    assertStateEquals(expected.getState(), actual.getState());
+  }
+
+  private void assertStateEquals(final State expected, final State actual) throws Exception {
+    // TODO: Write and use State.equals. Thus, this is only thorough for the State base class.
+    assertEquals(expected.getStateLabel(), actual.getStateLabel());
+    assertEquals(expected.email, actual.email);
+    assertEquals(expected.uid, actual.uid);
+    assertEquals(expected.verified, actual.verified);
+  }
+}