reader/android: create/join syncgroup and watch for changes

Basic synchronization between two Android devices is working.
A dummy device set added from one device shows up on the other device.

subitems:
- bootstrap local/cloud syncbase instances
- create a syncgroup, if one does not exist
- join the syncgroup
- use watch API to react to data changes
- "Add Device Set" button for testing

Change-Id: I1aa650d6fc171eb2bb00975c2a78a9e2653aa14c
diff --git a/android/app/src/main/java/io/v/android/apps/reader/DeviceSetChooserActivity.java b/android/app/src/main/java/io/v/android/apps/reader/DeviceSetChooserActivity.java
index 14c040d..d52a796 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/DeviceSetChooserActivity.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/DeviceSetChooserActivity.java
@@ -12,8 +12,15 @@
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
+import android.widget.Button;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.UUID;
 
 import io.v.android.apps.reader.db.DB;
+import io.v.android.apps.reader.vdl.DeviceMeta;
+import io.v.android.apps.reader.vdl.DeviceSet;
 
 /**
  * Activity that displays all the active device sets of this user.
@@ -25,6 +32,7 @@
 
     private RecyclerView mRecyclerView;
     private DeviceSetListAdapter mAdapter;
+    private Button mButtonAddDeviceSet;
     private DB mDB;
 
     protected void onCreate(Bundle savedInstanceState) {
@@ -42,6 +50,20 @@
         // Use the linear layout manager for the recycler view
         RecyclerView.LayoutManager layoutManager= new LinearLayoutManager(this);
         mRecyclerView.setLayoutManager(layoutManager);
+
+        mButtonAddDeviceSet = (Button) findViewById(R.id.button_add_device_set);
+        mButtonAddDeviceSet.setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                // Create a new device set and add it to the database.
+                DeviceSet ds = new DeviceSet(
+                        UUID.randomUUID().toString(),              // Device Set ID
+                        UUID.randomUUID().toString(),              // File ID
+                        ImmutableMap.<String, DeviceMeta>of());
+
+                mDB.addDeviceSet(ds);
+            }
+        });
     }
 
     @Override
diff --git a/android/app/src/main/java/io/v/android/apps/reader/DeviceSetListAdapter.java b/android/app/src/main/java/io/v/android/apps/reader/DeviceSetListAdapter.java
index 9002e93..a43b65a 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/DeviceSetListAdapter.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/DeviceSetListAdapter.java
@@ -26,16 +26,19 @@
 
     private OnDeviceSetClickListener mClickListener;
     private DB mDB;
+    private DBList<File> mFiles;
     private DBList<DeviceSet> mDeviceSets;
 
     public class ViewHolder extends RecyclerView.ViewHolder {
         public CardView mCardView;
-        public TextView mTextView;
+        public TextView mTextViewTitle;
+        public TextView mTextViewId;
 
         public ViewHolder(CardView v) {
             super(v);
             mCardView = v;
-            mTextView = (TextView) mCardView.findViewById(R.id.device_set_list_item_text);
+            mTextViewTitle = (TextView) mCardView.findViewById(R.id.device_set_list_item_title);
+            mTextViewId = (TextView) mCardView.findViewById(R.id.device_set_list_item_id);
 
             mCardView.setOnClickListener(new View.OnClickListener() {
                 @Override
@@ -52,6 +55,7 @@
         mClickListener = null;
 
         mDB = DB.Singleton.get(context);
+        mFiles = mDB.getFileList();
         mDeviceSets = mDB.getDeviceSetList();
         mDeviceSets.setListener(this);
     }
@@ -67,17 +71,23 @@
 
     @Override
     public void onBindViewHolder(ViewHolder holder, int position) {
-        holder.mTextView.setText(getItemTitle(position));
+        DeviceSet ds = mDeviceSets.getItem(position);
+
+        holder.mTextViewTitle.setText("Title: " + getItemTitle(ds));
+        holder.mTextViewId.setText("Id: " + ds.getId());
     }
 
     public String getItemTitle(int position) {
-        DeviceSet ds = mDeviceSets.getItem(position);
-        File file = mDB.getFileById(ds.getFileId());
+        return getItemTitle(mDeviceSets.getItem(position));
+    }
+
+    public String getItemTitle(DeviceSet ds) {
+        File file = mFiles.getItemById(ds.getFileId());
 
         if (file != null) {
             return file.getTitle();
         } else {
-            return "*** Error retrieving the file name";
+            return "*** Error retrieving the title";
         }
     }
 
@@ -91,6 +101,8 @@
     }
 
     public void stop() {
+        mFiles.discard();
+        mFiles = null;
         mDeviceSets.discard();
         mDeviceSets = null;
     }
diff --git a/android/app/src/main/java/io/v/android/apps/reader/db/DB.java b/android/app/src/main/java/io/v/android/apps/reader/db/DB.java
index 38a2e37..abfd8e1 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/db/DB.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/db/DB.java
@@ -30,8 +30,8 @@
                     result = instance;
                     if (result == null) {
                         // uncomment either one
-                        instance = result = new FakeDB(context);
-//                        instance = result = new SyncbaseDB(context);
+//                        instance = result = new FakeDB(context);
+                        instance = result = new SyncbaseDB(context);
                     }
                 }
             }
@@ -72,6 +72,11 @@
         E getItem(int position);
 
         /**
+         * Returns the element with the given id.
+         */
+        E getItemById(String id);
+
+        /**
          * Sets the listener for changes to the list.
          * There can only be one listener.
          */
@@ -103,10 +108,9 @@
     DBList<DeviceSet> getDeviceSetList();
 
     /**
-     * Gets the PDF file with the given id.
-     * @param id the id of the file
-     * @return the file object, or null if there is no file with the given id.
+     * Adds a new device set to the db.
+     * @param ds the device set to be added.
      */
-    File getFileById(String id);
+    void addDeviceSet(DeviceSet ds);
 
 }
diff --git a/android/app/src/main/java/io/v/android/apps/reader/db/FakeDB.java b/android/app/src/main/java/io/v/android/apps/reader/db/FakeDB.java
index 4ca5244..008ffed 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/db/FakeDB.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/db/FakeDB.java
@@ -45,12 +45,10 @@
 
     static abstract class BaseFakeList<E> implements DBList<E> {
 
-        private Listener mListener;
+        protected Listener mListener;
 
         @Override
         public void setListener(Listener listener) {
-            // This fake list never calls the notify methods.
-            // Just check if the listener is set only once.
             assert mListener == null;
             mListener = listener;
         }
@@ -92,6 +90,17 @@
         public File getItem(int position) {
             return mFiles.get(position);
         }
+
+        @Override
+        public File getItemById(String id) {
+            for (File file : mFiles) {
+                if (file.getId().equals(id)) {
+                    return file;
+                }
+            }
+
+            return null;
+        }
     }
 
     static class FakeDeviceList extends BaseFakeList<Device> {
@@ -114,6 +123,16 @@
         public Device getItem(int position) {
             return DeviceInfoFactory.get(mContext);
         }
+
+        @Override
+        public Device getItemById(String id) {
+            Device device = DeviceInfoFactory.get(mContext);
+            if (device.getId().equals(id)) {
+                return device;
+            }
+
+            return null;
+        }
     }
 
     static class FakeDeviceSetList extends BaseFakeList<DeviceSet> {
@@ -144,6 +163,24 @@
         public DeviceSet getItem(int position) {
             return mDeviceSets.get(position);
         }
+
+        @Override
+        public DeviceSet getItemById(String id) {
+            for (DeviceSet ds : mDeviceSets) {
+                if (ds.getId().equals(id)) {
+                    return ds;
+                }
+            }
+
+            return null;
+        }
+
+        public void addItem(DeviceSet ds) {
+            mDeviceSets.add(ds);
+            if (mListener != null) {
+                mListener.notifyItemInserted(mDeviceSets.size() - 1);
+            }
+        }
     }
 
     public void init(Activity activity) {
@@ -172,15 +209,7 @@
     }
 
     @Override
-    public File getFileById(String id) {
-        for (int i = 0; i < mFileList.getItemCount(); ++i) {
-            File file = mFileList.getItem(i);
-            if (file.getId().equals(id)) {
-                return file;
-            }
-        }
-
-        return null;
+    public void addDeviceSet(DeviceSet ds) {
+        mDeviceSetList.addItem(ds);
     }
-
 }
diff --git a/android/app/src/main/java/io/v/android/apps/reader/db/SyncbaseDB.java b/android/app/src/main/java/io/v/android/apps/reader/db/SyncbaseDB.java
index 1f9b636..9ece043 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/db/SyncbaseDB.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/db/SyncbaseDB.java
@@ -7,12 +7,20 @@
 import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
+import android.os.Handler;
+import android.os.Looper;
 import android.util.Log;
 import android.widget.Toast;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import io.v.android.apps.reader.model.DeviceInfoFactory;
 import io.v.android.apps.reader.model.Listener;
 import io.v.android.apps.reader.vdl.Device;
 import io.v.android.apps.reader.vdl.DeviceSet;
@@ -21,21 +29,36 @@
 import io.v.android.v23.V;
 import io.v.android.v23.services.blessing.BlessingCreationException;
 import io.v.android.v23.services.blessing.BlessingService;
+import io.v.impl.google.naming.NamingUtil;
 import io.v.impl.google.services.syncbase.SyncbaseServer;
+import io.v.v23.VIterable;
+import io.v.v23.context.CancelableVContext;
 import io.v.v23.context.VContext;
 import io.v.v23.rpc.Server;
 import io.v.v23.security.BlessingPattern;
 import io.v.v23.security.Blessings;
+import io.v.v23.security.VCertificate;
 import io.v.v23.security.VPrincipal;
 import io.v.v23.security.VSecurity;
 import io.v.v23.security.access.AccessList;
 import io.v.v23.security.access.Constants;
 import io.v.v23.security.access.Permissions;
+import io.v.v23.services.syncbase.nosql.BatchOptions;
+import io.v.v23.services.syncbase.nosql.KeyValue;
+import io.v.v23.services.syncbase.nosql.SyncgroupMemberInfo;
+import io.v.v23.services.syncbase.nosql.SyncgroupSpec;
+import io.v.v23.services.syncbase.nosql.TableRow;
+import io.v.v23.services.watch.ResumeMarker;
 import io.v.v23.syncbase.Syncbase;
 import io.v.v23.syncbase.SyncbaseApp;
 import io.v.v23.syncbase.SyncbaseService;
+import io.v.v23.syncbase.nosql.BatchDatabase;
 import io.v.v23.syncbase.nosql.Database;
+import io.v.v23.syncbase.nosql.RowRange;
+import io.v.v23.syncbase.nosql.Syncgroup;
 import io.v.v23.syncbase.nosql.Table;
+import io.v.v23.syncbase.nosql.WatchChange;
+import io.v.v23.verror.Errors;
 import io.v.v23.verror.VException;
 import io.v.v23.vom.VomUtil;
 
@@ -51,20 +74,22 @@
      * The value must not conflict with any other blessing result codes.
      */
     private static final int BLESSING_REQUEST = 200;
+
+    private static final String GLOBAL_MOUNT_TABLE = "/ns.dev.v.io:8101";
     private static final String SYNCBASE_APP = "reader";
     private static final String SYNCBASE_DB = "db";
-
-    private static final String FILES_TABLE = "files";
-    private static final String DEVICES_TABLE = "devices";
-    private static final String DEVICE_SETS_TABLE = "deviceSets";
+    private static final String TABLE_FILES = "files";
+    private static final String TABLE_DEVICES = "devices";
+    private static final String TABLE_DEVICE_SETS = "deviceSets";
 
     private Permissions mPermissions;
     private Context mContext;
-    private VContext vContext;
+    private VContext mVContext;
+    private SyncbaseHierarchy mLocalSB;
+    private boolean mInitialized;
 
-    private Table mFiles;
-    private Table mDevices;
-    private Table mDeviceSets;
+    private String mUsername;
+    private String mSyncgroupName;
 
     SyncbaseDB(Context context) {
         mContext = context;
@@ -72,15 +97,15 @@
 
     @Override
     public void init(Activity activity) {
-        if (vContext != null) {
+        if (mVContext != null) {
             // Already initialized.
             return;
         }
 
-        vContext = V.init(mContext);
+        mVContext = V.init(mContext);
         try {
-            vContext = V.withListenSpec(
-                    vContext, V.getListenSpec(vContext).withProxy("proxy"));
+            mVContext = V.withListenSpec(
+                    mVContext, V.getListenSpec(mVContext).withProxy("proxy"));
         } catch (VException e) {
             handleError("Couldn't setup vanadium proxy: " + e.getMessage());
         }
@@ -140,27 +165,42 @@
 
     private void configurePrincipal(Blessings blessings) {
         try {
-            VPrincipal p = V.getPrincipal(vContext);
+            VPrincipal p = V.getPrincipal(mVContext);
             p.blessingStore().setDefaultBlessings(blessings);
             p.blessingStore().set(blessings, new BlessingPattern("..."));
             VSecurity.addToRoots(p, blessings);
+
+            // "<user_email>/android"
+            mUsername = mountNameFromBlessings(blessings);
         } catch (VException e) {
             handleError(String.format(
                     "Couldn't set local blessing %s: %s", blessings, e.getMessage()));
             return;
         }
-        setupSyncbase(blessings);
+
+        setupLocalSyncbase();
     }
 
-    private void setupSyncbase(Blessings blessings) {
+    private void setupLocalSyncbase() {
+        // "users/<user_email>/android/reader/<device_id>/syncbase"
+        final String syncbaseName = NamingUtil.join(
+                "users",
+                mUsername,
+                "reader",
+                DeviceInfoFactory.get(mContext).getId(),
+                "syncbase"
+        );
+        Log.i(TAG, "SyncbaseName: " + syncbaseName);
+
         // Prepare the syncbase storage directory.
         java.io.File storageDir = new java.io.File(mContext.getFilesDir(), "syncbase");
         storageDir.mkdirs();
 
         try {
-            vContext = SyncbaseServer.withNewServer(
-                    vContext,
+            mVContext = SyncbaseServer.withNewServer(
+                    mVContext,
                     new SyncbaseServer.Params()
+                            .withName(syncbaseName)
                             .withPermissions(mPermissions)
                             .withStorageRootDir(storageDir.getAbsolutePath()));
         } catch (SyncbaseServer.StartException e) {
@@ -169,41 +209,247 @@
         }
 
         try {
-            Server syncbaseServer = V.getServer(vContext);
+            Server syncbaseServer = V.getServer(mVContext);
             String serverName = "/" + syncbaseServer.getStatus().getEndpoints()[0];
-            SyncbaseService service = Syncbase.newService(serverName);
 
-            Toast.makeText(mContext, "serverName: " + serverName, Toast.LENGTH_LONG).show();
+            Log.i(TAG, "Local Syncbase ServerName: " + serverName);
 
-            SyncbaseApp app = service.getApp(SYNCBASE_APP);
-            if (!app.exists(vContext)) {
-                app.create(vContext, mPermissions);
-            }
+            mLocalSB = createHierarchy(serverName, "local");
 
-            Database db = app.getNoSqlDatabase(SYNCBASE_DB, null);
-            if (!db.exists(vContext)) {
-                db.create(vContext, mPermissions);
-            }
-
-            mFiles = db.getTable(FILES_TABLE);
-            if (!mFiles.exists(vContext)) {
-                mFiles.create(vContext, mPermissions);
-            }
-
-            mDevices = db.getTable(DEVICES_TABLE);
-            if (!mDevices.exists(vContext)) {
-                mDevices.create(vContext, mPermissions);
-            }
-
-            mDeviceSets = db.getTable(DEVICE_SETS_TABLE);
-            if (!mDeviceSets.exists(vContext)) {
-                mDeviceSets.create(vContext, mPermissions);
-            }
+            setupCloudSyncbase();
         } catch (VException e) {
             handleError("Couldn't setup syncbase service: " + e.getMessage());
         }
     }
 
+    /**
+     * This method assumes that there is a separate cloudsync instance running at:
+     * "users/[user_email]/reader/cloudsync"
+     */
+    private void setupCloudSyncbase() {
+        try {
+            // "users/<user_email>/reader/cloudsync"
+            String cloudsyncName = NamingUtil.join(
+                    "users",
+                    NamingUtil.trimSuffix(mUsername, "android"),
+                    "reader/cloudsync"
+            );
+
+            SyncbaseHierarchy cloudSB = createHierarchy(cloudsyncName, "cloud");
+
+            createSyncgroup(cloudSB.db);
+        } catch (VException e) {
+            handleError("Couldn't setup cloudsync: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Creates a syncgroup at cloudsync with the following name:
+     * "users/[user_email]/reader/cloudsync/%%sync/cloudsync"
+     */
+    private void createSyncgroup(Database db) {
+        mSyncgroupName = NamingUtil.join(
+                "users",
+                NamingUtil.trimSuffix(mUsername, "android"),
+                "reader/cloudsync/%%sync/cloudsync"
+        );
+
+        Syncgroup group = db.getSyncgroup(mSyncgroupName);
+
+        List<TableRow> prefixes = ImmutableList.of(
+                new TableRow(TABLE_FILES, ""),
+                new TableRow(TABLE_DEVICES, ""),
+                new TableRow(TABLE_DEVICE_SETS, "")
+        );
+
+        List<String> mountTables = ImmutableList.of(
+                NamingUtil.join(
+                        GLOBAL_MOUNT_TABLE,
+                        "users",
+                        NamingUtil.trimSuffix(mUsername, "android"),
+                        "reader/rendezvous"
+                )
+        );
+
+        SyncgroupSpec spec = new SyncgroupSpec(
+                "reader syncgroup",
+                mPermissions,
+                prefixes,
+                mountTables,
+                false
+        );
+
+        try {
+            group.create(mVContext, spec, new SyncgroupMemberInfo((byte) 0));
+            Log.i(TAG, "Syncgroup is created successfully.");
+        } catch (VException e) {
+            if (e.is(Errors.EXIST)) {
+                Log.i(TAG, "Syncgroup already exists.");
+            } else {
+                handleError("Syncgroup could not be created: " + e.getMessage());
+                return;
+            }
+        }
+
+        joinSyncgroup();
+    }
+
+    /**
+     * Sets up the local syncbase to join the syncgroup.
+     */
+    private void joinSyncgroup() {
+        Syncgroup group = mLocalSB.db.getSyncgroup(mSyncgroupName);
+
+        try {
+            SyncgroupSpec spec = group.join(mVContext, new SyncgroupMemberInfo((byte) 0));
+            Log.i(TAG, "Successfully joined the syncgroup!");
+            Log.i(TAG, "Syncgroup spec: " + spec);
+
+            Map<String, SyncgroupMemberInfo> members = group.getMembers(mVContext);
+            for (String memberName : members.keySet()) {
+                Log.i(TAG, "Member: " + memberName);
+            }
+        } catch (VException e) {
+            handleError("Could not join the syncgroup: " + e.getMessage());
+            return;
+        }
+
+        mInitialized = true;
+
+        // When successfully joined the syncgroup, first register the device information.
+        registerDevice();
+    }
+
+    private void registerDevice() {
+        try {
+            Device thisDevice = DeviceInfoFactory.get(mContext);
+            mLocalSB.devices.put(mVContext, thisDevice.getId(), thisDevice, Device.class);
+            Log.i(TAG, "Registered this device to the syncbase table: " + thisDevice);
+        } catch (VException e) {
+            handleError("Could not register this device: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Creates the "[app]/[db]/[table]" hierarchy at the provided syncbase name.
+     */
+    private SyncbaseHierarchy createHierarchy(
+            String syncbaseName, String debugName) throws VException {
+
+        SyncbaseService service = Syncbase.newService(syncbaseName);
+
+        SyncbaseHierarchy result = new SyncbaseHierarchy();
+
+        result.app = service.getApp(SYNCBASE_APP);
+        if (!result.app.exists(mVContext)) {
+            result.app.create(mVContext, mPermissions);
+            Log.i(TAG, String.format(
+                    "\"%s\" app is created at %s", result.app.name(), debugName));
+        } else {
+            Log.i(TAG, String.format(
+                    "\"%s\" app already exists at %s", result.app.name(), debugName));
+        }
+
+        result.db = result.app.getNoSqlDatabase(SYNCBASE_DB, null);
+        if (!result.db.exists(mVContext)) {
+            result.db.create(mVContext, mPermissions);
+            Log.i(TAG, String.format(
+                    "\"%s\" db is created at %s", result.db.name(), debugName));
+        } else {
+            Log.i(TAG, String.format(
+                    "\"%s\" db already exists at %s", result.db.name(), debugName));
+        }
+
+        result.files = result.db.getTable(TABLE_FILES);
+        if (!result.files.exists(mVContext)) {
+            result.files.create(mVContext, mPermissions);
+            Log.i(TAG, String.format(
+                    "\"%s\" table is created at %s", result.files.name(), debugName));
+        } else {
+            Log.i(TAG, String.format(
+                    "\"%s\" table already exists at %s", result.files.name(), debugName));
+        }
+
+        result.devices = result.db.getTable(TABLE_DEVICES);
+        if (!result.devices.exists(mVContext)) {
+            result.devices.create(mVContext, mPermissions);
+            Log.i(TAG, String.format(
+                    "\"%s\" table is created at %s", result.devices.name(), debugName));
+        } else {
+            Log.i(TAG, String.format(
+                    "\"%s\" table already exists at %s", result.devices.name(), debugName));
+        }
+
+        result.deviceSets = result.db.getTable(TABLE_DEVICE_SETS);
+        if (!result.deviceSets.exists(mVContext)) {
+            result.deviceSets.create(mVContext, mPermissions);
+            Log.i(TAG, String.format(
+                    "\"%s\" table is created at %s", result.deviceSets.name(), debugName));
+        } else {
+            Log.i(TAG, String.format(
+                    "\"%s\" table already exists at %s", result.deviceSets.name(), debugName));
+        }
+
+        return result;
+    }
+
+    /**
+     * This method finds the last certificate in our blessing's certificate
+     * chains whose extension contains an '@'. We will assume that extension to
+     * represent our username.
+     */
+    private static String mountNameFromBlessings(Blessings blessings) {
+        for (List<VCertificate> chain : blessings.getCertificateChains()) {
+            for (VCertificate certificate : Lists.reverse(chain)) {
+                if (certificate.getExtension().contains("@")) {
+                    return certificate.getExtension();
+                }
+            }
+        }
+        return "";
+    }
+
+    @Override
+    public DBList<File> getFileList() {
+        if (!mInitialized) {
+            return new EmptyList<>();
+        }
+
+        return new SyncbaseFileList(TABLE_FILES, File.class);
+    }
+
+    @Override
+    public DBList<Device> getDeviceList() {
+        if (!mInitialized) {
+            return new EmptyList<>();
+        }
+
+        return new SyncbaseDeviceList(TABLE_DEVICES, Device.class);
+    }
+
+    @Override
+    public DBList<DeviceSet> getDeviceSetList() {
+        if (!mInitialized) {
+            return new EmptyList<>();
+        }
+
+        return new SyncbaseDeviceSetList(TABLE_DEVICE_SETS, DeviceSet.class);
+    }
+
+    @Override
+    public void addDeviceSet(DeviceSet ds) {
+        try {
+            mLocalSB.deviceSets.put(mVContext, ds.getId(), ds, DeviceSet.class);
+        } catch (VException e) {
+            handleError("Failed to add the device set(" + ds + "): " + e.getMessage());
+        }
+    }
+
+    private void handleError(String msg) {
+        Log.e(TAG, msg);
+        Toast.makeText(mContext, msg, Toast.LENGTH_LONG).show();
+    }
+
     // TODO(youngseokyoon): Remove once the list is implemented properly.
     private static class EmptyList<E> implements DBList<E> {
         @Override
@@ -217,6 +463,11 @@
         }
 
         @Override
+        public E getItemById(String id) {
+            return null;
+        }
+
+        @Override
         public void setListener(Listener listener) {
         }
 
@@ -225,28 +476,258 @@
         }
     }
 
-    @Override
-    public DBList<File> getFileList() {
-        return new EmptyList<File>();
+    private class SyncbaseFileList extends SyncbaseDBList<File> {
+
+        public SyncbaseFileList(String tableName, Class clazz) {
+            super(tableName, clazz);
+        }
+
+        @Override
+        protected String getId(File file) {
+            return file.getId();
+        }
     }
 
-    @Override
-    public DBList<Device> getDeviceList() {
-        return new EmptyList<Device>();
+    private class SyncbaseDeviceList extends SyncbaseDBList<Device> {
+
+        public SyncbaseDeviceList(String tableName, Class clazz) {
+            super(tableName, clazz);
+        }
+
+        @Override
+        protected String getId(Device device) {
+            return device.getId();
+        }
     }
 
-    @Override
-    public DBList<DeviceSet> getDeviceSetList() {
-        return new EmptyList<DeviceSet>();
+    private class SyncbaseDeviceSetList extends SyncbaseDBList<DeviceSet> {
+
+        public SyncbaseDeviceSetList(String tableName, Class clazz) {
+            super(tableName, clazz);
+        }
+
+        @Override
+        protected String getId(DeviceSet deviceSet) {
+            return deviceSet.getId();
+        }
     }
 
-    @Override
-    public File getFileById(String id) {
-        return null;
+    private abstract class SyncbaseDBList<E> implements DBList<E> {
+
+        private final String TAG;
+
+        private CancelableVContext mCancelableVContext;
+        private Handler mHandler;
+        private Listener mListener;
+        private ResumeMarker mResumeMarker;
+        private String mTableName;
+        private Class mClass;
+        private List<E> mItems;
+
+        public SyncbaseDBList(String tableName, Class clazz) {
+            mCancelableVContext = mVContext.withCancel();
+            mTableName = tableName;
+            mClass = clazz;
+            mItems = new ArrayList<>();
+            mHandler = new Handler(Looper.getMainLooper());
+
+            TAG = String.format("%s<%s>",
+                    SyncbaseDBList.class.getSimpleName(), mClass.getSimpleName());
+
+            readInitialData();
+
+            // Run this in a background thread
+            new Thread(new Runnable() {
+                @Override
+                public void run() {
+                    watchForChanges();
+                }
+            }).start();
+        }
+
+        private void readInitialData() {
+            try {
+                Log.i(TAG, "Reading initial data from table: " + mTableName);
+
+                BatchDatabase batch = mLocalSB.db.beginBatch(
+                        mCancelableVContext, new BatchOptions("fetch", true));
+
+                // Read existing data from the table.
+                Table table = batch.getTable(mTableName);
+                VIterable<KeyValue> kvs = table.scan(mCancelableVContext, RowRange.range("", ""));
+                for (KeyValue kv : kvs) {
+                    @SuppressWarnings("unchecked")
+                    E item = (E) VomUtil.decode(kv.getValue(), mClass);
+                    mItems.add(item);
+                }
+
+                // Remember this resume marker for the watch call.
+                mResumeMarker = batch.getResumeMarker(mVContext);
+
+                batch.abort(mCancelableVContext);
+
+                Log.i(TAG, "Done reading initial data from table: " + mTableName);
+            } catch (Exception e) {
+                handleError(e.getMessage());
+            }
+        }
+
+        private void watchForChanges() {
+            try {
+                // Watch for new changes coming from other Syncbase peers.
+                VIterable<WatchChange> watchStream = mLocalSB.db.watch(
+                        mCancelableVContext, mTableName, "", mResumeMarker);
+
+                Log.i(TAG, "Watching for changes of table: " + mTableName + "...");
+
+                for (final WatchChange wc : watchStream) {
+                    printWatchChange(wc);
+
+                    // Handle the watch change differently, depending on the change type.
+                    switch (wc.getChangeType()) {
+                        case PUT_CHANGE:
+                            // Run this in the UI thread.
+                            mHandler.post(new Runnable() {
+                                @Override
+                                public void run() {
+                                    handlePutChange(wc);
+                                }
+                            });
+                            break;
+
+                        case DELETE_CHANGE:
+                            // Run this in the UI thread.
+                            mHandler.post(new Runnable() {
+                                @Override
+                                public void run() {
+                                    handleDeleteChange(wc);
+                                }
+                            });
+                            break;
+                    }
+                }
+            } catch (Exception e) {
+                handleError(e.getMessage());
+                Log.e(TAG, "Stack Trace: ", e);
+            }
+        }
+
+        private void printWatchChange(WatchChange wc) {
+            Log.i(TAG, "*** New Watch Change ***");
+            Log.i(TAG, "- ChangeType: " + wc.getChangeType().toString());
+            Log.i(TAG, "- RowName: " + wc.getRowName());
+            Log.i(TAG, "- TableName: " + wc.getTableName());
+            Log.i(TAG, "- VomValue: " + VomUtil.bytesToHexString(wc.getVomValue()));
+            Log.i(TAG, "- isContinued: " + wc.isContinued());
+            Log.i(TAG, "- isFromSync: " + wc.isFromSync());
+            Log.i(TAG, "========================");
+        }
+
+        private void handlePutChange(WatchChange wc) {
+            E item = null;
+
+            try {
+                item = (E) VomUtil.decode(wc.getVomValue(), mClass);
+            } catch (VException e) {
+                handleError("Could not decode the Vom: " + e.getMessage());
+            }
+
+            if (item == null) {
+                return;
+            }
+
+            boolean handled = false;
+            for (int i = 0; i < mItems.size(); ++i) {
+                E e = mItems.get(i);
+
+                if (wc.getRowName().equals(getId(e))) {
+                    // Update the file record here.
+
+                    mItems.remove(i);
+                    mItems.add(i, item);
+
+                    if (mListener != null) {
+                        mListener.notifyItemChanged(i);
+                    }
+
+                    handled = true;
+                }
+            }
+
+            if (handled) {
+                return;
+            }
+
+            // This is a new row added in the table.
+            mItems.add(item);
+
+            if (mListener != null) {
+                mListener.notifyItemInserted(mItems.size() - 1);
+            }
+        }
+
+        private void handleDeleteChange(WatchChange wc) {
+            boolean handled = false;
+            for (int i = 0; i < mItems.size(); ++i) {
+                E e = mItems.get(i);
+
+                if (wc.getRowName().equals(getId(e))) {
+                    mItems.remove(i);
+
+                    if (mListener != null) {
+                        mListener.notifyItemRemoved(i);
+                    }
+
+                    handled = true;
+                }
+            }
+
+            if (!handled) {
+                handleError("DELETE_CHANGE arrived but no matching item found in the table.");
+            }
+        }
+
+        protected abstract String getId(E e);
+
+        @Override
+        public int getItemCount() {
+            return mItems.size();
+        }
+
+        @Override
+        public E getItem(int position) {
+            return mItems.get(position);
+        }
+
+        @Override
+        public E getItemById(String id) {
+            for (E e : mItems) {
+                if (getId(e).equals(id)) {
+                    return e;
+                }
+            }
+
+            return null;
+        }
+
+        @Override
+        public void setListener(Listener listener) {
+            assert mListener == null;
+            mListener = listener;
+        }
+
+        @Override
+        public void discard() {
+            Log.i(TAG, "Cancelling the watch stream.");
+            mCancelableVContext.cancel();
+        }
     }
 
-    private void handleError(String msg) {
-        Log.e(TAG, msg);
-        Toast.makeText(mContext, msg, Toast.LENGTH_LONG).show();
+    private static class SyncbaseHierarchy {
+        public SyncbaseApp app;
+        public Database db;
+        public Table files;
+        public Table devices;
+        public Table deviceSets;
     }
 }
diff --git a/android/app/src/main/res/layout/activity_device_set_chooser.xml b/android/app/src/main/res/layout/activity_device_set_chooser.xml
index 4a18c57..38e1971 100644
--- a/android/app/src/main/res/layout/activity_device_set_chooser.xml
+++ b/android/app/src/main/res/layout/activity_device_set_chooser.xml
@@ -1,13 +1,26 @@
-<android.support.v7.widget.RecyclerView
-    xmlns:android="http://schemas.android.com/apk/res/android"
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/device_set_list"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
+    android:orientation="vertical"
     android:paddingBottom="@dimen/activity_vertical_margin"
     android:paddingLeft="@dimen/activity_horizontal_margin"
     android:paddingRight="@dimen/activity_horizontal_margin"
     android:paddingTop="@dimen/activity_vertical_margin"
     tools:context=".DeviceSetChooserActivity">
 
-</android.support.v7.widget.RecyclerView>
+    <android.support.v7.widget.RecyclerView
+        android:id="@+id/device_set_list"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1" />
+
+    <Button
+        android:id="@+id/button_add_device_set"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/add_device_set" />
+
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/device_set_list_item.xml b/android/app/src/main/res/layout/device_set_list_item.xml
index 851c899..7a70c23 100644
--- a/android/app/src/main/res/layout/device_set_list_item.xml
+++ b/android/app/src/main/res/layout/device_set_list_item.xml
@@ -1,4 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
+
 <android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
     android:id="@+id/device_set_list_item"
     android:layout_width="match_parent"
@@ -9,13 +10,25 @@
     android:layout_marginTop="@dimen/device_set_list_vertical_margin"
     android:scrollbars="vertical">
 
-    <TextView
-        android:id="@+id/device_set_list_item_text"
+    <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_marginBottom="@dimen/device_set_list_item_vertical_margin"
         android:layout_marginLeft="@dimen/device_set_list_item_horizontal_margin"
         android:layout_marginRight="@dimen/device_set_list_item_horizontal_margin"
-        android:layout_marginTop="@dimen/device_set_list_item_vertical_margin"/>
+        android:layout_marginTop="@dimen/device_set_list_item_vertical_margin"
+        android:orientation="vertical">
+
+        <TextView
+            android:id="@+id/device_set_list_item_title"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
+
+        <TextView
+            android:id="@+id/device_set_list_item_id"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
+
+    </LinearLayout>
 
 </android.support.v7.widget.CardView>
diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml
index 5fd0d9c..cc388d5 100644
--- a/android/app/src/main/res/values-v21/styles.xml
+++ b/android/app/src/main/res/values-v21/styles.xml
@@ -1,10 +1,14 @@
 <?xml version="1.0" encoding="utf-8"?>
+
 <resources>
+
     <style name="AppTheme" parent="android:Theme.Material">
         <item name="android:colorPrimary">@color/primary</item>
         <item name="android:colorPrimaryDark">@color/primaryDark</item>
         <item name="android:textColorPrimary">@color/textPrimary</item>
         <item name="android:textColor">#000000</item>
         <item name="android:windowBackground">@color/windowBackground</item>
+        <item name="android:colorButtonNormal">#E0F2F1</item>
     </style>
+
 </resources>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index d043fa0..be1cf81 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
 <resources>
     <string name="app_name">PDF Reader</string>
     <string name="action_settings">Settings</string>
+    <string name="add_device_set">Add Device Set</string>
 </resources>