Bug 1363924 p1 - Add flowID to Client commands. r?grisha draft
authorEdouard Oger <eoger@fastmail.com>
Fri, 02 Feb 2018 13:57:17 -0500
changeset 759831 788fd955addc194e27bcf69ea778ce5076f940ce
parent 759830 6d72eade26af359ffc3cd3e381fd79c88922b9b8
child 759832 5cf1fabfee63c4f47133f1488d59e898de67a030
push id100479
push userbmo:eoger@fastmail.com
push dateMon, 26 Feb 2018 17:35:30 +0000
reviewersgrisha
bugs1363924
milestone60.0a1
Bug 1363924 p1 - Add flowID to Client commands. r?grisha MozReview-Commit-ID: 58rumpyfQy6
mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/db/TestClientsDatabase.java
mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/db/TestClientsDatabaseAccessor.java
mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java
mobile/android/services/src/test/java/org/mozilla/gecko/background/db/TestClientsDatabase.java
mobile/android/services/src/test/java/org/mozilla/gecko/background/db/TestClientsDatabaseAccessor.java
mobile/android/services/src/test/java/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
deleted file mode 100644
--- a/mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/db/TestClientsDatabase.java
+++ /dev/null
@@ -1,200 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.background.db;
-
-import java.util.ArrayList;
-
-import org.json.simple.JSONArray;
-import org.mozilla.gecko.sync.Utils;
-import org.mozilla.gecko.sync.repositories.NullCursorException;
-import org.mozilla.gecko.sync.repositories.android.ClientsDatabase;
-import org.mozilla.gecko.sync.repositories.android.RepoUtils;
-import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
-import org.mozilla.gecko.sync.setup.Constants;
-
-import android.database.Cursor;
-import android.test.AndroidTestCase;
-
-public class TestClientsDatabase extends AndroidTestCase {
-
-  protected ClientsDatabase db;
-
-  public void setUp() {
-    db = new ClientsDatabase(mContext);
-    db.wipeDB();
-  }
-
-  public void testStoreAndFetch() {
-    ClientRecord record = new ClientRecord();
-    String profileConst = Constants.DEFAULT_PROFILE;
-    db.store(profileConst, record);
-
-    Cursor cur = null;
-    try {
-      // Test stored item gets fetched correctly.
-      cur = db.fetchClientsCursor(record.guid, profileConst);
-      assertTrue(cur.moveToFirst());
-      assertEquals(1, cur.getCount());
-
-      String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
-      String profileId = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_PROFILE);
-      String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME);
-      String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE);
-
-      assertEquals(record.guid, guid);
-      assertEquals(profileConst, profileId);
-      assertEquals(record.name, clientName);
-      assertEquals(record.type, clientType);
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-
-  public void testStoreAndFetchSpecificCommands() {
-    String accountGUID = Utils.generateGuid();
-    ArrayList<String> args = new ArrayList<String>();
-    args.add("URI of Page");
-    args.add("Sender GUID");
-    args.add("Title of Page");
-    String jsonArgs = JSONArray.toJSONString(args);
-
-    Cursor cur = null;
-    try {
-      db.store(accountGUID, "displayURI", jsonArgs);
-
-      // This row should not show up in the fetch.
-      args.add("Another arg.");
-      db.store(accountGUID, "displayURI", JSONArray.toJSONString(args));
-
-      // Test stored item gets fetched correctly.
-      cur = db.fetchSpecificCommand(accountGUID, "displayURI", jsonArgs);
-      assertTrue(cur.moveToFirst());
-      assertEquals(1, cur.getCount());
-
-      String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
-      String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND);
-      String fetchedArgs = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ARGS);
-
-      assertEquals(accountGUID, guid);
-      assertEquals("displayURI", commandType);
-      assertEquals(jsonArgs, fetchedArgs);
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-
-  public void testFetchCommandsForClient() {
-    String accountGUID = Utils.generateGuid();
-    ArrayList<String> args = new ArrayList<String>();
-    args.add("URI of Page");
-    args.add("Sender GUID");
-    args.add("Title of Page");
-    String jsonArgs = JSONArray.toJSONString(args);
-
-    Cursor cur = null;
-    try {
-      db.store(accountGUID, "displayURI", jsonArgs);
-
-      // This row should ALSO show up in the fetch.
-      args.add("Another arg.");
-      db.store(accountGUID, "displayURI", JSONArray.toJSONString(args));
-
-      // Test both stored items with the same GUID but different command are fetched.
-      cur = db.fetchCommandsForClient(accountGUID);
-      assertTrue(cur.moveToFirst());
-      assertEquals(2, cur.getCount());
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-
-  @SuppressWarnings("resource")
-  public void testDelete() {
-    ClientRecord record1 = new ClientRecord();
-    ClientRecord record2 = new ClientRecord();
-    String profileConst = Constants.DEFAULT_PROFILE;
-
-    db.store(profileConst, record1);
-    db.store(profileConst, record2);
-
-    Cursor cur = null;
-    try {
-      // Test record doesn't exist after delete.
-      db.deleteClient(record1.guid, profileConst);
-      cur = db.fetchClientsCursor(record1.guid, profileConst);
-      assertFalse(cur.moveToFirst());
-      assertEquals(0, cur.getCount());
-
-      // Test record2 still there after deleting record1.
-      cur = db.fetchClientsCursor(record2.guid, profileConst);
-      assertTrue(cur.moveToFirst());
-      assertEquals(1, cur.getCount());
-
-      String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
-      String profileId = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_PROFILE);
-      String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME);
-      String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE);
-
-      assertEquals(record2.guid, guid);
-      assertEquals(profileConst, profileId);
-      assertEquals(record2.name, clientName);
-      assertEquals(record2.type, clientType);
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-
-  @SuppressWarnings("resource")
-  public void testWipe() {
-    ClientRecord record1 = new ClientRecord();
-    ClientRecord record2 = new ClientRecord();
-    String profileConst = Constants.DEFAULT_PROFILE;
-
-    db.store(profileConst, record1);
-    db.store(profileConst, record2);
-
-
-    Cursor cur = null;
-    try {
-      // Test before wipe the records are there.
-      cur = db.fetchClientsCursor(record2.guid, profileConst);
-      assertTrue(cur.moveToFirst());
-      assertEquals(1, cur.getCount());
-      cur = db.fetchClientsCursor(record2.guid, profileConst);
-      assertTrue(cur.moveToFirst());
-      assertEquals(1, cur.getCount());
-
-      // Test after wipe neither record exists.
-      db.wipeClientsTable();
-      cur = db.fetchClientsCursor(record2.guid, profileConst);
-      assertFalse(cur.moveToFirst());
-      assertEquals(0, cur.getCount());
-      cur = db.fetchClientsCursor(record1.guid, profileConst);
-      assertFalse(cur.moveToFirst());
-      assertEquals(0, cur.getCount());
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-}
deleted file mode 100644
--- a/mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/db/TestClientsDatabaseAccessor.java
+++ /dev/null
@@ -1,165 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.background.db;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-
-import org.mozilla.gecko.background.testhelpers.CommandHelpers;
-import org.mozilla.gecko.sync.CommandProcessor.Command;
-import org.mozilla.gecko.sync.Utils;
-import org.mozilla.gecko.sync.repositories.NullCursorException;
-import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
-import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.test.AndroidTestCase;
-
-public class TestClientsDatabaseAccessor extends AndroidTestCase {
-
-  public class StubbedClientsDatabaseAccessor extends ClientsDatabaseAccessor {
-    public StubbedClientsDatabaseAccessor(Context mContext) {
-      super(mContext);
-    }
-  }
-
-  StubbedClientsDatabaseAccessor db;
-
-  public void setUp() {
-    db = new StubbedClientsDatabaseAccessor(mContext);
-    db.wipeDB();
-  }
-
-  public void tearDown() {
-    db.close();
-  }
-
-  public void testStoreArrayListAndFetch() throws NullCursorException {
-    ArrayList<ClientRecord> list = new ArrayList<ClientRecord>();
-    ClientRecord record1 = new ClientRecord(Utils.generateGuid());
-    ClientRecord record2 = new ClientRecord(Utils.generateGuid());
-    ClientRecord record3 = new ClientRecord(Utils.generateGuid());
-
-    list.add(record1);
-    list.add(record2);
-    db.store(list);
-
-    ClientRecord r1 = db.fetchClient(record1.guid);
-    ClientRecord r2 = db.fetchClient(record2.guid);
-    ClientRecord r3 = db.fetchClient(record3.guid);
-
-    assertNotNull(r1);
-    assertNotNull(r2);
-    assertNull(r3);
-    assertTrue(record1.equals(r1));
-    assertTrue(record2.equals(r2));
-    assertFalse(record3.equals(r3));
-  }
-
-  public void testStoreAndFetchCommandsForClient() {
-    String accountGUID1 = Utils.generateGuid();
-    String accountGUID2 = Utils.generateGuid();
-
-    Command command1 = CommandHelpers.getCommand1();
-    Command command2 = CommandHelpers.getCommand2();
-    Command command3 = CommandHelpers.getCommand3();
-
-    Cursor cur = null;
-    try {
-      db.store(accountGUID1, command1);
-      db.store(accountGUID1, command2);
-      db.store(accountGUID2, command3);
-
-      List<Command> commands = db.fetchCommandsForClient(accountGUID1);
-      assertEquals(2, commands.size());
-      assertEquals(1, commands.get(0).args.size());
-      assertEquals(1, commands.get(1).args.size());
-    } catch (NullCursorException e) {
-      fail("Should not have NullCursorException");
-    } finally {
-      if (cur != null) {
-        cur.close();
-      }
-    }
-  }
-
-  public void testNumClients() {
-    final int COUNT = 5;
-    ArrayList<ClientRecord> list = new ArrayList<ClientRecord>();
-    for (int i = 0; i < 5; i++) {
-      list.add(new ClientRecord());
-    }
-    db.store(list);
-    assertEquals(COUNT, db.clientsCount());
-  }
-
-  public void testFetchAll() throws NullCursorException {
-    ArrayList<ClientRecord> list = new ArrayList<ClientRecord>();
-    ClientRecord record1 = new ClientRecord(Utils.generateGuid());
-    ClientRecord record2 = new ClientRecord(Utils.generateGuid());
-
-    list.add(record1);
-    list.add(record2);
-
-    boolean thrown = false;
-    try {
-      Map<String, ClientRecord> records =  db.fetchAllClients();
-
-      assertNotNull(records);
-      assertEquals(0, records.size());
-
-      db.store(list);
-      records = db.fetchAllClients();
-      assertNotNull(records);
-      assertEquals(2, records.size());
-      assertTrue(record1.equals(records.get(record1.guid)));
-      assertTrue(record2.equals(records.get(record2.guid)));
-
-      // put() should throw an exception since records is immutable.
-      records.put(null, null);
-    } catch (UnsupportedOperationException e) {
-      thrown = true;
-    }
-    assertTrue(thrown);
-  }
-
-  public void testFetchNonStaleClients() throws NullCursorException {
-    String goodRecord1 = Utils.generateGuid();
-    ClientRecord record1 = new ClientRecord(goodRecord1);
-    record1.fxaDeviceId = "fxa1";
-    ClientRecord record2 = new ClientRecord(Utils.generateGuid());
-    record2.fxaDeviceId = "fxa2";
-    String goodRecord2 = Utils.generateGuid();
-    ClientRecord record3 = new ClientRecord(goodRecord2);
-    record3.fxaDeviceId = "fxa4";
-
-    ArrayList<ClientRecord> list = new ArrayList<>();
-    list.add(record1);
-    list.add(record2);
-    list.add(record3);
-    db.store(list);
-
-    assertTrue(db.hasNonStaleClients(new String[]{"fxa1", "fxa-unknown"}));
-    assertFalse(db.hasNonStaleClients(new String[]{}));
-
-    String noFxADeviceId = Utils.generateGuid();
-    ClientRecord record4 = new ClientRecord(noFxADeviceId);
-    record4.fxaDeviceId = null;
-    list.clear();
-    list.add(record4);
-    db.store(list);
-
-    assertTrue(db.hasNonStaleClients(new String[]{}));
-
-    Collection<ClientRecord> filtered = db.fetchNonStaleClients(new String[]{"fxa1", "fxa4", "fxa-unknown"});
-    ClientRecord[] filteredArr = filtered.toArray(new ClientRecord[0]);
-    assertEquals(3, filteredArr.length);
-    assertEquals(filteredArr[0].guid, goodRecord1);
-    assertEquals(filteredArr[1].guid, goodRecord2);
-    assertEquals(filteredArr[2].guid, noFxADeviceId);
-  }
-}
deleted file mode 100644
--- a/mobile/android/services/src/androidTest/java/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
+++ /dev/null
@@ -1,40 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.background.testhelpers;
-
-import org.json.simple.JSONArray;
-import org.mozilla.gecko.sync.CommandProcessor.Command;
-
-public class CommandHelpers {
-
-  @SuppressWarnings("unchecked")
-  public static Command getCommand1() {
-    JSONArray args = new JSONArray();
-    args.add("argsA");
-    return new Command("displayURI", args);
-  }
-
-  @SuppressWarnings("unchecked")
-  public static Command getCommand2() {
-    JSONArray args = new JSONArray();
-    args.add("argsB");
-    return new Command("displayURI", args);
-  }
-
-  @SuppressWarnings("unchecked")
-  public static Command getCommand3() {
-    JSONArray args = new JSONArray();
-    args.add("argsC");
-    return new Command("displayURI", args);
-  }
-
-  @SuppressWarnings("unchecked")
-  public static Command getCommand4() {
-    JSONArray args = new JSONArray();
-    args.add("URI of Page");
-    args.add("Sender ID");
-    args.add("Title of Page");
-    return new Command("displayURI", args);
-  }
-}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync;
 
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
+import android.support.annotation.Nullable;
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
 
@@ -50,20 +51,22 @@ public class CommandProcessor {
   public static CommandProcessor getProcessor() {
     return processor;
   }
 
   public static class Command {
     public final String commandType;
     public final JSONArray args;
     private List<String> argsList;
+    @Nullable public String flowID;
 
-    public Command(String commandType, JSONArray args) {
+    public Command(String commandType, JSONArray args, @Nullable String flowID) {
       this.commandType = commandType;
       this.args = args;
+      this.flowID = flowID;
     }
 
     /**
      * Get list of arguments as strings.  Individual arguments may be null.
      *
      * @return list of strings.
      */
     public synchronized List<String> getArgsList() {
@@ -83,16 +86,19 @@ public class CommandProcessor {
       return this.argsList;
     }
 
     @SuppressWarnings("unchecked")
     public JSONObject asJSONObject() {
       JSONObject out = new JSONObject();
       out.put("command", this.commandType);
       out.put("args", this.args);
+      if (this.flowID != null) {
+        out.put("flowID", this.flowID);
+      }
       return out;
     }
   }
 
   /**
    * Register a command.
    * <p>
    * Any existing registration is overwritten.
@@ -144,18 +150,19 @@ public class CommandProcessor {
       return null;
     }
 
     try {
       JSONArray unparsedArgs = unparsedCommand.getArray("args");
       if (unparsedArgs == null) {
         return null;
       }
+      final String flowID = unparsedCommand.getString("flowID");
 
-      return new Command(type, unparsedArgs);
+      return new Command(type, unparsedArgs, flowID);
     } catch (NonArrayJSONException e) {
       Logger.debug(LOG_TAG, "Unable to parse args array. Invalid command");
       return null;
     }
   }
 
   @SuppressWarnings("unchecked")
   public void sendURIToClientForDisplay(String uri, String clientID, String title, String sender, Context context) {
@@ -164,17 +171,18 @@ public class CommandProcessor {
       Logger.pii(LOG_TAG, "URI is " + uri + "; title is '" + title + "'.");
     }
 
     final JSONArray args = new JSONArray();
     args.add(uri);
     args.add(sender);
     args.add(title);
 
-    final Command displayURICommand = new Command("displayURI", args);
+    final String flowID = Utils.generateGuid();
+    final Command displayURICommand = new Command("displayURI", args, flowID);
     this.sendCommand(clientID, displayURICommand, context);
   }
 
   /**
    * Validates and sends a command to a client or all clients.
    *
    * Calling this does not actually sync the command data to the server. If the
    * client already has the command/args pair, it won't receive a duplicate
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabase.java
@@ -14,17 +14,17 @@ import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
 
 public class ClientsDatabase extends CachedSQLiteOpenHelper {
 
   public static final String LOG_TAG = "ClientsDatabase";
 
   // Database Specifications.
   protected static final String DB_NAME = "clients_database";
-  protected static final int SCHEMA_VERSION = 4;
+  protected static final int SCHEMA_VERSION = 5;
 
   // Clients Table.
   public static final String TBL_CLIENTS      = "clients";
   public static final String COL_ACCOUNT_GUID = "guid";
   public static final String COL_PROFILE      = "profile";
   public static final String COL_NAME         = "name";
   public static final String COL_TYPE         = "device_type";
 
@@ -41,18 +41,19 @@ public class ClientsDatabase extends Cac
                                                                     COL_DEVICE, COL_FXA_DEVICE_ID};
   public static final String TBL_CLIENTS_KEY = COL_ACCOUNT_GUID + " = ? AND " +
                                                COL_PROFILE + " = ?";
 
   // Commands Table.
   public static final String TBL_COMMANDS = "commands";
   public static final String COL_COMMAND  = "command";
   public static final String COL_ARGS     = "args";
+  public static final String COL_FLOW_ID  = "flow_id";
 
-  public static final String[] TBL_COMMANDS_COLUMNS    = new String[] { COL_ACCOUNT_GUID, COL_COMMAND, COL_ARGS };
+  public static final String[] TBL_COMMANDS_COLUMNS    = new String[] { COL_ACCOUNT_GUID, COL_COMMAND, COL_ARGS, COL_FLOW_ID };
   public static final String   TBL_COMMANDS_KEY        = COL_ACCOUNT_GUID + " = ? AND " +
                                                          COL_COMMAND + " = ? AND " +
                                                          COL_ARGS + " = ?";
   public static final String   TBL_COMMANDS_GUID_QUERY = COL_ACCOUNT_GUID + " = ? ";
 
   private final RepoUtils.QueryHelper queryHelper;
 
   public ClientsDatabase(Context context) {
@@ -86,16 +87,17 @@ public class ClientsDatabase extends Cac
   }
 
   public static void createCommandsTable(SQLiteDatabase db) {
     Logger.debug(LOG_TAG, "ClientsDatabase.createCommandsTable().");
     String createCommandsTableSql = "CREATE TABLE " + TBL_COMMANDS + " ("
         + COL_ACCOUNT_GUID + " TEXT, "
         + COL_COMMAND + " TEXT, "
         + COL_ARGS + " TEXT, "
+        + COL_FLOW_ID + " TEXT, "
         + "PRIMARY KEY (" + COL_ACCOUNT_GUID + ", " + COL_COMMAND + ", " + COL_ARGS + "), "
         + "FOREIGN KEY (" + COL_ACCOUNT_GUID + ") REFERENCES " + TBL_CLIENTS + " (" + COL_ACCOUNT_GUID + "))";
     db.execSQL(createCommandsTableSql);
   }
 
   @Override
   public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
     Logger.debug(LOG_TAG, "ClientsDatabase.onUpgrade(" + oldVersion + ", " + newVersion + ").");
@@ -115,16 +117,20 @@ public class ClientsDatabase extends Cac
       db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_APP_PACKAGE + " TEXT");
       db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_DEVICE + " TEXT");
     }
 
     if (oldVersion < 4 && newVersion >= 4) {
       db.execSQL("ALTER TABLE " + TBL_CLIENTS + " ADD COLUMN " + COL_FXA_DEVICE_ID + " TEXT");
       db.execSQL("CREATE INDEX idx_fxa_device_id ON " + TBL_CLIENTS + "(" + COL_FXA_DEVICE_ID + ")");
     }
+
+    if (oldVersion < 5 && newVersion >= 5) {
+      db.execSQL("ALTER TABLE " + TBL_COMMANDS + " ADD COLUMN " + COL_FLOW_ID + " TEXT");
+    }
   }
 
   public void wipeDB() {
     SQLiteDatabase db = this.getCachedWritableDatabase();
     onUpgrade(db, 0, SCHEMA_VERSION);
   }
 
   public void wipeClientsTable() {
@@ -184,34 +190,38 @@ public class ClientsDatabase extends Cac
   }
 
   /**
    * Store a command in the commands database if it doesn't already exist.
    *
    * @param accountGUID
    * @param command - The command type
    * @param args - A JSON string of args
+   * @param flowID - Optional - The flowID
    * @throws NullCursorException
    */
-  public void store(String accountGUID, String command, String args) throws NullCursorException {
+  public void store(String accountGUID, String command, String args, String flowID) throws NullCursorException {
     if (Logger.LOG_PERSONAL_INFORMATION) {
       Logger.pii(LOG_TAG, "Storing command " + command + " with args " + args);
     } else {
       Logger.trace(LOG_TAG, "Storing command " + command + ".");
     }
     SQLiteDatabase db = this.getCachedWritableDatabase();
 
     ContentValues cv = new ContentValues();
     cv.put(COL_ACCOUNT_GUID, accountGUID);
     cv.put(COL_COMMAND, command);
     if (args == null) {
       cv.put(COL_ARGS, "[]");
     } else {
       cv.put(COL_ARGS, args);
     }
+    if (flowID != null) {
+      cv.put(COL_FLOW_ID, flowID);
+    }
 
     Cursor cur = this.fetchSpecificCommand(accountGUID, command, args);
     try {
       if (cur.moveToFirst()) {
         Logger.debug(LOG_TAG, "Command already exists in database.");
         return;
       }
     } finally {
@@ -224,16 +234,18 @@ public class ClientsDatabase extends Cac
 
   public Cursor fetchClientsCursor(String accountGUID, String profileId) throws NullCursorException {
     String[] args = new String[] { accountGUID, profileId };
     SQLiteDatabase db = this.getCachedReadableDatabase();
 
     return queryHelper.safeQuery(db, ".fetchClientsCursor", TBL_CLIENTS, TBL_CLIENTS_COLUMNS, TBL_CLIENTS_KEY, args);
   }
 
+  // This method does not check flowID on purpose because we do not want to take it into account
+  // when de-duping commands.
   public Cursor fetchSpecificCommand(String accountGUID, String command, String commandArgs) throws NullCursorException {
     String[] args = new String[] { accountGUID, command, commandArgs };
     SQLiteDatabase db = this.getCachedReadableDatabase();
 
     return queryHelper.safeQuery(db, ".fetchSpecificCommand", TBL_COMMANDS, TBL_COMMANDS_COLUMNS, TBL_COMMANDS_KEY, args);
   }
 
   public Cursor fetchCommandsForClient(String accountGUID) throws NullCursorException {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/ClientsDatabaseAccessor.java
@@ -45,17 +45,17 @@ public class ClientsDatabaseAccessor {
 
   public void store(Collection<ClientRecord> records) {
     for (ClientRecord record : records) {
       this.store(record);
     }
   }
 
   public void store(String accountGUID, Command command) throws NullCursorException {
-    db.store(accountGUID, command.commandType, command.args.toJSONString());
+    db.store(accountGUID, command.commandType, command.args.toJSONString(), command.flowID);
   }
 
   public ClientRecord fetchClient(String accountGUID) throws NullCursorException {
     final Cursor cur = db.fetchClientsCursor(accountGUID, getProfileId());
     try {
       if (!cur.moveToFirst()) {
         return null;
       }
@@ -190,19 +190,20 @@ public class ClientsDatabaseAccessor {
     record.fxaDeviceId = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_FXA_DEVICE_ID);
     record.appPackage = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APP_PACKAGE);
     record.application = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_APPLICATION);
 
     return record;
   }
 
   protected static Command commandFromCursor(Cursor cur) {
-    String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND);
-    JSONArray commandArgs = RepoUtils.getJSONArrayFromCursor(cur, ClientsDatabase.COL_ARGS);
-    return new Command(commandType, commandArgs);
+    final String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND);
+    final JSONArray commandArgs = RepoUtils.getJSONArrayFromCursor(cur, ClientsDatabase.COL_ARGS);
+    final String flowID = RepoUtils.optStringFromCursor(cur, ClientsDatabase.COL_FLOW_ID);
+    return new Command(commandType, commandArgs, flowID);
   }
 
   public int clientsCount() {
     try {
       final Cursor cur = db.fetchAllClients();
       try {
         return cur.getCount();
       } finally {
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/background/db/TestClientsDatabase.java
@@ -0,0 +1,220 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.db;
+
+import android.content.Context;
+import android.database.Cursor;
+
+import org.json.simple.JSONArray;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabase;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.setup.Constants;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.ArrayList;
+
+@RunWith(TestRunner.class)
+public class TestClientsDatabase {
+
+    protected ClientsDatabase db;
+
+    @Before
+    public void setUp() {
+        final Context context = RuntimeEnvironment.application;
+        db = new ClientsDatabase(context);
+        db.wipeDB();
+    }
+
+    @After
+    public void tearDown() {
+        db.close();
+    }
+
+    @Test
+    public void testStoreAndFetch() {
+        ClientRecord record = new ClientRecord();
+        String profileConst = Constants.DEFAULT_PROFILE;
+        db.store(profileConst, record);
+
+        Cursor cur = null;
+        try {
+            // Test stored item gets fetched correctly.
+            cur = db.fetchClientsCursor(record.guid, profileConst);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(1, cur.getCount());
+
+            String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
+            String profileId = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_PROFILE);
+            String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME);
+            String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE);
+
+            Assert.assertEquals(record.guid, guid);
+            Assert.assertEquals(profileConst, profileId);
+            Assert.assertEquals(record.name, clientName);
+            Assert.assertEquals(record.type, clientType);
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        } finally {
+            if (cur != null) {
+                cur.close();
+            }
+        }
+    }
+
+    @Test
+    public void testStoreAndFetchSpecificCommands() {
+        String accountGUID = Utils.generateGuid();
+        ArrayList<String> args = new ArrayList<>();
+        args.add("URI of Page");
+        args.add("Sender GUID");
+        args.add("Title of Page");
+        String jsonArgs = JSONArray.toJSONString(args);
+
+        Cursor cur = null;
+        try {
+            db.store(accountGUID, "displayURI", jsonArgs, "flowID");
+
+            // This row should not show up in the fetch.
+            args.add("Another arg.");
+            db.store(accountGUID, "displayURI", JSONArray.toJSONString(args), "flowID");
+
+            // Test stored item gets fetched correctly.
+            cur = db.fetchSpecificCommand(accountGUID, "displayURI", jsonArgs);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(1, cur.getCount());
+
+            String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
+            String commandType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_COMMAND);
+            String fetchedArgs = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ARGS);
+
+            Assert.assertEquals(accountGUID, guid);
+            Assert.assertEquals("displayURI", commandType);
+            Assert.assertEquals(jsonArgs, fetchedArgs);
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        } finally {
+            if (cur != null) {
+                cur.close();
+            }
+        }
+    }
+
+    @Test
+    public void testFetchCommandsForClient() {
+        String accountGUID = Utils.generateGuid();
+        ArrayList<String> args = new ArrayList<>();
+        args.add("URI of Page");
+        args.add("Sender GUID");
+        args.add("Title of Page");
+        String jsonArgs = JSONArray.toJSONString(args);
+
+        Cursor cur = null;
+        try {
+            db.store(accountGUID, "displayURI", jsonArgs, "flowID");
+
+            // This row should ALSO show up in the fetch.
+            args.add("Another arg.");
+            db.store(accountGUID, "displayURI", JSONArray.toJSONString(args), "flowID");
+
+            // Test both stored items with the same GUID but different command are fetched.
+            cur = db.fetchCommandsForClient(accountGUID);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(2, cur.getCount());
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        } finally {
+            if (cur != null) {
+                cur.close();
+            }
+        }
+    }
+
+    @Test
+    @SuppressWarnings("resource")
+    public void testDelete() {
+        ClientRecord record1 = new ClientRecord();
+        ClientRecord record2 = new ClientRecord();
+        String profileConst = Constants.DEFAULT_PROFILE;
+
+        db.store(profileConst, record1);
+        db.store(profileConst, record2);
+
+        Cursor cur = null;
+        try {
+            // Test record doesn't exist after delete.
+            db.deleteClient(record1.guid, profileConst);
+            cur = db.fetchClientsCursor(record1.guid, profileConst);
+            Assert.assertFalse(cur.moveToFirst());
+            Assert.assertEquals(0, cur.getCount());
+
+            // Test record2 still there after deleting record1.
+            cur = db.fetchClientsCursor(record2.guid, profileConst);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(1, cur.getCount());
+
+            String guid = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_ACCOUNT_GUID);
+            String profileId = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_PROFILE);
+            String clientName = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_NAME);
+            String clientType = RepoUtils.getStringFromCursor(cur, ClientsDatabase.COL_TYPE);
+
+            Assert.assertEquals(record2.guid, guid);
+            Assert.assertEquals(profileConst, profileId);
+            Assert.assertEquals(record2.name, clientName);
+            Assert.assertEquals(record2.type, clientType);
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        } finally {
+            if (cur != null) {
+                cur.close();
+            }
+        }
+    }
+
+    @Test
+    @SuppressWarnings("resource")
+    public void testWipe() {
+        ClientRecord record1 = new ClientRecord();
+        ClientRecord record2 = new ClientRecord();
+        String profileConst = Constants.DEFAULT_PROFILE;
+
+        db.store(profileConst, record1);
+        db.store(profileConst, record2);
+
+
+        Cursor cur = null;
+        try {
+            // Test before wipe the records are there.
+            cur = db.fetchClientsCursor(record2.guid, profileConst);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(1, cur.getCount());
+            cur = db.fetchClientsCursor(record2.guid, profileConst);
+            Assert.assertTrue(cur.moveToFirst());
+            Assert.assertEquals(1, cur.getCount());
+
+            // Test after wipe neither record exists.
+            db.wipeClientsTable();
+            cur = db.fetchClientsCursor(record2.guid, profileConst);
+            Assert.assertFalse(cur.moveToFirst());
+            Assert.assertEquals(0, cur.getCount());
+            cur = db.fetchClientsCursor(record1.guid, profileConst);
+            Assert.assertFalse(cur.moveToFirst());
+            Assert.assertEquals(0, cur.getCount());
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        } finally {
+            if (cur != null) {
+                cur.close();
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/background/db/TestClientsDatabaseAccessor.java
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.db;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.CommandHelpers;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CommandProcessor.Command;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.robolectric.RuntimeEnvironment;
+
+import android.content.Context;
+
+@RunWith(TestRunner.class)
+public class TestClientsDatabaseAccessor {
+
+    ClientsDatabaseAccessor db;
+
+    @Before
+    public void setUp() {
+        final Context context = RuntimeEnvironment.application;
+        db = new ClientsDatabaseAccessor(context);
+        db.wipeDB();
+    }
+
+    @After
+    public void tearDown() {
+        db.close();
+    }
+
+    public void testStoreArrayListAndFetch() throws NullCursorException {
+        ArrayList<ClientRecord> list = new ArrayList<>();
+        ClientRecord record1 = new ClientRecord(Utils.generateGuid());
+        ClientRecord record2 = new ClientRecord(Utils.generateGuid());
+        ClientRecord record3 = new ClientRecord(Utils.generateGuid());
+
+        list.add(record1);
+        list.add(record2);
+        db.store(list);
+
+        ClientRecord r1 = db.fetchClient(record1.guid);
+        ClientRecord r2 = db.fetchClient(record2.guid);
+        ClientRecord r3 = db.fetchClient(record3.guid);
+
+        Assert.assertNotNull(r1);
+        Assert.assertNotNull(r2);
+        Assert.assertNull(r3);
+        Assert.assertTrue(record1.equals(r1));
+        Assert.assertTrue(record2.equals(r2));
+    }
+
+    @Test
+    public void testStoreAndFetchCommandsForClient() {
+        String accountGUID1 = Utils.generateGuid();
+        String accountGUID2 = Utils.generateGuid();
+
+        Command command1 = CommandHelpers.getCommand1();
+        Command command2 = CommandHelpers.getCommand2();
+        Command command3 = CommandHelpers.getCommand3();
+
+        try {
+            db.store(accountGUID1, command1);
+            db.store(accountGUID1, command2);
+            db.store(accountGUID2, command3);
+
+            List<Command> commands = db.fetchCommandsForClient(accountGUID1);
+            Assert.assertEquals(2, commands.size());
+            Assert.assertEquals(1, commands.get(0).args.size());
+            Assert.assertEquals(1, commands.get(1).args.size());
+        } catch (NullCursorException e) {
+            Assert.fail("Should not have NullCursorException");
+        }
+    }
+
+    @Test
+    public void testNumClients() {
+        final int COUNT = 5;
+        ArrayList<ClientRecord> list = new ArrayList<>();
+        for (int i = 0; i < 5; i++) {
+            list.add(new ClientRecord());
+        }
+        db.store(list);
+        Assert.assertEquals(COUNT, db.clientsCount());
+    }
+
+    @Test
+    public void testFetchAll() throws NullCursorException {
+        ArrayList<ClientRecord> list = new ArrayList<>();
+        ClientRecord record1 = new ClientRecord(Utils.generateGuid());
+        ClientRecord record2 = new ClientRecord(Utils.generateGuid());
+
+        list.add(record1);
+        list.add(record2);
+
+        boolean thrown = false;
+        try {
+            Map<String, ClientRecord> records = db.fetchAllClients();
+
+            Assert.assertNotNull(records);
+            Assert.assertEquals(0, records.size());
+
+            db.store(list);
+            records = db.fetchAllClients();
+            Assert.assertNotNull(records);
+            Assert.assertEquals(2, records.size());
+            Assert.assertTrue(record1.equals(records.get(record1.guid)));
+            Assert.assertTrue(record2.equals(records.get(record2.guid)));
+
+            // put() should throw an exception since records is immutable.
+            records.put(null, null);
+        } catch (UnsupportedOperationException e) {
+            thrown = true;
+        }
+        Assert.assertTrue(thrown);
+    }
+
+    @Test
+    public void testFetchNonStaleClients() throws NullCursorException {
+        String goodRecord1 = Utils.generateGuid();
+        ClientRecord record1 = new ClientRecord(goodRecord1);
+        record1.fxaDeviceId = "fxa1";
+        ClientRecord record2 = new ClientRecord(Utils.generateGuid());
+        record2.fxaDeviceId = "fxa2";
+        String goodRecord2 = Utils.generateGuid();
+        ClientRecord record3 = new ClientRecord(goodRecord2);
+        record3.fxaDeviceId = "fxa4";
+
+        ArrayList<ClientRecord> list = new ArrayList<>();
+        list.add(record1);
+        list.add(record2);
+        list.add(record3);
+        db.store(list);
+
+        Assert.assertTrue(db.hasNonStaleClients(new String[]{"fxa1", "fxa-unknown"}));
+        Assert.assertFalse(db.hasNonStaleClients(new String[]{}));
+
+        String noFxADeviceId = Utils.generateGuid();
+        ClientRecord record4 = new ClientRecord(noFxADeviceId);
+        record4.fxaDeviceId = null;
+        list.clear();
+        list.add(record4);
+        db.store(list);
+
+        Assert.assertTrue(db.hasNonStaleClients(new String[]{}));
+
+        Collection<ClientRecord> filtered = db.fetchNonStaleClients(new String[]{"fxa1", "fxa4", "fxa-unknown"});
+        ClientRecord[] filteredArr = filtered.toArray(new ClientRecord[0]);
+        Assert.assertEquals(3, filteredArr.length);
+        Assert.assertEquals(filteredArr[0].guid, goodRecord1);
+        Assert.assertEquals(filteredArr[1].guid, goodRecord2);
+        Assert.assertEquals(filteredArr[2].guid, noFxADeviceId);
+    }
+}
--- a/mobile/android/services/src/test/java/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/background/testhelpers/CommandHelpers.java
@@ -7,34 +7,34 @@ import org.json.simple.JSONArray;
 import org.mozilla.gecko.sync.CommandProcessor.Command;
 
 public class CommandHelpers {
 
   @SuppressWarnings("unchecked")
   public static Command getCommand1() {
     JSONArray args = new JSONArray();
     args.add("argsA");
-    return new Command("displayURI", args);
+    return new Command("displayURI", args, null);
   }
 
   @SuppressWarnings("unchecked")
   public static Command getCommand2() {
     JSONArray args = new JSONArray();
     args.add("argsB");
-    return new Command("displayURI", args);
+    return new Command("displayURI", args, null);
   }
 
   @SuppressWarnings("unchecked")
   public static Command getCommand3() {
     JSONArray args = new JSONArray();
     args.add("argsC");
-    return new Command("displayURI", args);
+    return new Command("displayURI", args, null);
   }
 
   @SuppressWarnings("unchecked")
   public static Command getCommand4() {
     JSONArray args = new JSONArray();
     args.add("URI of Page");
     args.add("Sender ID");
     args.add("Title of Page");
-    return new Command("displayURI", args);
+    return new Command("displayURI", args, null);
   }
 }