syncslides: Port importDeck().

- Minor changes to DeckChooserFragment's importDeck
  - Don't use a DeckFactory.
  - Show a toast when the import completes.
  - Move it to a library class since it is mostly unrelated to the UI.
- Return a future from DB.importDeck() instead of just dropping
  any errors on the floor.

Change-Id: I4b40099a59c597aadf45efbee2750c373860527a
diff --git a/android/app/build.gradle b/android/app/build.gradle
index fa1e8f7..54d3658 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -44,6 +44,8 @@
     testCompile 'junit:junit:4.12'
     compile 'com.android.support:appcompat-v7:23.1.0'
     compile 'com.android.support:design:23.1.0'
+    compile 'com.android.support:recyclerview-v7:23.0.1'
+    compile 'com.android.support:cardview-v7:23.0.1'
     compile 'io.v:vanadium-android:0.2'
 }
 
diff --git a/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java b/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
index a2a612d..807b305 100644
--- a/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
+++ b/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
@@ -8,6 +8,8 @@
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
 import android.provider.DocumentsContract;
 import android.support.design.widget.FloatingActionButton;
 import android.support.v4.app.Fragment;
@@ -23,6 +25,9 @@
 
 import com.google.common.base.Charsets;
 import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 
 import org.json.JSONArray;
 import org.json.JSONException;
@@ -33,6 +38,11 @@
 import java.util.UUID;
 
 import io.v.syncslides.db.DB;
+import io.v.syncslides.lib.DeckImporter;
+import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.DeckImpl;
+import io.v.syncslides.model.Slide;
+import io.v.syncslides.model.SlideImpl;
 
 /**
  * This fragment contains the list of decks as well as the FAB to create a new
@@ -110,7 +120,21 @@
                     break;
                 }
                 Uri uri = data.getData();
-                importDeck(DocumentFile.fromTreeUri(getContext(), uri));
+                DeckImporter importer = new DeckImporter(
+                        getActivity().getContentResolver(), DB.Singleton.get());
+                ListenableFuture<Void> future = importer.importDeck(
+                        DocumentFile.fromTreeUri(getContext(), uri));
+                Futures.addCallback(future, new FutureCallback<Void>() {
+                    @Override
+                    public void onSuccess(Void result) {
+                        toast("Import complete");
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        toast("Import failed: " + t.getMessage());
+                    }
+                });
                 break;
         }
     }
@@ -147,105 +171,15 @@
     }
 
     /**
-     * Import a slide deck from the given (local) folder.
-     *
-     * The folder must contain a JSON metadata file 'deck.json' with the following format:
-     * {
-     *     "Title" : "<title>",
-     *     "Thumb" : "<filename>,
-     *     "Slides" : [
-     *          {
-     *              "Thumb" : "<thumb_filename1>",
-     *              "Image" : "<image_filename1>",
-     *              "Note" : "<note1>"
-     *          },
-     *          {
-     *              "Thumb" : "<thumb_filename2>",
-     *              "Image" : "<image_filename2>",
-     *              "Note" : "<note2>"
-     *          },
-     *
-     *          ...
-     *     ]
-     * }
-     *
-     * All the filenames must be local to the given folder.
+     * Creates a toast in the main looper.  Useful since lots of this class runs in a
+     * background thread.
      */
-    private void importDeck(DocumentFile dir) {
-//        if (!dir.isDirectory()) {
-//            toast("Must import from a directory, got: " + dir);
-//            return;
-//        }
-//        // Read the deck metadata file.
-//        DocumentFile metadataFile = dir.findFile("deck.json");
-//        if (metadataFile == null) {
-//            toast("Couldn't find deck metadata file 'deck.json'");
-//            return;
-//        }
-//        JSONObject metadata = null;
-//        try {
-//            String data = new String(ByteStreams.toByteArray(
-//                    getActivity().getContentResolver().openInputStream(metadataFile.getUri())),
-//                    Charsets.UTF_8);
-//            metadata = new JSONObject(data);
-//        } catch (FileNotFoundException e) {
-//            toast("Couldn't open deck metadata file: " + e.getMessage());
-//            return;
-//        } catch (IOException e) {
-//            toast("Couldn't read data from deck metadata file: " + e.getMessage());
-//            return;
-//        } catch (JSONException e) {
-//            toast("Couldn't parse deck metadata: " + e.getMessage());
-//            return;
-//        }
-//
-//        try {
-//            String id = UUID.randomUUID().toString();
-//            String title = metadata.getString("Title");
-//            byte[] thumbData = readImage(dir, metadata.getString("Thumb"));
-//            Deck deck = DeckFactory.Singleton.get().make(title, thumbData, id);
-//            Slide[] slides = readSlides(dir, metadata);
-//            DB.Singleton.get(getActivity().getApplicationContext()).importDeck(deck, slides, null);
-//        } catch (JSONException e) {
-//            toast("Invalid format for deck metadata: " + e.getMessage());
-//            return;
-//        } catch (IOException e) {
-//            toast("Error interpreting deck metadata: " + e.getMessage());
-//            return;
-//        }
-    }
-
-//    private Slide[] readSlides(DocumentFile dir, JSONObject metadata)
-//            throws JSONException, IOException {
-//        if (!metadata.has("Slides")) {
-//            return new Slide[0];
-//        }
-//        JSONArray slides = metadata.getJSONArray("Slides");
-//        Slide[] ret = new Slide[slides.length()];
-//        for (int i = 0; i < slides.length(); ++i) {
-//            JSONObject slide = slides.getJSONObject(i);
-//            byte[] thumbData = readImage(dir, slide.getString("Thumb"));
-//            byte[] imageData = thumbData;
-//            if (slide.has("Image")) {
-//                imageData = readImage(dir, slide.getString("Image"));
-//            }
-//            String note = slide.getString("Note");
-//            ret[i] = new SlideImpl(thumbData, imageData, note);
-//        }
-//        return ret;
-//    }
-//
-//    private byte[] readImage(DocumentFile dir, String fileName) throws IOException {
-//        DocumentFile file = dir.findFile(fileName);
-//        if (file == null) {
-//            throw new FileNotFoundException(
-//                    "Image file doesn't exist: " + fileName);
-//        }
-//        return ByteStreams.toByteArray(
-//                getActivity().getContentResolver().openInputStream(file.getUri()));
-//    }
-//
-    private void toast(String msg) {
-        Toast.makeText(getActivity(), msg, Toast.LENGTH_LONG).show();
+    private void toast(final String msg) {
+        new Handler(Looper.getMainLooper()).post(new Runnable() {
+            @Override
+            public void run() {
+                Toast.makeText(getActivity(), msg, Toast.LENGTH_LONG).show();
+            }
+        });
     }
 }
diff --git a/android/app/src/main/java/io/v/syncslides/db/DB.java b/android/app/src/main/java/io/v/syncslides/db/DB.java
index bdb6b0a..8b00aa8 100644
--- a/android/app/src/main/java/io/v/syncslides/db/DB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/DB.java
@@ -6,9 +6,12 @@
 
 import android.content.Context;
 
+import com.google.common.util.concurrent.ListenableFuture;
+
 import io.v.syncslides.InitException;
 import io.v.syncslides.model.Deck;
 import io.v.syncslides.model.DynamicList;
+import io.v.syncslides.model.Slide;
 
 /**
  * Provides high-level methods for getting and setting the state of SyncSlides.
@@ -41,4 +44,14 @@
      * Returns a dynamically updating list of decks that are visible to the user.
      */
     DynamicList<Deck> getDecks();
+
+    /**
+     * Asynchronously imports the slide deck along with its slides.
+     *
+     * @param deck     deck to import
+     * @param slides   slides belonging to the above deck
+     * @return allows the client to detect when the import is complete
+     */
+    ListenableFuture<Void> importDeck(Deck deck, Slide[] slides);
+
 }
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
index 9d05596..01133fa 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
@@ -12,17 +12,26 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
 
 import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.util.Arrays;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
 
 import io.v.android.v23.V;
+import io.v.impl.google.naming.NamingUtil;
 import io.v.impl.google.services.syncbase.SyncbaseServer;
 import io.v.syncslides.InitException;
 import io.v.syncslides.V23;
 import io.v.syncslides.model.Deck;
 import io.v.syncslides.model.DynamicList;
 import io.v.syncslides.model.NoopList;
+import io.v.syncslides.model.Slide;
 import io.v.v23.context.VContext;
 import io.v.v23.rpc.Server;
 import io.v.v23.security.BlessingPattern;
@@ -33,9 +42,11 @@
 import io.v.v23.syncbase.Syncbase;
 import io.v.v23.syncbase.SyncbaseApp;
 import io.v.v23.syncbase.SyncbaseService;
+import io.v.v23.syncbase.nosql.BlobWriter;
 import io.v.v23.syncbase.nosql.Database;
 import io.v.v23.syncbase.nosql.Table;
 import io.v.v23.verror.VException;
+
 import static io.v.v23.VFutures.sync;
 
 public class SyncbaseDB implements DB {
@@ -51,6 +62,7 @@
 
     private boolean mInitialized = false;
     private Handler mHandler;
+    private ListeningExecutorService mExecutorService;
     private Permissions mPermissions;
     private Context mContext;
     private VContext mVContext;
@@ -68,6 +80,7 @@
         }
         mContext = context;
         mHandler = new Handler(Looper.getMainLooper());
+        mExecutorService = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
 
         // If blessings aren't in place, the fragment that called this
         // initialization may continue to load and use DB, but nothing will
@@ -153,4 +166,61 @@
         }
         return new WatchedList<Deck>(mVContext, new DeckWatcher(mDB));
     }
+
+    @Override
+    public ListenableFuture<Void> importDeck(final Deck deck, final Slide[] slides) {
+        return mExecutorService.submit(new Callable<Void>() {
+            @Override
+            public Void call() throws Exception {
+                putDeck(deck);
+                for (int i = 0; i < slides.length; ++i) {
+                    Slide slide = slides[i];
+                    putSlide(deck.getId(), i, slide);
+                }
+                return null;
+            }
+        });
+    }
+
+    private void putDeck(Deck deck) throws VException {
+        Log.i(TAG, String.format("Adding deck %s, %s", deck.getId(), deck.getTitle()));
+        Table decks = mDB.getTable(DECKS_TABLE);
+        if (!sync(decks.getRow(deck.getId()).exists(mVContext))) {
+            decks.put(
+                    mVContext,
+                    deck.getId(),
+                    new VDeck(deck.getTitle(), deck.getThumbData()),
+                    VDeck.class);
+        }
+    }
+
+    private void putSlide(String prefix, int idx, Slide slide) throws VException {
+        String key = slideRowKey(prefix, idx);
+        Log.i(TAG, "Adding slide " + key);
+        BlobWriter writer = sync(mDB.writeBlob(mVContext, null));
+        try (OutputStream out = sync(writer.stream(mVContext))) {
+            out.write(slide.getImageData());
+        } catch (IOException e) {
+            throw new VException("Couldn't write slide: " + key + ": " + e.getMessage());
+        }
+        writer.commit(mVContext);
+        Table decks = mDB.getTable(DECKS_TABLE);
+        if (!sync(decks.getRow(key).exists(mVContext))) {
+            VSlide vSlide = new VSlide(slide.getThumbData(), writer.getRef().getValue());
+            decks.put(mVContext, key, vSlide, VSlide.class);
+        }
+        Log.i(TAG, "Adding note: " + slide.getNotes());
+        Table notes = mDB.getTable(NOTES_TABLE);
+        notes.put(mVContext, key, new VNote(slide.getNotes()), VNote.class);
+        // Update the LastViewed timestamp.
+        notes.put(
+                mVContext,
+                NamingUtil.join(prefix, "LastViewed"),
+                System.currentTimeMillis(),
+                Long.class);
+    }
+
+    private String slideRowKey(String deckId, int slideNum) {
+        return NamingUtil.join(deckId, "slides", String.format("%04d", slideNum));
+    }
 }
diff --git a/android/app/src/main/java/io/v/syncslides/lib/DeckImporter.java b/android/app/src/main/java/io/v/syncslides/lib/DeckImporter.java
new file mode 100644
index 0000000..8ea9e59
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/lib/DeckImporter.java
@@ -0,0 +1,157 @@
+// 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.syncslides.lib;
+
+import android.content.ContentResolver;
+import android.support.v4.provider.DocumentFile;
+
+import com.google.common.base.Charsets;
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+
+import io.v.syncslides.db.DB;
+import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.DeckImpl;
+import io.v.syncslides.model.Slide;
+import io.v.syncslides.model.SlideImpl;
+import io.v.v23.verror.VException;
+
+import static io.v.v23.VFutures.sync;
+
+/**
+ * Imports a slide deck from the given (local) folder directly into the DB.
+ *
+ * The folder must contain a JSON metadata file 'deck.json' with the following format:
+ * {
+ *     "Title" : "<title>",
+ *     "Thumb" : "<filename>,
+ *     "Slides" : [
+ *          {
+ *              "Thumb" : "<thumb_filename1>",
+ *              "Image" : "<image_filename1>",
+ *              "Note" : "<note1>"
+ *          },
+ *          {
+ *              "Thumb" : "<thumb_filename2>",
+ *              "Image" : "<image_filename2>",
+ *              "Note" : "<note2>"
+ *          },
+ *
+ *          ...
+ *     ]
+ * }
+ *
+ * All the filenames must be local to the given folder.
+ */
+public class DeckImporter {
+
+    private static final String DECK_JSON = "deck.json";
+    private static final String TITLE = "Title";
+    private static final String THUMB = "Thumb";
+    private static final String SLIDES = "Slides";
+    private static final String IMAGE = "Image";
+    private static final String NOTE = "Note";
+
+    private final ListeningExecutorService mExecutorService;
+    private ContentResolver mContentResolver;
+    private DB mDB;
+
+    public DeckImporter(ContentResolver contentResolver, DB db) {
+        mExecutorService = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+        mContentResolver = contentResolver;
+        mDB = db;
+    }
+
+    public ListenableFuture<Void> importDeck(final DocumentFile dir) {
+        return mExecutorService.submit(new Callable<Void>() {
+            @Override
+            public Void call() throws Exception {
+                return importDeckImpl(dir);
+            }
+        });
+    }
+
+    private Void importDeckImpl(DocumentFile dir) throws ImportException {
+        if (!dir.isDirectory()) {
+            throw new ImportException("Must import from a directory, got: " + dir);
+        }
+        // Read the deck metadata file.
+        DocumentFile metadataFile = dir.findFile(DECK_JSON);
+        if (metadataFile == null) {
+            throw new ImportException("Couldn't find deck metadata file 'deck.json'");
+        }
+        JSONObject metadata = null;
+        try {
+            String data = new String(ByteStreams.toByteArray(
+                    mContentResolver.openInputStream(metadataFile.getUri())),
+                    Charsets.UTF_8);
+            metadata = new JSONObject(data);
+        } catch (FileNotFoundException e) {
+            throw new ImportException("Couldn't open deck metadata file", e);
+        } catch (IOException e) {
+            throw new ImportException("Couldn't read data from deck metadata file", e);
+        } catch (JSONException e) {
+            throw new ImportException("Couldn't parse deck metadata", e);
+        }
+
+        try {
+            String id = UUID.randomUUID().toString();
+            String title = metadata.getString(TITLE);
+            byte[] thumbData = readImage(dir, metadata.getString(THUMB));
+            Deck deck = new DeckImpl(title, thumbData, id);
+            Slide[] slides = readSlides(dir, metadata);
+            sync(mDB.importDeck(deck, slides));
+        } catch (JSONException e) {
+            throw new ImportException("Invalid format for deck metadata", e);
+        } catch (IOException e) {
+            throw new ImportException("Error interpreting deck metadata", e);
+        } catch (VException e) {
+            throw new ImportException("Error importing deck", e);
+        }
+        return null;
+    }
+
+    // TODO(kash): Lazily read the slide images so we don't need to have them all
+    // in memory simultaneously.
+    private Slide[] readSlides(DocumentFile dir, JSONObject metadata)
+            throws JSONException, IOException {
+        if (!metadata.has(SLIDES)) {
+            return new Slide[0];
+        }
+        JSONArray slides = metadata.getJSONArray(SLIDES);
+        Slide[] ret = new Slide[slides.length()];
+        for (int i = 0; i < slides.length(); ++i) {
+            JSONObject slide = slides.getJSONObject(i);
+            byte[] thumbData = readImage(dir, slide.getString(THUMB));
+            byte[] imageData = thumbData;
+            if (slide.has(IMAGE)) {
+                imageData = readImage(dir, slide.getString(IMAGE));
+            }
+            String note = slide.getString(NOTE);
+            ret[i] = new SlideImpl(thumbData, imageData, note);
+        }
+        return ret;
+    }
+
+    private byte[] readImage(DocumentFile dir, String fileName) throws IOException {
+        DocumentFile file = dir.findFile(fileName);
+        if (file == null) {
+            throw new FileNotFoundException("Image file doesn't exist: " + fileName);
+        }
+        return ByteStreams.toByteArray(mContentResolver.openInputStream(file.getUri()));
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/lib/ImportException.java b/android/app/src/main/java/io/v/syncslides/lib/ImportException.java
new file mode 100644
index 0000000..8dcd82e
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/lib/ImportException.java
@@ -0,0 +1,22 @@
+// 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.syncslides.lib;
+
+/**
+ * Thrown when DeckImporter fails to import a deck.
+ */
+public class ImportException extends Exception {
+    public ImportException(Throwable throwable) {
+        super(throwable);
+    }
+
+    public ImportException(String detailMessage, Throwable throwable) {
+        super(detailMessage, throwable);
+    }
+
+    public ImportException(String detailMessage) {
+        super(detailMessage);
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/model/Slide.java b/android/app/src/main/java/io/v/syncslides/model/Slide.java
new file mode 100644
index 0000000..7f6da9d
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/model/Slide.java
@@ -0,0 +1,37 @@
+// 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.syncslides.model;
+
+import android.graphics.Bitmap;
+
+/**
+ * A slide.
+ */
+public interface Slide {
+    /**
+     * Returns a Bitmap of the slide thumbnail.
+     */
+    Bitmap getThumb();
+
+    /**
+     * Returns the raw thumbnail data.
+     */
+    byte[] getThumbData();
+
+    /**
+     * Returns a Bitmap of the slide image.
+     */
+    Bitmap getImage();
+
+    /**
+     * Returns the raw image data.
+     */
+    byte[] getImageData();
+
+    /**
+     * Returns the slide notes.
+     */
+    String getNotes();
+}
diff --git a/android/app/src/main/java/io/v/syncslides/model/SlideImpl.java b/android/app/src/main/java/io/v/syncslides/model/SlideImpl.java
new file mode 100644
index 0000000..af8d338
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/model/SlideImpl.java
@@ -0,0 +1,45 @@
+// 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.syncslides.model;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+/**
+ * Slide implementation that decodes byte arrays into Bitmaps only when
+ * a getter is called, to conserve memory.
+ */
+public class SlideImpl implements Slide {
+    private final byte[] mThumbnail;
+    private final byte[] mImage;
+    private String mNotes;
+
+    public SlideImpl(byte[] thumbnail, byte[] image, String notes) {
+        mThumbnail = thumbnail;
+        mImage = image;
+        mNotes = notes;
+    }
+
+    @Override
+    public Bitmap getThumb() {
+        return BitmapFactory.decodeByteArray(mThumbnail, 0 /* offset */, mThumbnail.length);
+    }
+    @Override
+    public byte[] getThumbData() {
+        return mThumbnail;
+    }
+    @Override
+    public Bitmap getImage() {
+        return BitmapFactory.decodeByteArray(mImage, 0 /* offset */, mImage.length);
+    }
+    @Override
+    public byte[] getImageData() {
+        return mImage;
+    }
+    @Override
+    public String getNotes() {
+        return mNotes;
+    }
+}