reader/android: companion wear app for page navigation

Adds an Android Wear companion app for Reader, which has basic page
navigation buttons: Toggle Link, Prev Page, Next Page.

When Reader app is running on a handheld device paired with the
wearable device, the handheld app receives page navigation messages
from the wearable app and handles them.

Change-Id: Ie96950233b663450b957c6c9e9fe16b71f4ddf90
diff --git a/Makefile b/Makefile
index 45c6e34..442fffa 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,7 @@
 
 .PHONY:
 test-android:
-	$(GRADLE) -p android :app:test
+	$(GRADLE) -p android test
 
 .PHONY: vdl
 vdl:
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 673176b..93bf241 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -81,15 +81,25 @@
         exclude 'META-INF/NOTICE.txt'
         exclude 'META-INF/LICENSE.txt'
     }
+
+    sourceSets {
+        main {
+            java {
+                srcDir { '../common/src/main/java' }
+            }
+        }
+    }
 }
 
 dependencies {
     compile fileTree(dir: 'libs', include: ['*.jar'])
+    wearApp project(':wear')
     compile 'com.android.support:design:23.0.1'
     compile 'com.android.support:appcompat-v7:23.0.1'
     compile 'com.android.support:cardview-v7:23.0.1'
     compile 'com.android.support:recyclerview-v7:23.0.1'
-    compile 'com.google.android.gms:play-services-analytics:8.3.0'
+    compile 'com.google.android.gms:play-services-analytics:8.4.0'
+    compile 'com.google.android.gms:play-services-wearable:8.4.0'
     compile 'org.apache.commons:commons-csv:1.2'
     compile 'org.apache.commons:commons-io:1.3.2'
     compile 'io.v:vanadium-android:1.5'
diff --git a/android/app/src/main/java/io/v/android/apps/reader/Constants.java b/android/app/src/main/java/io/v/android/apps/reader/Constants.java
index 975c7a9..93c519d 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/Constants.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/Constants.java
@@ -11,5 +11,6 @@
 
     public static final int REQUEST_CODE_SEEK_BLESSINGS = 200;
     public static final int REQUEST_CODE_PERMISSION_EXTERNAL_STORAGE = 201;
+    public static final int REQUEST_CODE_RESOLVE_ERROR = 1000;
 
 }
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 eb3663f..7b2f1c0 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,6 +6,7 @@
 
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentSender;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.AsyncTask;
@@ -20,9 +21,15 @@
 import android.widget.TextView;
 
 import com.google.android.gms.analytics.HitBuilders;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.wearable.MessageApi;
+import com.google.android.gms.wearable.MessageEvent;
+import com.google.android.gms.wearable.Wearable;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -39,15 +46,19 @@
 /**
  * Activity that shows the contents of the selected pdf file.
  */
-public class PdfViewerActivity extends BaseReaderActivity {
+public class PdfViewerActivity extends BaseReaderActivity
+        implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener,
+        MessageApi.MessageListener {
 
     private static final String TAG = PdfViewerActivity.class.getSimpleName();
     private static final int BLOCK_SIZE = 0x1000;   // 4K
 
-    // 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 GoogleApiClient mGoogleApiClient;
+    private boolean mResolvingError = false;
+
     private PdfViewWrapper mPdfView;
     private ProgressBar mProgressBar;
     private TextView mProgressText;
@@ -101,12 +112,22 @@
                 });
 
         mPdfView.setOnTouchListener((v, e) -> swipeDetector.onTouchEvent(e));
+
+        mGoogleApiClient = new GoogleApiClient.Builder(this)
+                .addApi(Wearable.API)
+                .addConnectionCallbacks(this)
+                .addOnConnectionFailedListener(this)
+                .build();
     }
 
     @Override
     protected void onStart() {
         super.onStart();
 
+        if (!mResolvingError) {
+            mGoogleApiClient.connect();
+        }
+
         /**
          * Suppress the start process until the DB initialization is completed.
          * onStart() method will be called again after the user selects her blessings.
@@ -237,13 +258,17 @@
 
     @Override
     protected void onStop() {
-        super.onStop();
+        if (!mResolvingError) {
+            Wearable.MessageApi.removeListener(mGoogleApiClient, this);
+        }
 
         if (mDeviceSets != null) {
             mDeviceSets.discard();
         }
 
         leaveDeviceSet();
+
+        super.onStop();
     }
 
     @Override
@@ -266,6 +291,76 @@
         }
     }
 
+    @Override //ConnectionCallbacks
+    public void onConnected(Bundle connectionHint) {
+        Log.i(TAG, "Google API Client was connected");
+        mResolvingError = false;
+        Wearable.MessageApi.addListener(mGoogleApiClient, this);
+    }
+
+    @Override //ConnectionCallbacks
+    public void onConnectionSuspended(int cause) {
+        Log.i(TAG, "Connection to Google API client was suspended");
+    }
+
+    @Override //OnConnectionFailedListener
+    public void onConnectionFailed(ConnectionResult result) {
+        if (mResolvingError) {
+            // Already attempting to resolve an error.
+            return;
+        } else if (result.hasResolution()) {
+            try {
+                mResolvingError = true;
+                result.startResolutionForResult(this, Constants.REQUEST_CODE_RESOLVE_ERROR);
+            } catch (IntentSender.SendIntentException e) {
+                // There was an error with the resolution intent. Try again.
+                mGoogleApiClient.connect();
+            }
+        } else {
+            Log.e(TAG, "Connection to Google API client has failed");
+            mResolvingError = false;
+            Wearable.MessageApi.removeListener(mGoogleApiClient, this);
+        }
+    }
+
+    @Override //MessageListener
+    public void onMessageReceived(final MessageEvent messageEvent) {
+        Log.i(TAG, "onMessageReceived() A message from watch was received:" + messageEvent
+                .getRequestId() + " " + messageEvent.getPath());
+
+        if (PageControlMessage.PATH.equals(messageEvent.getPath())) {
+            String message = new String(messageEvent.getData(), StandardCharsets.UTF_8);
+            switch (message) {
+                case PageControlMessage.TOGGLE_LINK:
+                    toggleLinkedState();
+                    break;
+
+                case PageControlMessage.PREV_PAGE:
+                    prevPage();
+                    break;
+
+                case PageControlMessage.NEXT_PAGE:
+                    nextPage();
+                    break;
+
+                // Ignore all other messages.
+                default:
+                    break;
+            }
+
+        }
+
+    }
+
+    private void toggleLinkedState() {
+        DeviceMeta dm = getDeviceMeta();
+        if (dm == null) {
+            return;
+        }
+
+        toggleLinkedState(dm.getLinked());
+    }
+
     private void toggleLinkedState(boolean checked) {
         writeNavigationAction(checked ? "Unlink Page" : "Link Page");
 
diff --git a/android/common/src/main/java/io/v/android/apps/reader/PageControlMessage.java b/android/common/src/main/java/io/v/android/apps/reader/PageControlMessage.java
new file mode 100644
index 0000000..5458b1e
--- /dev/null
+++ b/android/common/src/main/java/io/v/android/apps/reader/PageControlMessage.java
@@ -0,0 +1,18 @@
+// 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;
+
+/**
+ * A class that contains a few constant strings used for sending/receiving page control messages
+ * between the handheld and the wearable apps.
+ */
+public class PageControlMessage {
+
+    public static final String PATH = "/page-control";
+    public static final String TOGGLE_LINK = "ToggleLink";
+    public static final String PREV_PAGE = "PrevPage";
+    public static final String NEXT_PAGE = "NextPage";
+
+}
diff --git a/android/settings.gradle b/android/settings.gradle
index 40ea8da..4e9ed39 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -1,4 +1,4 @@
-include ':app'
+include ':app', ':wear'
 
 if (System.getProperty('useLocalVanadiumLib') != null) {
     include ':lib', ':android-lib'
diff --git a/android/wear/.gitignore b/android/wear/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/android/wear/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/android/wear/build.gradle b/android/wear/build.gradle
new file mode 100644
index 0000000..d52b371
--- /dev/null
+++ b/android/wear/build.gradle
@@ -0,0 +1,60 @@
+buildscript {
+    repositories {
+        mavenCentral()
+        jcenter()
+    }
+    dependencies {
+        // This introduces the Android plugin to make building Android
+        // applications easier.
+        classpath 'com.android.tools.build:gradle:1.5.0'
+        // Use the Android SDK manager, which will automatically download
+        // the required Android SDK.
+        classpath 'com.jakewharton.sdkmanager:gradle-plugin:0.12.0'
+    }
+}
+
+// Make our lives easier by automatically downloading the appropriate Android SDK.
+apply plugin: 'android-sdk-manager'
+// It's an Android application.
+apply plugin: 'com.android.application'
+// Retrolambda plugin
+apply plugin: 'me.tatarka.retrolambda'
+
+
+android {
+    compileSdkVersion 23
+    buildToolsVersion "23.0.1"
+
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+
+    defaultConfig {
+        applicationId "io.v.android.apps.reader"
+        minSdkVersion 21
+        targetSdkVersion 23
+        versionCode 1
+        versionName "1.0"
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+
+    sourceSets {
+        main {
+            java {
+                srcDir { '../common/src/main/java' }
+            }
+        }
+    }
+}
+
+dependencies {
+    compile fileTree(dir: 'libs', include: ['*.jar'])
+    compile 'com.google.android.support:wearable:1.3.0'
+    compile 'com.google.android.gms:play-services-wearable:8.4.0'
+}
diff --git a/android/wear/proguard-rules.pro b/android/wear/proguard-rules.pro
new file mode 100644
index 0000000..7b6b962
--- /dev/null
+++ b/android/wear/proguard-rules.pro
@@ -0,0 +1,17 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /usr/local/google/home/youngseokyoon/Android/Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
diff --git a/android/wear/src/main/AndroidManifest.xml b/android/wear/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bd7a6ed
--- /dev/null
+++ b/android/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="io.v.android.apps.reader">
+
+    <uses-feature android:name="android.hardware.type.watch" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@android:style/Theme.DeviceDefault">
+        <activity
+            android:name=".PageControlActivity"
+            android:label="@string/app_name">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
diff --git a/android/wear/src/main/java/io/v/android/apps/reader/PageControlActivity.java b/android/wear/src/main/java/io/v/android/apps/reader/PageControlActivity.java
new file mode 100644
index 0000000..a9825a5
--- /dev/null
+++ b/android/wear/src/main/java/io/v/android/apps/reader/PageControlActivity.java
@@ -0,0 +1,103 @@
+// 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.Activity;
+import android.os.Bundle;
+import android.support.wearable.view.WatchViewStub;
+import android.util.Log;
+import android.widget.Button;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.common.api.PendingResult;
+import com.google.android.gms.wearable.Node;
+import com.google.android.gms.wearable.NodeApi;
+import com.google.android.gms.wearable.Wearable;
+
+import java.nio.charset.StandardCharsets;
+
+public class PageControlActivity extends Activity implements GoogleApiClient.ConnectionCallbacks,
+        GoogleApiClient.OnConnectionFailedListener {
+
+    private static final String TAG = PageControlActivity.class.getSimpleName();
+
+    private GoogleApiClient mGoogleApiClient;
+
+    private Button mButtonToggle;
+    private Button mButtonPrev;
+    private Button mButtonNext;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_page_control);
+
+        final WatchViewStub stub = (WatchViewStub) findViewById(R.id.watch_view_stub);
+        stub.setOnLayoutInflatedListener(s -> {
+            mButtonToggle = (Button) s.findViewById(R.id.button_toggle_link);
+            mButtonToggle.setOnClickListener(
+                    v -> sendMessageToAllNodes(PageControlMessage.TOGGLE_LINK));
+
+            mButtonPrev = (Button) s.findViewById(R.id.button_prev);
+            mButtonPrev.setOnClickListener(
+                    v -> sendMessageToAllNodes(PageControlMessage.PREV_PAGE));
+
+            mButtonNext = (Button) s.findViewById(R.id.button_next);
+            mButtonNext.setOnClickListener(
+                    v -> sendMessageToAllNodes(PageControlMessage.NEXT_PAGE));
+        });
+
+        mGoogleApiClient = new GoogleApiClient.Builder(this)
+                .addApi(Wearable.API)
+                .addConnectionCallbacks(this)
+                .addOnConnectionFailedListener(this)
+                .build();
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mGoogleApiClient.connect();
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mGoogleApiClient.disconnect();
+    }
+
+    @Override
+    public void onConnected(Bundle connectionHint) {
+        Log.i(TAG, "onConnected(): Successfully connected to Google API client");
+    }
+
+    @Override
+    public void onConnectionSuspended(int cause) {
+        Log.i(TAG, "onConnectionSuspended(): Connection to Google API client was suspended");
+    }
+
+    @Override
+    public void onConnectionFailed(ConnectionResult result) {
+        Log.i(TAG, "onConnectionFailed(): Failed to connect, with result: " + result);
+    }
+
+    private boolean sendMessageToAllNodes(final String message) {
+        PendingResult<NodeApi.GetConnectedNodesResult> pendingResult =
+                Wearable.NodeApi.getConnectedNodes(mGoogleApiClient);
+
+        pendingResult.setResultCallback(result -> {
+            for (Node node : result.getNodes()) {
+                Wearable.MessageApi.sendMessage(
+                        mGoogleApiClient,
+                        node.getId(),
+                        PageControlMessage.PATH,
+                        message.getBytes(StandardCharsets.UTF_8));
+            }
+        });
+
+        return true;
+    }
+}
diff --git a/android/wear/src/main/res/layout/activity_page_control.xml b/android/wear/src/main/res/layout/activity_page_control.xml
new file mode 100644
index 0000000..b00ffa2
--- /dev/null
+++ b/android/wear/src/main/res/layout/activity_page_control.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<android.support.wearable.view.WatchViewStub
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/watch_view_stub"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    app:rectLayout="@layout/rect_activity_page_control"
+    app:roundLayout="@layout/round_activity_page_control"
+    tools:context="io.v.android.apps.reader.PageControlActivity"
+    tools:deviceIds="wear">
+
+</android.support.wearable.view.WatchViewStub>
diff --git a/android/wear/src/main/res/layout/rect_activity_page_control.xml b/android/wear/src/main/res/layout/rect_activity_page_control.xml
new file mode 100644
index 0000000..63099e0
--- /dev/null
+++ b/android/wear/src/main/res/layout/rect_activity_page_control.xml
@@ -0,0 +1,47 @@
+<?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:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    tools:context="io.v.android.apps.reader.PageControlActivity"
+    tools:deviceIds="wear_square">
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1">
+
+        <Button
+            android:id="@+id/button_toggle_link"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:text="@string/toggle_link" />
+
+    </RelativeLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/button_prev"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:text="@string/prev_page" />
+
+        <Button
+            android:id="@+id/button_next"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:text="@string/next_page" />
+
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/android/wear/src/main/res/layout/round_activity_page_control.xml b/android/wear/src/main/res/layout/round_activity_page_control.xml
new file mode 100644
index 0000000..0a61ae2
--- /dev/null
+++ b/android/wear/src/main/res/layout/round_activity_page_control.xml
@@ -0,0 +1,47 @@
+<?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:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    tools:context="io.v.android.apps.reader.PageControlActivity"
+    tools:deviceIds="wear_round">
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1">
+
+        <Button
+            android:id="@+id/button_toggle_link"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:text="@string/toggle_link" />
+
+    </RelativeLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/button_prev"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:text="@string/prev_page" />
+
+        <Button
+            android:id="@+id/button_next"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:text="@string/next_page" />
+
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/android/wear/src/main/res/mipmap-hdpi/ic_launcher.png b/android/wear/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..cde69bc
--- /dev/null
+++ b/android/wear/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/android/wear/src/main/res/mipmap-mdpi/ic_launcher.png b/android/wear/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c133a0c
--- /dev/null
+++ b/android/wear/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..bfa42f0
--- /dev/null
+++ b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..324e72c
--- /dev/null
+++ b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/android/wear/src/main/res/values/strings.xml b/android/wear/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a84917a
--- /dev/null
+++ b/android/wear/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+<resources>
+    <string name="app_name">PDF Reader</string>
+    <string name="toggle_link">Toggle Link</string>
+    <string name="prev_page">Prev Page</string>
+    <string name="next_page">Next Page</string>
+</resources>