reader/android: fix a few CSV logging issues

- separate UserActionLogger class to reuse it in different places
- write each line atomically
- changed the location and name of the log files
- page navigation events are also logged

Change-Id: Id1f95ea816c30797d9c8b32c5d51a423d75c6af0
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 41c5550..11e9254 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
 
     <uses-permission android:name="android.permission.INTERNET"/>
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
 
     <application
         android:name="ReaderApplication"
diff --git a/android/app/src/main/java/io/v/android/apps/reader/BaseReaderActivity.java b/android/app/src/main/java/io/v/android/apps/reader/BaseReaderActivity.java
index d2d74e2..474dbc4 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/BaseReaderActivity.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/BaseReaderActivity.java
@@ -24,6 +24,7 @@
     private String mDeviceId;
     private DB mDB;
     private Tracker mTracker;
+    private UserActionLogger mLogger;
     private GestureDetectorCompat mGestureDetector;
     private GestureListener mGestureListener;
 
@@ -35,6 +36,10 @@
         return mTracker;
     }
 
+    protected UserActionLogger getLogger() {
+        return mLogger;
+    }
+
     protected String getDeviceId() {
         if (mDeviceId == null) {
             mDeviceId = DeviceInfoFactory.getDeviceId(this);
@@ -71,7 +76,9 @@
     }
 
     private void initTracker() {
-        mGestureListener = new GestureListener(this, DeviceInfoFactory.getDeviceId(this));
+        // TODO(youngseokyoon): consolidate the Tracker into UserActionLogger
+        mLogger = UserActionLogger.getInstance(this);
+        mGestureListener = new GestureListener(this);
         mGestureDetector = new GestureDetectorCompat(this, mGestureListener);
         mGestureDetector.setOnDoubleTapListener(mGestureListener);
     }
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 5aba732..8b4aa25 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
@@ -4,17 +4,10 @@
 
 package io.v.android.apps.reader;
 
-import android.util.Log;
+import android.content.Context;
 import android.view.GestureDetector;
 import android.view.MotionEvent;
 import android.view.ScaleGestureDetector;
-import android.content.Context;
-
-import java.io.BufferedWriter;
-import java.io.FileWriter;
-import java.io.File;
-import java.io.IOException;
-import java.sql.Timestamp;
 
 /**
  * Gesture listener implementation for sending gesture events to the Google Analytics tracker.
@@ -23,122 +16,78 @@
 public class GestureListener implements GestureDetector.OnGestureListener,
         GestureDetector.OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener {
 
-    private static final String TAG = GestureListener.class.getSimpleName();
-    private static final String CATEGORY = "Touch Gesture";
-    private static BufferedWriter buffer;
+    private UserActionLogger mLogger;
 
-    private final String mDeviceId;
-
-    public GestureListener(Context context, String deviceId) {
-        mDeviceId = deviceId;
-
-        if (buffer == null) {
-            File directory = context.getFilesDir();
-            String basename = String.format("reader-%s.log", now());
-            File file = new File(directory, basename);
-
-            try {
-                buffer = new BufferedWriter(new FileWriter(file));
-                buffer.write("DEVICE ID");
-                buffer.write(",");
-                buffer.write("ACTION");
-                buffer.write(",");
-                buffer.write("TIMESTAMP");
-                buffer.newLine();
-                buffer.flush();
-            } catch (IOException e) {
-                handleException(e);
-            }
-        }
-    }
-
-    private void send(String action) {
-        try {
-            buffer.write(mDeviceId);
-            buffer.write(",");
-            buffer.write(action);
-            buffer.write(",");
-            buffer.write(now().toString());
-            buffer.newLine();
-        } catch (IOException e) {
-            handleException(e);
-        }
-    }
-
-    private Timestamp now() {
-        return new Timestamp(System.currentTimeMillis());
+    public GestureListener(Context context) {
+        mLogger = UserActionLogger.getInstance(context);
     }
 
     @Override
     public boolean onDown(MotionEvent e) {
-        send("Down");
+        mLogger.writeAction("Down");
         return true;
     }
 
     @Override
     public void onShowPress(MotionEvent e) {
-        send("ShowPress");
+        mLogger.writeAction("ShowPress");
     }
 
     @Override
     public boolean onSingleTapUp(MotionEvent e) {
-        send("SingleTapUp");
+        mLogger.writeAction("SingleTapUp");
         return true;
     }
 
     @Override
     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
-        send("Scroll");
+        mLogger.writeAction("Scroll");
         return true;
     }
 
     @Override
     public void onLongPress(MotionEvent e) {
-        send("LongPress");
+        mLogger.writeAction("LongPress");
     }
 
     @Override
     public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
-        send("Fling");
+        mLogger.writeAction("Fling");
         return true;
     }
 
     @Override
     public boolean onSingleTapConfirmed(MotionEvent e) {
-        send("SingleTapConfirmed");
+        mLogger.writeAction("SingleTapConfirmed");
         return true;
     }
 
     @Override
     public boolean onDoubleTap(MotionEvent e) {
-        send("DoubleTap");
+        mLogger.writeAction("DoubleTap");
         return true;
     }
 
     @Override
     public boolean onDoubleTapEvent(MotionEvent e) {
-        send("DoubleTapEvent");
+        mLogger.writeAction("DoubleTapEvent");
         return true;
     }
 
     @Override
     public boolean onScale(ScaleGestureDetector detector) {
-        send("Scale");
+        mLogger.writeAction("Scale");
         return true;
     }
 
     @Override
     public boolean onScaleBegin(ScaleGestureDetector detector) {
-        send("ScaleBegin");
+        mLogger.writeAction("ScaleBegin");
         return true;
     }
 
     @Override
     public void onScaleEnd(ScaleGestureDetector detector) {
-        send("ScaleEnd");
-    }
-
-    private static void handleException(Exception e) {
-        Log.e(TAG, e.getMessage(), e);
+        mLogger.writeAction("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 269c507..b39f77a 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
@@ -228,7 +228,7 @@
     }
 
     private void toggleLinkedState(boolean checked) {
-        sendAction(checked ? "Unlink Page" : "Link Page");
+        writeAction(checked ? "Unlink Page" : "Link Page");
 
         DeviceMeta dm = getDeviceMeta();
         if (dm == null) {
@@ -361,7 +361,7 @@
      * Move all the linked pages to their previous pages.
      */
     private void prevPage() {
-        sendAction("Previous Page");
+        writeAction("Previous Page");
 
         if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
             return;
@@ -397,7 +397,7 @@
      * Move all the linked pages to their next pages.
      */
     private void nextPage() {
-        sendAction("Next Page");
+        writeAction("Next Page");
 
         if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
             return;
@@ -475,7 +475,7 @@
     /**
      * Send an event to the tracker with the given action string.
      */
-    private void sendAction(String action) {
+    private void writeAction(String action) {
         if (getTracker() != null) {
             getTracker().send(new HitBuilders.EventBuilder()
                     .setCustomDimension(1, Long.toString(System.currentTimeMillis()))
@@ -484,6 +484,10 @@
                     .setLabel(getDeviceId())
                     .build());
         }
+
+        if (getLogger() != null) {
+            getLogger().writeAction(action);
+        }
     }
 
     private static void handleException(Exception e) {
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
new file mode 100644
index 0000000..3126f28
--- /dev/null
+++ b/android/app/src/main/java/io/v/android/apps/reader/UserActionLogger.java
@@ -0,0 +1,111 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.android.apps.reader;
+
+import android.content.Context;
+import android.os.Environment;
+import android.util.Log;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+import io.v.android.apps.reader.model.DeviceInfoFactory;
+
+/**
+ * A utility class for logging user actions, such as touch gestures and button presses.
+ * Writes the user actions into a CSV file.
+ */
+public class UserActionLogger {
+
+    private static final String TAG = GestureListener.class.getSimpleName();
+
+    private static volatile UserActionLogger instance;
+
+    private String mDeviceId;
+    private BufferedWriter mWriter;
+
+    /**
+     * Singleton accessor of the UserActionLogger class.
+     */
+    public static UserActionLogger getInstance(Context context) {
+        UserActionLogger result = instance;
+        if (instance == null) {
+            synchronized (UserActionLogger.class) {
+                result = instance;
+                if (result == null) {
+                    instance = result = new UserActionLogger(context);
+                }
+            }
+        }
+
+        return result;
+    }
+
+    private UserActionLogger(Context context) {
+        mDeviceId = DeviceInfoFactory.getDeviceId(context);
+
+        // 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();
+        }
+
+        // Avoid having colons in the start timestamp
+        SimpleDateFormat formatter = new SimpleDateFormat(
+                "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);
+
+        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();
+        } catch (IOException e) {
+            handleException(e);
+            mWriter = null;
+        }
+    }
+
+    /**
+     * Writes the given action to the CSV file.
+     *
+     * @param action name of the user action.
+     */
+    public synchronized void writeAction(String action) {
+        if (mWriter == null) {
+            return;
+        }
+
+        try {
+            mWriter.write(mDeviceId);
+            mWriter.write(",");
+            mWriter.write(action);
+            mWriter.write(",");
+            mWriter.write(Long.toString(System.currentTimeMillis()));
+            mWriter.newLine();
+            mWriter.flush();
+        } catch (IOException e) {
+            handleException(e);
+        }
+    }
+
+    private static void handleException(Exception e) {
+        Log.e(TAG, e.getMessage(), e);
+    }
+}