syncslides: Editing notes.

Port the functionality for editing notes.  The old code handled this
differently: it would update the in-memory state in addition to
saving the notes to the DB.  This change switches to a unidirectional
flow of data, so it updates the DB and then watches for changes to
the notes.

Change-Id: Ie9757fd53b20f89e687378d5aa41b912ff0725e0
diff --git a/android/app/src/main/java/io/v/syncslides/NavigateFragment.java b/android/app/src/main/java/io/v/syncslides/NavigateFragment.java
index 7a2cb34..105df9f 100644
--- a/android/app/src/main/java/io/v/syncslides/NavigateFragment.java
+++ b/android/app/src/main/java/io/v/syncslides/NavigateFragment.java
@@ -4,6 +4,7 @@
 
 package io.v.syncslides;
 
+import android.content.Context;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
 import android.os.Bundle;
@@ -15,6 +16,8 @@
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
 import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.Toast;
@@ -43,6 +46,7 @@
     private ImageView mNextThumb;
     private ImageView mCurrentSlide;
     private TextView mSlideNumText;
+    private EditText mNotes;
     private boolean mEditing;
     private DynamicList<Slide> mSlides;
 
@@ -142,26 +146,34 @@
 //        });
 //
         mSlideNumText = (TextView) rootView.findViewById(R.id.slide_num_text);
-//        mNotes = (EditText) rootView.findViewById(R.id.notes);
-//        mNotes.setOnFocusChangeListener(new View.OnFocusChangeListener() {
-//            @Override
-//            public void onFocusChange(View v, boolean hasFocus) {
-//                ((PresentationActivity) getActivity()).getSupportActionBar().show();
-//                mEditing = hasFocus;
-//                getActivity().invalidateOptionsMenu();
-//                unsync();
-//            }
-//        });
-//
-//        // The parent of mNotes needs to be focusable in order to clear focus
-//        // from mNotes when done editing.  We set the attributes in code rather
-//        // than in XML because it is too easy to add an extra level of layout
-//        // in XML and forget to add these attributes.
-//        ViewGroup parent = (ViewGroup) mNotes.getParent();
-//        parent.setFocusable(true);
-//        parent.setClickable(true);
-//        parent.setFocusableInTouchMode(true);
-//
+        mNotes = (EditText) rootView.findViewById(R.id.notes);
+        mNotes.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+            @Override
+            public void onFocusChange(View v, boolean hasFocus) {
+                if (hasFocus) {
+                    ((PresentationActivity) getActivity()).getSupportActionBar().show();
+                    mEditing = true;
+                    getActivity().invalidateOptionsMenu();
+                    // We don't want the presentation to advance while the user
+                    // is editing the notes.  Force the app to stay on this slide.
+                    try {
+                        mSession.setLocalSlideNum(mSlideNum);
+                    } catch (VException e) {
+                        handleFatalError("Could not set local slide num", e);
+                    }
+                }
+            }
+        });
+
+        // The parent of mNotes needs to be focusable in order to clear focus
+        // from mNotes when done editing.  We set the attributes in code rather
+        // than in XML because it is too easy to add an extra level of layout
+        // in XML and forget to add these attributes.
+        ViewGroup parent = (ViewGroup) mNotes.getParent();
+        parent.setFocusable(true);
+        parent.setClickable(true);
+        parent.setFocusableInTouchMode(true);
+
 //        View slideListIcon = rootView.findViewById(R.id.slide_list);
 //        slideListIcon.setOnClickListener(new NavigateClickListener() {
 //            @Override
@@ -294,12 +306,11 @@
         } else {
             setThumbBitmap(mNextThumb, mSlides.get(mSlideNum + 1).getThumb());
         }
-        // TODO(kash): Implement me.
-//        if (!mSlides.get(mSlideNum).getNotes().equals("")) {
-//            mNotes.setText(mSlides.get(mSlideNum).getNotes());
-//        } else {
-//            mNotes.getText().clear();
-//        }
+        if (!mSlides.get(mSlideNum).getNotes().equals("")) {
+            mNotes.setText(mSlides.get(mSlideNum).getNotes());
+        } else {
+            mNotes.getText().clear();
+        }
         mSlideNumText.setText(
                 String.valueOf(mSlideNum + 1) + " of " + String.valueOf(mSlides.getItemCount()));
     }
@@ -329,26 +340,28 @@
 
     /**
      * If the user is editing the text field and the text has changed, save the
-     * notes locally and in Syncbase.
+     * notes in Syncbase.  That will trigger a notification that the slide has
+     * changed and the UI will refresh.
      */
     public void saveNotes() {
-        // TODO(kash): Port this code.
-//        final String notes = mNotes.getText().toString();
-//        if (mEditing && (!notes.equals(mSlides.get(mUserSlideNum).getNotes()))) {
-//            toast("Saving notes");
-//            mSlides.get(mUserSlideNum).setNotes(notes);
-//            mDB.setSlideNotes(mDeckId, mUserSlideNum, notes);
-//        }
-//        mNotes.clearFocus();
-//        InputMethodManager inputManager =
-//                (InputMethodManager) getContext().
-//                        getSystemService(Context.INPUT_METHOD_SERVICE);
-//        if (getActivity().getCurrentFocus() != null) {
-//            inputManager.hideSoftInputFromWindow(
-//                    getActivity().getCurrentFocus().getWindowToken(),
-//                    InputMethodManager.HIDE_NOT_ALWAYS);
-//        }
-//        ((PresentationActivity) getActivity()).setUiImmersive(true);
+        final String notes = mNotes.getText().toString();
+        if (mEditing && (!notes.equals(mSlides.get(mSlideNum).getNotes()))) {
+            try {
+                mSession.setNotes(mSlideNum, notes);
+            } catch (VException e) {
+                handleError("Could not save notes", e);
+            }
+        }
+        mNotes.clearFocus();
+        mEditing = false;
+        InputMethodManager inputManager =
+                (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+        if (getActivity().getCurrentFocus() != null) {
+            inputManager.hideSoftInputFromWindow(
+                    getActivity().getCurrentFocus().getWindowToken(),
+                    InputMethodManager.HIDE_NOT_ALWAYS);
+        }
+        ((PresentationActivity) getActivity()).setUiImmersive(true);
     }
 
     private void handleError(String msg, Throwable throwable) {
diff --git a/android/app/src/main/java/io/v/syncslides/db/SlideWatcher.java b/android/app/src/main/java/io/v/syncslides/db/SlideWatcher.java
index 391b42c..a69b9cf 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SlideWatcher.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SlideWatcher.java
@@ -42,12 +42,24 @@
     }
 
     @Override
-    public void watch(VContext context, Listener<Slide> listener) {
+    public void watch(final VContext context, final Listener<Slide> listener) {
         try {
             BatchDatabase batch = sync(mDb.beginBatch(context, null));
-            ResumeMarker watchMarker = sync(batch.getResumeMarker(context));
+            final ResumeMarker watchMarker = sync(batch.getResumeMarker(context));
             fetchInitialState(context, listener, batch);
-            watchChanges(context, listener, watchMarker);
+            // Need to watch two tables, but the API allows watching only one
+            // table at a time.  Start another thread for watching the notes.
+            new Thread(new Runnable() {
+                @Override
+                public void run() {
+                    try {
+                        watchNoteChanges(context, listener, watchMarker);
+                    } catch (VException e) {
+                        listener.onError(e);
+                    }
+                }
+            }).start();
+            watchSlideChanges(context, listener, watchMarker);
         } catch (VException e) {
             listener.onError(e);
         }
@@ -77,8 +89,8 @@
         }
     }
 
-    private void watchChanges(VContext context, Listener<Slide> listener, ResumeMarker watchMarker)
-            throws VException {
+    private void watchSlideChanges(VContext context, Listener<Slide> listener,
+                                   ResumeMarker watchMarker) throws VException {
         Table notesTable = mDb.getTable(SyncbaseDB.NOTES_TABLE);
         VIterable<WatchChange> changes =
                 sync(mDb.watch(context, SyncbaseDB.DECKS_TABLE, mDeckId, watchMarker));
@@ -109,6 +121,42 @@
         }
     }
 
+    private void watchNoteChanges(VContext context, Listener<Slide> listener,
+                                  ResumeMarker watchMarker) throws VException {
+        Table decksTable = mDb.getTable(SyncbaseDB.DECKS_TABLE);
+        VIterable<WatchChange> changes =
+                sync(mDb.watch(context, SyncbaseDB.NOTES_TABLE, mDeckId, watchMarker));
+        for (WatchChange change : changes) {
+            String key = change.getRowName();
+            if (!SyncbaseDB.isSlideKey(key)) {
+                continue;
+            }
+            VSlide vSlide = fetchVSlide(context, decksTable, key);
+            if (vSlide == null) {
+                // If the VSlide was deleted, the other watcher will handle the notification.
+                continue;
+            }
+            String notes = null;
+            if (change.getChangeType().equals(ChangeType.PUT_CHANGE)) {
+                VNote vNote = null;
+                try {
+                    vNote = (VNote) VomUtil.decode(change.getVomValue(), VNote.class);
+                } catch (VException e) {
+                    Log.e(TAG, "Couldn't decode notes: " + e.toString());
+                    continue; // Just skip it.
+                }
+                notes = vNote.getText();
+            } else { // ChangeType.DELETE_CHANGE
+                notes = "";
+            }
+            Slide newSlide = new DBSlide(key, vSlide, notes);
+            listener.onPut(newSlide);
+        }
+        if (changes.error() != null) {
+            throw changes.error();
+        }
+    }
+
     /**
      * Returns true if {@code key} looks like a VDeck and not a VSlide.
      */
@@ -126,4 +174,13 @@
             return "";
         }
     }
+
+    private static VSlide fetchVSlide(VContext context, Table decksTable, String key)
+            throws VException {
+        try {
+            return (VSlide) sync(decksTable.get(context, key, VSlide.class));
+        } catch (NoExistException e) {
+            return null;
+        }
+    }
 }
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 99306fb..1580df3 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
@@ -22,6 +22,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Arrays;
+import java.util.List;
 import java.util.UUID;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executors;
@@ -65,6 +66,7 @@
     static final String CURRENT_SLIDE = "CurrentSlide";
     static final String QUESTIONS = "questions";
     private static final String SYNCGROUP_PRESENTATION_DESCRIPTION = "Live Presentation";
+    public static final String SLIDE_DIR = "slides";
 
     private boolean mInitialized = false;
     private Handler mHandler;
@@ -246,7 +248,16 @@
                 Long.class);
     }
 
-    private String slideRowKey(String deckId, int slideNum) {
-        return NamingUtil.join(deckId, "slides", String.format("%04d", slideNum));
+    static String slideRowKey(String deckId, int slideNum) {
+        return NamingUtil.join(deckId, SLIDE_DIR, String.format("%04d", slideNum));
     }
+
+    static boolean isSlideKey(String key) {
+        List<String> parts = NamingUtil.split(key);
+        if (parts.size() != 3 || !parts.get(1).equals(SLIDE_DIR)) {
+            return false;
+        }
+        return true;
+    }
+
 }
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java b/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
index 2ac8865..fbecc23 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SyncbaseSession.java
@@ -86,6 +86,14 @@
         return mSlides;
     }
 
+    @Override
+    public void setNotes(int slideNum, String notes) throws VException {
+        Table table = mDb.getTable(SyncbaseDB.NOTES_TABLE);
+        CancelableVContext context = mVContext.withTimeout(Duration.millis(5000));
+        String rowKey = SyncbaseDB.slideRowKey(mVSession.getDeckId(), slideNum);
+        sync(table.put(context, rowKey, new VNote(notes), VNote.class));
+    }
+
     /**
      * Persists the VSession to the UI_TABLE.
      *
diff --git a/android/app/src/main/java/io/v/syncslides/model/Session.java b/android/app/src/main/java/io/v/syncslides/model/Session.java
index 0c6a877..a570f1f 100644
--- a/android/app/src/main/java/io/v/syncslides/model/Session.java
+++ b/android/app/src/main/java/io/v/syncslides/model/Session.java
@@ -58,4 +58,8 @@
      */
     DynamicList<Slide> getSlides();
 
+    /**
+     * Sets the notes for the given slide.
+     */
+    void setNotes(int slideNum, String notes) throws VException;
 }