michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.fxa.authenticator; michael@0: michael@0: import java.io.FileOutputStream; michael@0: import java.io.PrintStream; michael@0: import java.security.NoSuchAlgorithmException; michael@0: import java.security.spec.InvalidKeySpecException; michael@0: michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.fxa.FxAccountConstants; michael@0: import org.mozilla.gecko.fxa.login.State; michael@0: import org.mozilla.gecko.fxa.login.State.StateLabel; michael@0: import org.mozilla.gecko.fxa.login.StateFactory; michael@0: import org.mozilla.gecko.sync.ExtendedJSONObject; michael@0: import org.mozilla.gecko.sync.NonObjectJSONException; michael@0: import org.mozilla.gecko.sync.Utils; michael@0: michael@0: import android.content.Context; michael@0: michael@0: /** michael@0: * Android deletes Account objects when the Authenticator that owns the Account michael@0: * disappears. This happens when an App is installed to the SD card and the SD michael@0: * card is un-mounted or the device is rebooted. michael@0: *

michael@0: * We work around this by pickling the current Firefox account data every sync michael@0: * and unpickling when we check if Firefox accounts exist (called from Fennec). michael@0: *

michael@0: * Android just doesn't support installing Apps that define long-lived Services michael@0: * and/or own Account types onto the SD card. The documentation says not to do michael@0: * it. There are hordes of developers who want to do it, and have tried to michael@0: * register for almost every "package installation changed" broadcast intent michael@0: * that Android supports. They all explicitly state that the package that has michael@0: * changed does *not* receive the broadcast intent, thereby preventing an App michael@0: * from re-establishing its state. michael@0: *

michael@0: * Reference. michael@0: *

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

michael@0: * Quote: Your running Service will be killed and will not be restarted michael@0: * when external storage is remounted. You can, however, register for the michael@0: * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify michael@0: * your application when applications installed on external storage have become michael@0: * available to the system again. At which time, you can restart your Service. michael@0: *

michael@0: * Problem: that intent doesn't work! michael@0: *

michael@0: * See bug 768102 for more information in the context of Sync. michael@0: */ michael@0: public class AccountPickler { michael@0: public static final String LOG_TAG = AccountPickler.class.getSimpleName(); michael@0: michael@0: public static final long PICKLE_VERSION = 2; michael@0: michael@0: private static final String KEY_PICKLE_VERSION = "pickle_version"; michael@0: private static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp"; michael@0: michael@0: private static final String KEY_ACCOUNT_VERSION = "account_version"; michael@0: private static final String KEY_ACCOUNT_TYPE = "account_type"; michael@0: private static final String KEY_EMAIL = "email"; michael@0: private static final String KEY_PROFILE = "profile"; michael@0: private static final String KEY_IDP_SERVER_URI = "idpServerURI"; michael@0: private static final String KEY_TOKEN_SERVER_URI = "tokenServerURI"; michael@0: private static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled"; michael@0: michael@0: private static final String KEY_BUNDLE = "bundle"; michael@0: michael@0: /** michael@0: * Remove Firefox account persisted to disk. michael@0: * michael@0: * @param context Android context. michael@0: * @param filename name of persisted pickle file; must not contain path separators. michael@0: * @return true if given pickle existed and was successfully deleted. michael@0: */ michael@0: public static boolean deletePickle(final Context context, final String filename) { michael@0: return context.deleteFile(filename); michael@0: } michael@0: michael@0: /** michael@0: * Persist Firefox account to disk as a JSON object. michael@0: * michael@0: * @param AndroidFxAccount the account to persist to disk michael@0: * @param filename name of file to persist to; must not contain path separators. michael@0: */ michael@0: public static void pickle(final AndroidFxAccount account, final String filename) { michael@0: final ExtendedJSONObject o = new ExtendedJSONObject(); michael@0: o.put(KEY_PICKLE_VERSION, Long.valueOf(PICKLE_VERSION)); michael@0: o.put(KEY_PICKLE_TIMESTAMP, Long.valueOf(System.currentTimeMillis())); michael@0: michael@0: o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION); michael@0: o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE); michael@0: o.put(KEY_EMAIL, account.getEmail()); michael@0: o.put(KEY_PROFILE, account.getProfile()); michael@0: o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI()); michael@0: o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI()); michael@0: o.put(KEY_IS_SYNCING_ENABLED, account.isSyncing()); michael@0: michael@0: // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs. michael@0: michael@0: final ExtendedJSONObject bundle = account.unbundle(); michael@0: if (bundle == null) { michael@0: Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting."); michael@0: return; michael@0: } michael@0: o.put(KEY_BUNDLE, bundle); michael@0: michael@0: writeToDisk(account.context, filename, o); michael@0: } michael@0: michael@0: private static void writeToDisk(final Context context, final String filename, michael@0: final ExtendedJSONObject pickle) { michael@0: try { michael@0: final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE); michael@0: try { michael@0: final PrintStream ps = new PrintStream(fos); michael@0: try { michael@0: ps.print(pickle.toJSONString()); michael@0: Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() + michael@0: " account settings to " + filename + "."); michael@0: } finally { michael@0: ps.close(); michael@0: } michael@0: } finally { michael@0: fos.close(); michael@0: } michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename + michael@0: "; ignoring.", e); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Create Android account from saved JSON object. Assumes that an account does not exist. michael@0: * michael@0: * @param context michael@0: * Android context. michael@0: * @param filename michael@0: * name of file to read from; must not contain path separators. michael@0: * @return created Android account, or null on error. michael@0: */ michael@0: public static AndroidFxAccount unpickle(final Context context, final String filename) { michael@0: final String jsonString = Utils.readFile(context, filename); michael@0: if (jsonString == null) { michael@0: Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting."); michael@0: return null; michael@0: } michael@0: michael@0: ExtendedJSONObject json = null; michael@0: try { michael@0: json = ExtendedJSONObject.parseJSONObject(jsonString); michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e); michael@0: return null; michael@0: } michael@0: michael@0: final UnpickleParams params; michael@0: try { michael@0: params = UnpickleParams.fromJSON(json); michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e); michael@0: return null; michael@0: } michael@0: michael@0: final AndroidFxAccount account; michael@0: try { michael@0: account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile, michael@0: params.idpServerURI, params.tokenServerURI, params.state, params.accountVersion, michael@0: params.isSyncingEnabled, true, params.bundle); michael@0: } catch (Exception e) { michael@0: Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e); michael@0: return null; michael@0: } michael@0: michael@0: if (account == null) { michael@0: Logger.warn(LOG_TAG, "Failed to add Android Account; aborting."); michael@0: return null; michael@0: } michael@0: michael@0: Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP); michael@0: if (timestamp == null) { michael@0: Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring."); michael@0: timestamp = Long.valueOf(-1); michael@0: } michael@0: michael@0: Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " + michael@0: params.pickleVersion + ", pickled at " + timestamp + ")."); michael@0: michael@0: return account; michael@0: } michael@0: michael@0: private static class UnpickleParams { michael@0: private Long pickleVersion; michael@0: michael@0: private int accountVersion; michael@0: private String email; michael@0: private String profile; michael@0: private String idpServerURI; michael@0: private String tokenServerURI; michael@0: private boolean isSyncingEnabled; michael@0: michael@0: private ExtendedJSONObject bundle; michael@0: private State state; michael@0: michael@0: private UnpickleParams() { michael@0: } michael@0: michael@0: private static UnpickleParams fromJSON(final ExtendedJSONObject json) michael@0: throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { michael@0: final UnpickleParams params = new UnpickleParams(); michael@0: params.pickleVersion = json.getLong(KEY_PICKLE_VERSION); michael@0: if (params.pickleVersion == null) { michael@0: throw new IllegalStateException("Pickle version not found."); michael@0: } michael@0: michael@0: /* michael@0: * Version 1 and version 2 are identical, except version 2 throws if the michael@0: * internal Android Account type has changed. Version 1 used to throw in michael@0: * this case, but we intentionally used the pickle file to migrate across michael@0: * Account types, bumping the version simultaneously. michael@0: */ michael@0: switch (params.pickleVersion.intValue()) { michael@0: case 2: { michael@0: // Sanity check. michael@0: final String accountType = json.getString(KEY_ACCOUNT_TYPE); michael@0: if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { michael@0: throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "."); michael@0: } michael@0: michael@0: params.unpickleV1(json); michael@0: } michael@0: break; michael@0: michael@0: case 1: { michael@0: // Warn about account type changing, but don't throw over it. michael@0: final String accountType = json.getString(KEY_ACCOUNT_TYPE); michael@0: if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { michael@0: Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring."); michael@0: } michael@0: michael@0: params.unpickleV1(json); michael@0: } michael@0: break; michael@0: michael@0: default: michael@0: throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + "."); michael@0: } michael@0: michael@0: return params; michael@0: } michael@0: michael@0: private void unpickleV1(final ExtendedJSONObject json) michael@0: throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { michael@0: michael@0: this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION); michael@0: this.email = json.getString(KEY_EMAIL); michael@0: this.profile = json.getString(KEY_PROFILE); michael@0: this.idpServerURI = json.getString(KEY_IDP_SERVER_URI); michael@0: this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI); michael@0: this.isSyncingEnabled = json.getBoolean(KEY_IS_SYNCING_ENABLED); michael@0: michael@0: this.bundle = json.getObject(KEY_BUNDLE); michael@0: if (bundle == null) { michael@0: throw new IllegalStateException("Pickle bundle is null."); michael@0: } michael@0: this.state = getState(bundle); michael@0: } michael@0: michael@0: private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException, michael@0: NonObjectJSONException, NoSuchAlgorithmException { michael@0: // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain michael@0: // old versions? michael@0: final StateLabel stateLabel = StateLabel.valueOf( michael@0: bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL)); michael@0: final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE); michael@0: if (stateLabel == null) { michael@0: throw new IllegalStateException("stateLabel must not be null"); michael@0: } michael@0: if (stateString == null) { michael@0: throw new IllegalStateException("stateString must not be null"); michael@0: } michael@0: michael@0: try { michael@0: return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); michael@0: } catch (Exception e) { michael@0: throw new IllegalStateException("could not get state", e); michael@0: } michael@0: } michael@0: } michael@0: }