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;
+ }
+}