reader/android: more improvements to the CSV logging

This CL addresses Tao's comments about the CSV logs. Now there are two
sets of log files are generated, one for generic touch events, and the
other for page navigation events.

Also, the CSV is written using the apache commons CSV library, which
is going to be less error-prone.

Closes #45.

Change-Id: I4f33f1d85d40e3d279501c83e95e13d1dd3e3601
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 5744842..981933e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -74,6 +74,10 @@
             universalApk false
         }
     }
+    packagingOptions {
+        exclude 'META-INF/NOTICE.txt'
+        exclude 'META-INF/LICENSE.txt'
+    }
 }
 
 dependencies {
@@ -84,6 +88,7 @@
     compile 'com.android.support:recyclerview-v7:22.2.1'
     compile 'io.v:vanadium-android:0.3'
     compile 'com.google.android.gms:play-services-analytics:8.3.0'
+    compile 'org.apache.commons:commons-csv:1.2'
 }
 
 vdl {
diff --git a/android/app/src/main/java/io/v/android/apps/reader/GestureListener.java b/android/app/src/main/java/io/v/android/apps/reader/GestureListener.java
index 8b4aa25..fe56d2f 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/GestureListener.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/GestureListener.java
@@ -24,70 +24,70 @@
 
     @Override
     public boolean onDown(MotionEvent e) {
-        mLogger.writeAction("Down");
+        mLogger.writeTouchAction("Down");
         return true;
     }
 
     @Override
     public void onShowPress(MotionEvent e) {
-        mLogger.writeAction("ShowPress");
+        mLogger.writeTouchAction("ShowPress");
     }
 
     @Override
     public boolean onSingleTapUp(MotionEvent e) {
-        mLogger.writeAction("SingleTapUp");
+        mLogger.writeTouchAction("SingleTapUp");
         return true;
     }
 
     @Override
     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
-        mLogger.writeAction("Scroll");
+        mLogger.writeTouchAction("Scroll");
         return true;
     }
 
     @Override
     public void onLongPress(MotionEvent e) {
-        mLogger.writeAction("LongPress");
+        mLogger.writeTouchAction("LongPress");
     }
 
     @Override
     public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
-        mLogger.writeAction("Fling");
+        mLogger.writeTouchAction("Fling");
         return true;
     }
 
     @Override
     public boolean onSingleTapConfirmed(MotionEvent e) {
-        mLogger.writeAction("SingleTapConfirmed");
+        mLogger.writeTouchAction("SingleTapConfirmed");
         return true;
     }
 
     @Override
     public boolean onDoubleTap(MotionEvent e) {
-        mLogger.writeAction("DoubleTap");
+        mLogger.writeTouchAction("DoubleTap");
         return true;
     }
 
     @Override
     public boolean onDoubleTapEvent(MotionEvent e) {
-        mLogger.writeAction("DoubleTapEvent");
+        mLogger.writeTouchAction("DoubleTapEvent");
         return true;
     }
 
     @Override
     public boolean onScale(ScaleGestureDetector detector) {
-        mLogger.writeAction("Scale");
+        mLogger.writeTouchAction("Scale");
         return true;
     }
 
     @Override
     public boolean onScaleBegin(ScaleGestureDetector detector) {
-        mLogger.writeAction("ScaleBegin");
+        mLogger.writeTouchAction("ScaleBegin");
         return true;
     }
 
     @Override
     public void onScaleEnd(ScaleGestureDetector detector) {
-        mLogger.writeAction("ScaleEnd");
+        mLogger.writeTouchAction("ScaleEnd");
     }
 }
diff --git a/android/app/src/main/java/io/v/android/apps/reader/PdfViewerActivity.java b/android/app/src/main/java/io/v/android/apps/reader/PdfViewerActivity.java
index f94c2a6..0a2a303 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/PdfViewerActivity.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/PdfViewerActivity.java
@@ -121,12 +121,18 @@
                     return;
                 }
 
-                mCurrentDS = changed;
+                int oldPage = getPage();
+                mCurrentDS = cloneDeviceSet(changed);
+                int newPage = getPage();
 
-                DeviceMeta dm = getDeviceMeta();
-                mPdfView.setPage(dm.getPage());
-                if (mMenuItemLinkPage != null) {
-                    mMenuItemLinkPage.setChecked(dm.getLinked());
+                if (oldPage != newPage) {
+                    DeviceMeta dm = getDeviceMeta();
+                    mPdfView.setPage(dm.getPage());
+                    if (mMenuItemLinkPage != null) {
+                        mMenuItemLinkPage.setChecked(dm.getLinked());
+                    }
+
+                    writeNavigationAction("Page Changed", newPage);
                 }
             }
 
@@ -178,6 +184,32 @@
         }
     }
 
+    // TODO(youngseokyoon): generalize these clone methods
+    private DeviceSet cloneDeviceSet(DeviceSet ds) {
+        if (ds == null) {
+            return null;
+        }
+
+        Map<String, DeviceMeta> devicesCopy = null;
+        if (ds.getDevices() != null) {
+            devicesCopy = new HashMap<>();
+            for (Map.Entry<String, DeviceMeta> entry : ds.getDevices().entrySet()) {
+                devicesCopy.put(entry.getKey(), cloneDeviceMeta(entry.getValue()));
+            }
+        }
+
+        return new DeviceSet(ds.getId(), ds.getFileId(), devicesCopy);
+    }
+
+    // TODO(youngseokyoon): generalize these clone methods
+    private DeviceMeta cloneDeviceMeta(DeviceMeta dm) {
+        if (dm == null) {
+            return null;
+        }
+
+        return new DeviceMeta(dm.getDeviceId(), dm.getPage(), dm.getZoom(), dm.getLinked());
+    }
+
     private void createAndJoinDeviceSet(Uri fileUri) {
         // Get the file content.
         byte[] bytes = getBytesFromUri(fileUri);
@@ -230,7 +262,7 @@
     }
 
     private void toggleLinkedState(boolean checked) {
-        writeAction(checked ? "Unlink Page" : "Link Page");
+        writeNavigationAction(checked ? "Unlink Page" : "Link Page");
 
         DeviceMeta dm = getDeviceMeta();
         if (dm == null) {
@@ -285,6 +317,8 @@
             public void onPageFinished(WebView view, String url) {
                 super.onPageFinished(view, url);
                 mPdfView.loadPdfFile(jFile.getPath());
+
+                writeNavigationAction("Page Changed", 1);
             }
         });
 
@@ -352,37 +386,48 @@
     }
 
     private DeviceMeta getDeviceMeta(DeviceSet ds) {
-        if (ds == null || !ds.getDevices().containsKey(getDeviceId())) {
+        if (ds == null) {
             return null;
         }
 
         return ds.getDevices().get(getDeviceId());
     }
 
+    private int getPage() {
+        DeviceMeta dm = getDeviceMeta();
+        if (dm == null) {
+            return 0;
+        }
+
+        return dm.getPage();
+    }
+
     /**
      * Move all the linked pages to their previous pages.
      */
     private void prevPage() {
-        writeAction("Previous Page");
+        writeNavigationAction("Previous Page");
 
         if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
             return;
         }
 
+        DeviceSet ds = cloneDeviceSet(mCurrentDS);
+
         // First, check if this device is linked or not.
         // If not, simply move the page of the current device.
-        if (!getDeviceMeta().getLinked()) {
-            DeviceMeta dm = getDeviceMeta();
+        if (!getDeviceMeta(ds).getLinked()) {
+            DeviceMeta dm = getDeviceMeta(ds);
             if (dm.getPage() > 1) {
                 dm.setPage(dm.getPage() - 1);
             }
 
-            getDB().updateDeviceSet(mCurrentDS);
+            getDB().updateDeviceSet(ds);
             return;
         }
 
         // Move all the linked pages
-        Map<String, DeviceMeta> linkedDevices = getLinkedDevices();
+        Map<String, DeviceMeta> linkedDevices = getLinkedDevices(ds);
         int smallestPage = getSmallestPage(linkedDevices);
 
         if (smallestPage > 1) {
@@ -391,7 +436,7 @@
                 dm.setPage(dm.getPage() - 1);
             }
 
-            getDB().updateDeviceSet(mCurrentDS);
+            getDB().updateDeviceSet(ds);
         }
     }
 
@@ -399,26 +444,28 @@
      * Move all the linked pages to their next pages.
      */
     private void nextPage() {
-        writeAction("Next Page");
+        writeNavigationAction("Next Page");
 
         if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
             return;
         }
 
+        DeviceSet ds = cloneDeviceSet(mCurrentDS);
+
         // First, check if this device is linked or not.
         // If not, simply move the page of the current device.
-        if (!getDeviceMeta().getLinked()) {
-            DeviceMeta dm = getDeviceMeta();
+        if (!getDeviceMeta(ds).getLinked()) {
+            DeviceMeta dm = getDeviceMeta(ds);
             if (dm.getPage() < mPdfView.getPageCount()) {
                 dm.setPage(dm.getPage() + 1);
             }
 
-            getDB().updateDeviceSet(mCurrentDS);
+            getDB().updateDeviceSet(ds);
             return;
         }
 
         // Move all the linked pages
-        Map<String, DeviceMeta> linkedDevices = getLinkedDevices();
+        Map<String, DeviceMeta> linkedDevices = getLinkedDevices(ds);
         int largestPage = getLargestPage(linkedDevices);
 
         if (largestPage < mPdfView.getPageCount()) {
@@ -427,16 +474,16 @@
                 dm.setPage(dm.getPage() + 1);
             }
 
-            getDB().updateDeviceSet(mCurrentDS);
+            getDB().updateDeviceSet(ds);
         }
     }
 
-    private Map<String, DeviceMeta> getLinkedDevices() {
-        if (mCurrentDS == null) {
+    private Map<String, DeviceMeta> getLinkedDevices(DeviceSet ds) {
+        if (ds == null) {
             return null;
         }
 
-        Map<String, DeviceMeta> devices = mCurrentDS.getDevices();
+        Map<String, DeviceMeta> devices = ds.getDevices();
         Map<String, DeviceMeta> result = new HashMap<>();
         for (String deviceId : devices.keySet()) {
             DeviceMeta dm = devices.get(deviceId);
@@ -475,12 +522,20 @@
     }
 
     /**
-     * Send an event to the tracker with the given action string.
+     * Log a navigation event to the available trackers.
      */
-    private void writeAction(String action) {
+    private void writeNavigationAction(String action) {
+        writeNavigationAction(action, 0);
+    }
+
+    /**
+     * Log a navigation event to the available trackers.
+     */
+    private void writeNavigationAction(String action, int value) {
         if (getTracker() != null) {
             getTracker().send(new HitBuilders.EventBuilder()
                     .setCustomDimension(1, Long.toString(System.currentTimeMillis()))
+                    .setCustomDimension(2, Integer.toString(value))
                     .setCategory(CATEGORY_PAGE_NAVIGATION)
                     .setAction(action)
                     .setLabel(getDeviceId())
@@ -488,7 +543,7 @@
         }
 
         if (getLogger() != null) {
-            getLogger().writeAction(action);
+            getLogger().writeNavigationAction(action, value);
         }
     }
 
diff --git a/android/app/src/main/java/io/v/android/apps/reader/UserActionLogger.java b/android/app/src/main/java/io/v/android/apps/reader/UserActionLogger.java
index 3126f28..c91b9a3 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/UserActionLogger.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/UserActionLogger.java
@@ -8,7 +8,10 @@
 import android.os.Environment;
 import android.util.Log;
 
-import java.io.BufferedWriter;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVPrinter;
+
+import java.io.Closeable;
 import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
@@ -21,15 +24,20 @@
 /**
  * A utility class for logging user actions, such as touch gestures and button presses.
  * Writes the user actions into a CSV file.
+ *
+ * This class implements {@link Closeable} interface, but closing this logger is not necessary to
+ * get the full logs, because the log printing streams are flushed right after each row is written.
  */
-public class UserActionLogger {
+public class UserActionLogger implements Closeable {
 
     private static final String TAG = GestureListener.class.getSimpleName();
 
     private static volatile UserActionLogger instance;
 
     private String mDeviceId;
-    private BufferedWriter mWriter;
+
+    private CSVPrinter mTouchPrinter;
+    private CSVPrinter mNavigationPrinter;
 
     /**
      * Singleton accessor of the UserActionLogger class.
@@ -53,10 +61,9 @@
 
         // Use an app-independent files directory to avoid accidentally deleting log
         // files by clearing the app data.
-        File directory = Environment.getExternalStoragePublicDirectory(
-                Environment.DIRECTORY_DOCUMENTS);
-        if (!directory.exists()) {
-            directory.mkdirs();
+        File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
+        if (!dir.exists()) {
+            dir.mkdirs();
         }
 
         // Avoid having colons in the start timestamp
@@ -64,47 +71,103 @@
                 "yyyyMMdd-HHmmss.SSS", Locale.getDefault());
         String startTime = formatter.format(new Date());
 
-        String basename = String.format("reader-%s.log", startTime);
-        File file = new File(directory, basename);
-
+        File touchLogFile = new File(dir,
+                String.format("reader-%s-touch-%s.log", mDeviceId, startTime));
+        File navigationLogFile = new File(dir,
+                String.format("reader-%s-navigation-%s.log", mDeviceId, startTime));
         try {
-            mWriter = new BufferedWriter(new FileWriter(file));
-            mWriter.write("DEVICE ID");
-            mWriter.write(",");
-            mWriter.write("ACTION");
-            mWriter.write(",");
-            mWriter.write("TIMESTAMP");
-            mWriter.newLine();
-            mWriter.flush();
+            mTouchPrinter = CSVFormat.DEFAULT
+                    .withHeader("ACTION", "TIMESTAMP")
+                    .print(new FileWriter(touchLogFile));
+
+            mNavigationPrinter = CSVFormat.DEFAULT
+                    .withHeader("ACTION", "VALUE", "TIMESTAMP")
+                    .print(new FileWriter(navigationLogFile));
         } catch (IOException e) {
             handleException(e);
-            mWriter = null;
+
+            try {
+                close();
+            } catch (IOException e2) {
+                // Nothing to do here.
+            }
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        IOException ex = null;
+
+        if (mTouchPrinter != null) {
+            try {
+                mTouchPrinter.close();
+            } catch (IOException e) {
+                ex = e;
+            } finally {
+                mTouchPrinter = null;
+            }
+        }
+
+        if (mNavigationPrinter != null) {
+            try {
+                mNavigationPrinter.close();
+            } catch (IOException e) {
+                if (ex != null) {
+                    ex.addSuppressed(e);
+                } else {
+                    ex = e;
+                }
+            } finally {
+                mNavigationPrinter = null;
+            }
+        }
+
+        if (ex != null) {
+            throw ex;
         }
     }
 
     /**
-     * Writes the given action to the CSV file.
+     * Writes the given touch action to the CSV file.
      *
-     * @param action name of the user action.
+     * @param action name of the touch action.
      */
-    public synchronized void writeAction(String action) {
-        if (mWriter == null) {
+    public void writeTouchAction(String action) {
+        if (mTouchPrinter == null) {
             return;
         }
 
         try {
-            mWriter.write(mDeviceId);
-            mWriter.write(",");
-            mWriter.write(action);
-            mWriter.write(",");
-            mWriter.write(Long.toString(System.currentTimeMillis()));
-            mWriter.newLine();
-            mWriter.flush();
+            mTouchPrinter.printRecord(action, timestamp());
+            mTouchPrinter.flush();
         } catch (IOException e) {
             handleException(e);
         }
     }
 
+    /**
+     * Writes the given navigation action to the CSV file.
+     *
+     * @param action name of the navigation action.
+     * @param value the value associated with the action.
+     */
+    public void writeNavigationAction(String action, int value) {
+        if (mNavigationPrinter == null) {
+            return;
+        }
+
+        try {
+            mNavigationPrinter.printRecord(action, value, timestamp());
+            mNavigationPrinter.flush();
+        } catch (IOException e) {
+            handleException(e);
+        }
+    }
+
+    private String timestamp() {
+        return Long.toString(System.currentTimeMillis());
+    }
+
     private static void handleException(Exception e) {
         Log.e(TAG, e.getMessage(), e);
     }