--- 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);
+ }
+}