reader/android: Google Analytics integration for collecting user events
This CL integrates Google Analytics to the project. In order to enable
the GA feature, google-services.json file should be downloaded and put
under android/app directory (instructions: http://goo.gl/8Rd3yj)
The ReaderApplication has two implementations, and only one of them is
conditionally added to the project source sets to avoid breaking build
when the "google-services.json" file does not exist.
The activities are refactored to have a common base class. The base
class performs several initializations needed by all activities used
in the Reader app.
The following events are sent to the Google Analytics server.
- all the gesture events detected by the GestureDetectors
- prev/next page button
- link/unlink page button
Change-Id: I1ad5fab10ee92e3f877028a3ab944250e68b76f2
diff --git a/android/app/.gitignore b/android/app/.gitignore
index 796b96d..57d76e1 100644
--- a/android/app/.gitignore
+++ b/android/app/.gitignore
@@ -1 +1,3 @@
/build
+google-services.json
+
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4346593..6e68ba9 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -23,6 +23,18 @@
// It's going to use VDL.
apply plugin: 'io.v.vdl'
+
+// Conditionally apply the google services plugin, depending on existence of the configuration file.
+// Also, add conditional source folders as source directories.
+// This is a workaround for not having conditional compilation in Java.
+if (new File(projectDir, 'google-services.json').exists()) {
+ apply plugin: 'com.google.gms.google-services'
+ android.sourceSets.main.java.srcDir { 'src/conditional/ga_enabled' }
+} else {
+ android.sourceSets.main.java.srcDir { 'src/conditional/ga_disabled' }
+}
+
+
repositories {
mavenCentral()
}
@@ -69,6 +81,7 @@
compile 'com.android.support:cardview-v7:22.2.1'
compile 'com.android.support:recyclerview-v7:22.2.1'
compile 'io.v:vanadium-android:0.1'
+ compile 'com.google.android.gms:play-services-analytics:8.3.0'
}
vdl {
diff --git a/android/app/src/conditional/ga_disabled/io/v/android/apps/reader/ReaderApplication.java b/android/app/src/conditional/ga_disabled/io/v/android/apps/reader/ReaderApplication.java
new file mode 100644
index 0000000..fbd3664
--- /dev/null
+++ b/android/app/src/conditional/ga_disabled/io/v/android/apps/reader/ReaderApplication.java
@@ -0,0 +1,27 @@
+// 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.app.Application;
+
+import com.google.android.gms.analytics.Tracker;
+
+/**
+ * This is a subclass of {@link Application} used to provide the {@link Tracker}.
+ *
+ * This file is conditionally included to the project, when the "google-services.json" file does not
+ * exist.
+ */
+public class ReaderApplication extends Application {
+ /**
+ * Gets the default {@link Tracker} for this {@link Application}.
+ * Always returns null to disable tracking.
+ *
+ * @return null
+ */
+ public Tracker getDefaultTracker() {
+ return null;
+ }
+}
diff --git a/android/app/src/conditional/ga_enabled/io/v/android/apps/reader/ReaderApplication.java b/android/app/src/conditional/ga_enabled/io/v/android/apps/reader/ReaderApplication.java
new file mode 100644
index 0000000..8fd5363
--- /dev/null
+++ b/android/app/src/conditional/ga_enabled/io/v/android/apps/reader/ReaderApplication.java
@@ -0,0 +1,34 @@
+// 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.app.Application;
+
+import com.google.android.gms.analytics.GoogleAnalytics;
+import com.google.android.gms.analytics.Tracker;
+
+/**
+ * This is a subclass of {@link Application} used to provide shared objects for this app, such as
+ * the {@link Tracker}.
+ *
+ * This file is conditionally included to the project, when the "google-services.json" file exists.
+ */
+public class ReaderApplication extends Application {
+ private Tracker mTracker;
+
+ /**
+ * Gets the default {@link Tracker} for this {@link Application}.
+ *
+ * @return tracker
+ */
+ synchronized public Tracker getDefaultTracker() {
+ if (mTracker == null) {
+ GoogleAnalytics analytics = GoogleAnalytics.getInstance(this);
+ mTracker = analytics.newTracker(R.xml.global_tracker);
+ }
+
+ return mTracker;
+ }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0c278cf..41c5550 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.v.android.apps.reader">
+ <uses-permission android:name="android.permission.INTERNET"/>
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
<application
+ android:name="ReaderApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
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
new file mode 100644
index 0000000..688cef9
--- /dev/null
+++ b/android/app/src/main/java/io/v/android/apps/reader/BaseReaderActivity.java
@@ -0,0 +1,96 @@
+// 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.pm.ActivityInfo;
+import android.os.Bundle;
+import android.support.v4.view.GestureDetectorCompat;
+import android.support.v7.app.AppCompatActivity;
+import android.view.MotionEvent;
+
+import com.google.android.gms.analytics.HitBuilders;
+import com.google.android.gms.analytics.Tracker;
+
+import io.v.android.apps.reader.db.DB;
+import io.v.android.apps.reader.model.DeviceInfoFactory;
+
+/**
+ * Base activity class for all the Reader app activities. Its responsibilities include DB
+ * initialization, touch gesture detection, and google analytics tracking
+ */
+public abstract class BaseReaderActivity extends AppCompatActivity {
+ private String mDeviceId;
+ private DB mDB;
+ private Tracker mTracker;
+ private GestureDetectorCompat mGestureDetector;
+ private GestureListener mGestureListener;
+
+ protected DB getDB() {
+ return mDB;
+ }
+
+ protected Tracker getTracker() {
+ return mTracker;
+ }
+
+ protected String getDeviceId() {
+ if (mDeviceId == null) {
+ mDeviceId = DeviceInfoFactory.getDeviceId(this);
+ }
+
+ return mDeviceId;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ initTracker();
+
+ // TODO(youngseokyoon): allow screen rotation and properly handle orientation changes
+ setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+
+ // Initialize the DB
+ mDB = DB.Singleton.get(this);
+ mDB.init(this);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ if (mTracker != null) {
+ String deviceId = DeviceInfoFactory.getDeviceId(this);
+ mTracker.setScreenName(String.format("%s.%s", deviceId, getClass().getSimpleName()));
+ mTracker.send(new HitBuilders.ScreenViewBuilder()
+ .setCustomDimension(1, Long.toString(System.currentTimeMillis()))
+ .build());
+ }
+ }
+
+ private void initTracker() {
+ // Obtain the shared Tracker instance.
+ ReaderApplication application = (ReaderApplication) getApplication();
+ mTracker = application.getDefaultTracker();
+
+ if (mTracker != null) {
+ mGestureListener = new GestureListener(mTracker, DeviceInfoFactory.getDeviceId(this));
+ mGestureDetector = new GestureDetectorCompat(this, mGestureListener);
+ mGestureDetector.setOnDoubleTapListener(mGestureListener);
+ }
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ // Forward all the touch event to the gesture detector.
+ // Implementing this in onTouchEvent is not enough, because it can only capture touch events
+ // that are not consumed by any child views.
+ if (mGestureDetector != null) {
+ mGestureDetector.onTouchEvent(ev);
+ }
+
+ return super.dispatchTouchEvent(ev);
+ }
+}
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 4addeac..266fa2c 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
@@ -5,11 +5,9 @@
package io.v.android.apps.reader;
import android.content.Intent;
-import android.content.pm.ActivityInfo;
import android.net.Uri;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
-import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.helper.ItemTouchHelper;
@@ -18,15 +16,13 @@
import android.view.MenuItem;
import android.view.View;
-import io.v.android.apps.reader.db.DB;
-
/**
* Activity that displays all the active device sets of this user.
* <p/>
* When the user clicks on one of the device sets, it starts the PdfViewerActivity with the file
* associated with the device set.
*/
-public class DeviceSetChooserActivity extends AppCompatActivity {
+public class DeviceSetChooserActivity extends BaseReaderActivity {
private static final String TAG = DeviceSetChooserActivity.class.getSimpleName();
@@ -35,18 +31,11 @@
private RecyclerView mRecyclerView;
private DeviceSetListAdapter mAdapter;
private FloatingActionButton mButtonAddDeviceSet;
- private DB mDB;
+ @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // TODO(youngseokyoon): allow screen rotation and properly handle orientation changes
- setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
-
- // Initialize the DB
- mDB = DB.Singleton.get(this);
- mDB.init(this);
-
setContentView(R.layout.activity_device_set_chooser);
mRecyclerView = (RecyclerView) findViewById(R.id.device_set_list);
@@ -104,7 +93,7 @@
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// Delete the device set on left swipe.
if (direction == ItemTouchHelper.LEFT) {
- mDB.deleteDeviceSet(
+ getDB().deleteDeviceSet(
mAdapter.getDeviceSetId(viewHolder.getLayoutPosition()));
}
}
@@ -149,7 +138,7 @@
super.onActivityResult(requestCode, resultCode, data);
Log.i(TAG, String.format("onActivityResult(%d, %d, data) called", requestCode, resultCode));
- if (mDB.onActivityResult(requestCode, resultCode, data)) {
+ if (getDB().onActivityResult(requestCode, resultCode, data)) {
return;
}
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
new file mode 100644
index 0000000..a7bc6e1
--- /dev/null
+++ b/android/app/src/main/java/io/v/android/apps/reader/GestureListener.java
@@ -0,0 +1,108 @@
+// 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.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+
+import com.google.android.gms.analytics.HitBuilders;
+import com.google.android.gms.analytics.Tracker;
+
+/**
+ * Gesture listener implementation for sending gesture events to the Google Analytics tracker.
+ * Send all the data without filtering, so that the events are not lost.
+ */
+public class GestureListener implements GestureDetector.OnGestureListener,
+ GestureDetector.OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener {
+
+ private static final String CATEGORY = "Touch Gesture";
+
+ private final Tracker mTracker;
+ private final String mLabel;
+
+ public GestureListener(Tracker tracker, String label) {
+ mTracker = tracker;
+ mLabel = label;
+ }
+
+ private void send(String action) {
+ mTracker.send(new HitBuilders.EventBuilder()
+ .setCustomDimension(1, Long.toString(System.currentTimeMillis()))
+ .setCategory(CATEGORY)
+ .setAction(action)
+ .setLabel(mLabel)
+ .build());
+ }
+
+ @Override
+ public boolean onDown(MotionEvent e) {
+ send("Down");
+ return true;
+ }
+
+ @Override
+ public void onShowPress(MotionEvent e) {
+ send("ShowPress");
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ send("SingleTapUp");
+ return true;
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ send("Scroll");
+ return true;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ send("LongPress");
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ send("Fling");
+ return true;
+ }
+
+ @Override
+ public boolean onSingleTapConfirmed(MotionEvent e) {
+ send("SingleTapConfirmed");
+ return true;
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ send("DoubleTap");
+ return true;
+ }
+
+ @Override
+ public boolean onDoubleTapEvent(MotionEvent e) {
+ send("DoubleTapEvent");
+ return true;
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ send("Scale");
+ return true;
+ }
+
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ send("ScaleBegin");
+ return true;
+ }
+
+ @Override
+ public void onScaleEnd(ScaleGestureDetector detector) {
+ send("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 44b85a8..269c507 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
@@ -6,12 +6,10 @@
import android.content.Context;
import android.content.Intent;
-import android.content.pm.ActivityInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
-import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
@@ -21,6 +19,7 @@
import android.widget.Button;
import android.widget.Toast;
+import com.google.android.gms.analytics.HitBuilders;
import com.google.common.io.ByteStreams;
import java.io.FileOutputStream;
@@ -29,9 +28,7 @@
import java.util.HashMap;
import java.util.Map;
-import io.v.android.apps.reader.db.DB;
import io.v.android.apps.reader.db.DB.DBList;
-import io.v.android.apps.reader.model.DeviceInfoFactory;
import io.v.android.apps.reader.model.IdFactory;
import io.v.android.apps.reader.model.Listener;
import io.v.android.apps.reader.vdl.DeviceMeta;
@@ -41,10 +38,12 @@
/**
* Activity that shows the contents of the selected pdf file.
*/
-public class PdfViewerActivity extends AppCompatActivity {
+public class PdfViewerActivity extends BaseReaderActivity {
private static final String TAG = PdfViewerActivity.class.getSimpleName();
+ // Category string used for Google Analytics tracking.
+ private static final String CATEGORY_PAGE_NAVIGATION = "Page Navigation";
private static final String EXTRA_DEVICE_SET_ID = "device_set_id";
private PdfViewWrapper mPdfView;
@@ -52,7 +51,6 @@
private Button mButtonNext;
private MenuItem mMenuItemLinkPage;
- private DB mDB;
private DBList<DeviceSet> mDeviceSets;
private DeviceSet mCurrentDS;
@@ -75,13 +73,6 @@
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // TODO(youngseokyoon): allow screen rotation and properly handle orientation changes
- setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
-
- // Initialize the DB
- mDB = DB.Singleton.get(this);
- mDB.init(this);
-
setContentView(R.layout.activity_pdf_viewer);
mPdfView = (PdfViewWrapper) findViewById(R.id.pdfview);
@@ -113,11 +104,11 @@
* Suppress the start process until the DB initialization is completed.
* onStart() method will be called again after the user selects her blessings.
*/
- if (!mDB.isInitialized()) {
+ if (!getDB().isInitialized()) {
return;
}
- mDeviceSets = mDB.getDeviceSetList();
+ mDeviceSets = getDB().getDeviceSetList();
mDeviceSets.setListener(new Listener() {
@Override
public void notifyItemChanged(int position) {
@@ -190,16 +181,16 @@
byte[] bytes = getBytesFromUri(fileUri);
// Create a vdl File object representing this pdf file and put it in the db.
- File vFile = mDB.storeBytes(bytes, getTitleFromUri(fileUri));
+ File vFile = getDB().storeBytes(bytes, getTitleFromUri(fileUri));
Log.i(TAG, "vFile created: " + vFile);
if (vFile == null) {
Log.e(TAG, "Could not store the file content of Uri: " + fileUri.toString());
}
- mDB.addFile(vFile);
+ getDB().addFile(vFile);
// Create a device set object and put it in the db.
DeviceSet ds = createDeviceSet(vFile);
- mDB.addDeviceSet(ds);
+ getDB().addDeviceSet(ds);
// Join the device set.
joinDeviceSet(ds);
@@ -237,22 +228,23 @@
}
private void toggleLinkedState(boolean checked) {
+ sendAction(checked ? "Unlink Page" : "Link Page");
+
DeviceMeta dm = getDeviceMeta();
if (dm == null) {
return;
}
dm.setLinked(!checked);
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
}
private DeviceMeta createDeviceMeta() {
- String deviceId = DeviceInfoFactory.getDeviceId(this);
int page = 1;
int zoom = 1;
boolean linked = true;
- return new DeviceMeta(deviceId, page, zoom, linked);
+ return new DeviceMeta(getDeviceId(), page, zoom, linked);
}
private DeviceSet createDeviceSet(File vFile) {
@@ -266,8 +258,8 @@
private void joinDeviceSet(DeviceSet ds) {
// Get the file contents from the database
// TODO(youngseokyoon): get the blob asynchronously. right now, it's blocking the UI thread.
- File file = mDB.getFileList().getItemById(ds.getFileId());
- byte[] bytes = mDB.readBytes(file);
+ File file = getDB().getFileList().getItemById(ds.getFileId());
+ byte[] bytes = getDB().readBytes(file);
if (bytes == null) {
Toast.makeText(this, "Could not load the file contents.", Toast.LENGTH_LONG).show();
return;
@@ -298,7 +290,7 @@
Log.i(TAG, "Joining device set: " + ds.getId());
DeviceMeta dm = createDeviceMeta();
ds.getDevices().put(dm.getDeviceId(), dm);
- mDB.updateDeviceSet(ds);
+ getDB().updateDeviceSet(ds);
mCurrentDS = ds;
}
@@ -310,13 +302,13 @@
Log.i(TAG, "Leaving device set: " + mCurrentDS.getId());
Map<String, DeviceMeta> devices = mCurrentDS.getDevices();
- devices.remove(DeviceInfoFactory.getDeviceId(this));
+ devices.remove(getDeviceId());
if (devices.isEmpty()) {
Log.i(TAG, "Last one to leave the device set. Deleting " + mCurrentDS.getId());
- mDB.deleteDeviceSet(mCurrentDS.getId());
+ getDB().deleteDeviceSet(mCurrentDS.getId());
} else {
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
}
mCurrentDS = null;
@@ -358,19 +350,19 @@
}
private DeviceMeta getDeviceMeta(DeviceSet ds) {
- String deviceId = DeviceInfoFactory.getDeviceId(this);
-
- if (ds == null || !ds.getDevices().containsKey(deviceId)) {
+ if (ds == null || !ds.getDevices().containsKey(getDeviceId())) {
return null;
}
- return ds.getDevices().get(deviceId);
+ return ds.getDevices().get(getDeviceId());
}
/**
* Move all the linked pages to their previous pages.
*/
private void prevPage() {
+ sendAction("Previous Page");
+
if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
return;
}
@@ -383,7 +375,7 @@
dm.setPage(dm.getPage() - 1);
}
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
return;
}
@@ -397,7 +389,7 @@
dm.setPage(dm.getPage() - 1);
}
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
}
}
@@ -405,6 +397,8 @@
* Move all the linked pages to their next pages.
*/
private void nextPage() {
+ sendAction("Next Page");
+
if (mCurrentDS == null || mPdfView.getPageCount() <= 0) {
return;
}
@@ -417,7 +411,7 @@
dm.setPage(dm.getPage() + 1);
}
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
return;
}
@@ -431,7 +425,7 @@
dm.setPage(dm.getPage() + 1);
}
- mDB.updateDeviceSet(mCurrentDS);
+ getDB().updateDeviceSet(mCurrentDS);
}
}
@@ -478,6 +472,20 @@
return result;
}
+ /**
+ * Send an event to the tracker with the given action string.
+ */
+ private void sendAction(String action) {
+ if (getTracker() != null) {
+ getTracker().send(new HitBuilders.EventBuilder()
+ .setCustomDimension(1, Long.toString(System.currentTimeMillis()))
+ .setCategory(CATEGORY_PAGE_NAVIGATION)
+ .setAction(action)
+ .setLabel(getDeviceId())
+ .build());
+ }
+ }
+
private static void handleException(Exception e) {
Log.e(TAG, e.getMessage(), e);
}
@@ -487,7 +495,7 @@
super.onActivityResult(requestCode, resultCode, data);
Log.i(TAG, String.format("onActivityResult(%d, %d, data) called", requestCode, resultCode));
- if (mDB.onActivityResult(requestCode, resultCode, data)) {
+ if (getDB().onActivityResult(requestCode, resultCode, data)) {
return;
}
diff --git a/android/build.gradle b/android/build.gradle
index 1b7886d..cfecc13 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,6 +7,9 @@
dependencies {
classpath 'com.android.tools.build:gradle:1.3.0'
+ // This is for sending log data to Google Analytics
+ classpath 'com.google.gms:google-services:1.5.0-beta2'
+
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}