Bug 1295348 - Send/Handle push messages for send tab to device on Fennec. r?sebastian draft
authorEdouard Oger <eoger@fastmail.com>
Thu, 25 Aug 2016 16:25:58 -0700
changeset 407883 01ef26d55327344a7a2da3fb5e360442e0418306
parent 407496 b18c8bcdc116eef8799880b7c50317bf54218474
child 529977 41ff7cd9d6ea380d80fc87058f4bbfe08d89d04b
push id28073
push userbmo:eoger@fastmail.com
push dateWed, 31 Aug 2016 06:18:32 +0000
reviewerssebastian
bugs1295348
milestone51.0a1
Bug 1295348 - Send/Handle push messages for send tab to device on Fennec. r?sebastian MozReview-Commit-ID: 1NSMPLQdoXv
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/FxAccountDeviceRegistrator.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
--- 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
@@ -1,21 +1,24 @@
 /* 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.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.RecoveryEmailStatusResponse;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.TwoKeys;
 import org.mozilla.gecko.fxa.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);
   public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate);
   public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> requestDelegate);
   public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate);
+  public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> 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
@@ -1,14 +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.background.fxa;
 
+import android.support.annotation.NonNull;
+
 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;
@@ -28,16 +30,17 @@ import java.io.UnsupportedEncodingExcept
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URLEncoder;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.concurrent.Executor;
 
 import javax.crypto.Mac;
 
 import ch.boye.httpclientandroidlib.HttpEntity;
@@ -827,17 +830,16 @@ public class FxAccountClient20 implement
     try {
       HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
     } catch (Exception e) {
       invokeHandleError(delegate, e);
       return;
     }
 
     final BaseResource resource;
-    final ExtendedJSONObject body;
     try {
       resource = getBaseResource("account/devices");
     } catch (URISyntaxException | UnsupportedEncodingException e) {
       invokeHandleError(delegate, e);
       return;
     }
 
     resource.delegate = new ResourceDelegate<FxAccountDevice[]>(resource, delegate, ResponseType.JSON_ARRAY, tokenId, reqHMACKey) {
@@ -853,9 +855,60 @@ public class FxAccountClient20 implement
         } catch (Exception e) {
           delegate.handleError(e);
         }
       }
     };
 
     resource.get();
   }
+
+  @Override
+  public void notifyDevices(@NonNull byte[] sessionToken, @NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> delegate) {
+    final byte[] tokenId = new byte[32];
+    final byte[] reqHMACKey = new byte[32];
+    final byte[] requestKey = new byte[32];
+    try {
+      HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+    } catch (Exception e) {
+      invokeHandleError(delegate, e);
+      return;
+    }
+
+    final BaseResource resource;
+    final ExtendedJSONObject body = createNotifyDevicesBody(deviceIds, payload, TTL);
+    try {
+      resource = getBaseResource("account/devices/notify");
+    } catch (URISyntaxException | UnsupportedEncodingException e) {
+      invokeHandleError(delegate, e);
+      return;
+    }
+
+    resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+      @Override
+      public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+        try {
+          delegate.handleSuccess(body);
+        } catch (Exception e) {
+          delegate.handleError(e);
+        }
+      }
+    };
+
+    post(resource, body);
+  }
+
+  @NonNull
+  @SuppressWarnings("unchecked")
+  private ExtendedJSONObject createNotifyDevicesBody(@NonNull List<String> deviceIds, ExtendedJSONObject payload, Long TTL) {
+    final ExtendedJSONObject body = new ExtendedJSONObject();
+    final JSONArray to = new JSONArray();
+    to.addAll(deviceIds);
+    body.put("to", to);
+    if (payload != null) {
+      body.put("payload", payload);
+    }
+    if (TTL != null) {
+      body.put("TTL", TTL);
+    }
+    return body;
+  }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
@@ -14,19 +14,18 @@ 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.authenticator.AndroidFxAccount.InvalidFxAState;
 import org.mozilla.gecko.fxa.login.State;
-import org.mozilla.gecko.fxa.login.State.StateLabel;
-import org.mozilla.gecko.fxa.login.TokensAndKeysState;
 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 
 import java.io.UnsupportedEncodingException;
 import java.lang.ref.WeakReference;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -107,17 +106,21 @@ public class FxAccountDeviceRegistrator 
   }
 
   private static void doFxaRegistration(final Context context, final Bundle subscription, final boolean allowRecursion) throws InvalidFxAState {
     String pushCallback = subscription.getString("pushCallback");
     String pushPublicKey = subscription.getString("pushPublicKey");
     String pushAuthKey = subscription.getString("pushAuthKey");
 
     final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
-    final byte[] sessionToken = getSessionToken(fxAccount);
+    if (fxAccount == null) {
+      Log.e(LOG_TAG, "AndroidFxAccount is null");
+      return;
+    }
+    final byte[] sessionToken = fxAccount.getSessionToken();
     final FxAccountDevice device;
     String deviceId = fxAccount.getDeviceId();
     String clientName = getClientName(fxAccount, context);
     if (TextUtils.isEmpty(deviceId)) {
       Log.i(LOG_TAG, "Attempting registration for a new device");
       device = FxAccountDevice.forRegister(clientName, "mobile", pushCallback, pushPublicKey, pushAuthKey);
     } else {
       Log.i(LOG_TAG, "Attempting registration for an existing device");
@@ -175,27 +178,16 @@ public class FxAccountDeviceRegistrator 
           new SharedPreferencesClientsDataDelegate(fxAccount.getSyncPrefs(), context);
       return clientsDataDelegate.getClientName();
     } catch (UnsupportedEncodingException | GeneralSecurityException e) {
       Log.e(LOG_TAG, "Unable to get client name.", e);
       return null;
     }
   }
 
-  @Nullable
-  private static byte[] getSessionToken(final AndroidFxAccount fxAccount) throws InvalidFxAState {
-    State state = fxAccount.getState();
-    StateLabel stateLabel = state.getStateLabel();
-    if (stateLabel == StateLabel.Cohabiting || stateLabel == StateLabel.Married) {
-      TokensAndKeysState tokensAndKeysState = (TokensAndKeysState) state;
-      return tokensAndKeysState.getSessionToken();
-    }
-    throw new InvalidFxAState("Cannot get sessionToken: not in a TokensAndKeysState state");
-  }
-
   private static void handleTokenError(final FxAccountClientRemoteException error,
                                        final FxAccountClient fxAccountClient,
                                        final AndroidFxAccount fxAccount) {
     Log.i(LOG_TAG, "Recovering from invalid token error: ", error);
     logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
     fxAccountClient.accountStatus(fxAccount.getState().uid,
         new RequestDelegate<AccountStatusResponse>() {
       @Override
@@ -282,17 +274,9 @@ public class FxAccountDeviceRegistrator 
     // We have no choice but to use reflection here, sorry :(
     Class<?> eventDispatcher = Class.forName("org.mozilla.gecko.EventDispatcher");
     Method getInstance = eventDispatcher.getMethod("getInstance");
     Object instance = getInstance.invoke(null);
     Method registerBackgroundThreadListener = eventDispatcher.getMethod("registerBackgroundThreadListener",
             BundleEventListener.class, String[].class);
     registerBackgroundThreadListener.invoke(instance, this, new String[] { "FxAccountsPush:Subscribe:Response" });
   }
-
-  public static class InvalidFxAState extends Exception {
-    private static final long serialVersionUID = -8537626959811195978L;
-
-    public InvalidFxAState(String message) {
-      super(message);
-    }
-  }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
@@ -2,24 +2,28 @@ package org.mozilla.gecko.fxa;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.content.Context;
 import android.os.Bundle;
 import android.text.TextUtils;
 import android.util.Log;
 
+import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 
 public class FxAccountPushHandler {
     private static final String LOG_TAG = "FxAccountPush";
 
     private static final String COMMAND_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
+    private static final String COMMAND_COLLECTION_CHANGED = "sync:collection_changed";
+
+    private static final String CLIENTS_COLLECTION = "clients";
 
     // Forbid instantiation
     private FxAccountPushHandler() {}
 
     public static void handleFxAPushMessage(Context context, Bundle bundle) {
         Log.i(LOG_TAG, "Handling FxA Push Message");
         String rawMessage = bundle.getString("message");
         JSONObject message = null;
@@ -40,25 +44,45 @@ public class FxAccountPushHandler {
         }
         try {
             String command = message.getString("command");
             JSONObject data = message.getJSONObject("data");
             switch (command) {
                 case COMMAND_DEVICE_DISCONNECTED:
                     handleDeviceDisconnection(context, data);
                     break;
+                case COMMAND_COLLECTION_CHANGED:
+                    handleCollectionChanged(context, data);
+                    break;
                 default:
                     Log.d(LOG_TAG, "No handler defined for FxA Push command " + command);
                     break;
             }
         } catch (JSONException e) {
             Log.e(LOG_TAG, "Error while handling FxA push notification", e);
         }
     }
 
+    private static void handleCollectionChanged(Context context, JSONObject data) throws JSONException {
+        JSONArray collections = data.getJSONArray("collections");
+        int len = collections.length();
+        for (int i = 0; i < len; i++) {
+            if (collections.getString(i).equals(CLIENTS_COLLECTION)) {
+                final Account account = FirefoxAccounts.getFirefoxAccount(context);
+                if (account == null) {
+                    Log.e(LOG_TAG, "The account does not exist anymore");
+                    return;
+                }
+                final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+                fxAccount.requestImmediateSync(new String[] { CLIENTS_COLLECTION }, null);
+                return;
+            }
+        }
+    }
+
     private static void handleDeviceDisconnection(Context context, JSONObject data) throws JSONException {
         final Account account = FirefoxAccounts.getFirefoxAccount(context);
         if (account == null) {
             Log.e(LOG_TAG, "The account does not exist anymore");
             return;
         }
         final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
         if (!fxAccount.getDeviceId().equals(data.getString("id"))) {
--- 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
@@ -25,16 +25,17 @@ import org.mozilla.gecko.background.comm
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.StateLabel;
 import org.mozilla.gecko.fxa.login.StateFactory;
+import org.mozilla.gecko.fxa.login.TokensAndKeysState;
 import org.mozilla.gecko.fxa.sync.FxAccountProfileService;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.setup.Constants;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import java.io.UnsupportedEncodingException;
 import java.net.URISyntaxException;
@@ -602,16 +603,34 @@ public class AndroidFxAccount {
       StateLabel stateLabel = StateLabel.valueOf(stateLabelString);
       Logger.debug(LOG_TAG, "Account is in state " + stateLabel);
       return StateFactory.fromJSONObject(stateLabel, new ExtendedJSONObject(stateString));
     } catch (Exception e) {
       throw new IllegalStateException("could not get state", e);
     }
   }
 
+  public byte[] getSessionToken() throws InvalidFxAState {
+    State state = getState();
+    StateLabel stateLabel = state.getStateLabel();
+    if (stateLabel == StateLabel.Cohabiting || stateLabel == StateLabel.Married) {
+      TokensAndKeysState tokensAndKeysState = (TokensAndKeysState) state;
+      return tokensAndKeysState.getSessionToken();
+    }
+    throw new InvalidFxAState("Cannot get sessionToken: not in a TokensAndKeysState state");
+  }
+
+  public static class InvalidFxAState extends Exception {
+    private static final long serialVersionUID = -8537626959811195978L;
+
+    public InvalidFxAState(String message) {
+      super(message);
+    }
+  }
+
   /**
    * <b>For debugging only!</b>
    */
   public void dump() {
     if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
       return;
     }
     ExtendedJSONObject o = toJSONObject();
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
@@ -1,29 +1,37 @@
 /* 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.stage;
 
 import android.accounts.Account;
 import android.content.Context;
+import android.support.annotation.NonNull;
 import android.text.TextUtils;
+import android.util.Log;
 
 import java.io.UnsupportedEncodingException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.mozilla.gecko.AppConstants;
 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.FxAccountClientException;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.sync.CommandProcessor;
 import org.mozilla.gecko.sync.CommandProcessor.Command;
 import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.HTTPFailureException;
 import org.mozilla.gecko.sync.NoCollectionKeysSetException;
@@ -49,28 +57,29 @@ import ch.boye.httpclientandroidlib.Http
 
 public class SyncClientsEngineStage extends AbstractSessionManagingSyncStage {
   private static final String LOG_TAG = "SyncClientsEngineStage";
 
   public static final String COLLECTION_NAME       = "clients";
   public static final String STAGE_NAME            = COLLECTION_NAME;
   public static final int CLIENTS_TTL_REFRESH      = 604800000;   // 7 days in milliseconds.
   public static final int MAX_UPLOAD_FAILURE_COUNT = 5;
+  public static final long NOTIFY_TAB_SENT_TTL_SECS = TimeUnit.SECONDS.convert(1L, TimeUnit.HOURS); // 1 hour
 
   protected final ClientRecordFactory factory = new ClientRecordFactory();
   protected ClientUploadDelegate clientUploadDelegate;
   protected ClientDownloadDelegate clientDownloadDelegate;
 
   // Be sure to use this safely via getClientsDatabaseAccessor/closeDataAccessor.
   protected ClientsDatabaseAccessor db;
 
   protected volatile boolean shouldWipe;
   protected volatile boolean shouldUploadLocalRecord;     // Set if, e.g., we received commands or need to refresh our version.
   protected final AtomicInteger uploadAttemptsCount = new AtomicInteger();
-  protected final List<ClientRecord> toUpload = new ArrayList<ClientRecord>();
+  protected final List<ClientRecord> modifiedClientsToUpload = new ArrayList<ClientRecord>();
 
   protected int getClientsCount() {
     return getClientsDatabaseAccessor().clientsCount();
   }
 
   protected synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() {
     if (db == null) {
       db = new ClientsDatabaseAccessor(session.getContext());
@@ -146,23 +155,90 @@ public class SyncClientsEngineStage exte
       Logger.debug(LOG_TAG, "Server response asserts " + response.weaveRecords() + " records.");
 
       // TODO: persist the response timestamp to know whether to download next time (Bug 726055).
       clientUploadDelegate = new ClientUploadDelegate();
       clientsDelegate.setClientsCount(clientsCount);
 
       // If we upload remote records, checkAndUpload() will be called upon
       // upload success in the delegate. Otherwise call checkAndUpload() now.
-      if (toUpload.size() > 0) {
+      if (modifiedClientsToUpload.size() > 0) {
+        // modifiedClientsToUpload is cleared in uploadRemoteRecords, save what we need here
+        final List<String> devicesToNotify = new ArrayList<>();
+        for (ClientRecord record : modifiedClientsToUpload) {
+          if (!TextUtils.isEmpty(record.fxaDeviceId)) {
+            devicesToNotify.add(record.fxaDeviceId);
+          }
+        }
+
+        // This method is synchronous, there's no risk of notifying the clients
+        // before we actually uploaded the records
         uploadRemoteRecords();
+
+        // Notify the clients who got their record written
+        notifyClients(devicesToNotify);
+
         return;
       }
       checkAndUpload();
     }
 
+    private void notifyClients(final List<String> devicesToNotify) {
+      final ExecutorService executor = Executors.newSingleThreadExecutor();
+      final Context context = session.getContext();
+      final Account account = FirefoxAccounts.getFirefoxAccount(context);
+      if (account == null) {
+        Log.e(LOG_TAG, "Can't notify other clients: no account");
+        return;
+      }
+      final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
+      final ExtendedJSONObject payload = createNotifyDevicesPayload();
+
+      final byte[] sessionToken;
+      try {
+        sessionToken = fxAccount.getSessionToken();
+      } catch (AndroidFxAccount.InvalidFxAState invalidFxAState) {
+        Log.e(LOG_TAG, "Could not get session token", invalidFxAState);
+        return;
+      }
+
+      // API doc : https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountdevicesnotify
+      final FxAccountClient fxAccountClient = new FxAccountClient20(fxAccount.getAccountServerURI(), executor);
+      fxAccountClient.notifyDevices(sessionToken, devicesToNotify, payload, NOTIFY_TAB_SENT_TTL_SECS, new FxAccountClient20.RequestDelegate<ExtendedJSONObject>() {
+        @Override
+        public void handleError(Exception e) {
+          Log.e(LOG_TAG, "Error while notifying devices", e);
+        }
+
+        @Override
+        public void handleFailure(FxAccountClientException.FxAccountClientRemoteException e) {
+          Log.e(LOG_TAG, "Error while notifying devices", e);
+        }
+
+        @Override
+        public void handleSuccess(ExtendedJSONObject result) {
+          Log.i(LOG_TAG, devicesToNotify.size() + " devices notified");
+        }
+      });
+    }
+
+    @NonNull
+    @SuppressWarnings("unchecked")
+    private ExtendedJSONObject createNotifyDevicesPayload() {
+      final ExtendedJSONObject payload = new ExtendedJSONObject();
+      payload.put("version", 1);
+      payload.put("command", "sync:collection_changed");
+      final ExtendedJSONObject data = new ExtendedJSONObject();
+      final JSONArray collections = new JSONArray();
+      collections.add("clients");
+      data.put("collections", collections);
+      payload.put("data", data);
+      return payload;
+    }
+
     @Override
     public void handleRequestFailure(SyncStorageResponse response) {
       BaseResource.consumeEntity(response); // We don't need the response at all, and any exception handling shouldn't need the response body.
       localAccountGUIDDownloaded = false;
 
       try {
         Logger.info(LOG_TAG, "Client upload failed. Aborting sync.");
         session.abort(new HTTPFailureException(response), "Client download failed.");
@@ -285,17 +361,17 @@ public class SyncClientsEngineStage exte
       // If upload failed because of `ifUnmodifiedSince` then there are new
       // commands uploaded to our record. We must download and process them first.
       if (!shouldUploadLocalRecord ||
           statusCode == HttpStatus.SC_PRECONDITION_FAILED ||
           uploadAttemptsCount.incrementAndGet() > MAX_UPLOAD_FAILURE_COUNT) {
 
         Logger.debug(LOG_TAG, "Client upload failed. Aborting sync.");
         if (!currentlyUploadingLocalRecord) {
-          toUpload.clear(); // These will be redownloaded.
+          modifiedClientsToUpload.clear(); // These will be redownloaded.
         }
         BaseResource.consumeEntity(response); // The exception thrown should need the response body.
         session.abort(new HTTPFailureException(response), "Client upload failed.");
         return;
       }
       Logger.trace(LOG_TAG, "Retrying upload…");
       // Preconditions:
       // shouldUploadLocalRecord == true &&
@@ -469,41 +545,41 @@ public class SyncClientsEngineStage exte
 
     for (Command command : commands) {
       JSONObject jsonCommand = command.asJSONObject();
       if (record.commands == null) {
         record.commands = new JSONArray();
       }
       record.commands.add(jsonCommand);
     }
-    toUpload.add(record);
+    modifiedClientsToUpload.add(record);
   }
 
   @SuppressWarnings("unchecked")
   protected void uploadRemoteRecords() {
-    Logger.trace(LOG_TAG, "In uploadRemoteRecords. Uploading " + toUpload.size() + " records" );
+    Logger.trace(LOG_TAG, "In uploadRemoteRecords. Uploading " + modifiedClientsToUpload.size() + " records" );
 
-    for (ClientRecord r : toUpload) {
+    for (ClientRecord r : modifiedClientsToUpload) {
       Logger.trace(LOG_TAG, ">> Uploading record " + r.guid + ": " + r.name);
     }
 
-    if (toUpload.size() == 1) {
-      ClientRecord record = toUpload.get(0);
+    if (modifiedClientsToUpload.size() == 1) {
+      ClientRecord record = modifiedClientsToUpload.get(0);
       Logger.debug(LOG_TAG, "Only 1 remote record to upload.");
       Logger.debug(LOG_TAG, "Record last modified: " + record.lastModified);
       CryptoRecord cryptoRecord = encryptClientRecord(record);
       if (cryptoRecord != null) {
         clientUploadDelegate.setUploadDetails(false);
         this.uploadClientRecord(cryptoRecord);
       }
       return;
     }
 
     JSONArray cryptoRecords = new JSONArray();
-    for (ClientRecord record : toUpload) {
+    for (ClientRecord record : modifiedClientsToUpload) {
       Logger.trace(LOG_TAG, "Record " + record.guid + " is being uploaded" );
 
       CryptoRecord cryptoRecord = encryptClientRecord(record);
       cryptoRecords.add(cryptoRecord.toJSONObject());
     }
     Logger.debug(LOG_TAG, "Uploading records: " + cryptoRecords.size());
     clientUploadDelegate.setUploadDetails(false);
     this.uploadClientRecords(cryptoRecords);
@@ -542,17 +618,17 @@ public class SyncClientsEngineStage exte
       session.abort(e, encryptionFailure);
     }
     return null;
   }
 
   public void clearRecordsToUpload() {
     try {
       getClientsDatabaseAccessor().wipeCommandsTable();
-      toUpload.clear();
+      modifiedClientsToUpload.clear();
     } finally {
       closeDataAccessor();
     }
   }
 
   protected void downloadClientRecords() {
     shouldWipe = true;
     clientDownloadDelegate = makeClientDownloadDelegate();
--- a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
@@ -760,36 +760,36 @@ public class TestClientsEngineStage exte
   public void testAddCommandsToUnversionedClient() throws NullCursorException {
     db = new TestAddCommandsMockClientsDatabaseAccessor();
 
     final ClientRecord remoteRecord = new ClientRecord();
     remoteRecord.version = null;
     final String expectedGUID = remoteRecord.guid;
 
     this.addCommands(remoteRecord);
-    assertEquals(1, toUpload.size());
+    assertEquals(1, modifiedClientsToUpload.size());
 
-    final ClientRecord recordToUpload = toUpload.get(0);
+    final ClientRecord recordToUpload = modifiedClientsToUpload.get(0);
     assertEquals(4, recordToUpload.commands.size());
     assertEquals(expectedGUID, recordToUpload.guid);
     assertEquals(null, recordToUpload.version);
   }
 
   @Test
   public void testAddCommandsToVersionedClient() throws NullCursorException {
     db = new TestAddCommandsMockClientsDatabaseAccessor();
 
     final ClientRecord remoteRecord = new ClientRecord();
     remoteRecord.version = "12a1";
     final String expectedGUID = remoteRecord.guid;
 
     this.addCommands(remoteRecord);
-    assertEquals(1, toUpload.size());
+    assertEquals(1, modifiedClientsToUpload.size());
 
-    final ClientRecord recordToUpload = toUpload.get(0);
+    final ClientRecord recordToUpload = modifiedClientsToUpload.get(0);
     assertEquals(4, recordToUpload.commands.size());
     assertEquals(expectedGUID, recordToUpload.guid);
     assertEquals("12a1", recordToUpload.version);
   }
 
   @Test
   public void testLastModifiedTimestamp() throws NullCursorException {
     // If we uploaded a record a moment ago, we shouldn't upload another.
--- 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,16 +1,17 @@
 /* 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;
@@ -18,16 +19,17 @@ import org.mozilla.gecko.fxa.FxAccountDe
 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;
+import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 
 import ch.boye.httpclientandroidlib.HttpStatus;
 import ch.boye.httpclientandroidlib.ProtocolVersion;
 import ch.boye.httpclientandroidlib.entity.StringEntity;
 import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
 
@@ -211,9 +213,14 @@ 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;
     }
     Collection<FxAccountDevice> devices = user.devices.values();
     FxAccountDevice[] devicesArray = devices.toArray(new FxAccountDevice[devices.size()]);
     requestDelegate.handleSuccess(devicesArray);
   }
+
+  @Override
+  public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> requestDelegate) {
+    requestDelegate.handleSuccess(new ExtendedJSONObject());
+  }
 }