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.devices;
6 
7 import android.content.Context;
8 import android.content.Intent;
9 import android.support.annotation.NonNull;
10 import android.support.annotation.Nullable;
11 import android.support.annotation.VisibleForTesting;
12 import android.text.TextUtils;
13 import android.util.Log;
14 
15 import org.mozilla.gecko.background.common.log.Logger;
16 import org.mozilla.gecko.background.fxa.FxAccountClient;
17 import org.mozilla.gecko.background.fxa.FxAccountClient20;
18 import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
19 import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
20 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
21 import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
22 import org.mozilla.gecko.background.fxa.FxAccountUtils;
23 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
24 import org.mozilla.gecko.fxa.login.State;
25 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
26 import org.mozilla.gecko.util.BundleEventListener;
27 import org.mozilla.gecko.util.EventCallback;
28 import org.mozilla.gecko.util.GeckoBundle;
29 
30 import java.io.IOException;
31 import java.io.UnsupportedEncodingException;
32 import java.lang.ref.WeakReference;
33 import java.lang.reflect.InvocationTargetException;
34 import java.lang.reflect.Method;
35 import java.security.GeneralSecurityException;
36 import java.util.concurrent.ExecutorService;
37 import java.util.concurrent.Executors;
38 
39 /* This class provides a way to register the current device against FxA
40  * and also stores the registration details in the Android FxAccount.
41  * This should be used in a state where we possess a sessionToken, most likely the Engaged/Married states.
42  */
43 public class FxAccountDeviceRegistrator implements BundleEventListener {
44   private static final String LOG_TAG = "FxADeviceRegistrator";
45 
46   // The autopush endpoint expires stale channel subscriptions every 30 days (at a set time during
47   // the month, although we don't depend on this). To avoid the FxA service channel silently
48   // expiring from underneath us, we unsubscribe and resubscribe every 21 days.
49   // Note that this simple schedule means that we might unsubscribe perfectly valid (but old)
50   // subscriptions. This will be improved as part of Bug 1345651.
51   @VisibleForTesting
52   static final long TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS = 21 * 24 * 60 * 60 * 1000L;
53 
54   @VisibleForTesting
55   static final long RETRY_TIME_AFTER_GCM_DISABLED_ERROR = 15 * 24 * 60 * 60 * 1000L;
56 
57 
58   public static final String PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR = "error";
59   @VisibleForTesting
60   static final long ERROR_GCM_DISABLED = 2154627078L; // = NS_ERROR_DOM_PUSH_GCM_DISABLED
61 
62   // The current version of the device registration, we use this to re-register
63   // devices after we update what we send on device registration.
64   @VisibleForTesting
65   static final Integer DEVICE_REGISTRATION_VERSION = 2;
66 
67   private static FxAccountDeviceRegistrator instance;
68   private final WeakReference<Context> context;
69 
FxAccountDeviceRegistrator(Context appContext)70   private FxAccountDeviceRegistrator(Context appContext) {
71     this.context = new WeakReference<>(appContext);
72   }
73 
getInstance(Context appContext)74   private static FxAccountDeviceRegistrator getInstance(Context appContext) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
75     if (instance == null) {
76       final FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext);
77       tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response
78       instance = tempInstance;
79     }
80     return instance;
81   }
82 
shouldRegister(final AndroidFxAccount fxAccount)83   public static boolean shouldRegister(final AndroidFxAccount fxAccount) {
84     if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION ||
85             TextUtils.isEmpty(fxAccount.getDeviceId())) {
86       return true;
87     }
88     // At this point, we have a working up-to-date registration, but it might be a partial one
89     // (no push registration).
90     return fxAccount.getDevicePushRegistrationError() == ERROR_GCM_DISABLED &&
91            (System.currentTimeMillis() - fxAccount.getDevicePushRegistrationErrorTime()) > RETRY_TIME_AFTER_GCM_DISABLED_ERROR;
92   }
93 
shouldRenewRegistration(final AndroidFxAccount fxAccount)94   public static boolean shouldRenewRegistration(final AndroidFxAccount fxAccount) {
95     final long deviceRegistrationTimestamp = fxAccount.getDeviceRegistrationTimestamp();
96     // NB: we're comparing wall clock to wall clock, at different points in time.
97     // It's possible that wall clocks have changed, and our comparison will be meaningless.
98     // However, this happens in the context of a sync, and we won't be able to sync anyways if our
99     // wall clock deviates too much from time on the server.
100     return (System.currentTimeMillis() - deviceRegistrationTimestamp) > TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS;
101   }
102 
register(Context context)103   public static void register(Context context) {
104     final Context appContext = context.getApplicationContext();
105     try {
106       getInstance(appContext).beginRegistration(appContext);
107     } catch (Exception e) {
108       Log.e(LOG_TAG, "Could not start FxA device registration", e);
109     }
110   }
111 
renewRegistration(Context context)112   public static void renewRegistration(Context context) {
113     final Context appContext = context.getApplicationContext();
114     try {
115       getInstance(appContext).beginRegistrationRenewal(appContext);
116     } catch (Exception e) {
117       Log.e(LOG_TAG, "Could not start FxA device re-registration", e);
118     }
119   }
120 
beginRegistration(Context context)121   private void beginRegistration(Context context) {
122     // Fire up gecko and send event
123     // We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices
124     // because we can't import these modules (circular dependency between browser and services)
125     final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-subscribe");
126     context.startService(geckoIntent);
127     // -> handleMessage()
128   }
129 
beginRegistrationRenewal(Context context)130   private void beginRegistrationRenewal(Context context) {
131     // Same as registration, but unsubscribe first to get a fresh subscription.
132     final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-resubscribe");
133     context.startService(geckoIntent);
134     // -> handleMessage()
135   }
136 
buildCreatePushServiceIntent(final Context context, final String data)137   private Intent buildCreatePushServiceIntent(final Context context, final String data) {
138     final Intent intent = new Intent();
139     intent.setAction("create-services");
140     intent.setClassName(context, "org.mozilla.gecko.GeckoService");
141     intent.putExtra("category", "android-push-service");
142     intent.putExtra("data", data);
143     final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
144     intent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
145     return intent;
146   }
147 
148   @Override
handleMessage(String event, GeckoBundle message, EventCallback callback)149   public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
150     if ("FxAccountsPush:Subscribe:Response".equals(event)) {
151       handlePushSubscriptionResponse(message);
152     } else {
153       Log.e(LOG_TAG, "No action defined for " + event);
154     }
155   }
156 
handlePushSubscriptionResponse(final GeckoBundle message)157   private void handlePushSubscriptionResponse(final GeckoBundle message) {
158     // Make sure the context has not been gc'd during the push registration
159     // and the FxAccount still exists.
160     final Context context = this.context.get();
161     if (context == null) {
162       throw new IllegalStateException("Application context has been gc'ed");
163     }
164     final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
165     if (fxAccount == null) {
166       Log.e(LOG_TAG, "AndroidFxAccount is null");
167       return;
168     }
169 
170     fxAccount.resetDevicePushRegistrationError();
171     final long error = getSubscriptionReplyError(message);
172 
173     final FxAccountDevice device;
174     if (error == 0L) {
175       Log.i(LOG_TAG, "Push registration succeeded. Beginning normal FxA Registration.");
176       device = buildFxAccountDevice(context, fxAccount, message.getBundle("subscription"));
177     } else {
178       fxAccount.setDevicePushRegistrationError(error, System.currentTimeMillis());
179       Log.i(LOG_TAG, "Push registration failed. Beginning degraded FxA Registration.");
180       device = buildFxAccountDevice(context, fxAccount);
181     }
182 
183     doFxaRegistration(context, fxAccount, device, true);
184   }
185 
getSubscriptionReplyError(final GeckoBundle message)186   private long getSubscriptionReplyError(final GeckoBundle message) {
187     String errorStr = message.getString(PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR);
188     if (TextUtils.isEmpty(errorStr)) {
189       return 0L;
190     }
191     return Long.parseLong(errorStr);
192   }
193 
doFxaRegistration(final Context context, final AndroidFxAccount fxAccount, final FxAccountDevice device, final boolean allowRecursion)194   private static void doFxaRegistration(final Context context, final AndroidFxAccount fxAccount,
195                                         final FxAccountDevice device, final boolean allowRecursion) {
196     final byte[] sessionToken;
197     try {
198       sessionToken = fxAccount.getState().getSessionToken();
199     } catch (State.NotASessionTokenState e) {
200       Log.e(LOG_TAG, "Could not get a session token", e);
201       return;
202     }
203 
204     if (device.id == null) {
205       Log.i(LOG_TAG, "Attempting registration for a new device");
206     } else {
207       Log.i(LOG_TAG, "Attempting registration for an existing device");
208     }
209 
210     final ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
211     final FxAccountClient20 fxAccountClient =
212             new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
213     fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() {
214       @Override
215       public void handleError(Exception e) {
216         Log.e(LOG_TAG, "Error while updating a device registration: ", e);
217         fxAccount.setDeviceRegistrationTimestamp(0L);
218       }
219 
220       @Override
221       public void handleFailure(FxAccountClientRemoteException error) {
222         Log.e(LOG_TAG, "Error while updating a device registration: ", error);
223 
224         fxAccount.setDeviceRegistrationTimestamp(0L);
225 
226         if (error.httpStatusCode == 400) {
227           if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) {
228             recoverFromUnknownDevice(fxAccount);
229           } else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) {
230             // This can happen if a device was already registered using our session token, and we
231             // tried to create a new one (no id field).
232             recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, device,
233                     context, allowRecursion);
234           }
235         } else
236         if (error.httpStatusCode == 401
237                 && error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) {
238           handleTokenError(error, fxAccountClient, fxAccount);
239         } else {
240           logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
241         }
242       }
243 
244       @Override
245       public void handleSuccess(FxAccountDevice result) {
246         Log.i(LOG_TAG, "Device registration complete");
247         Logger.pii(LOG_TAG, "Registered device ID: " + result.id);
248         Log.i(LOG_TAG, "Setting DEVICE_REGISTRATION_VERSION to " + DEVICE_REGISTRATION_VERSION);
249         fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION, System.currentTimeMillis());
250       }
251     });
252   }
253 
buildFxAccountDevice(Context context, AndroidFxAccount fxAccount)254   private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount) {
255     return makeFxADeviceCommonBuilder(context, fxAccount).build();
256   }
257 
buildFxAccountDevice(Context context, AndroidFxAccount fxAccount, @NonNull GeckoBundle subscription)258   private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount, @NonNull GeckoBundle subscription) {
259     final FxAccountDevice.Builder builder = makeFxADeviceCommonBuilder(context, fxAccount);
260     final String pushCallback = subscription.getString("pushCallback");
261     final String pushPublicKey = subscription.getString("pushPublicKey");
262     final String pushAuthKey = subscription.getString("pushAuthKey");
263     if (!TextUtils.isEmpty(pushCallback) && !TextUtils.isEmpty(pushPublicKey) &&
264         !TextUtils.isEmpty(pushAuthKey)) {
265       builder.pushCallback(pushCallback);
266       builder.pushPublicKey(pushPublicKey);
267       builder.pushAuthKey(pushAuthKey);
268     }
269     return builder.build();
270   }
271 
272   // Do not call this directly, use buildFxAccountDevice instead.
makeFxADeviceCommonBuilder(Context context, AndroidFxAccount fxAccount)273   private static FxAccountDevice.Builder makeFxADeviceCommonBuilder(Context context, AndroidFxAccount fxAccount) {
274     final String deviceId = fxAccount.getDeviceId();
275     final String clientName = getClientName(fxAccount, context);
276 
277     final FxAccountDevice.Builder builder = new FxAccountDevice.Builder();
278     builder.name(clientName);
279     builder.type("mobile");
280     if (!TextUtils.isEmpty(deviceId)) {
281       builder.id(deviceId);
282     }
283     return builder;
284   }
285 
logErrorAndResetDeviceRegistrationVersionAndTimestamp( final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount)286   private static void logErrorAndResetDeviceRegistrationVersionAndTimestamp(
287       final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) {
288     Log.e(LOG_TAG, "Device registration failed", error);
289     fxAccount.resetDeviceRegistrationVersion();
290     fxAccount.setDeviceRegistrationTimestamp(0L);
291   }
292 
getClientName(final AndroidFxAccount fxAccount, final Context context)293   private static String getClientName(final AndroidFxAccount fxAccount, final Context context) {
294     try {
295       final SharedPreferencesClientsDataDelegate clientsDataDelegate =
296           new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
297       return clientsDataDelegate.getClientName();
298     } catch (Exception e) {
299       Log.e(LOG_TAG, "Unable to get client name.", e);
300       // It's possible we're racing against account pickler.
301       // In either case, it should be always safe to perform registration using our default name.
302       return FxAccountUtils.defaultClientName(context);
303     }
304   }
305 
handleTokenError(final FxAccountClientRemoteException error, final FxAccountClient fxAccountClient, final AndroidFxAccount fxAccount)306   private static void handleTokenError(final FxAccountClientRemoteException error,
307                                        final FxAccountClient fxAccountClient,
308                                        final AndroidFxAccount fxAccount) {
309     Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
310     logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
311     fxAccountClient.accountStatus(fxAccount.getState().uid,
312         new RequestDelegate<AccountStatusResponse>() {
313       @Override
314       public void handleError(Exception e) {
315       }
316 
317       @Override
318       public void handleFailure(FxAccountClientRemoteException e) {
319       }
320 
321       @Override
322       public void handleSuccess(AccountStatusResponse result) {
323         final State doghouseState = fxAccount.getState().makeDoghouseState();
324         if (!result.exists) {
325           Log.i(LOG_TAG, "token invalidated because the account no longer exists");
326           // TODO: Should be in a "I have an Android account, but the FxA is gone." State.
327           // This will do for now..
328           fxAccount.setState(doghouseState);
329           return;
330         }
331         Log.e(LOG_TAG, "sessionToken invalid");
332         fxAccount.setState(doghouseState);
333       }
334     });
335   }
336 
recoverFromUnknownDevice(final AndroidFxAccount fxAccount)337   private static void recoverFromUnknownDevice(final AndroidFxAccount fxAccount) {
338     Log.i(LOG_TAG, "unknown device id, clearing the cached device id");
339     fxAccount.setDeviceId(null);
340   }
341 
342   /**
343    * Will call delegate#complete in all cases
344    */
recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error, final FxAccountClient fxAccountClient, final byte[] sessionToken, final AndroidFxAccount fxAccount, final FxAccountDevice device, final Context context, final boolean allowRecursion)345   private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error,
346                                                        final FxAccountClient fxAccountClient,
347                                                        final byte[] sessionToken,
348                                                        final AndroidFxAccount fxAccount,
349                                                        final FxAccountDevice device,
350                                                        final Context context,
351                                                        final boolean allowRecursion) {
352     // Recovery strategy: re-try a registration, UPDATING (instead of creating) the device.
353     // We do that by finding the device ID who conflicted with us and try a registration update
354     // using that id.
355     Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
356     fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
357       private void onError() {
358         Log.e(LOG_TAG, "failed to recover from device-session conflict");
359         logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
360       }
361 
362       @Override
363       public void handleError(Exception e) {
364         onError();
365       }
366 
367       @Override
368       public void handleFailure(FxAccountClientRemoteException e) {
369         onError();
370       }
371 
372       @Override
373       public void handleSuccess(FxAccountDevice[] devices) {
374         for (final FxAccountDevice fxaDevice : devices) {
375           if (!fxaDevice.isCurrentDevice) {
376             continue;
377           }
378           fxAccount.setFxAUserData(fxaDevice.id, 0, 0L); // Reset device registration version/timestamp
379           if (!allowRecursion) {
380             Log.d(LOG_TAG, "Failure to register a device on the second try");
381             break;
382           }
383           final FxAccountDevice updatedDevice = new FxAccountDevice(device.name, fxaDevice.id, device.type,
384                                                                     null, null,
385                                                                     device.pushCallback, device.pushPublicKey,
386                                                                     device.pushAuthKey, null);
387           doFxaRegistration(context, fxAccount, updatedDevice, false);
388           return;
389         }
390         onError();
391       }
392     });
393   }
394 
setupListeners()395   private void setupListeners() throws ClassNotFoundException, NoSuchMethodException,
396           InvocationTargetException, IllegalAccessException {
397     // We have no choice but to use reflection here, sorry :(
398     final Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher");
399     final Method getInstance = eventDispatcher.getMethod("getInstance");
400     final Object instance = getInstance.invoke(null);
401     final Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener",
402             BundleEventListener.class, String[].class);
403     registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" });
404   }
405 }
406