1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/fxa/authenticator/AccountPickler.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,293 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +package org.mozilla.gecko.fxa.authenticator; 1.9 + 1.10 +import java.io.FileOutputStream; 1.11 +import java.io.PrintStream; 1.12 +import java.security.NoSuchAlgorithmException; 1.13 +import java.security.spec.InvalidKeySpecException; 1.14 + 1.15 +import org.mozilla.gecko.background.common.log.Logger; 1.16 +import org.mozilla.gecko.fxa.FxAccountConstants; 1.17 +import org.mozilla.gecko.fxa.login.State; 1.18 +import org.mozilla.gecko.fxa.login.State.StateLabel; 1.19 +import org.mozilla.gecko.fxa.login.StateFactory; 1.20 +import org.mozilla.gecko.sync.ExtendedJSONObject; 1.21 +import org.mozilla.gecko.sync.NonObjectJSONException; 1.22 +import org.mozilla.gecko.sync.Utils; 1.23 + 1.24 +import android.content.Context; 1.25 + 1.26 +/** 1.27 + * Android deletes Account objects when the Authenticator that owns the Account 1.28 + * disappears. This happens when an App is installed to the SD card and the SD 1.29 + * card is un-mounted or the device is rebooted. 1.30 + * <p> 1.31 + * We work around this by pickling the current Firefox account data every sync 1.32 + * and unpickling when we check if Firefox accounts exist (called from Fennec). 1.33 + * <p> 1.34 + * Android just doesn't support installing Apps that define long-lived Services 1.35 + * and/or own Account types onto the SD card. The documentation says not to do 1.36 + * it. There are hordes of developers who want to do it, and have tried to 1.37 + * register for almost every "package installation changed" broadcast intent 1.38 + * that Android supports. They all explicitly state that the package that has 1.39 + * changed does *not* receive the broadcast intent, thereby preventing an App 1.40 + * from re-establishing its state. 1.41 + * <p> 1.42 + * <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a> 1.43 + * <p> 1.44 + * <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality 1.45 + * will not work until external storage is remounted. 1.46 + * <p> 1.47 + * <b>Quote</b>: Your running Service will be killed and will not be restarted 1.48 + * when external storage is remounted. You can, however, register for the 1.49 + * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify 1.50 + * your application when applications installed on external storage have become 1.51 + * available to the system again. At which time, you can restart your Service. 1.52 + * <p> 1.53 + * Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>! 1.54 + * <p> 1.55 + * See bug 768102 for more information in the context of Sync. 1.56 + */ 1.57 +public class AccountPickler { 1.58 + public static final String LOG_TAG = AccountPickler.class.getSimpleName(); 1.59 + 1.60 + public static final long PICKLE_VERSION = 2; 1.61 + 1.62 + private static final String KEY_PICKLE_VERSION = "pickle_version"; 1.63 + private static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp"; 1.64 + 1.65 + private static final String KEY_ACCOUNT_VERSION = "account_version"; 1.66 + private static final String KEY_ACCOUNT_TYPE = "account_type"; 1.67 + private static final String KEY_EMAIL = "email"; 1.68 + private static final String KEY_PROFILE = "profile"; 1.69 + private static final String KEY_IDP_SERVER_URI = "idpServerURI"; 1.70 + private static final String KEY_TOKEN_SERVER_URI = "tokenServerURI"; 1.71 + private static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled"; 1.72 + 1.73 + private static final String KEY_BUNDLE = "bundle"; 1.74 + 1.75 + /** 1.76 + * Remove Firefox account persisted to disk. 1.77 + * 1.78 + * @param context Android context. 1.79 + * @param filename name of persisted pickle file; must not contain path separators. 1.80 + * @return <code>true</code> if given pickle existed and was successfully deleted. 1.81 + */ 1.82 + public static boolean deletePickle(final Context context, final String filename) { 1.83 + return context.deleteFile(filename); 1.84 + } 1.85 + 1.86 + /** 1.87 + * Persist Firefox account to disk as a JSON object. 1.88 + * 1.89 + * @param AndroidFxAccount the account to persist to disk 1.90 + * @param filename name of file to persist to; must not contain path separators. 1.91 + */ 1.92 + public static void pickle(final AndroidFxAccount account, final String filename) { 1.93 + final ExtendedJSONObject o = new ExtendedJSONObject(); 1.94 + o.put(KEY_PICKLE_VERSION, Long.valueOf(PICKLE_VERSION)); 1.95 + o.put(KEY_PICKLE_TIMESTAMP, Long.valueOf(System.currentTimeMillis())); 1.96 + 1.97 + o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION); 1.98 + o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE); 1.99 + o.put(KEY_EMAIL, account.getEmail()); 1.100 + o.put(KEY_PROFILE, account.getProfile()); 1.101 + o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI()); 1.102 + o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI()); 1.103 + o.put(KEY_IS_SYNCING_ENABLED, account.isSyncing()); 1.104 + 1.105 + // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs. 1.106 + 1.107 + final ExtendedJSONObject bundle = account.unbundle(); 1.108 + if (bundle == null) { 1.109 + Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting."); 1.110 + return; 1.111 + } 1.112 + o.put(KEY_BUNDLE, bundle); 1.113 + 1.114 + writeToDisk(account.context, filename, o); 1.115 + } 1.116 + 1.117 + private static void writeToDisk(final Context context, final String filename, 1.118 + final ExtendedJSONObject pickle) { 1.119 + try { 1.120 + final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE); 1.121 + try { 1.122 + final PrintStream ps = new PrintStream(fos); 1.123 + try { 1.124 + ps.print(pickle.toJSONString()); 1.125 + Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() + 1.126 + " account settings to " + filename + "."); 1.127 + } finally { 1.128 + ps.close(); 1.129 + } 1.130 + } finally { 1.131 + fos.close(); 1.132 + } 1.133 + } catch (Exception e) { 1.134 + Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename + 1.135 + "; ignoring.", e); 1.136 + } 1.137 + } 1.138 + 1.139 + /** 1.140 + * Create Android account from saved JSON object. Assumes that an account does not exist. 1.141 + * 1.142 + * @param context 1.143 + * Android context. 1.144 + * @param filename 1.145 + * name of file to read from; must not contain path separators. 1.146 + * @return created Android account, or null on error. 1.147 + */ 1.148 + public static AndroidFxAccount unpickle(final Context context, final String filename) { 1.149 + final String jsonString = Utils.readFile(context, filename); 1.150 + if (jsonString == null) { 1.151 + Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting."); 1.152 + return null; 1.153 + } 1.154 + 1.155 + ExtendedJSONObject json = null; 1.156 + try { 1.157 + json = ExtendedJSONObject.parseJSONObject(jsonString); 1.158 + } catch (Exception e) { 1.159 + Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e); 1.160 + return null; 1.161 + } 1.162 + 1.163 + final UnpickleParams params; 1.164 + try { 1.165 + params = UnpickleParams.fromJSON(json); 1.166 + } catch (Exception e) { 1.167 + Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e); 1.168 + return null; 1.169 + } 1.170 + 1.171 + final AndroidFxAccount account; 1.172 + try { 1.173 + account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile, 1.174 + params.idpServerURI, params.tokenServerURI, params.state, params.accountVersion, 1.175 + params.isSyncingEnabled, true, params.bundle); 1.176 + } catch (Exception e) { 1.177 + Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e); 1.178 + return null; 1.179 + } 1.180 + 1.181 + if (account == null) { 1.182 + Logger.warn(LOG_TAG, "Failed to add Android Account; aborting."); 1.183 + return null; 1.184 + } 1.185 + 1.186 + Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP); 1.187 + if (timestamp == null) { 1.188 + Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring."); 1.189 + timestamp = Long.valueOf(-1); 1.190 + } 1.191 + 1.192 + Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " + 1.193 + params.pickleVersion + ", pickled at " + timestamp + ")."); 1.194 + 1.195 + return account; 1.196 + } 1.197 + 1.198 + private static class UnpickleParams { 1.199 + private Long pickleVersion; 1.200 + 1.201 + private int accountVersion; 1.202 + private String email; 1.203 + private String profile; 1.204 + private String idpServerURI; 1.205 + private String tokenServerURI; 1.206 + private boolean isSyncingEnabled; 1.207 + 1.208 + private ExtendedJSONObject bundle; 1.209 + private State state; 1.210 + 1.211 + private UnpickleParams() { 1.212 + } 1.213 + 1.214 + private static UnpickleParams fromJSON(final ExtendedJSONObject json) 1.215 + throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { 1.216 + final UnpickleParams params = new UnpickleParams(); 1.217 + params.pickleVersion = json.getLong(KEY_PICKLE_VERSION); 1.218 + if (params.pickleVersion == null) { 1.219 + throw new IllegalStateException("Pickle version not found."); 1.220 + } 1.221 + 1.222 + /* 1.223 + * Version 1 and version 2 are identical, except version 2 throws if the 1.224 + * internal Android Account type has changed. Version 1 used to throw in 1.225 + * this case, but we intentionally used the pickle file to migrate across 1.226 + * Account types, bumping the version simultaneously. 1.227 + */ 1.228 + switch (params.pickleVersion.intValue()) { 1.229 + case 2: { 1.230 + // Sanity check. 1.231 + final String accountType = json.getString(KEY_ACCOUNT_TYPE); 1.232 + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { 1.233 + throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "."); 1.234 + } 1.235 + 1.236 + params.unpickleV1(json); 1.237 + } 1.238 + break; 1.239 + 1.240 + case 1: { 1.241 + // Warn about account type changing, but don't throw over it. 1.242 + final String accountType = json.getString(KEY_ACCOUNT_TYPE); 1.243 + if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { 1.244 + Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring."); 1.245 + } 1.246 + 1.247 + params.unpickleV1(json); 1.248 + } 1.249 + break; 1.250 + 1.251 + default: 1.252 + throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + "."); 1.253 + } 1.254 + 1.255 + return params; 1.256 + } 1.257 + 1.258 + private void unpickleV1(final ExtendedJSONObject json) 1.259 + throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { 1.260 + 1.261 + this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION); 1.262 + this.email = json.getString(KEY_EMAIL); 1.263 + this.profile = json.getString(KEY_PROFILE); 1.264 + this.idpServerURI = json.getString(KEY_IDP_SERVER_URI); 1.265 + this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI); 1.266 + this.isSyncingEnabled = json.getBoolean(KEY_IS_SYNCING_ENABLED); 1.267 + 1.268 + this.bundle = json.getObject(KEY_BUNDLE); 1.269 + if (bundle == null) { 1.270 + throw new IllegalStateException("Pickle bundle is null."); 1.271 + } 1.272 + this.state = getState(bundle); 1.273 + } 1.274 + 1.275 + private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException, 1.276 + NonObjectJSONException, NoSuchAlgorithmException { 1.277 + // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain 1.278 + // old versions? 1.279 + final StateLabel stateLabel = StateLabel.valueOf( 1.280 + bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL)); 1.281 + final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE); 1.282 + if (stateLabel == null) { 1.283 + throw new IllegalStateException("stateLabel must not be null"); 1.284 + } 1.285 + if (stateString == null) { 1.286 + throw new IllegalStateException("stateString must not be null"); 1.287 + } 1.288 + 1.289 + try { 1.290 + return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); 1.291 + } catch (Exception e) { 1.292 + throw new IllegalStateException("could not get state", e); 1.293 + } 1.294 + } 1.295 + } 1.296 +}