Bug 1351805 part 1 - Create a org.mozilla.gecko.fxa.devices package. r?Grisha draft
authorEdouard Oger <eoger@fastmail.com>
Fri, 07 Apr 2017 11:56:38 -0400
changeset 568110 7afb7d1d69892d888ce2d278985aea0ef5761659
parent 567802 f229b7e5d91eb70d23d3e31db7caff9d69a2ef04
child 568111 44886b46809ad18d579f4e7b36f7d7335247deaa
child 568250 ff490b34a5e4f347cabf85d7eaaea4c647f62ba0
push id55760
push userbmo:eoger@fastmail.com
push dateTue, 25 Apr 2017 19:03:26 +0000
reviewersGrisha
bugs1351805
milestone55.0a1
Bug 1351805 part 1 - Create a org.mozilla.gecko.fxa.devices package. r?Grisha MozReview-Commit-ID: FjJmRiHlqEg
mobile/android/base/android-services.mozbuild
mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java
mobile/android/base/java/org/mozilla/gecko/push/PushService.java
mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDevice.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceRegistrator.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestFxAccountDeviceRegistrator.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/devices/TestFxAccountDeviceRegistrator.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -826,20 +826,20 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     'fxa/activities/PicassoPreferenceIconTarget.java',
     'fxa/authenticator/AccountPickler.java',
     'fxa/authenticator/AndroidFxAccount.java',
     'fxa/authenticator/FxAccountAuthenticator.java',
     'fxa/authenticator/FxAccountAuthenticatorService.java',
     'fxa/authenticator/FxAccountLoginDelegate.java',
     'fxa/authenticator/FxAccountLoginException.java',
     'fxa/authenticator/FxADefaultLoginStateMachineDelegate.java',
+    'fxa/devices/FxAccountDevice.java',
+    'fxa/devices/FxAccountDeviceRegistrator.java',
     'fxa/FirefoxAccounts.java',
     'fxa/FxAccountConstants.java',
-    'fxa/FxAccountDevice.java',
-    'fxa/FxAccountDeviceRegistrator.java',
     'fxa/FxAccountPushHandler.java',
     'fxa/login/BaseRequestDelegate.java',
     'fxa/login/Cohabiting.java',
     'fxa/login/Doghouse.java',
     'fxa/login/Engaged.java',
     'fxa/login/FxAccountLoginStateMachine.java',
     'fxa/login/FxAccountLoginTransition.java',
     'fxa/login/Married.java',
--- a/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java
@@ -14,17 +14,16 @@ import android.accounts.OperationCancele
 import android.content.Context;
 import android.content.Intent;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
-import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.login.Engaged;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.util.BundleEventListener;
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -20,17 +20,17 @@ import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoService;
 import org.mozilla.gecko.GeckoThread;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.annotation.ReflectionTarget;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.fxa.FxAccountConstants;
-import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
+import org.mozilla.gecko.fxa.devices.FxAccountDeviceRegistrator;
 import org.mozilla.gecko.fxa.FxAccountPushHandler;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.gcm.GcmTokenClient;
 import org.mozilla.gecko.push.autopush.AutopushClientException;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
@@ -3,17 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.background.fxa;
 
 import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.RecoveryEmailStatusResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
-import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.fxa.devices.FxAccountDevice;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 
 import java.util.List;
 
 public interface FxAccountClient {
   public void accountStatus(String uid, RequestDelegate<AccountStatusResponse> requestDelegate);
   public void recoveryEmailStatus(byte[] sessionToken, RequestDelegate<RecoveryEmailStatusResponse> requestDelegate);
   public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate);
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
@@ -8,17 +8,17 @@ import android.support.annotation.NonNul
 
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientMalformedResponseException;
 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.Locales;
-import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.fxa.devices.FxAccountDevice;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.HKDF;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BaseResource;
 import org.mozilla.gecko.sync.net.BaseResourceDelegate;
 import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
 import org.mozilla.gecko.sync.net.Resource;
deleted file mode 100644
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDevice.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
-* License, v. 2.0. If a copy of the MPL was not distributed with this
-* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package org.mozilla.gecko.fxa;
-
-import org.mozilla.gecko.sync.ExtendedJSONObject;
-
-public class FxAccountDevice {
-
-  public static final String JSON_KEY_NAME = "name";
-  public static final String JSON_KEY_ID = "id";
-  public static final String JSON_KEY_TYPE = "type";
-  public static final String JSON_KEY_ISCURRENTDEVICE = "isCurrentDevice";
-  public static final String JSON_KEY_PUSH_CALLBACK = "pushCallback";
-  public static final String JSON_KEY_PUSH_PUBLICKEY = "pushPublicKey";
-  public static final String JSON_KEY_PUSH_AUTHKEY = "pushAuthKey";
-
-  public final String id;
-  public final String name;
-  public final String type;
-  public final Boolean isCurrentDevice;
-  public final String pushCallback;
-  public final String pushPublicKey;
-  public final String pushAuthKey;
-
-  public FxAccountDevice(String name, String id, String type, Boolean isCurrentDevice,
-                         String pushCallback, String pushPublicKey, String pushAuthKey) {
-    this.name = name;
-    this.id = id;
-    this.type = type;
-    this.isCurrentDevice = isCurrentDevice;
-    this.pushCallback = pushCallback;
-    this.pushPublicKey = pushPublicKey;
-    this.pushAuthKey = pushAuthKey;
-  }
-
-  public static FxAccountDevice fromJson(ExtendedJSONObject json) {
-    String name = json.getString(JSON_KEY_NAME);
-    String id = json.getString(JSON_KEY_ID);
-    String type = json.getString(JSON_KEY_TYPE);
-    Boolean isCurrentDevice = json.getBoolean(JSON_KEY_ISCURRENTDEVICE);
-    String pushCallback = json.getString(JSON_KEY_PUSH_CALLBACK);
-    String pushPublicKey = json.getString(JSON_KEY_PUSH_PUBLICKEY);
-    String pushAuthKey = json.getString(JSON_KEY_PUSH_AUTHKEY);
-    return new FxAccountDevice(name, id, type, isCurrentDevice, pushCallback, pushPublicKey, pushAuthKey);
-  }
-
-  public ExtendedJSONObject toJson() {
-    final ExtendedJSONObject body = new ExtendedJSONObject();
-    if (this.name != null) {
-      body.put(JSON_KEY_NAME, this.name);
-    }
-    if (this.id != null) {
-      body.put(JSON_KEY_ID, this.id);
-    }
-    if (this.type != null) {
-      body.put(JSON_KEY_TYPE, this.type);
-    }
-    if (this.pushCallback != null) {
-      body.put(JSON_KEY_PUSH_CALLBACK, this.pushCallback);
-    }
-    if (this.pushPublicKey != null) {
-      body.put(JSON_KEY_PUSH_PUBLICKEY, this.pushPublicKey);
-    }
-    if (this.pushAuthKey != null) {
-      body.put(JSON_KEY_PUSH_AUTHKEY, this.pushAuthKey);
-    }
-    return body;
-  }
-
-  public static class Builder {
-    private String id;
-    private String name;
-    private String type;
-    private Boolean isCurrentDevice;
-    private String pushCallback;
-    private String pushPublicKey;
-    private String pushAuthKey;
-
-    public void id(String id) {
-      this.id = id;
-    }
-
-    public void name(String name) {
-      this.name = name;
-    }
-
-    public void type(String type) {
-      this.type = type;
-    }
-
-    public void isCurrentDevice() {
-      this.isCurrentDevice = Boolean.TRUE;
-    }
-
-    public void pushCallback(String pushCallback) {
-      this.pushCallback = pushCallback;
-    }
-
-    public void pushPublicKey(String pushPublicKey) {
-      this.pushPublicKey = pushPublicKey;
-    }
-
-    public void pushAuthKey(String pushAuthKey) {
-      this.pushAuthKey = pushAuthKey;
-    }
-
-    public FxAccountDevice build() {
-      return new FxAccountDevice(this.name, this.id, this.type, this.isCurrentDevice,
-                                 this.pushCallback, this.pushPublicKey, this.pushAuthKey);
-    }
-  }
-}
deleted file mode 100644
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
+++ /dev/null
@@ -1,401 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
-* License, v. 2.0. If a copy of the MPL was not distributed with this
-* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package org.mozilla.gecko.fxa;
-
-import android.content.Context;
-import android.content.Intent;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.annotation.VisibleForTesting;
-import android.text.TextUtils;
-import android.util.Log;
-
-import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.background.fxa.FxAccountClient;
-import org.mozilla.gecko.background.fxa.FxAccountClient20;
-import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
-import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
-import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
-import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
-import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
-import org.mozilla.gecko.fxa.login.State;
-import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
-import org.mozilla.gecko.util.BundleEventListener;
-import org.mozilla.gecko.util.EventCallback;
-import org.mozilla.gecko.util.GeckoBundle;
-
-import java.io.UnsupportedEncodingException;
-import java.lang.ref.WeakReference;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.security.GeneralSecurityException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-/* This class provides a way to register the current device against FxA
- * and also stores the registration details in the Android FxAccount.
- * This should be used in a state where we possess a sessionToken, most likely the Engaged/Married states.
- */
-public class FxAccountDeviceRegistrator implements BundleEventListener {
-  private static final String LOG_TAG = "FxADeviceRegistrator";
-
-  // The autopush endpoint expires stale channel subscriptions every 30 days (at a set time during
-  // the month, although we don't depend on this). To avoid the FxA service channel silently
-  // expiring from underneath us, we unsubscribe and resubscribe every 21 days.
-  // Note that this simple schedule means that we might unsubscribe perfectly valid (but old)
-  // subscriptions. This will be improved as part of Bug 1345651.
-  @VisibleForTesting
-  static final long TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS = 21 * 24 * 60 * 60 * 1000L;
-
-  @VisibleForTesting
-  static final long RETRY_TIME_AFTER_GCM_DISABLED_ERROR = 15 * 24 * 60 * 60 * 1000L;
-
-
-  public static final String PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR = "error";
-  @VisibleForTesting
-  static final long ERROR_GCM_DISABLED = 2154627078L; // = NS_ERROR_DOM_PUSH_GCM_DISABLED
-
-  // The current version of the device registration, we use this to re-register
-  // devices after we update what we send on device registration.
-  @VisibleForTesting
-  static final Integer DEVICE_REGISTRATION_VERSION = 2;
-
-  private static FxAccountDeviceRegistrator instance;
-  private final WeakReference<Context> context;
-
-  private FxAccountDeviceRegistrator(Context appContext) {
-    this.context = new WeakReference<>(appContext);
-  }
-
-  private static FxAccountDeviceRegistrator getInstance(Context appContext) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
-    if (instance == null) {
-      final FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext);
-      tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response
-      instance = tempInstance;
-    }
-    return instance;
-  }
-
-  public static boolean shouldRegister(final AndroidFxAccount fxAccount) {
-    if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION ||
-            TextUtils.isEmpty(fxAccount.getDeviceId())) {
-      return true;
-    }
-    // At this point, we have a working up-to-date registration, but it might be a partial one
-    // (no push registration).
-    return fxAccount.getDevicePushRegistrationError() == ERROR_GCM_DISABLED &&
-           (System.currentTimeMillis() - fxAccount.getDevicePushRegistrationErrorTime()) > RETRY_TIME_AFTER_GCM_DISABLED_ERROR;
-  }
-
-  public static boolean shouldRenewRegistration(final AndroidFxAccount fxAccount) {
-    final long deviceRegistrationTimestamp = fxAccount.getDeviceRegistrationTimestamp();
-    // NB: we're comparing wall clock to wall clock, at different points in time.
-    // It's possible that wall clocks have changed, and our comparison will be meaningless.
-    // However, this happens in the context of a sync, and we won't be able to sync anyways if our
-    // wall clock deviates too much from time on the server.
-    return (System.currentTimeMillis() - deviceRegistrationTimestamp) > TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS;
-  }
-
-  public static void register(Context context) {
-    final Context appContext = context.getApplicationContext();
-    try {
-      getInstance(appContext).beginRegistration(appContext);
-    } catch (Exception e) {
-      Log.e(LOG_TAG, "Could not start FxA device registration", e);
-    }
-  }
-
-  public static void renewRegistration(Context context) {
-    final Context appContext = context.getApplicationContext();
-    try {
-      getInstance(appContext).beginRegistrationRenewal(appContext);
-    } catch (Exception e) {
-      Log.e(LOG_TAG, "Could not start FxA device re-registration", e);
-    }
-  }
-
-  private void beginRegistration(Context context) {
-    // Fire up gecko and send event
-    // We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices
-    // because we can't import these modules (circular dependency between browser and services)
-    final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-subscribe");
-    context.startService(geckoIntent);
-    // -> handleMessage()
-  }
-
-  private void beginRegistrationRenewal(Context context) {
-    // Same as registration, but unsubscribe first to get a fresh subscription.
-    final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-resubscribe");
-    context.startService(geckoIntent);
-    // -> handleMessage()
-  }
-
-  private Intent buildCreatePushServiceIntent(final Context context, final String data) {
-    final Intent intent = new Intent();
-    intent.setAction("create-services");
-    intent.setClassName(context, "org.mozilla.gecko.GeckoService");
-    intent.putExtra("category", "android-push-service");
-    intent.putExtra("data", data);
-    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
-    intent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
-    return intent;
-  }
-
-  @Override
-  public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
-    if ("FxAccountsPush:Subscribe:Response".equals(event)) {
-      handlePushSubscriptionResponse(message);
-    } else {
-      Log.e(LOG_TAG, "No action defined for " + event);
-    }
-  }
-
-  private void handlePushSubscriptionResponse(final GeckoBundle message) {
-    // Make sure the context has not been gc'd during the push registration
-    // and the FxAccount still exists.
-    final Context context = this.context.get();
-    if (context == null) {
-      throw new IllegalStateException("Application context has been gc'ed");
-    }
-    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
-    if (fxAccount == null) {
-      Log.e(LOG_TAG, "AndroidFxAccount is null");
-      return;
-    }
-
-    fxAccount.resetDevicePushRegistrationError();
-    final long error = getSubscriptionReplyError(message);
-
-    final FxAccountDevice device;
-    if (error == 0L) {
-      Log.i(LOG_TAG, "Push registration succeeded. Beginning normal FxA Registration.");
-      device = buildFxAccountDevice(context, fxAccount, message.getBundle("subscription"));
-    } else {
-      fxAccount.setDevicePushRegistrationError(error, System.currentTimeMillis());
-      Log.i(LOG_TAG, "Push registration failed. Beginning degraded FxA Registration.");
-      device = buildFxAccountDevice(context, fxAccount);
-    }
-
-    doFxaRegistration(context, fxAccount, device, true);
-  }
-
-  private long getSubscriptionReplyError(final GeckoBundle message) {
-    String errorStr = message.getString(PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR);
-    if (TextUtils.isEmpty(errorStr)) {
-      return 0L;
-    }
-    return Long.parseLong(errorStr);
-  }
-
-  private static void doFxaRegistration(final Context context, final AndroidFxAccount fxAccount,
-                                        final FxAccountDevice device, final boolean allowRecursion) {
-    final byte[] sessionToken;
-    try {
-      sessionToken = fxAccount.getState().getSessionToken();
-    } catch (State.NotASessionTokenState e) {
-      Log.e(LOG_TAG, "Could not get a session token", e);
-      return;
-    }
-
-    if (device.id == null) {
-      Log.i(LOG_TAG, "Attempting registration for a new device");
-    } else {
-      Log.i(LOG_TAG, "Attempting registration for an existing device");
-    }
-
-    final ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
-    final FxAccountClient20 fxAccountClient =
-            new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
-    fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() {
-      @Override
-      public void handleError(Exception e) {
-        Log.e(LOG_TAG, "Error while updating a device registration: ", e);
-        fxAccount.setDeviceRegistrationTimestamp(0L);
-      }
-
-      @Override
-      public void handleFailure(FxAccountClientRemoteException error) {
-        Log.e(LOG_TAG, "Error while updating a device registration: ", error);
-
-        fxAccount.setDeviceRegistrationTimestamp(0L);
-
-        if (error.httpStatusCode == 400) {
-          if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) {
-            recoverFromUnknownDevice(fxAccount);
-          } else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) {
-            // This can happen if a device was already registered using our session token, and we
-            // tried to create a new one (no id field).
-            recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, device,
-                    context, allowRecursion);
-          }
-        } else
-        if (error.httpStatusCode == 401
-                && error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) {
-          handleTokenError(error, fxAccountClient, fxAccount);
-        } else {
-          logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
-        }
-      }
-
-      @Override
-      public void handleSuccess(FxAccountDevice result) {
-        Log.i(LOG_TAG, "Device registration complete");
-        Logger.pii(LOG_TAG, "Registered device ID: " + result.id);
-        Log.i(LOG_TAG, "Setting DEVICE_REGISTRATION_VERSION to " + DEVICE_REGISTRATION_VERSION);
-        fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION, System.currentTimeMillis());
-      }
-    });
-  }
-
-  private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount) {
-    return makeFxADeviceCommonBuilder(context, fxAccount).build();
-  }
-
-  private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount, @NonNull GeckoBundle subscription) {
-    final FxAccountDevice.Builder builder = makeFxADeviceCommonBuilder(context, fxAccount);
-    final String pushCallback = subscription.getString("pushCallback");
-    final String pushPublicKey = subscription.getString("pushPublicKey");
-    final String pushAuthKey = subscription.getString("pushAuthKey");
-    if (!TextUtils.isEmpty(pushCallback) && !TextUtils.isEmpty(pushPublicKey) &&
-        !TextUtils.isEmpty(pushAuthKey)) {
-      builder.pushCallback(pushCallback);
-      builder.pushPublicKey(pushPublicKey);
-      builder.pushAuthKey(pushAuthKey);
-    }
-    return builder.build();
-  }
-
-  // Do not call this directly, use buildFxAccountDevice instead.
-  private static FxAccountDevice.Builder makeFxADeviceCommonBuilder(Context context, AndroidFxAccount fxAccount) {
-    final String deviceId = fxAccount.getDeviceId();
-    final String clientName = getClientName(fxAccount, context);
-
-    final FxAccountDevice.Builder builder = new FxAccountDevice.Builder();
-    builder.name(clientName);
-    builder.type("mobile");
-    if (!TextUtils.isEmpty(deviceId)) {
-      builder.id(deviceId);
-    }
-    return builder;
-  }
-
-  private static void logErrorAndResetDeviceRegistrationVersionAndTimestamp(
-      final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) {
-    Log.e(LOG_TAG, "Device registration failed", error);
-    fxAccount.resetDeviceRegistrationVersion();
-    fxAccount.setDeviceRegistrationTimestamp(0L);
-  }
-
-  @Nullable
-  private static String getClientName(final AndroidFxAccount fxAccount, final Context context) {
-    try {
-      final SharedPreferencesClientsDataDelegate clientsDataDelegate =
-          new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
-      return clientsDataDelegate.getClientName();
-    } catch (UnsupportedEncodingException | GeneralSecurityException e) {
-      Log.e(LOG_TAG, "Unable to get client name.", e);
-      return null;
-    }
-  }
-
-  private static void handleTokenError(final FxAccountClientRemoteException error,
-                                       final FxAccountClient fxAccountClient,
-                                       final AndroidFxAccount fxAccount) {
-    Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
-    logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
-    fxAccountClient.accountStatus(fxAccount.getState().uid,
-        new RequestDelegate<AccountStatusResponse>() {
-      @Override
-      public void handleError(Exception e) {
-      }
-
-      @Override
-      public void handleFailure(FxAccountClientRemoteException e) {
-      }
-
-      @Override
-      public void handleSuccess(AccountStatusResponse result) {
-        final State doghouseState = fxAccount.getState().makeDoghouseState();
-        if (!result.exists) {
-          Log.i(LOG_TAG, "token invalidated because the account no longer exists");
-          // TODO: Should be in a "I have an Android account, but the FxA is gone." State.
-          // This will do for now..
-          fxAccount.setState(doghouseState);
-          return;
-        }
-        Log.e(LOG_TAG, "sessionToken invalid");
-        fxAccount.setState(doghouseState);
-      }
-    });
-  }
-
-  private static void recoverFromUnknownDevice(final AndroidFxAccount fxAccount) {
-    Log.i(LOG_TAG, "unknown device id, clearing the cached device id");
-    fxAccount.setDeviceId(null);
-  }
-
-  /**
-   * Will call delegate#complete in all cases
-   */
-  private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error,
-                                                       final FxAccountClient fxAccountClient,
-                                                       final byte[] sessionToken,
-                                                       final AndroidFxAccount fxAccount,
-                                                       final FxAccountDevice device,
-                                                       final Context context,
-                                                       final boolean allowRecursion) {
-    // Recovery strategy: re-try a registration, UPDATING (instead of creating) the device.
-    // We do that by finding the device ID who conflicted with us and try a registration update
-    // using that id.
-    Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
-    fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
-      private void onError() {
-        Log.e(LOG_TAG, "failed to recover from device-session conflict");
-        logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
-      }
-
-      @Override
-      public void handleError(Exception e) {
-        onError();
-      }
-
-      @Override
-      public void handleFailure(FxAccountClientRemoteException e) {
-        onError();
-      }
-
-      @Override
-      public void handleSuccess(FxAccountDevice[] devices) {
-        for (final FxAccountDevice fxaDevice : devices) {
-          if (!fxaDevice.isCurrentDevice) {
-            continue;
-          }
-          fxAccount.setFxAUserData(fxaDevice.id, 0, 0L); // Reset device registration version/timestamp
-          if (!allowRecursion) {
-            Log.d(LOG_TAG, "Failure to register a device on the second try");
-            break;
-          }
-          final FxAccountDevice updatedDevice = new FxAccountDevice(device.name, fxaDevice.id, device.type,
-                                                                    device.isCurrentDevice, device.pushCallback,
-                                                                    device.pushPublicKey, device.pushAuthKey);
-          doFxaRegistration(context, fxAccount, updatedDevice, false);
-          return;
-        }
-        onError();
-      }
-    });
-  }
-
-  private void setupListeners() throws ClassNotFoundException, NoSuchMethodException,
-          InvocationTargetException, IllegalAccessException {
-    // We have no choice but to use reflection here, sorry :(
-    final Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher");
-    final Method getInstance = eventDispatcher.getMethod("getInstance");
-    final Object instance = getInstance.invoke(null);
-    final Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener",
-            BundleEventListener.class, String[].class);
-    registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" });
-  }
-}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDevice.java
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.devices;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+public class FxAccountDevice {
+
+  public static final String JSON_KEY_NAME = "name";
+  public static final String JSON_KEY_ID = "id";
+  public static final String JSON_KEY_TYPE = "type";
+  public static final String JSON_KEY_ISCURRENTDEVICE = "isCurrentDevice";
+  public static final String JSON_KEY_PUSH_CALLBACK = "pushCallback";
+  public static final String JSON_KEY_PUSH_PUBLICKEY = "pushPublicKey";
+  public static final String JSON_KEY_PUSH_AUTHKEY = "pushAuthKey";
+
+  public final String id;
+  public final String name;
+  public final String type;
+  public final Boolean isCurrentDevice;
+  public final String pushCallback;
+  public final String pushPublicKey;
+  public final String pushAuthKey;
+
+  public FxAccountDevice(String name, String id, String type, Boolean isCurrentDevice,
+                         String pushCallback, String pushPublicKey, String pushAuthKey) {
+    this.name = name;
+    this.id = id;
+    this.type = type;
+    this.isCurrentDevice = isCurrentDevice;
+    this.pushCallback = pushCallback;
+    this.pushPublicKey = pushPublicKey;
+    this.pushAuthKey = pushAuthKey;
+  }
+
+  public static FxAccountDevice fromJson(ExtendedJSONObject json) {
+    String name = json.getString(JSON_KEY_NAME);
+    String id = json.getString(JSON_KEY_ID);
+    String type = json.getString(JSON_KEY_TYPE);
+    Boolean isCurrentDevice = json.getBoolean(JSON_KEY_ISCURRENTDEVICE);
+    String pushCallback = json.getString(JSON_KEY_PUSH_CALLBACK);
+    String pushPublicKey = json.getString(JSON_KEY_PUSH_PUBLICKEY);
+    String pushAuthKey = json.getString(JSON_KEY_PUSH_AUTHKEY);
+    return new FxAccountDevice(name, id, type, isCurrentDevice, pushCallback, pushPublicKey, pushAuthKey);
+  }
+
+  public ExtendedJSONObject toJson() {
+    final ExtendedJSONObject body = new ExtendedJSONObject();
+    if (this.name != null) {
+      body.put(JSON_KEY_NAME, this.name);
+    }
+    if (this.id != null) {
+      body.put(JSON_KEY_ID, this.id);
+    }
+    if (this.type != null) {
+      body.put(JSON_KEY_TYPE, this.type);
+    }
+    if (this.pushCallback != null) {
+      body.put(JSON_KEY_PUSH_CALLBACK, this.pushCallback);
+    }
+    if (this.pushPublicKey != null) {
+      body.put(JSON_KEY_PUSH_PUBLICKEY, this.pushPublicKey);
+    }
+    if (this.pushAuthKey != null) {
+      body.put(JSON_KEY_PUSH_AUTHKEY, this.pushAuthKey);
+    }
+    return body;
+  }
+
+  public static class Builder {
+    private String id;
+    private String name;
+    private String type;
+    private Boolean isCurrentDevice;
+    private String pushCallback;
+    private String pushPublicKey;
+    private String pushAuthKey;
+
+    public void id(String id) {
+      this.id = id;
+    }
+
+    public void name(String name) {
+      this.name = name;
+    }
+
+    public void type(String type) {
+      this.type = type;
+    }
+
+    public void isCurrentDevice() {
+      this.isCurrentDevice = Boolean.TRUE;
+    }
+
+    public void pushCallback(String pushCallback) {
+      this.pushCallback = pushCallback;
+    }
+
+    public void pushPublicKey(String pushPublicKey) {
+      this.pushPublicKey = pushPublicKey;
+    }
+
+    public void pushAuthKey(String pushAuthKey) {
+      this.pushAuthKey = pushAuthKey;
+    }
+
+    public FxAccountDevice build() {
+      return new FxAccountDevice(this.name, this.id, this.type, this.isCurrentDevice,
+                                 this.pushCallback, this.pushPublicKey, this.pushAuthKey);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceRegistrator.java
@@ -0,0 +1,401 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.fxa.devices;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
+import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
+import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
+import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.security.GeneralSecurityException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/* This class provides a way to register the current device against FxA
+ * and also stores the registration details in the Android FxAccount.
+ * This should be used in a state where we possess a sessionToken, most likely the Engaged/Married states.
+ */
+public class FxAccountDeviceRegistrator implements BundleEventListener {
+  private static final String LOG_TAG = "FxADeviceRegistrator";
+
+  // The autopush endpoint expires stale channel subscriptions every 30 days (at a set time during
+  // the month, although we don't depend on this). To avoid the FxA service channel silently
+  // expiring from underneath us, we unsubscribe and resubscribe every 21 days.
+  // Note that this simple schedule means that we might unsubscribe perfectly valid (but old)
+  // subscriptions. This will be improved as part of Bug 1345651.
+  @VisibleForTesting
+  static final long TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS = 21 * 24 * 60 * 60 * 1000L;
+
+  @VisibleForTesting
+  static final long RETRY_TIME_AFTER_GCM_DISABLED_ERROR = 15 * 24 * 60 * 60 * 1000L;
+
+
+  public static final String PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR = "error";
+  @VisibleForTesting
+  static final long ERROR_GCM_DISABLED = 2154627078L; // = NS_ERROR_DOM_PUSH_GCM_DISABLED
+
+  // The current version of the device registration, we use this to re-register
+  // devices after we update what we send on device registration.
+  @VisibleForTesting
+  static final Integer DEVICE_REGISTRATION_VERSION = 2;
+
+  private static FxAccountDeviceRegistrator instance;
+  private final WeakReference<Context> context;
+
+  private FxAccountDeviceRegistrator(Context appContext) {
+    this.context = new WeakReference<>(appContext);
+  }
+
+  private static FxAccountDeviceRegistrator getInstance(Context appContext) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+    if (instance == null) {
+      final FxAccountDeviceRegistrator tempInstance = new FxAccountDeviceRegistrator(appContext);
+      tempInstance.setupListeners(); // Set up listener for FxAccountPush:Subscribe:Response
+      instance = tempInstance;
+    }
+    return instance;
+  }
+
+  public static boolean shouldRegister(final AndroidFxAccount fxAccount) {
+    if (fxAccount.getDeviceRegistrationVersion() != FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION ||
+            TextUtils.isEmpty(fxAccount.getDeviceId())) {
+      return true;
+    }
+    // At this point, we have a working up-to-date registration, but it might be a partial one
+    // (no push registration).
+    return fxAccount.getDevicePushRegistrationError() == ERROR_GCM_DISABLED &&
+           (System.currentTimeMillis() - fxAccount.getDevicePushRegistrationErrorTime()) > RETRY_TIME_AFTER_GCM_DISABLED_ERROR;
+  }
+
+  public static boolean shouldRenewRegistration(final AndroidFxAccount fxAccount) {
+    final long deviceRegistrationTimestamp = fxAccount.getDeviceRegistrationTimestamp();
+    // NB: we're comparing wall clock to wall clock, at different points in time.
+    // It's possible that wall clocks have changed, and our comparison will be meaningless.
+    // However, this happens in the context of a sync, and we won't be able to sync anyways if our
+    // wall clock deviates too much from time on the server.
+    return (System.currentTimeMillis() - deviceRegistrationTimestamp) > TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS;
+  }
+
+  public static void register(Context context) {
+    final Context appContext = context.getApplicationContext();
+    try {
+      getInstance(appContext).beginRegistration(appContext);
+    } catch (Exception e) {
+      Log.e(LOG_TAG, "Could not start FxA device registration", e);
+    }
+  }
+
+  public static void renewRegistration(Context context) {
+    final Context appContext = context.getApplicationContext();
+    try {
+      getInstance(appContext).beginRegistrationRenewal(appContext);
+    } catch (Exception e) {
+      Log.e(LOG_TAG, "Could not start FxA device re-registration", e);
+    }
+  }
+
+  private void beginRegistration(Context context) {
+    // Fire up gecko and send event
+    // We create the Intent ourselves instead of using GeckoService.getIntentToCreateServices
+    // because we can't import these modules (circular dependency between browser and services)
+    final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-subscribe");
+    context.startService(geckoIntent);
+    // -> handleMessage()
+  }
+
+  private void beginRegistrationRenewal(Context context) {
+    // Same as registration, but unsubscribe first to get a fresh subscription.
+    final Intent geckoIntent = buildCreatePushServiceIntent(context, "android-fxa-resubscribe");
+    context.startService(geckoIntent);
+    // -> handleMessage()
+  }
+
+  private Intent buildCreatePushServiceIntent(final Context context, final String data) {
+    final Intent intent = new Intent();
+    intent.setAction("create-services");
+    intent.setClassName(context, "org.mozilla.gecko.GeckoService");
+    intent.putExtra("category", "android-push-service");
+    intent.putExtra("data", data);
+    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+    intent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
+    return intent;
+  }
+
+  @Override
+  public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
+    if ("FxAccountsPush:Subscribe:Response".equals(event)) {
+      handlePushSubscriptionResponse(message);
+    } else {
+      Log.e(LOG_TAG, "No action defined for " + event);
+    }
+  }
+
+  private void handlePushSubscriptionResponse(final GeckoBundle message) {
+    // Make sure the context has not been gc'd during the push registration
+    // and the FxAccount still exists.
+    final Context context = this.context.get();
+    if (context == null) {
+      throw new IllegalStateException("Application context has been gc'ed");
+    }
+    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+    if (fxAccount == null) {
+      Log.e(LOG_TAG, "AndroidFxAccount is null");
+      return;
+    }
+
+    fxAccount.resetDevicePushRegistrationError();
+    final long error = getSubscriptionReplyError(message);
+
+    final FxAccountDevice device;
+    if (error == 0L) {
+      Log.i(LOG_TAG, "Push registration succeeded. Beginning normal FxA Registration.");
+      device = buildFxAccountDevice(context, fxAccount, message.getBundle("subscription"));
+    } else {
+      fxAccount.setDevicePushRegistrationError(error, System.currentTimeMillis());
+      Log.i(LOG_TAG, "Push registration failed. Beginning degraded FxA Registration.");
+      device = buildFxAccountDevice(context, fxAccount);
+    }
+
+    doFxaRegistration(context, fxAccount, device, true);
+  }
+
+  private long getSubscriptionReplyError(final GeckoBundle message) {
+    String errorStr = message.getString(PUSH_SUBSCRIPTION_REPLY_BUNDLE_KEY_ERROR);
+    if (TextUtils.isEmpty(errorStr)) {
+      return 0L;
+    }
+    return Long.parseLong(errorStr);
+  }
+
+  private static void doFxaRegistration(final Context context, final AndroidFxAccount fxAccount,
+                                        final FxAccountDevice device, final boolean allowRecursion) {
+    final byte[] sessionToken;
+    try {
+      sessionToken = fxAccount.getState().getSessionToken();
+    } catch (State.NotASessionTokenState e) {
+      Log.e(LOG_TAG, "Could not get a session token", e);
+      return;
+    }
+
+    if (device.id == null) {
+      Log.i(LOG_TAG, "Attempting registration for a new device");
+    } else {
+      Log.i(LOG_TAG, "Attempting registration for an existing device");
+    }
+
+    final ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
+    final FxAccountClient20 fxAccountClient =
+            new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+    fxAccountClient.registerOrUpdateDevice(sessionToken, device, new RequestDelegate<FxAccountDevice>() {
+      @Override
+      public void handleError(Exception e) {
+        Log.e(LOG_TAG, "Error while updating a device registration: ", e);
+        fxAccount.setDeviceRegistrationTimestamp(0L);
+      }
+
+      @Override
+      public void handleFailure(FxAccountClientRemoteException error) {
+        Log.e(LOG_TAG, "Error while updating a device registration: ", error);
+
+        fxAccount.setDeviceRegistrationTimestamp(0L);
+
+        if (error.httpStatusCode == 400) {
+          if (error.apiErrorNumber == FxAccountRemoteError.UNKNOWN_DEVICE) {
+            recoverFromUnknownDevice(fxAccount);
+          } else if (error.apiErrorNumber == FxAccountRemoteError.DEVICE_SESSION_CONFLICT) {
+            // This can happen if a device was already registered using our session token, and we
+            // tried to create a new one (no id field).
+            recoverFromDeviceSessionConflict(error, fxAccountClient, sessionToken, fxAccount, device,
+                    context, allowRecursion);
+          }
+        } else
+        if (error.httpStatusCode == 401
+                && error.apiErrorNumber == FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN) {
+          handleTokenError(error, fxAccountClient, fxAccount);
+        } else {
+          logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
+        }
+      }
+
+      @Override
+      public void handleSuccess(FxAccountDevice result) {
+        Log.i(LOG_TAG, "Device registration complete");
+        Logger.pii(LOG_TAG, "Registered device ID: " + result.id);
+        Log.i(LOG_TAG, "Setting DEVICE_REGISTRATION_VERSION to " + DEVICE_REGISTRATION_VERSION);
+        fxAccount.setFxAUserData(result.id, DEVICE_REGISTRATION_VERSION, System.currentTimeMillis());
+      }
+    });
+  }
+
+  private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount) {
+    return makeFxADeviceCommonBuilder(context, fxAccount).build();
+  }
+
+  private static FxAccountDevice buildFxAccountDevice(Context context, AndroidFxAccount fxAccount, @NonNull GeckoBundle subscription) {
+    final FxAccountDevice.Builder builder = makeFxADeviceCommonBuilder(context, fxAccount);
+    final String pushCallback = subscription.getString("pushCallback");
+    final String pushPublicKey = subscription.getString("pushPublicKey");
+    final String pushAuthKey = subscription.getString("pushAuthKey");
+    if (!TextUtils.isEmpty(pushCallback) && !TextUtils.isEmpty(pushPublicKey) &&
+        !TextUtils.isEmpty(pushAuthKey)) {
+      builder.pushCallback(pushCallback);
+      builder.pushPublicKey(pushPublicKey);
+      builder.pushAuthKey(pushAuthKey);
+    }
+    return builder.build();
+  }
+
+  // Do not call this directly, use buildFxAccountDevice instead.
+  private static FxAccountDevice.Builder makeFxADeviceCommonBuilder(Context context, AndroidFxAccount fxAccount) {
+    final String deviceId = fxAccount.getDeviceId();
+    final String clientName = getClientName(fxAccount, context);
+
+    final FxAccountDevice.Builder builder = new FxAccountDevice.Builder();
+    builder.name(clientName);
+    builder.type("mobile");
+    if (!TextUtils.isEmpty(deviceId)) {
+      builder.id(deviceId);
+    }
+    return builder;
+  }
+
+  private static void logErrorAndResetDeviceRegistrationVersionAndTimestamp(
+      final FxAccountClientRemoteException error, final AndroidFxAccount fxAccount) {
+    Log.e(LOG_TAG, "Device registration failed", error);
+    fxAccount.resetDeviceRegistrationVersion();
+    fxAccount.setDeviceRegistrationTimestamp(0L);
+  }
+
+  @Nullable
+  private static String getClientName(final AndroidFxAccount fxAccount, final Context context) {
+    try {
+      final SharedPreferencesClientsDataDelegate clientsDataDelegate =
+          new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
+      return clientsDataDelegate.getClientName();
+    } catch (UnsupportedEncodingException | GeneralSecurityException e) {
+      Log.e(LOG_TAG, "Unable to get client name.", e);
+      return null;
+    }
+  }
+
+  private static void handleTokenError(final FxAccountClientRemoteException error,
+                                       final FxAccountClient fxAccountClient,
+                                       final AndroidFxAccount fxAccount) {
+    Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
+    logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
+    fxAccountClient.accountStatus(fxAccount.getState().uid,
+        new RequestDelegate<AccountStatusResponse>() {
+      @Override
+      public void handleError(Exception e) {
+      }
+
+      @Override
+      public void handleFailure(FxAccountClientRemoteException e) {
+      }
+
+      @Override
+      public void handleSuccess(AccountStatusResponse result) {
+        final State doghouseState = fxAccount.getState().makeDoghouseState();
+        if (!result.exists) {
+          Log.i(LOG_TAG, "token invalidated because the account no longer exists");
+          // TODO: Should be in a "I have an Android account, but the FxA is gone." State.
+          // This will do for now..
+          fxAccount.setState(doghouseState);
+          return;
+        }
+        Log.e(LOG_TAG, "sessionToken invalid");
+        fxAccount.setState(doghouseState);
+      }
+    });
+  }
+
+  private static void recoverFromUnknownDevice(final AndroidFxAccount fxAccount) {
+    Log.i(LOG_TAG, "unknown device id, clearing the cached device id");
+    fxAccount.setDeviceId(null);
+  }
+
+  /**
+   * Will call delegate#complete in all cases
+   */
+  private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error,
+                                                       final FxAccountClient fxAccountClient,
+                                                       final byte[] sessionToken,
+                                                       final AndroidFxAccount fxAccount,
+                                                       final FxAccountDevice device,
+                                                       final Context context,
+                                                       final boolean allowRecursion) {
+    // Recovery strategy: re-try a registration, UPDATING (instead of creating) the device.
+    // We do that by finding the device ID who conflicted with us and try a registration update
+    // using that id.
+    Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
+    fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
+      private void onError() {
+        Log.e(LOG_TAG, "failed to recover from device-session conflict");
+        logErrorAndResetDeviceRegistrationVersionAndTimestamp(error, fxAccount);
+      }
+
+      @Override
+      public void handleError(Exception e) {
+        onError();
+      }
+
+      @Override
+      public void handleFailure(FxAccountClientRemoteException e) {
+        onError();
+      }
+
+      @Override
+      public void handleSuccess(FxAccountDevice[] devices) {
+        for (final FxAccountDevice fxaDevice : devices) {
+          if (!fxaDevice.isCurrentDevice) {
+            continue;
+          }
+          fxAccount.setFxAUserData(fxaDevice.id, 0, 0L); // Reset device registration version/timestamp
+          if (!allowRecursion) {
+            Log.d(LOG_TAG, "Failure to register a device on the second try");
+            break;
+          }
+          final FxAccountDevice updatedDevice = new FxAccountDevice(device.name, fxaDevice.id, device.type,
+                                                                    device.isCurrentDevice, device.pushCallback,
+                                                                    device.pushPublicKey, device.pushAuthKey);
+          doFxaRegistration(context, fxAccount, updatedDevice, false);
+          return;
+        }
+        onError();
+      }
+    });
+  }
+
+  private void setupListeners() throws ClassNotFoundException, NoSuchMethodException,
+          InvocationTargetException, IllegalAccessException {
+    // We have no choice but to use reflection here, sorry :(
+    final Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher");
+    final Method getInstance = eventDispatcher.getMethod("getInstance");
+    final Object instance = getInstance.invoke(null);
+    final Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener",
+            BundleEventListener.class, String[].class);
+    registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" });
+  }
+}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
@@ -17,29 +17,28 @@ import android.text.TextUtils;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.fxa.SkewHandler;
 import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
-import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
+import org.mozilla.gecko.fxa.devices.FxAccountDeviceRegistrator;
 import org.mozilla.gecko.fxa.authenticator.AccountPickler;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.authenticator.FxADefaultLoginStateMachineDelegate;
 import org.mozilla.gecko.fxa.authenticator.FxAccountAuthenticator;
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine;
 import org.mozilla.gecko.fxa.login.Married;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.StateLabel;
 import org.mozilla.gecko.fxa.sync.FxAccountSyncDelegate.Result;
 import org.mozilla.gecko.sync.BackoffHandler;
 import org.mozilla.gecko.sync.GlobalSession;
-import org.mozilla.gecko.sync.MetaGlobal;
 import org.mozilla.gecko.sync.PrefsBackoffHandler;
 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.ThreadPool;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/SharedPreferencesClientsDataDelegate.java
@@ -1,17 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync;
 
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
-import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.util.HardwareUtils;
 
 import android.accounts.Account;
 import android.content.Context;
 import android.content.SharedPreferences;
 
deleted file mode 100644
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/TestFxAccountDeviceRegistrator.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.fxa;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-import org.mozilla.gecko.background.testhelpers.TestRunner;
-import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
-
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.when;
-
-@RunWith(TestRunner.class)
-public class TestFxAccountDeviceRegistrator {
-
-    @Mock
-    AndroidFxAccount fxAccount;
-
-    @Before
-    public void init() {
-        // Process Mockito annotations
-        MockitoAnnotations.initMocks(this);
-    }
-
-    @Test
-    public void shouldRegister() {
-        // Assuming there is no previous push registration errors recorded:
-        when(fxAccount.getDevicePushRegistrationError()).thenReturn(0L);
-        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(0L);
-
-        // Should return false if the device registration version is up-to-date and a device ID is stored.
-        // (general case after a successful device registration)
-        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
-        when(fxAccount.getDeviceId()).thenReturn("bogusdeviceid");
-        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return true if the device registration version is up-to-date but no device ID is stored.
-        // (data mangling)
-        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
-        when(fxAccount.getDeviceId()).thenReturn(null);
-        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return true if the device ID is stored but no device registration version can be found.
-        // (data mangling)
-        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(0);
-        when(fxAccount.getDeviceId()).thenReturn("bogusid");
-        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return true if the device registration version is too old.
-        // (code update pushed)
-        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION - 1);
-        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return true if the device registration is OK, but we didn't get a push subscription because
-        // Google Play Services were unavailable at the time and the retry delay is passed.
-        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
-        when(fxAccount.getDevicePushRegistrationError()).thenReturn(FxAccountDeviceRegistrator.ERROR_GCM_DISABLED);
-        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(System.currentTimeMillis() -
-                                                                        FxAccountDeviceRegistrator.RETRY_TIME_AFTER_GCM_DISABLED_ERROR - 1);
-        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return false if the device registration is OK, but we didn't get a push subscription because
-        // Google Play Services were unavailable at the time and the retry delay has not passed.
-        // We assume that RETRY_TIME_AFTER_GCM_DISABLED_ERROR is longer than the time it takes to execute this test :)
-        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(System.currentTimeMillis());
-        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-
-        // Should return false if the device registration is OK, but we didn't get a push subscription because
-        // an unknown error happened at the time.
-        when(fxAccount.getDevicePushRegistrationError()).thenReturn(12345L);
-        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
-    }
-
-    @Test
-    public void shouldRenewRegistration() {
-        // Should return true if our last push registration was done a day before our expiration threshold.
-        when(fxAccount.getDeviceRegistrationTimestamp()).thenReturn(System.currentTimeMillis() -
-                                                                    FxAccountDeviceRegistrator.TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS -
-                                                                    1 * 24 * 60 * 60 * 1000L);
-
-        // Should return false if our last push registration is recent enough.
-        // We assume that TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS is longer than a day + the time it takes to run this test.
-        when(fxAccount.getDeviceRegistrationTimestamp()).thenReturn(System.currentTimeMillis() -
-                                                                    1 * 24 * 60 * 60 * 1000L);
-    }
-}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/devices/TestFxAccountDeviceRegistrator.java
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.devices;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+@RunWith(TestRunner.class)
+public class TestFxAccountDeviceRegistrator {
+
+    @Mock
+    AndroidFxAccount fxAccount;
+
+    @Before
+    public void init() {
+        // Process Mockito annotations
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void shouldRegister() {
+        // Assuming there is no previous push registration errors recorded:
+        when(fxAccount.getDevicePushRegistrationError()).thenReturn(0L);
+        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(0L);
+
+        // Should return false if the device registration version is up-to-date and a device ID is stored.
+        // (general case after a successful device registration)
+        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
+        when(fxAccount.getDeviceId()).thenReturn("bogusdeviceid");
+        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return true if the device registration version is up-to-date but no device ID is stored.
+        // (data mangling)
+        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
+        when(fxAccount.getDeviceId()).thenReturn(null);
+        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return true if the device ID is stored but no device registration version can be found.
+        // (data mangling)
+        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(0);
+        when(fxAccount.getDeviceId()).thenReturn("bogusid");
+        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return true if the device registration version is too old.
+        // (code update pushed)
+        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION - 1);
+        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return true if the device registration is OK, but we didn't get a push subscription because
+        // Google Play Services were unavailable at the time and the retry delay is passed.
+        when(fxAccount.getDeviceRegistrationVersion()).thenReturn(FxAccountDeviceRegistrator.DEVICE_REGISTRATION_VERSION);
+        when(fxAccount.getDevicePushRegistrationError()).thenReturn(FxAccountDeviceRegistrator.ERROR_GCM_DISABLED);
+        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(System.currentTimeMillis() -
+                                                                        FxAccountDeviceRegistrator.RETRY_TIME_AFTER_GCM_DISABLED_ERROR - 1);
+        assertTrue(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return false if the device registration is OK, but we didn't get a push subscription because
+        // Google Play Services were unavailable at the time and the retry delay has not passed.
+        // We assume that RETRY_TIME_AFTER_GCM_DISABLED_ERROR is longer than the time it takes to execute this test :)
+        when(fxAccount.getDevicePushRegistrationErrorTime()).thenReturn(System.currentTimeMillis());
+        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+
+        // Should return false if the device registration is OK, but we didn't get a push subscription because
+        // an unknown error happened at the time.
+        when(fxAccount.getDevicePushRegistrationError()).thenReturn(12345L);
+        assertFalse(FxAccountDeviceRegistrator.shouldRegister(fxAccount));
+    }
+
+    @Test
+    public void shouldRenewRegistration() {
+        // Should return true if our last push registration was done a day before our expiration threshold.
+        when(fxAccount.getDeviceRegistrationTimestamp()).thenReturn(System.currentTimeMillis() -
+                                                                    FxAccountDeviceRegistrator.TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS -
+                                                                    1 * 24 * 60 * 60 * 1000L);
+
+        // Should return false if our last push registration is recent enough.
+        // We assume that TIME_BETWEEN_CHANNEL_REGISTRATION_IN_MILLIS is longer than a day + the time it takes to run this test.
+        when(fxAccount.getDeviceRegistrationTimestamp()).thenReturn(System.currentTimeMillis() -
+                                                                    1 * 24 * 60 * 60 * 1000L);
+    }
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
@@ -1,26 +1,25 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.fxa.login;
 
 import android.text.TextUtils;
 
 import org.mozilla.gecko.background.fxa.FxAccountClient;
-import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.RequestDelegate;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.RecoveryEmailStatusResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.LoginResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
 import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
-import org.mozilla.gecko.fxa.FxAccountDevice;
+import org.mozilla.gecko.fxa.devices.FxAccountDevice;
 import org.mozilla.gecko.browserid.MockMyIDTokenFactory;
 import org.mozilla.gecko.browserid.RSACryptoImplementation;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 
 import java.io.UnsupportedEncodingException;
 import java.util.Collection;
 import java.util.HashMap;