reader/android: progress indicator for the device set creation phase.

Initial attempt to show a progress indicator when creating a new
device set. To enable this, the device set creation logic became
largely asynchronous, using ListenableFutures.

The progress indicator for the second phase (device set joining phase)
will be implemented later in a slightly different way.

Change-Id: I9846868ca41aa16e48e8df15d90c0311016093fb
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 ab57c61..f531fd6 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
@@ -9,6 +9,8 @@
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
 import android.provider.OpenableColumns;
 import android.support.v4.view.GestureDetectorCompat;
 import android.util.Log;
@@ -16,15 +18,20 @@
 import android.view.MenuItem;
 import android.view.MotionEvent;
 import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.TextView;
 
 import com.google.android.gms.analytics.HitBuilders;
-import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
 
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.Executors;
 
+import io.v.android.apps.reader.db.DB;
 import io.v.android.apps.reader.db.DB.DBList;
 import io.v.android.apps.reader.model.IdFactory;
 import io.v.android.apps.reader.model.Listener;
@@ -38,17 +45,23 @@
 public class PdfViewerActivity extends BaseReaderActivity {
 
     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 PdfViewWrapper mPdfView;
+    private ProgressBar mProgressBar;
+    private TextView mProgressText;
     private MenuItem mMenuItemLinkPage;
 
     private DBList<DeviceSet> mDeviceSets;
     private DeviceSet mCurrentDS;
 
+    private ListeningExecutorService mThreadPool;
+    private Handler mHandler;
+
     /**
      * Helper methods for creating an intent to start a PdfViewerActivity.
      */
@@ -70,9 +83,15 @@
 
         setContentView(R.layout.activity_pdf_viewer);
 
+        mThreadPool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(1));
+        mHandler = new Handler(Looper.getMainLooper());
+
         mPdfView = (PdfViewWrapper) findViewById(R.id.pdfview);
         mPdfView.init();
 
+        mProgressBar = (ProgressBar) findViewById(R.id.pdf_progress_bar);
+        mProgressText = (TextView) findViewById(R.id.pdf_progress_text);
+
         // Swipe gesture detection.
         final GestureDetectorCompat swipeDetector = SwipeGestureDetector.create(
                 this,
@@ -213,24 +232,69 @@
         return new DeviceMeta(dm.getDeviceId(), dm.getPage(), dm.getZoom(), dm.getLinked());
     }
 
-    private void createAndJoinDeviceSet(Uri fileUri) {
-        // Get the file content.
-        byte[] bytes = getBytesFromUri(fileUri);
+    private void createAndJoinDeviceSet(final Uri fileUri) {
+        mThreadPool.submit(new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    byte[] bytes = getBytesFromUri(fileUri);
+                    File file = createFile(bytes, getTitleFromUri(fileUri));
+                    final DeviceSet ds = createDeviceSet(file);
+
+                    // Join the device set from the UI thread.
+                    mHandler.post(new Runnable() {
+                        @Override
+                        public void run() {
+                            joinDeviceSet(ds);
+                        }
+                    });
+                } catch (Exception e) {
+                    Log.e(TAG, "Could not create the device set: " + e.getMessage(), e);
+
+                    // In case of an error, finish this activity and go back to the previous one.
+                    mHandler.post(new Runnable() {
+                        @Override
+                        public void run() {
+                            finish();
+                        }
+                    });
+                }
+            }
+        });
+    }
+
+    private File createFile(final byte[] bytes, final String title) throws IOException {
+        initProgress(R.string.progress_writing_pdf, bytes.length);
 
         // Create a vdl File object representing this pdf file and put it in the db.
-        File vFile = getDB().storeBytes(bytes, getTitleFromUri(fileUri));
+        DB.FileBuilder builder;
+        try {
+            builder = getDB().getFileBuilder(title);
+        } catch (RuntimeException e) {
+            throw new IOException(e);
+        }
+
+        int cur = 0;
+        int available = bytes.length;
+        while (available > 0) {
+            int numBytes = Math.min(BLOCK_SIZE, available);
+            builder.write(bytes, cur, numBytes);
+            cur += numBytes;
+            available -= numBytes;
+
+            updateProgress(cur);
+        }
+
+        initProgress(R.string.progress_finishing_up_writing, -1);
+        File vFile = builder.build();
+
         Log.i(TAG, "vFile created: " + vFile);
         if (vFile == null) {
-            Log.e(TAG, "Could not store the file content of Uri: " + fileUri.toString());
+            throw new IOException("Could not store the file content: " + title);
         }
         getDB().addFile(vFile);
 
-        // Create a device set object and put it in the db.
-        DeviceSet ds = createDeviceSet(vFile);
-        getDB().addDeviceSet(ds);
-
-        // Join the device set.
-        joinDeviceSet(ds);
+        return vFile;
     }
 
     @Override
@@ -285,14 +349,23 @@
     }
 
     private DeviceSet createDeviceSet(File vFile) {
+        initProgress(R.string.progress_creating_device_set, 1);
+
         String id = IdFactory.getRandomId();
         String fileId = vFile.getId();
         Map<String, DeviceMeta> devices = new HashMap<>();
 
-        return new DeviceSet(id, fileId, devices);
+        DeviceSet ds = new DeviceSet(id, fileId, devices);
+        getDB().addDeviceSet(ds);
+
+        updateProgress(1);
+
+        return ds;
     }
 
     private void joinDeviceSet(DeviceSet ds) {
+        showProgressWidgets(false);
+
         mPdfView.loadPdfFile("/file_id/" + ds.getFileId());
 
         // Create a new device meta, and update the device set with it.
@@ -324,17 +397,28 @@
         mCurrentDS = null;
     }
 
-    private byte[] getBytesFromUri(Uri uri) {
+    private byte[] getBytesFromUri(final Uri uri) throws IOException {
         Log.i(TAG, "getBytesFromUri: " + uri.toString());
 
-        try (InputStream in = getContentResolver().openInputStream(uri)) {
-            // Get the entire file contents as a byte array.
-            return ByteStreams.toByteArray(in);
-        } catch (IOException e) {
-            handleException(e);
+        InputStream in = getContentResolver().openInputStream(uri);
+        int available = in.available();
+
+        initProgress(R.string.progress_reading_source_pdf, available);
+
+        byte[] result = new byte[available];
+        int cur = 0;
+        int bytesRead;
+
+        while ((bytesRead = in.read(result, cur, Math.min(BLOCK_SIZE, available))) != -1 &&
+                available > 0) {
+            cur += bytesRead;
+            available -= bytesRead;
+            updateProgress(cur);
         }
 
-        return null;
+        in.close();
+
+        return result;
     }
 
     private String getTitleFromUri(Uri uri) {
@@ -521,8 +605,8 @@
         }
     }
 
-    private static void handleException(Exception e) {
-        Log.e(TAG, e.getMessage(), e);
+    private static void handleException(Throwable t) {
+        Log.e(TAG, t.getMessage(), t);
     }
 
     @Override
@@ -537,4 +621,48 @@
                 requestCode, resultCode));
     }
 
+    private void initProgress(final int progressTextRes, final int maxProgress) {
+        showProgressWidgets(true);
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mProgressText.setText(progressTextRes);
+
+                if (maxProgress >= 0) {
+                    mProgressBar.setIndeterminate(false);
+                    mProgressBar.setMax(maxProgress);
+                    mProgressBar.setProgress(0);
+                } else {
+                    mProgressBar.setIndeterminate(true);
+                }
+            }
+        });
+    }
+
+    private void updateProgress(final int progress) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mProgressBar.setProgress(progress);
+            }
+        });
+    }
+
+    private void showProgressWidgets(final boolean showProgress) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                if (showProgress) {
+                    mProgressText.setVisibility(View.VISIBLE);
+                    mProgressBar.setVisibility(View.VISIBLE);
+                    mPdfView.setVisibility(View.INVISIBLE);
+                } else {
+                    mProgressText.setVisibility(View.INVISIBLE);
+                    mProgressBar.setVisibility(View.INVISIBLE);
+                    mPdfView.setVisibility(View.VISIBLE);
+                }
+            }
+        });
+    }
+
 }
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 28b0b69..9ddf29b 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
@@ -7,6 +7,8 @@
 import android.app.Activity;
 import android.content.Context;
 
+import java.io.Closeable;
+import java.io.IOException;
 import java.io.InputStream;
 
 import io.v.android.apps.reader.model.Listener;
@@ -41,6 +43,11 @@
         }
     }
 
+    interface FileBuilder extends Closeable {
+        void write(byte[] b, int off, int len) throws IOException;
+        File build();
+    }
+
     /**
      * Perform initialization steps.  This method must be called early in the lifetime
      * of the activity.  As part of the initialization, it might send an intent to
@@ -146,16 +153,13 @@
     void deleteDeviceSet(String id);
 
     /**
-     * Stores the given bytes and returns a File object representing the written data, which can be
-     * passed to readBytes() method to read the data back.
+     * Returns a {@link FileBuilder} object on which the user can write the file content and finally
+     * obtain the {@link File} object representing the pdf file.
      *
-     * The returned File object should be explicitly added to the database by calling addFile().
-     *
-     * @param bytes bytes to be written.
      * @param title title of this file.
-     * @return      a File object representing the written data.
+     * @return      a {@link FileBuilder} object for building the {@link File}.
      */
-    File storeBytes(byte[] bytes, String title);
+    FileBuilder getFileBuilder(String title);
 
     /**
      * Opens an {@link InputStream} for the given file.
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 c7f2497..687e5d7 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
@@ -8,20 +8,23 @@
 import android.content.Context;
 import android.util.Log;
 
+import java.io.ByteArrayOutputStream;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.List;
 
 import io.v.android.apps.reader.Constants;
 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.Device;
 import io.v.android.apps.reader.vdl.DeviceSet;
 import io.v.android.apps.reader.vdl.File;
+import io.v.v23.vom.VomUtil;
 
 /**
  * A fake implementation of the DB interface for manual testing.
@@ -142,6 +145,67 @@
         }
     }
 
+    private class FakeFileBuilder implements FileBuilder {
+
+        private MessageDigest mDigest;
+        private String mTitle;
+        private long mSize;
+        private ByteArrayOutputStream mOutputStream;
+
+        public FakeFileBuilder(String title) {
+            try {
+                mDigest = MessageDigest.getInstance("MD5");
+            } catch (NoSuchAlgorithmException e) {
+                Log.e(TAG, "Could not create md5 digest object: " + e.getMessage(), e);
+                throw new RuntimeException(e);
+            }
+
+            mTitle = title;
+            mSize = 0L;
+            mOutputStream = new ByteArrayOutputStream();
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException {
+            mOutputStream.write(b, off, len);
+            mDigest.update(b, off, len);
+            mSize += len;
+        }
+
+        @Override
+        public File build() {
+            try {
+                mOutputStream.close();
+
+                String id = VomUtil.bytesToHexString(mDigest.digest());
+
+                java.io.File jFile = new java.io.File(mContext.getCacheDir(), id);
+                try (FileOutputStream out = new FileOutputStream(jFile)) {
+                    out.write(mOutputStream.toByteArray());
+                } catch (IOException e) {
+                    Log.e(TAG, e.getMessage(), e);
+                }
+
+                return new File(
+                        id,
+                        null,
+                        mTitle,
+                        mSize,
+                        Constants.PDF_MIME_TYPE);
+
+            } catch (IOException e) {
+                Log.e(TAG, "Could not build the File: " + e.getMessage(), e);
+            }
+            return null;
+        }
+
+        @Override
+        public void close() throws IOException {
+            mOutputStream.close();
+            mOutputStream = null;
+        }
+    }
+
     @Override
     public void init(Activity activity) {
         // Nothing to do.
@@ -193,24 +257,8 @@
     }
 
     @Override
-    public File storeBytes(byte[] bytes, String title) {
-        // In Fake DB, store the bytes as a temporary file in the local filesystem.
-        String id = IdFactory.getFileId(bytes);
-
-        java.io.File jFile = new java.io.File(mContext.getCacheDir(), id);
-        try (FileOutputStream out = new FileOutputStream(jFile)) {
-            out.write(bytes);
-        } catch (IOException e) {
-            Log.e(TAG, e.getMessage(), e);
-        }
-
-        return new File(
-                id,
-                null,
-                title,
-                bytes.length,
-                Constants.PDF_MIME_TYPE
-        );
+    public FileBuilder getFileBuilder(String title) {
+        return new FakeFileBuilder(title);
     }
 
     @Override
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 6216a39..ad3dc21 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
@@ -23,12 +23,13 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 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.IdFactory;
 import io.v.android.apps.reader.model.Listener;
 import io.v.android.apps.reader.vdl.Device;
 import io.v.android.apps.reader.vdl.DeviceSet;
@@ -490,31 +491,8 @@
     }
 
     @Override
-    public File storeBytes(byte[] bytes, String title) {
-        // In case of Syncbase DB, store the bytes as a blob.
-        // TODO(youngseokyoon): check if the same blob is already in the database.
-        try {
-            BlobWriter writer = sync(mLocalSB.db.writeBlob(mVContext, null));
-            OutputStream out = writer.stream(mVContext);
-            out.write(bytes);
-            out.close();
-
-            sync(writer.commit(mVContext));
-
-            BlobRef ref = writer.getRef();
-
-            return new File(
-                    IdFactory.getFileId(bytes),
-                    ref,
-                    title,
-                    bytes.length,
-                    io.v.android.apps.reader.Constants.PDF_MIME_TYPE
-            );
-        } catch (VException | IOException e) {
-            handleError("Could not write the blob: " + e.getMessage());
-        }
-
-        return null;
+    public FileBuilder getFileBuilder(String title) {
+        return new SyncbaseFileBuilder(title);
     }
 
     @Override
@@ -830,4 +808,73 @@
         public Table devices;
         public Table deviceSets;
     }
+
+    private class SyncbaseFileBuilder implements DB.FileBuilder {
+
+        private MessageDigest mDigest;
+        private String mTitle;
+        private long mSize;
+        private BlobWriter mBlobWriter;
+        private OutputStream mOutputStream;
+
+        public SyncbaseFileBuilder(String title) {
+            try {
+                mDigest = MessageDigest.getInstance("MD5");
+            } catch (NoSuchAlgorithmException e) {
+                Log.e(TAG, "Could not create md5 digest object: " + e.getMessage(), e);
+                throw new RuntimeException(e);
+            }
+
+            mTitle = title;
+            mSize = 0L;
+
+            try {
+                mBlobWriter = sync(mLocalSB.db.writeBlob(mVContext, null));
+                mOutputStream = mBlobWriter.stream(mVContext);
+            } catch (VException e) {
+                Log.e(TAG, "Could not create SyncbaseFileBuilder: " + e.getMessage(), e);
+                throw new RuntimeException(e);
+            }
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException {
+            mOutputStream.write(b, off, len);
+            mDigest.update(b, off, len);
+            mSize += len;
+        }
+
+        @Override
+        public File build() {
+            try {
+                Log.i(TAG, "build() method called.");
+                mOutputStream.close();
+                Log.i(TAG, "after mOutputStream.close()");
+                sync(mBlobWriter.commit(mVContext));
+                Log.i(TAG, "after commit.");
+
+                String id = VomUtil.bytesToHexString(mDigest.digest());
+                Log.i(TAG, "after digest.");
+                BlobRef ref = mBlobWriter.getRef();
+                Log.i(TAG, "after getRef().");
+
+                return new File(
+                        id,
+                        ref,
+                        mTitle,
+                        mSize,
+                        io.v.android.apps.reader.Constants.PDF_MIME_TYPE);
+
+            } catch (IOException | VException e) {
+                Log.e(TAG, "Could not build the File: " + e.getMessage(), e);
+            }
+            return null;
+        }
+
+        @Override
+        public void close() throws IOException {
+            mOutputStream.close();
+            mOutputStream = null;
+        }
+    }
 }
diff --git a/android/app/src/main/java/io/v/android/apps/reader/model/IdFactory.java b/android/app/src/main/java/io/v/android/apps/reader/model/IdFactory.java
index bb4e098..4decd22 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/model/IdFactory.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/model/IdFactory.java
@@ -6,12 +6,8 @@
 
 import android.util.Log;
 
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
 import java.util.UUID;
 
-import io.v.v23.vom.VomUtil;
-
 /**
  * Helper class for generating id strings to be used in Syncbase tables.
  *
@@ -29,38 +25,6 @@
         return UUID.randomUUID().toString();
     }
 
-    /**
-     * Gets a file id string. Uses MD5 hash to generate the key of the file.
-     * When the MD5 hashing fails, use a random id as a fallback.
-     *
-     * @param fileContents actual file contents as a byte array
-     * @return
-     */
-    public static String getFileId(byte[] fileContents) {
-        String result = getMD5HashString(fileContents);
-        if (result == null) {
-            result = getRandomId();
-        }
-
-        return result;
-    }
-
-    private static String getMD5HashString(byte[] bytes) {
-        if (bytes == null) {
-            return null;
-        }
-
-        try {
-            MessageDigest md = MessageDigest.getInstance("MD5");
-            md.update(bytes);
-            return VomUtil.bytesToHexString(md.digest());
-        } catch (NoSuchAlgorithmException e) {
-            handleException(e);
-        }
-
-        return null;
-    }
-
     private static void handleException(Exception e) {
         Log.e(TAG, e.getMessage(), e);
     }
diff --git a/android/app/src/main/res/layout/activity_pdf_viewer.xml b/android/app/src/main/res/layout/activity_pdf_viewer.xml
index 77bb12a..fe01f63 100644
--- a/android/app/src/main/res/layout/activity_pdf_viewer.xml
+++ b/android/app/src/main/res/layout/activity_pdf_viewer.xml
@@ -15,6 +15,22 @@
         android:id="@+id/pdfview"
         android:layout_width="match_parent"
         android:layout_height="fill_parent"
-        android:layout_alignParentTop="true" />
+        android:layout_alignParentTop="true"
+        android:visibility="invisible" />
+
+    <ProgressBar
+        android:id="@+id/pdf_progress_bar"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:layout_centerVertical="true"
+        style="@android:style/Widget.Material.Light.ProgressBar.Horizontal" />
+
+    <TextView
+        android:id="@+id/pdf_progress_text"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:layout_above="@id/pdf_progress_bar"
+        android:textColor="@color/textPrimary"
+        android:gravity="center" />
 
 </RelativeLayout>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 49ab1eb..77fe7de 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -3,4 +3,9 @@
     <string name="action_settings">Settings</string>
     <string name="action_link">Link Page</string>
     <string name="action_add_pdf">Add PDF</string>
+
+    <string name="progress_reading_source_pdf">Reading the source PDF file...</string>
+    <string name="progress_writing_pdf">Writing the PDF file to Syncbase...</string>
+    <string name="progress_finishing_up_writing">Finishing up the writing...</string>
+    <string name="progress_creating_device_set">Creating a new device set...</string>
 </resources>