Bug 1351805 part 3 - Refresh the remote devices list on Married/Engaged states. r?Grisha draft
authorEdouard Oger <eoger@fastmail.com>
Wed, 19 Apr 2017 17:45:49 -0400
changeset 568251 8e38270a2abf370f85db4dc0f3184ede11191bf5
parent 568250 ff490b34a5e4f347cabf85d7eaaea4c647f62ba0
child 625859 29d140cbf28bea69ca3aff882e07882b9a24aad4
push id55799
push userbmo:eoger@fastmail.com
push dateTue, 25 Apr 2017 21:51:45 +0000
reviewersGrisha
bugs1351805
milestone55.0a1
Bug 1351805 part 3 - Refresh the remote devices list on Married/Engaged states. r?Grisha MozReview-Commit-ID: 1Ktbtlzc1fI
mobile/android/base/android-services.mozbuild
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.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/FxAccountDeviceListUpdater.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceRegistrator.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/devices/TestFxAccountDeviceListUpdater.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
@@ -827,16 +827,17 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     '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/FxAccountDeviceListUpdater.java',
     'fxa/devices/FxAccountDeviceRegistrator.java',
     'fxa/FirefoxAccounts.java',
     'fxa/FxAccountConstants.java',
     'fxa/FxAccountPushHandler.java',
     'fxa/login/BaseRequestDelegate.java',
     'fxa/login/Cohabiting.java',
     'fxa/login/Doghouse.java',
     'fxa/login/Engaged.java',
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
@@ -800,49 +800,57 @@ public class AndroidFxAccount {
         intent.putExtra(FxAccountProfileService.KEY_AUTH_TOKEN, authToken);
         intent.putExtra(FxAccountProfileService.KEY_PROFILE_SERVER_URI, getProfileServerURI());
         intent.putExtra(FxAccountProfileService.KEY_RESULT_RECEIVER, new ProfileResultReceiver(new Handler()));
         context.startService(intent);
       }
     });
   }
 
-  @SuppressWarnings("unchecked")
-  private <T extends Number> T getUserDataNumber(String key, T defaultValue) {
+  private long getUserDataLong(String key, long defaultValue) {
     final String numStr = accountManager.getUserData(account, key);
     if (TextUtils.isEmpty(numStr)) {
       return defaultValue;
     }
     try {
-      return (T) NumberFormat.getInstance().parse(numStr);
-    } catch (ParseException e) {
-      Logger.warn(LOG_TAG, "Couldn't parse " + key + "; defaulting to 0L.", e);
+      return Long.parseLong(key);
+    } catch (NumberFormatException e) {
+      Logger.warn(LOG_TAG, "Couldn't parse " + key + "; defaulting to " + defaultValue, e);
       return defaultValue;
     }
   }
 
   @Nullable
   public synchronized String getDeviceId() {
     return accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_ID);
   }
 
   public synchronized int getDeviceRegistrationVersion() {
-    return getUserDataNumber(ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION, 0);
+    final String numStr = accountManager.getUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION);
+    if (TextUtils.isEmpty(numStr)) {
+      return 0;
+    }
+    try {
+      return Integer.parseInt(numStr);
+    } catch (NumberFormatException e) {
+      Logger.warn(LOG_TAG, "Couldn't parse ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION; defaulting to 0", e);
+      return 0;
+    }
   }
 
   public synchronized long getDeviceRegistrationTimestamp() {
-    return getUserDataNumber(ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP, 0L);
+    return getUserDataLong(ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP, 0L);
   }
 
   public synchronized long getDevicePushRegistrationError() {
-    return getUserDataNumber(ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR, 0L);
+    return getUserDataLong(ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR, 0L);
   }
 
   public synchronized long getDevicePushRegistrationErrorTime() {
-    return getUserDataNumber(ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR_TIME, 0L);
+    return getUserDataLong(ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR_TIME, 0L);
   }
 
   public synchronized void setDeviceId(String id) {
     accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_ID, id);
   }
 
   public synchronized void setDeviceRegistrationVersion(int deviceRegistrationVersion) {
     accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION,
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDevice.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDevice.java
@@ -3,52 +3,58 @@
 * 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";
+  private static final String JSON_KEY_NAME = "name";
+  private static final String JSON_KEY_ID = "id";
+  private static final String JSON_KEY_TYPE = "type";
+  private static final String JSON_KEY_ISCURRENTDEVICE = "isCurrentDevice";
+  private static final String JSON_KEY_PUSH_CALLBACK = "pushCallback";
+  private static final String JSON_KEY_PUSH_PUBLICKEY = "pushPublicKey";
+  private static final String JSON_KEY_PUSH_AUTHKEY = "pushAuthKey";
+  private static final String JSON_LAST_ACCESS_TIME = "lastAccessTime";
 
   public final String id;
   public final String name;
   public final String type;
   public final Boolean isCurrentDevice;
+  public final Long lastAccessTime;
   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) {
+                         Long lastAccessTime, String pushCallback, String pushPublicKey,
+                         String pushAuthKey) {
     this.name = name;
     this.id = id;
     this.type = type;
     this.isCurrentDevice = isCurrentDevice;
+    this.lastAccessTime = lastAccessTime;
     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);
+    final String name = json.getString(JSON_KEY_NAME);
+    final String id = json.getString(JSON_KEY_ID);
+    final String type = json.getString(JSON_KEY_TYPE);
+    final Boolean isCurrentDevice = json.getBoolean(JSON_KEY_ISCURRENTDEVICE);
+    final Long lastAccessTime = json.getLong(JSON_LAST_ACCESS_TIME);
+    final String pushCallback = json.getString(JSON_KEY_PUSH_CALLBACK);
+    final String pushPublicKey = json.getString(JSON_KEY_PUSH_PUBLICKEY);
+    final String pushAuthKey = json.getString(JSON_KEY_PUSH_AUTHKEY);
+    return new FxAccountDevice(name, id, type, isCurrentDevice, lastAccessTime, 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) {
@@ -68,47 +74,42 @@ public class FxAccountDevice {
     }
     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,
+      return new FxAccountDevice(this.name, this.id, this.type, null, null,
                                  this.pushCallback, this.pushPublicKey, this.pushAuthKey);
     }
   }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceListUpdater.java
@@ -0,0 +1,111 @@
+/* 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.ContentResolver;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClientException;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.RemoteDevices;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+
+import java.util.concurrent.Executor;
+
+public class FxAccountDeviceListUpdater implements FxAccountClient20.RequestDelegate<FxAccountDevice[]> {
+    private static final String LOG_TAG = "FxADeviceListUpdater";
+
+    private final AndroidFxAccount fxAccount;
+    private final ContentResolver contentResolver;
+
+    public FxAccountDeviceListUpdater(final AndroidFxAccount fxAccount, final ContentResolver cr) {
+        this.fxAccount = fxAccount;
+        this.contentResolver = cr;
+    }
+
+    @Override
+    public void handleSuccess(final FxAccountDevice[] result) {
+        final Uri uri = RemoteDevices.CONTENT_URI
+                        .buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_PROFILE, fxAccount.getProfile())
+                        .build();
+
+        final Bundle valuesBundle = new Bundle();
+        final ContentValues[] insertValues = new ContentValues[result.length];
+
+        final long now = System.currentTimeMillis();
+        for (int i = 0; i < result.length; i++) {
+            final FxAccountDevice fxADevice = result[i];
+            final ContentValues deviceValues = new ContentValues();
+            deviceValues.put(RemoteDevices.GUID, fxADevice.id);
+            deviceValues.put(RemoteDevices.TYPE, fxADevice.type);
+            deviceValues.put(RemoteDevices.NAME, fxADevice.name);
+            deviceValues.put(RemoteDevices.IS_CURRENT_DEVICE, fxADevice.isCurrentDevice);
+            deviceValues.put(RemoteDevices.DATE_CREATED, now);
+            deviceValues.put(RemoteDevices.DATE_MODIFIED, now);
+            // TODO: Remove that line once FxA sends lastAccessTime all the time.
+            final Long lastAccessTime = fxADevice.lastAccessTime != null ? fxADevice.lastAccessTime : 0;
+            deviceValues.put(RemoteDevices.LAST_ACCESS_TIME, lastAccessTime);
+            insertValues[i] = deviceValues;
+        }
+        valuesBundle.putParcelableArray(BrowserContract.METHOD_PARAM_DATA, insertValues);
+        try {
+            contentResolver.call(uri, BrowserContract.METHOD_REPLACE_REMOTE_CLIENTS, uri.toString(),
+                                 valuesBundle);
+            Log.i(LOG_TAG, "FxA Device list update done.");
+        } catch (Exception e) {
+            Log.e(LOG_TAG, "Error persisting the new remote device list.", e);
+        }
+    }
+
+    @Override
+    public void handleError(Exception e) {
+        onError(e);
+    }
+
+    @Override
+    public void handleFailure(FxAccountClientException.FxAccountClientRemoteException e) {
+        onError(e);
+    }
+
+    private void onError(final Exception e) {
+        Log.e(LOG_TAG, "Error while getting the FxA device list.", e);
+    }
+
+    @VisibleForTesting
+    FxAccountClient getSynchronousFxaClient() {
+        return new FxAccountClient20(fxAccount.getAccountServerURI(),
+                // Current thread executor :)
+                new Executor() {
+                    @Override
+                    public void execute(@NonNull Runnable runnable) {
+                        runnable.run();
+                    }
+                }
+        );
+    }
+
+    public void update() {
+        Log.i(LOG_TAG, "Beginning FxA device list update.");
+        final byte[] sessionToken;
+        try {
+            sessionToken = fxAccount.getState().getSessionToken();
+        } catch (State.NotASessionTokenState e) {
+            // This should never happen, because the caller (FxAccountSyncAdapter) verifies that
+            // we are in a token state before calling this method.
+            throw new IllegalStateException("Could not get a session token during Sync (?) " + e);
+        }
+        final FxAccountClient fxaClient = getSynchronousFxaClient();
+        fxaClient.deviceList(sessionToken, this);
+    }
+}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceRegistrator.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/devices/FxAccountDeviceRegistrator.java
@@ -373,18 +373,19 @@ public class FxAccountDeviceRegistrator 
             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);
+                                                                    null, null,
+                                                                    device.pushCallback, device.pushPublicKey,
+                                                                    device.pushAuthKey);
           doFxaRegistration(context, fxAccount, updatedDevice, false);
           return;
         }
         onError();
       }
     });
   }
 
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
@@ -1,25 +1,28 @@
 /* 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.receivers;
 
 import android.app.IntentService;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
+import android.net.Uri;
 import android.text.TextUtils;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountClientException;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
+import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
 import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabase;
 import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
 
@@ -70,16 +73,18 @@ public class FxAccountDeletedService ext
     final String accountName = intent.getStringExtra(
         FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY);
     if (accountName == null) {
       Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " +
           "deleted Account.");
       return;
     }
 
+    clearRemoteDevicesList(intent, context);
+
     // Delete current device the from FxA devices list.
     deleteFxADevice(intent);
 
     // Fire up gecko and unsubscribe push
     final Intent geckoIntent = new Intent();
     geckoIntent.setAction("create-services");
     geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService");
     geckoIntent.putExtra("category", "android-push-service");
@@ -154,16 +159,27 @@ public class FxAccountDeletedService ext
           Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e);
         }
       }
     } else {
       Logger.error(LOG_TAG, "Cached OAuth server URI is null or cached OAuth tokens are null; ignoring.");
     }
   }
 
+  private void clearRemoteDevicesList(Intent intent, Context context) {
+    final Uri remoteDevicesUriWithProfile = BrowserContract.RemoteDevices.CONTENT_URI
+            .buildUpon()
+            .appendQueryParameter(BrowserContract.PARAM_PROFILE,
+                                  intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE))
+            .build();
+    ContentResolver cr = context.getContentResolver();
+
+    cr.delete(remoteDevicesUriWithProfile, null, null);
+  }
+
   // Remove our current device from the FxA device list.
   private void deleteFxADevice(Intent intent) {
     final byte[] sessionToken = intent.getByteArrayExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_SESSION_TOKEN);
     if (sessionToken == null) {
       Logger.warn(LOG_TAG, "Empty session token, skipping FxA device destruction.");
       return;
     }
     final String deviceId = intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_DEVICE_ID);
--- 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
@@ -1,32 +1,34 @@
 /* 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.sync;
 
 import android.accounts.Account;
 import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProvider;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.SyncResult;
 import android.os.Bundle;
 import android.os.SystemClock;
 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.devices.FxAccountDeviceListUpdater;
 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;
@@ -413,16 +415,25 @@ public class FxAccountSyncAdapter extend
       // We might need to re-register periodically to ensure our FxA push subscription is valid.
       // This involves unsubscribing, subscribing and updating remote FxA device record with
       // new push subscription information.
     } else if (FxAccountDeviceRegistrator.shouldRenewRegistration(fxAccount)) {
       FxAccountDeviceRegistrator.renewRegistration(context);
     }
   }
 
+  private void onSessionTokenStateReached(Context context, AndroidFxAccount fxAccount) {
+    // This does not block the main thread, if work has to be done it is executed in a new thread.
+    maybeRegisterDevice(context, fxAccount);
+
+    FxAccountDeviceListUpdater deviceListUpdater = new FxAccountDeviceListUpdater(fxAccount, context.getContentResolver());
+    // Since the clients stage requires a fresh list of remote devices, we update the device list synchronously.
+    deviceListUpdater.update();
+  }
+
   /**
    * A trivial Sync implementation that does not cache client keys,
    * certificates, or tokens.
    *
    * This should be replaced with a full {@link FxAccountAuthenticator}-based
    * token implementation.
    */
   @Override
@@ -540,17 +551,17 @@ public class FxAccountSyncAdapter extend
       final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
       stateMachine.advance(state, StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
         @Override
         public void handleNotMarried(State notMarried) {
           Logger.info(LOG_TAG, "handleNotMarried: in " + notMarried.getStateLabel());
           schedulePolicy.onHandleFinal(notMarried.getNeededAction());
           syncDelegate.handleCannotSync(notMarried);
           if (notMarried.getStateLabel() == StateLabel.Engaged) {
-            maybeRegisterDevice(context, fxAccount);
+            onSessionTokenStateReached(context, fxAccount);
           }
         }
 
         private boolean shouldRequestToken(final BackoffHandler tokenBackoffHandler, final Bundle extras) {
           return shouldPerformSync(tokenBackoffHandler, "token", extras);
         }
 
         @Override
@@ -583,25 +594,25 @@ public class FxAccountSyncAdapter extend
             // in the Married state, so instead we simply do this here, once.
             final BackoffHandler tokenBackoffHandler = new PrefsBackoffHandler(sharedPrefs, "token");
             if (!shouldRequestToken(tokenBackoffHandler, extras)) {
               Logger.info(LOG_TAG, "Not syncing (token server).");
               syncDelegate.postponeSync(tokenBackoffHandler.delayMilliseconds());
               return;
             }
 
+            onSessionTokenStateReached(context, fxAccount);
+
             final SessionCallback sessionCallback = new SessionCallback(syncDelegate, schedulePolicy);
             final KeyBundle syncKeyBundle = married.getSyncKeyBundle();
             final String clientState = married.getClientState();
             syncWithAssertion(
                     assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs,
                     syncKeyBundle, clientState, sessionCallback, extras, fxAccount, syncDeadline);
 
-            maybeRegisterDevice(context, fxAccount);
-
             // Force fetch the profile avatar information. (asynchronous, in another thread)
             Logger.info(LOG_TAG, "Fetching profile avatar information.");
             fxAccount.fetchProfileJSON();
           } catch (Exception e) {
             syncDelegate.handleError(e);
             return;
           }
         }
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/devices/TestFxAccountDeviceListUpdater.java
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.fxa.devices;
+
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.fxa.FxAccountClient;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import java.util.List;
+
+import static java.util.Objects.deepEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(TestRunner.class)
+public class TestFxAccountDeviceListUpdater {
+
+    @Before
+    public void init() {
+        // Process Mockito annotations
+        MockitoAnnotations.initMocks(this);
+        fxaDevicesUpdater = spy(new FxAccountDeviceListUpdater(fxAccount, contentResolver));
+    }
+
+    FxAccountDeviceListUpdater fxaDevicesUpdater;
+
+    @Mock
+    AndroidFxAccount fxAccount;
+
+    @Mock
+    State state;
+
+    @Mock
+    ContentResolver contentResolver;
+
+    @Mock
+    FxAccountClient fxaClient;
+
+    @Test
+    public void testUpdate() throws Throwable {
+        byte[] token = "usertoken".getBytes();
+
+        when(fxAccount.getState()).thenReturn(state);
+        when(state.getSessionToken()).thenReturn(token);
+        doReturn(fxaClient).when(fxaDevicesUpdater).getSynchronousFxaClient();
+
+        fxaDevicesUpdater.update();
+        verify(fxaClient).deviceList(token, fxaDevicesUpdater);
+    }
+
+    @Test
+    public void testSuccessHandler() throws Throwable {
+        FxAccountDevice[] result = new FxAccountDevice[2];
+        FxAccountDevice device1 = new FxAccountDevice("Current device", "deviceid1", "mobile", true, System.currentTimeMillis(),
+                "https://localhost/push/callback1", "abc123", "321cba");
+        FxAccountDevice device2 = new FxAccountDevice("Desktop PC", "deviceid2", "desktop", true, System.currentTimeMillis(),
+                "https://localhost/push/callback2", "abc123", "321cba");
+        result[0] = device1;
+        result[1] = device2;
+
+        when(fxAccount.getProfile()).thenReturn("default");
+
+        long timeBeforeCall = System.currentTimeMillis();
+        fxaDevicesUpdater.handleSuccess(result);
+
+        ArgumentCaptor<Bundle> captor = ArgumentCaptor.forClass(Bundle.class);
+        verify(contentResolver).call(any(Uri.class), eq(BrowserContract.METHOD_REPLACE_REMOTE_CLIENTS), anyString(), captor.capture());
+        List<Bundle> allArgs = captor.getAllValues();
+        assertTrue(allArgs.size() == 1);
+        ContentValues[] allValues = (ContentValues[]) allArgs.get(0).getParcelableArray(BrowserContract.METHOD_PARAM_DATA);
+
+        ContentValues firstDevice = allValues[0];
+        checkInsertDeviceContentValues(device1, firstDevice, timeBeforeCall);
+        ContentValues secondDevice = allValues[1];
+        checkInsertDeviceContentValues(device2, secondDevice, timeBeforeCall);
+    }
+
+    private void checkInsertDeviceContentValues(FxAccountDevice device, ContentValues firstDevice, long timeBeforeCall) {
+        assertEquals(firstDevice.getAsString(BrowserContract.RemoteDevices.GUID), device.id);
+        assertEquals(firstDevice.getAsString(BrowserContract.RemoteDevices.TYPE), device.type);
+        assertEquals(firstDevice.getAsString(BrowserContract.RemoteDevices.NAME), device.name);
+        assertEquals(firstDevice.getAsBoolean(BrowserContract.RemoteDevices.IS_CURRENT_DEVICE), device.isCurrentDevice);
+        deepEquals(firstDevice.getAsString(BrowserContract.RemoteDevices.LAST_ACCESS_TIME), device.lastAccessTime);
+        assertTrue(firstDevice.getAsLong(BrowserContract.RemoteDevices.DATE_CREATED) < timeBeforeCall + 10000); // Give 10 secs of leeway
+        assertTrue(firstDevice.getAsLong(BrowserContract.RemoteDevices.DATE_MODIFIED) < timeBeforeCall + 10000);
+        assertEquals(firstDevice.getAsString(BrowserContract.RemoteDevices.NAME), device.name);
+    }
+
+    @Test
+    public void testBrowserProvider() {
+        Uri uri = testUri(BrowserContract.RemoteDevices.CONTENT_URI);
+
+        BrowserProvider provider = new BrowserProvider();
+        Cursor c = null;
+        try {
+            provider.onCreate();
+            ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+            final ShadowContentResolver cr = new ShadowContentResolver();
+            ContentProviderClient remoteDevicesClient = cr.acquireContentProviderClient(BrowserContract.RemoteDevices.CONTENT_URI);
+
+            // First let's insert a client for initial state.
+
+            Bundle bundle = new Bundle();
+            ContentValues device1 = createMockRemoteClientValues("device1");
+            bundle.putParcelableArray(BrowserContract.METHOD_PARAM_DATA, new ContentValues[] { device1 });
+
+            remoteDevicesClient.call(BrowserContract.METHOD_REPLACE_REMOTE_CLIENTS, uri.toString(), bundle);
+
+            c = remoteDevicesClient.query(uri, null, null, null, "name ASC");
+            assertEquals(c.getCount(), 1);
+            c.moveToFirst();
+            int nameCol = c.getColumnIndexOrThrow("name");
+            assertEquals(c.getString(nameCol), "device1");
+            c.close();
+
+            // Then we replace our remote clients list with a new one.
+
+            bundle = new Bundle();
+            ContentValues device2 = createMockRemoteClientValues("device2");
+            ContentValues device3 = createMockRemoteClientValues("device3");
+            bundle.putParcelableArray(BrowserContract.METHOD_PARAM_DATA, new ContentValues[] { device2, device3 });
+
+            remoteDevicesClient.call(BrowserContract.METHOD_REPLACE_REMOTE_CLIENTS, uri.toString(), bundle);
+
+            c = remoteDevicesClient.query(uri, null, null, null, "name ASC");
+            assertEquals(c.getCount(), 2);
+            c.moveToFirst();
+            nameCol = c.getColumnIndexOrThrow("name");
+            assertEquals(c.getString(nameCol), "device2");
+            c.moveToNext();
+            assertEquals(c.getString(nameCol), "device3");
+            c.close();
+        } catch (RemoteException e) {
+            fail(e.getMessage());
+        } finally {
+            if (c != null && !c.isClosed()) {
+                c.close();
+            }
+            provider.shutdown();
+        }
+    }
+
+    private Uri testUri(Uri baseUri) {
+        return baseUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1").build();
+    }
+
+    private ContentValues createMockRemoteClientValues(String name) {
+        final long now = System.currentTimeMillis();
+        ContentValues cli = new ContentValues();
+        cli.put(BrowserContract.RemoteDevices.GUID, "R" + Math.floor(Math.random() * 10));
+        cli.put(BrowserContract.RemoteDevices.NAME, name);
+        cli.put(BrowserContract.RemoteDevices.TYPE, "mobile");
+        cli.put(BrowserContract.RemoteDevices.IS_CURRENT_DEVICE, false);
+        cli.put(BrowserContract.RemoteDevices.LAST_ACCESS_TIME, System.currentTimeMillis());
+        cli.put(BrowserContract.RemoteDevices.DATE_CREATED, now);
+        cli.put(BrowserContract.RemoteDevices.DATE_MODIFIED, now);
+        return cli;
+    }
+}
--- 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
@@ -174,27 +174,27 @@ public class MockFxAccountClient impleme
     if (!user.verified) {
       handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
       return;
     }
     try {
       String deviceId = deviceToRegister.id;
       if (TextUtils.isEmpty(deviceId)) { // Create
         deviceId = UUID.randomUUID().toString();
-        FxAccountDevice device = new FxAccountDevice(deviceToRegister.name, deviceId, deviceToRegister.type, null, null, null, null);
+        FxAccountDevice device = new FxAccountDevice(deviceToRegister.name, deviceId, deviceToRegister.type, null, null, null, null, null);
         requestDelegate.handleSuccess(device);
       } else { // Update
         FxAccountDevice existingDevice = user.devices.get(deviceId);
         if (existingDevice != null) {
           String deviceName = existingDevice.name;
           if (!TextUtils.isEmpty(deviceToRegister.name)) {
             deviceName = deviceToRegister.name;
           } // We could also update the other fields..
-          FxAccountDevice device = new FxAccountDevice(deviceName, existingDevice.id, existingDevice.type,
-                  existingDevice.isCurrentDevice, existingDevice.pushCallback, existingDevice.pushPublicKey,existingDevice.pushAuthKey);
+          FxAccountDevice device = new FxAccountDevice(deviceName, existingDevice.id, existingDevice.type, existingDevice.isCurrentDevice,
+                  existingDevice.lastAccessTime, existingDevice.pushCallback, existingDevice.pushPublicKey,existingDevice.pushAuthKey);
           requestDelegate.handleSuccess(device);
         } else { // Device unknown
           handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.UNKNOWN_DEVICE, "device is unknown");
           return;
         }
       }
     } catch (Exception e) {
       requestDelegate.handleError(e);