/* 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.goanna.fxa.authenticator; import java.io.FileOutputStream; import java.io.PrintStream; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; import org.mozilla.goanna.background.common.log.Logger; import org.mozilla.goanna.db.BrowserContract; import org.mozilla.goanna.fxa.FxAccountConstants; import org.mozilla.goanna.fxa.login.State; import org.mozilla.goanna.fxa.login.State.StateLabel; import org.mozilla.goanna.fxa.login.StateFactory; import org.mozilla.goanna.sync.ExtendedJSONObject; import org.mozilla.goanna.sync.NonObjectJSONException; import org.mozilla.goanna.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. *

* 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). *

* 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. *

* Reference. *

* Quote: Your AbstractThreadedSyncAdapter and all its sync functionality * will not work until external storage is remounted. *

* Quote: 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. *

* Problem: that intent doesn't work! *

* 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 = 3; public static final String KEY_PICKLE_VERSION = "pickle_version"; public static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp"; public static final String KEY_ACCOUNT_VERSION = "account_version"; public static final String KEY_ACCOUNT_TYPE = "account_type"; public static final String KEY_EMAIL = "email"; public static final String KEY_PROFILE = "profile"; public static final String KEY_IDP_SERVER_URI = "idpServerURI"; public static final String KEY_TOKEN_SERVER_URI = "tokenServerURI"; public static final String KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP = "authoritiesToSyncAutomaticallyMap"; // Deprecated, but maintained for migration purposes. public static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled"; public 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 true if given pickle existed and was successfully deleted. */ public static boolean deletePickle(final Context context, final String filename) { return context.deleteFile(filename); } public static ExtendedJSONObject toJSON(final AndroidFxAccount account, final long now) { final ExtendedJSONObject o = new ExtendedJSONObject(); o.put(KEY_PICKLE_VERSION, PICKLE_VERSION); o.put(KEY_PICKLE_TIMESTAMP, now); 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()); final ExtendedJSONObject p = new ExtendedJSONObject(); for (Entry pair : account.getAuthoritiesToSyncAutomaticallyMap().entrySet()) { p.put(pair.getKey(), pair.getValue()); } o.put(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP, p); // 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 null; } o.put(KEY_BUNDLE, bundle); return o; } /** * 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 = toJSON(account, System.currentTimeMillis()); 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.authServerURI, params.tokenServerURI, params.state, params.authoritiesToSyncAutomaticallyMap, params.accountVersion, 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 = -1L; } 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 authServerURI; private String tokenServerURI; private final Map authoritiesToSyncAutomaticallyMap = new HashMap<>(); 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."); } /* * Version 1 and version 2 are identical, except version 2 throws if the * internal Android Account type has changed. Version 1 used to throw in * this case, but we intentionally used the pickle file to migrate across * Account types, bumping the version simultaneously. * * Version 3 replaces "isSyncEnabled" with a map (String -> Boolean) * associating Android authorities to whether or not they are configured * to sync automatically. */ switch (params.pickleVersion.intValue()) { case 3: { // 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 + "."); } params.unpickleV3(json); } break; case 2: { // 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 + "."); } params.unpickleV1(json); } break; case 1: { // Warn about account type changing, but don't throw over it. final String accountType = json.getString(KEY_ACCOUNT_TYPE); if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring."); } 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 { this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION); this.email = json.getString(KEY_EMAIL); this.profile = json.getString(KEY_PROFILE); this.authServerURI = json.getString(KEY_IDP_SERVER_URI); this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI); // We get the default value for everything except syncing browser data. this.authoritiesToSyncAutomaticallyMap.put(BrowserContract.AUTHORITY, 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 void unpickleV3(final ExtendedJSONObject json) throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { // We'll overwrite the extracted sync automatically map. unpickleV1(json); // Extract the map of authorities to sync automatically. authoritiesToSyncAutomaticallyMap.clear(); final ExtendedJSONObject o = json.getObject(KEY_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP); if (o == null) { return; } for (String key : o.keySet()) { final Boolean enabled = o.getBoolean(key); if (enabled != null) { authoritiesToSyncAutomaticallyMap.put(key, enabled); } } } 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 stateLabelString = StateLabel.valueOf( bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL)); final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE); if (stateLabelString == null || stateString == null) { throw new IllegalStateException("stateLabel and stateString must not be null, but: " + "(stateLabel == null) = " + (stateLabelString == null) + " and (stateString == null) = " + (stateString == null)); } try { return StateFactory.fromJSONObject(stateLabelString, new ExtendedJSONObject(stateString)); } catch (Exception e) { throw new IllegalStateException("could not get state", e); } } } }