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