Wed, 31 Dec 2014 07:22:50 +0100
Correct previous dual key logic pending first delivery installment.
michael@0 | 1 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 4 | |
michael@0 | 5 | package org.mozilla.gecko.fxa.authenticator; |
michael@0 | 6 | |
michael@0 | 7 | import java.io.FileOutputStream; |
michael@0 | 8 | import java.io.PrintStream; |
michael@0 | 9 | import java.security.NoSuchAlgorithmException; |
michael@0 | 10 | import java.security.spec.InvalidKeySpecException; |
michael@0 | 11 | |
michael@0 | 12 | import org.mozilla.gecko.background.common.log.Logger; |
michael@0 | 13 | import org.mozilla.gecko.fxa.FxAccountConstants; |
michael@0 | 14 | import org.mozilla.gecko.fxa.login.State; |
michael@0 | 15 | import org.mozilla.gecko.fxa.login.State.StateLabel; |
michael@0 | 16 | import org.mozilla.gecko.fxa.login.StateFactory; |
michael@0 | 17 | import org.mozilla.gecko.sync.ExtendedJSONObject; |
michael@0 | 18 | import org.mozilla.gecko.sync.NonObjectJSONException; |
michael@0 | 19 | import org.mozilla.gecko.sync.Utils; |
michael@0 | 20 | |
michael@0 | 21 | import android.content.Context; |
michael@0 | 22 | |
michael@0 | 23 | /** |
michael@0 | 24 | * Android deletes Account objects when the Authenticator that owns the Account |
michael@0 | 25 | * disappears. This happens when an App is installed to the SD card and the SD |
michael@0 | 26 | * card is un-mounted or the device is rebooted. |
michael@0 | 27 | * <p> |
michael@0 | 28 | * We work around this by pickling the current Firefox account data every sync |
michael@0 | 29 | * and unpickling when we check if Firefox accounts exist (called from Fennec). |
michael@0 | 30 | * <p> |
michael@0 | 31 | * Android just doesn't support installing Apps that define long-lived Services |
michael@0 | 32 | * and/or own Account types onto the SD card. The documentation says not to do |
michael@0 | 33 | * it. There are hordes of developers who want to do it, and have tried to |
michael@0 | 34 | * register for almost every "package installation changed" broadcast intent |
michael@0 | 35 | * that Android supports. They all explicitly state that the package that has |
michael@0 | 36 | * changed does *not* receive the broadcast intent, thereby preventing an App |
michael@0 | 37 | * from re-establishing its state. |
michael@0 | 38 | * <p> |
michael@0 | 39 | * <a href="http://developer.android.com/guide/topics/data/install-location.html">Reference.</a> |
michael@0 | 40 | * <p> |
michael@0 | 41 | * <b>Quote</b>: Your AbstractThreadedSyncAdapter and all its sync functionality |
michael@0 | 42 | * will not work until external storage is remounted. |
michael@0 | 43 | * <p> |
michael@0 | 44 | * <b>Quote</b>: Your running Service will be killed and will not be restarted |
michael@0 | 45 | * when external storage is remounted. You can, however, register for the |
michael@0 | 46 | * ACTION_EXTERNAL_APPLICATIONS_AVAILABLE broadcast Intent, which will notify |
michael@0 | 47 | * your application when applications installed on external storage have become |
michael@0 | 48 | * available to the system again. At which time, you can restart your Service. |
michael@0 | 49 | * <p> |
michael@0 | 50 | * Problem: <a href="http://code.google.com/p/android/issues/detail?id=8485">that intent doesn't work</a>! |
michael@0 | 51 | * <p> |
michael@0 | 52 | * See bug 768102 for more information in the context of Sync. |
michael@0 | 53 | */ |
michael@0 | 54 | public class AccountPickler { |
michael@0 | 55 | public static final String LOG_TAG = AccountPickler.class.getSimpleName(); |
michael@0 | 56 | |
michael@0 | 57 | public static final long PICKLE_VERSION = 2; |
michael@0 | 58 | |
michael@0 | 59 | private static final String KEY_PICKLE_VERSION = "pickle_version"; |
michael@0 | 60 | private static final String KEY_PICKLE_TIMESTAMP = "pickle_timestamp"; |
michael@0 | 61 | |
michael@0 | 62 | private static final String KEY_ACCOUNT_VERSION = "account_version"; |
michael@0 | 63 | private static final String KEY_ACCOUNT_TYPE = "account_type"; |
michael@0 | 64 | private static final String KEY_EMAIL = "email"; |
michael@0 | 65 | private static final String KEY_PROFILE = "profile"; |
michael@0 | 66 | private static final String KEY_IDP_SERVER_URI = "idpServerURI"; |
michael@0 | 67 | private static final String KEY_TOKEN_SERVER_URI = "tokenServerURI"; |
michael@0 | 68 | private static final String KEY_IS_SYNCING_ENABLED = "isSyncingEnabled"; |
michael@0 | 69 | |
michael@0 | 70 | private static final String KEY_BUNDLE = "bundle"; |
michael@0 | 71 | |
michael@0 | 72 | /** |
michael@0 | 73 | * Remove Firefox account persisted to disk. |
michael@0 | 74 | * |
michael@0 | 75 | * @param context Android context. |
michael@0 | 76 | * @param filename name of persisted pickle file; must not contain path separators. |
michael@0 | 77 | * @return <code>true</code> if given pickle existed and was successfully deleted. |
michael@0 | 78 | */ |
michael@0 | 79 | public static boolean deletePickle(final Context context, final String filename) { |
michael@0 | 80 | return context.deleteFile(filename); |
michael@0 | 81 | } |
michael@0 | 82 | |
michael@0 | 83 | /** |
michael@0 | 84 | * Persist Firefox account to disk as a JSON object. |
michael@0 | 85 | * |
michael@0 | 86 | * @param AndroidFxAccount the account to persist to disk |
michael@0 | 87 | * @param filename name of file to persist to; must not contain path separators. |
michael@0 | 88 | */ |
michael@0 | 89 | public static void pickle(final AndroidFxAccount account, final String filename) { |
michael@0 | 90 | final ExtendedJSONObject o = new ExtendedJSONObject(); |
michael@0 | 91 | o.put(KEY_PICKLE_VERSION, Long.valueOf(PICKLE_VERSION)); |
michael@0 | 92 | o.put(KEY_PICKLE_TIMESTAMP, Long.valueOf(System.currentTimeMillis())); |
michael@0 | 93 | |
michael@0 | 94 | o.put(KEY_ACCOUNT_VERSION, AndroidFxAccount.CURRENT_ACCOUNT_VERSION); |
michael@0 | 95 | o.put(KEY_ACCOUNT_TYPE, FxAccountConstants.ACCOUNT_TYPE); |
michael@0 | 96 | o.put(KEY_EMAIL, account.getEmail()); |
michael@0 | 97 | o.put(KEY_PROFILE, account.getProfile()); |
michael@0 | 98 | o.put(KEY_IDP_SERVER_URI, account.getAccountServerURI()); |
michael@0 | 99 | o.put(KEY_TOKEN_SERVER_URI, account.getTokenServerURI()); |
michael@0 | 100 | o.put(KEY_IS_SYNCING_ENABLED, account.isSyncing()); |
michael@0 | 101 | |
michael@0 | 102 | // TODO: If prefs version changes under us, SyncPrefsPath will change, "clearing" prefs. |
michael@0 | 103 | |
michael@0 | 104 | final ExtendedJSONObject bundle = account.unbundle(); |
michael@0 | 105 | if (bundle == null) { |
michael@0 | 106 | Logger.warn(LOG_TAG, "Unable to obtain account bundle; aborting."); |
michael@0 | 107 | return; |
michael@0 | 108 | } |
michael@0 | 109 | o.put(KEY_BUNDLE, bundle); |
michael@0 | 110 | |
michael@0 | 111 | writeToDisk(account.context, filename, o); |
michael@0 | 112 | } |
michael@0 | 113 | |
michael@0 | 114 | private static void writeToDisk(final Context context, final String filename, |
michael@0 | 115 | final ExtendedJSONObject pickle) { |
michael@0 | 116 | try { |
michael@0 | 117 | final FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE); |
michael@0 | 118 | try { |
michael@0 | 119 | final PrintStream ps = new PrintStream(fos); |
michael@0 | 120 | try { |
michael@0 | 121 | ps.print(pickle.toJSONString()); |
michael@0 | 122 | Logger.debug(LOG_TAG, "Persisted " + pickle.keySet().size() + |
michael@0 | 123 | " account settings to " + filename + "."); |
michael@0 | 124 | } finally { |
michael@0 | 125 | ps.close(); |
michael@0 | 126 | } |
michael@0 | 127 | } finally { |
michael@0 | 128 | fos.close(); |
michael@0 | 129 | } |
michael@0 | 130 | } catch (Exception e) { |
michael@0 | 131 | Logger.warn(LOG_TAG, "Caught exception persisting account settings to " + filename + |
michael@0 | 132 | "; ignoring.", e); |
michael@0 | 133 | } |
michael@0 | 134 | } |
michael@0 | 135 | |
michael@0 | 136 | /** |
michael@0 | 137 | * Create Android account from saved JSON object. Assumes that an account does not exist. |
michael@0 | 138 | * |
michael@0 | 139 | * @param context |
michael@0 | 140 | * Android context. |
michael@0 | 141 | * @param filename |
michael@0 | 142 | * name of file to read from; must not contain path separators. |
michael@0 | 143 | * @return created Android account, or null on error. |
michael@0 | 144 | */ |
michael@0 | 145 | public static AndroidFxAccount unpickle(final Context context, final String filename) { |
michael@0 | 146 | final String jsonString = Utils.readFile(context, filename); |
michael@0 | 147 | if (jsonString == null) { |
michael@0 | 148 | Logger.info(LOG_TAG, "Pickle file '" + filename + "' not found; aborting."); |
michael@0 | 149 | return null; |
michael@0 | 150 | } |
michael@0 | 151 | |
michael@0 | 152 | ExtendedJSONObject json = null; |
michael@0 | 153 | try { |
michael@0 | 154 | json = ExtendedJSONObject.parseJSONObject(jsonString); |
michael@0 | 155 | } catch (Exception e) { |
michael@0 | 156 | Logger.warn(LOG_TAG, "Got exception reading pickle file '" + filename + "'; aborting.", e); |
michael@0 | 157 | return null; |
michael@0 | 158 | } |
michael@0 | 159 | |
michael@0 | 160 | final UnpickleParams params; |
michael@0 | 161 | try { |
michael@0 | 162 | params = UnpickleParams.fromJSON(json); |
michael@0 | 163 | } catch (Exception e) { |
michael@0 | 164 | Logger.warn(LOG_TAG, "Got exception extracting unpickle json; aborting.", e); |
michael@0 | 165 | return null; |
michael@0 | 166 | } |
michael@0 | 167 | |
michael@0 | 168 | final AndroidFxAccount account; |
michael@0 | 169 | try { |
michael@0 | 170 | account = AndroidFxAccount.addAndroidAccount(context, params.email, params.profile, |
michael@0 | 171 | params.idpServerURI, params.tokenServerURI, params.state, params.accountVersion, |
michael@0 | 172 | params.isSyncingEnabled, true, params.bundle); |
michael@0 | 173 | } catch (Exception e) { |
michael@0 | 174 | Logger.warn(LOG_TAG, "Exception when adding Android Account; aborting.", e); |
michael@0 | 175 | return null; |
michael@0 | 176 | } |
michael@0 | 177 | |
michael@0 | 178 | if (account == null) { |
michael@0 | 179 | Logger.warn(LOG_TAG, "Failed to add Android Account; aborting."); |
michael@0 | 180 | return null; |
michael@0 | 181 | } |
michael@0 | 182 | |
michael@0 | 183 | Long timestamp = json.getLong(KEY_PICKLE_TIMESTAMP); |
michael@0 | 184 | if (timestamp == null) { |
michael@0 | 185 | Logger.warn(LOG_TAG, "Did not find timestamp in pickle file; ignoring."); |
michael@0 | 186 | timestamp = Long.valueOf(-1); |
michael@0 | 187 | } |
michael@0 | 188 | |
michael@0 | 189 | Logger.info(LOG_TAG, "Un-pickled Android account named " + params.email + " (version " + |
michael@0 | 190 | params.pickleVersion + ", pickled at " + timestamp + ")."); |
michael@0 | 191 | |
michael@0 | 192 | return account; |
michael@0 | 193 | } |
michael@0 | 194 | |
michael@0 | 195 | private static class UnpickleParams { |
michael@0 | 196 | private Long pickleVersion; |
michael@0 | 197 | |
michael@0 | 198 | private int accountVersion; |
michael@0 | 199 | private String email; |
michael@0 | 200 | private String profile; |
michael@0 | 201 | private String idpServerURI; |
michael@0 | 202 | private String tokenServerURI; |
michael@0 | 203 | private boolean isSyncingEnabled; |
michael@0 | 204 | |
michael@0 | 205 | private ExtendedJSONObject bundle; |
michael@0 | 206 | private State state; |
michael@0 | 207 | |
michael@0 | 208 | private UnpickleParams() { |
michael@0 | 209 | } |
michael@0 | 210 | |
michael@0 | 211 | private static UnpickleParams fromJSON(final ExtendedJSONObject json) |
michael@0 | 212 | throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException { |
michael@0 | 213 | final UnpickleParams params = new UnpickleParams(); |
michael@0 | 214 | params.pickleVersion = json.getLong(KEY_PICKLE_VERSION); |
michael@0 | 215 | if (params.pickleVersion == null) { |
michael@0 | 216 | throw new IllegalStateException("Pickle version not found."); |
michael@0 | 217 | } |
michael@0 | 218 | |
michael@0 | 219 | /* |
michael@0 | 220 | * Version 1 and version 2 are identical, except version 2 throws if the |
michael@0 | 221 | * internal Android Account type has changed. Version 1 used to throw in |
michael@0 | 222 | * this case, but we intentionally used the pickle file to migrate across |
michael@0 | 223 | * Account types, bumping the version simultaneously. |
michael@0 | 224 | */ |
michael@0 | 225 | switch (params.pickleVersion.intValue()) { |
michael@0 | 226 | case 2: { |
michael@0 | 227 | // Sanity check. |
michael@0 | 228 | final String accountType = json.getString(KEY_ACCOUNT_TYPE); |
michael@0 | 229 | if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { |
michael@0 | 230 | throw new IllegalStateException("Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "."); |
michael@0 | 231 | } |
michael@0 | 232 | |
michael@0 | 233 | params.unpickleV1(json); |
michael@0 | 234 | } |
michael@0 | 235 | break; |
michael@0 | 236 | |
michael@0 | 237 | case 1: { |
michael@0 | 238 | // Warn about account type changing, but don't throw over it. |
michael@0 | 239 | final String accountType = json.getString(KEY_ACCOUNT_TYPE); |
michael@0 | 240 | if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { |
michael@0 | 241 | Logger.warn(LOG_TAG, "Account type has changed from " + accountType + " to " + FxAccountConstants.ACCOUNT_TYPE + "; ignoring."); |
michael@0 | 242 | } |
michael@0 | 243 | |
michael@0 | 244 | params.unpickleV1(json); |
michael@0 | 245 | } |
michael@0 | 246 | break; |
michael@0 | 247 | |
michael@0 | 248 | default: |
michael@0 | 249 | throw new IllegalStateException("Unknown pickle version, " + params.pickleVersion + "."); |
michael@0 | 250 | } |
michael@0 | 251 | |
michael@0 | 252 | return params; |
michael@0 | 253 | } |
michael@0 | 254 | |
michael@0 | 255 | private void unpickleV1(final ExtendedJSONObject json) |
michael@0 | 256 | throws NonObjectJSONException, NoSuchAlgorithmException, InvalidKeySpecException { |
michael@0 | 257 | |
michael@0 | 258 | this.accountVersion = json.getIntegerSafely(KEY_ACCOUNT_VERSION); |
michael@0 | 259 | this.email = json.getString(KEY_EMAIL); |
michael@0 | 260 | this.profile = json.getString(KEY_PROFILE); |
michael@0 | 261 | this.idpServerURI = json.getString(KEY_IDP_SERVER_URI); |
michael@0 | 262 | this.tokenServerURI = json.getString(KEY_TOKEN_SERVER_URI); |
michael@0 | 263 | this.isSyncingEnabled = json.getBoolean(KEY_IS_SYNCING_ENABLED); |
michael@0 | 264 | |
michael@0 | 265 | this.bundle = json.getObject(KEY_BUNDLE); |
michael@0 | 266 | if (bundle == null) { |
michael@0 | 267 | throw new IllegalStateException("Pickle bundle is null."); |
michael@0 | 268 | } |
michael@0 | 269 | this.state = getState(bundle); |
michael@0 | 270 | } |
michael@0 | 271 | |
michael@0 | 272 | private State getState(final ExtendedJSONObject bundle) throws InvalidKeySpecException, |
michael@0 | 273 | NonObjectJSONException, NoSuchAlgorithmException { |
michael@0 | 274 | // TODO: Should copy-pasta BUNDLE_KEY_STATE & LABEL to this file to ensure we maintain |
michael@0 | 275 | // old versions? |
michael@0 | 276 | final StateLabel stateLabel = StateLabel.valueOf( |
michael@0 | 277 | bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE_LABEL)); |
michael@0 | 278 | final String stateString = bundle.getString(AndroidFxAccount.BUNDLE_KEY_STATE); |
michael@0 | 279 | if (stateLabel == null) { |
michael@0 | 280 | throw new IllegalStateException("stateLabel must not be null"); |
michael@0 | 281 | } |
michael@0 | 282 | if (stateString == null) { |
michael@0 | 283 | throw new IllegalStateException("stateString must not be null"); |
michael@0 | 284 | } |
michael@0 | 285 | |
michael@0 | 286 | try { |
michael@0 | 287 | return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString)); |
michael@0 | 288 | } catch (Exception e) { |
michael@0 | 289 | throw new IllegalStateException("could not get state", e); |
michael@0 | 290 | } |
michael@0 | 291 | } |
michael@0 | 292 | } |
michael@0 | 293 | } |