diff -r 000000000000 -r 6474c204b198 mobile/android/base/fxa/authenticator/AndroidFxAccount.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mobile/android/base/fxa/authenticator/AndroidFxAccount.java Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,549 @@ +/* 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.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.fxa.FirefoxAccounts; +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.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. + *
+ * 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.
+ public static final String ACCOUNT_KEY_DESCRIPTOR = "descriptor";
+
+ public static final int CURRENT_BUNDLE_VERSION = 2;
+ public static final String BUNDLE_KEY_BUNDLE_VERSION = "version";
+ public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel";
+ public static final String BUNDLE_KEY_STATE = "state";
+
+ protected static final List
+ * We expect a long-lived application context to avoid life-cycle issues that
+ * might arise if the internally cached AccountManager instance surfaces UI.
+ *
+ * We take care to not install any listeners or observers that might outlive
+ * the AccountManager; and Android ensures the AccountManager doesn't outlive
+ * the associated context.
+ *
+ * @param applicationContext
+ * to use as long-lived ambient Android context.
+ * @param account
+ * 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.
+ *
+ * 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.
+ }
+
+ 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;
+ }
+
+ if (version > CURRENT_ACCOUNT_VERSION) {
+ // Oh dear.
+ return null;
+ }
+
+ String bundle = accountManager.getUserData(account, ACCOUNT_KEY_DESCRIPTOR);
+ if (bundle == null) {
+ return null;
+ }
+ return unbundleAccountV2(bundle);
+ }
+
+ protected String getBundleData(String key) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return null;
+ }
+ return o.getString(key);
+ }
+
+ protected boolean getBundleDataBoolean(String key, boolean def) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return def;
+ }
+ Boolean b = o.getBoolean(key);
+ if (b == null) {
+ return def;
+ }
+ return b.booleanValue();
+ }
+
+ protected byte[] getBundleDataBytes(String key) {
+ ExtendedJSONObject o = unbundle();
+ if (o == null) {
+ return null;
+ }
+ return o.getByteArrayHex(key);
+ }
+
+ protected void updateBundleDataBytes(String key, byte[] value) {
+ updateBundleValue(key, value == null ? null : Utils.byte2Hex(value));
+ }
+
+ protected void updateBundleValue(String key, boolean value) {
+ ExtendedJSONObject descriptor = unbundle();
+ if (descriptor == null) {
+ return;
+ }
+ descriptor.put(key, value);
+ persistBundle(descriptor);
+ }
+
+ protected void updateBundleValue(String key, String value) {
+ ExtendedJSONObject descriptor = unbundle();
+ if (descriptor == null) {
+ return;
+ }
+ descriptor.put(key, value);
+ persistBundle(descriptor);
+ }
+
+ private ExtendedJSONObject unbundleAccountV1(String bundle) {
+ ExtendedJSONObject o;
+ try {
+ o = new ExtendedJSONObject(bundle);
+ } catch (Exception e) {
+ return null;
+ }
+ if (CURRENT_BUNDLE_VERSION == o.getIntegerSafely(BUNDLE_KEY_BUNDLE_VERSION)) {
+ return o;
+ }
+ return null;
+ }
+
+ private ExtendedJSONObject unbundleAccountV2(String bundle) {
+ return unbundleAccountV1(bundle);
+ }
+
+ /**
+ * Note that if the user clears data, an account will be left pointing to a
+ * deleted profile. Such is life.
+ */
+ public String getProfile() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_PROFILE);
+ }
+
+ public String getAccountServerURI() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_IDP_SERVER);
+ }
+
+ public String getAudience() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_AUDIENCE);
+ }
+
+ public String getTokenServerURI() {
+ return accountManager.getUserData(account, ACCOUNT_KEY_TOKEN_SERVER);
+ }
+
+ /**
+ * This needs to return a string because of the tortured prefs access in GlobalSession.
+ */
+ public String getSyncPrefsPath() throws GeneralSecurityException, UnsupportedEncodingException {
+ String profile = getProfile();
+ String username = account.name;
+
+ if (profile == null) {
+ throw new IllegalStateException("Missing profile. Cannot fetch prefs.");
+ }
+
+ if (username == null) {
+ throw new IllegalStateException("Missing username. Cannot fetch prefs.");
+ }
+
+ final String tokenServerURI = getTokenServerURI();
+ if (tokenServerURI == null) {
+ throw new IllegalStateException("No token server URI. Cannot fetch prefs.");
+ }
+
+ final String fxaServerURI = getAccountServerURI();
+ if (fxaServerURI == null) {
+ throw new IllegalStateException("No account server URI. Cannot fetch prefs.");
+ }
+
+ final String product = GlobalConstants.BROWSER_INTENT_PACKAGE + ".fxa";
+ final long version = CURRENT_PREFS_VERSION;
+
+ // This is unique for each syncing 'view' of the account.
+ final String serverURLThing = fxaServerURI + "!" + tokenServerURI;
+ return Utils.getPrefsPath(product, username, serverURLThing, profile, version);
+ }
+
+ public SharedPreferences getSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
+ return context.getSharedPreferences(getSyncPrefsPath(), Utils.SHARED_PREFERENCES_MODE);
+ }
+
+ /**
+ * Extract a JSON dictionary of the string values associated to this account.
+ *
+ * For debugging use only! The contents of this JSON object completely
+ * determine the user's Firefox Account status and yield access to whatever
+ * user data the device has access to.
+ *
+ * @return JSON-object of Strings.
+ */
+ public ExtendedJSONObject toJSONObject() {
+ ExtendedJSONObject o = unbundle();
+ o.put("email", account.name);
+ try {
+ o.put("emailUTF8", Utils.byte2Hex(account.name.getBytes("UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ // Ignore.
+ }
+ return o;
+ }
+
+ 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);
+
+ 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());
+
+ 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);
+
+ if (!fromPickle) {
+ fxAccount.clearSyncPrefs();
+ }
+
+ if (syncEnabled) {
+ fxAccount.enableSyncing();
+ } else {
+ fxAccount.disableSyncing();
+ }
+
+ return fxAccount;
+ }
+
+ public void clearSyncPrefs() throws UnsupportedEncodingException, GeneralSecurityException {
+ getSyncPrefs().edit().clear().commit();
+ }
+
+ public static Iterable
+ * This is, confusingly, not the same thing as "being syncable": that refers
+ * to whether this account can be synced, ever; this refers to whether Android
+ * will try to sync the account at appropriate times.
+ *
+ * @return true if the account is set to sync automatically.
+ */
+ public boolean isSyncing() {
+ boolean isSyncEnabled = true;
+ for (String authority : getAndroidAuthorities()) {
+ isSyncEnabled &= ContentResolver.getSyncAutomatically(account, authority);
+ }
+ return isSyncEnabled;
+ }
+
+ public void enableSyncing() {
+ Logger.info(LOG_TAG, "Enabling sync for account named like " + getObfuscatedEmail());
+ for (String authority : getAndroidAuthorities()) {
+ ContentResolver.setSyncAutomatically(account, authority, true);
+ ContentResolver.setIsSyncable(account, authority, 1);
+ }
+ }
+
+ public void disableSyncing() {
+ Logger.info(LOG_TAG, "Disabling sync for account named like " + getObfuscatedEmail());
+ for (String authority : getAndroidAuthorities()) {
+ ContentResolver.setSyncAutomatically(account, authority, false);
+ }
+ }
+
+ /**
+ * Is a sync currently in progress?
+ *
+ * @return true if Android is currently syncing the underlying Android Account.
+ */
+ public boolean isCurrentlySyncing() {
+ boolean active = false;
+ for (String authority : AndroidFxAccount.getAndroidAuthorities()) {
+ active |= ContentResolver.isSyncActive(account, authority);
+ }
+ return active;
+ }
+
+ /**
+ * Request a sync. See {@link FirefoxAccounts#requestSync(Account, EnumSet, String[], String[])}.
+ */
+ public void requestSync() {
+ requestSync(FirefoxAccounts.SOON, null, null);
+ }
+
+ /**
+ * Request a sync. See {@link FirefoxAccounts#requestSync(Account, EnumSet, String[], String[])}.
+ *
+ * @param syncHints to pass to sync.
+ */
+ public void requestSync(EnumSet
+ * 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;
+ }
+
+ /**
+ * Return the Firefox Account's local email address, obfuscated.
+ *
+ * Use this when logging.
+ *
+ * @return local email address, obfuscated.
+ */
+ public String getObfuscatedEmail() {
+ return Utils.obfuscateEmail(account.name);
+ }
+
+ /**
+ * Create an intent announcing that a Firefox account will be deleted.
+ *
+ * @param context
+ * Android context.
+ * @param account
+ * Android account being removed.
+ * @return Intent
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;
+ }
+}