Adding granular RecyclerView notifications

Change-Id: I55d8537d58b642b87375bb8368f0b258c4c78e7c
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/CollectionAdapterBuilder.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/CollectionAdapterBuilder.java
index 9a8c972..d11f93a 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/CollectionAdapterBuilder.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/CollectionAdapterBuilder.java
@@ -55,6 +55,7 @@
     }
 
     public abstract Observable<? extends ListAccumulator<T>> buildListAccumulator();
+    public abstract Observable<? extends ListDeltaAccumulator<T>> buildListDeltaAccumulator();
 
     public RxListAdapter<T> buildListAdapter() {
         return subscribeAdapter(new RxListAdapter<>(
@@ -63,7 +64,7 @@
 
     public RxRecyclerAdapter<T, ?> buildRecyclerAdapter() {
         return subscribeAdapter(new RxRecyclerAdapter<>(
-                buildListAccumulator(), getViewAdapter(), mBase.mOnError));
+                buildListDeltaAccumulator(), getViewAdapter(), mBase.mOnError));
     }
 
     public RxListAdapter<T> bindTo(final ListView listView) {
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulator.java
new file mode 100644
index 0000000..b19a656
--- /dev/null
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulator.java
@@ -0,0 +1,286 @@
+// 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.baku.toolkit.bind;
+
+import android.support.v7.widget.RecyclerView;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+import java8.util.function.Function;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+import rx.Observable;
+
+/**
+ * This {@link ListDeltaAccumulator} derives its deltas from {@link ListAccumulator} snapshots.
+ *
+ * @param <T>
+ */
+@RequiredArgsConstructor
+public class DerivedListDeltaAccumulator<T> implements ListDeltaAccumulator<T> {
+    @RequiredArgsConstructor
+    private static class DiffContext<T> {
+        final NumericIdMapper ids;
+        final Function<? super ImmutableList<T>, ? extends ListAccumulator<T>> stepFactory;
+
+        final List<DerivedListDeltaAccumulator<T>> steps = new ArrayList<>();
+
+        /**
+         * Algorithm:
+         * <ol>
+         * <li>Notify of all removals.</li>
+         * <li>Notify of moves to reorder intersection.</li>
+         * <li>Notify of all insertions.</li>
+         * </ol>
+         * This algorithm does not trigger any item change events, preferring remove/insert instead.
+         * Also, it fails if any items are not unique.
+         */
+        DiffContext<T> diff(final ListAccumulator<T> a, final ListAccumulator<T> b) {
+            if (a == null) {
+                steps.add(new DerivedListDeltaAccumulator<>(ids, b));
+            } else {
+                final ImmutableList<T>
+                        aList = a.getListSnapshot(),
+                        bList = b.getListSnapshot();
+                final ImmutableSet<T>
+                        aItems = ImmutableSet.copyOf(aList),
+                        bItems = ImmutableSet.copyOf(bList);
+
+                final List<T> mutable = processRemovals(aList, bItems);
+
+                final List<T> to = new ArrayList<>(bList);
+                to.retainAll(aItems);
+                processMoves(mutable, to);
+
+                processInsertions(mutable, bList);
+            }
+            return this;
+        }
+
+        ListAccumulator<T> produceStepSnapshot(final List<T> working) {
+            return stepFactory.apply(ImmutableList.copyOf(working));
+        }
+
+        /**
+         * @param start the mutable ordered start state
+         * @param goal  the elements in the end state
+         * @return the result of retaining {@code goal} from {@code start}
+         */
+        List<T> processRemovals(final ImmutableList<T> start, final ImmutableSet<T> goal) {
+            final List<T> working = new ArrayList<>(start);
+            int removalCount = 0, i = 0;
+
+            for (final T item : start) {
+                if (goal.contains(item)) {
+                    processRemovals(working, i, removalCount);
+                    i++;
+                    removalCount = 0;
+                } else {
+                    removalCount++;
+                }
+            }
+            processRemovals(working, i, removalCount);
+            return working;
+        }
+
+        /**
+         * Helper to {@link #processRemovals(ImmutableList, ImmutableSet)} that performs multi-item
+         * removal, single-item removal, or no-ops based on contiguous scan results.
+         */
+        void processRemovals(final List<T> working, final int index, final int count) {
+            if (count > 0) {
+                working.subList(index, index + count).clear();
+                steps.add(new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
+                    @Override
+                    public void notifyDeltas(RecyclerView.Adapter<?> rva) {
+                        rva.notifyItemRangeRemoved(index, count);
+                    }
+                });
+            }
+        }
+
+        /**
+         * Transitions from {@code working} to {@code goal} by moving one item at a time. Moves are
+         * performed in clusters of like deltas, moving smaller clusters first in the hopes that
+         * larger clusters will fall into place as a result of the smaller moves. While this usually
+         * results in optimal plans, there are cases where it can result in terrible plan, e.g.
+         * <p>
+         * 3 4 2 1 -> 3 2 4 1 -> 2 4 3 1 -> 4 2 3 1 -> 2 3 1 4 -> 1 2 3 4
+         * <p>
+         * I believe we can prevent this pitfall by rejecting moves that would break clusters:
+         * <p>
+         * 3 4 2 1 -> 1 3 4 2 -> 1 2 3 4
+         * <p>
+         * TODO(rosswang): Do this (and add a test).
+         */
+        void processMoves(final List<T> working, final List<T> goal) {
+            final HashMap<T, Integer> goalIndices = new HashMap<>(goal.size());
+            for (int i = 0; i < goal.size(); i++) {
+                goalIndices.put(goal.get(i), i);
+            }
+
+            class MoveProcessor {
+                int start, end, clusterStart, clusterDelta,
+                        minClusterStart, minClusterEnd, minClusterDelta;
+
+                boolean hasMovingCluster() {
+                    return minClusterDelta != 0;
+                }
+
+                void startCluster(final int index, final int delta) {
+                    clusterStart = index;
+                    clusterDelta = delta;
+                }
+
+                void endCluster(final int indexAfter) {
+                    if (clusterDelta == 0) {
+                        if (!hasMovingCluster()) {
+                            start = indexAfter;
+                        } else if (indexAfter == end) {
+                            end = clusterStart;
+                        }
+                    } else if (!hasMovingCluster() ||
+                            indexAfter - clusterStart < minClusterEnd - minClusterStart) {
+                        // smallest cluster; keep it as a prime candidate for moving
+                        minClusterStart = clusterStart;
+                        minClusterEnd = indexAfter;
+                        minClusterDelta = clusterDelta;
+                    }
+                }
+
+                MoveProcessor() {
+                    this(0, working.size());
+                }
+
+                MoveProcessor(final int start, final int end) {
+                    this.start = start;
+                    this.end = end;
+                    for (int i = start; i < end; i++) {
+                        final int delta = goalIndices.get(working.get(i)) - i;
+                        if (delta != clusterDelta) {
+                            endCluster(i);
+                            startCluster(i, delta);
+                        }
+                    }
+                    endCluster(end);
+                }
+
+                DerivedListDeltaAccumulator<T> step(final int from, final int to) {
+                    working.add(to, working.remove(from));
+                    return new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
+                        @Override
+                        public void notifyDeltas(RecyclerView.Adapter<?> rva) {
+                            rva.notifyItemMoved(from, to);
+                        }
+                    };
+                }
+
+                void addMinClusterSteps() {
+                    if (minClusterDelta < 0) {
+                        for (int i = minClusterStart; i < minClusterEnd; i++) {
+                            steps.add(step(i, i + minClusterDelta));
+                        }
+                    } else {
+                        for (int i = minClusterEnd - 1; i >= minClusterStart; i--) {
+                            steps.add(step(i, i + minClusterDelta));
+                        }
+                    }
+                }
+            }
+
+            for (MoveProcessor mp = new MoveProcessor(); mp.hasMovingCluster();
+                 mp = new MoveProcessor(mp.start, mp.end)) {
+                mp.addMinClusterSteps();
+            }
+        }
+
+        /**
+         * @param working the mutable ordered (intermediate) start state
+         * @param goal    the ordered end state
+         */
+        void processInsertions(final List<T> working, final ImmutableList<T> goal) {
+            final List<T> insert = new ArrayList<>();
+
+            for (int i = 0; i < goal.size(); i++) {
+                final T item = goal.get(i);
+                final int insertAt = i - insert.size();
+                if (insertAt < working.size() && item.equals(working.get(insertAt))) {
+                    processInsertions(working, insertAt, insert);
+                    insert.clear();
+                } else {
+                    insert.add(item);
+                }
+            }
+            if (!insert.isEmpty()) {
+                // Capture these ints so that we're not holding onto instances of the working lists
+                // and opening us to errors if they're modified.
+                final int count = insert.size(), index = goal.size() - count;
+                steps.add(new DerivedListDeltaAccumulator<T>(ids, stepFactory.apply(goal)) {
+                    @Override
+                    public void notifyDeltas(RecyclerView.Adapter<?> rva) {
+                        rva.notifyItemRangeInserted(index, count);
+                    }
+                });
+            }
+        }
+
+        /**
+         * Helper to {@link #processInsertions(List, ImmutableList)} that performs multi-item
+         * insertion, single-item insertion, or no-ops based on contiguous scan results.
+         */
+        void processInsertions(final List<T> working, final int index, final List<T> insert) {
+            if (!insert.isEmpty()) {
+                working.addAll(index, insert);
+                // Capture count so that we're not holding onto an instance of insert and opening us
+                // to errors if (and when) it's modified.
+                final int count = insert.size();
+                steps.add(new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
+                    @Override
+                    public void notifyDeltas(RecyclerView.Adapter<?> rva) {
+                        rva.notifyItemRangeInserted(index, count);
+                    }
+                });
+            }
+        }
+    }
+
+    private final NumericIdMapper mIds;
+    @Delegate
+    private final ListAccumulator<T> mSnapshot;
+
+    public static <T> Observable<DerivedListDeltaAccumulator<T>> scanFrom(
+            final Observable<? extends ListAccumulator<T>> snapshots,
+            final Function<? super ImmutableList<T>, ? extends ListAccumulator<T>> stepFactory) {
+        final NumericIdMapper ids = new NumericIdMapper();
+        return Observable.concat(Observable.zip(
+                // need to wrap null as an Observable (or Iterable) to capture generic wildcard
+                // without overload ambiguity
+                snapshots.startWith(Observable.just(null)),
+                snapshots,
+                (previous, next) -> Observable.from(new DiffContext<>(ids, stepFactory)
+                        .diff(previous, next).steps)));
+    }
+
+    /**
+     * The default implementation simply calls {@link RecyclerView.Adapter#notifyDataSetChanged()}.
+     * However, it is fully expected that subclasses, such as the anonymous subclasses generated by
+     * {@link DiffContext#diff(ListAccumulator, ListAccumulator)}, override this for more specific
+     * notifications.
+     */
+    @Override
+    public void notifyDeltas(final RecyclerView.Adapter<?> rva) {
+        rva.notifyDataSetChanged();
+    }
+
+    @Override
+    public long getItemId(final int position) {
+        return mIds.assignNumericId(getRowAt(position));
+    }
+}
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListAccumulator.java
index 9ff9b85..b1ccbec 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListAccumulator.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListAccumulator.java
@@ -10,6 +10,10 @@
 import lombok.RequiredArgsConstructor;
 import rx.Observable;
 
+/**
+ * This accumulator is not a true accumulator, but rather a first-order transformation.
+ * TODO(rosswang): Rename these.
+ */
 @RequiredArgsConstructor
 public class IdListAccumulator implements ListAccumulator<String> {
     private final ImmutableList<String> mIds;
@@ -20,7 +24,7 @@
 
     public Observable<IdListAccumulator> scanFrom(
             final Observable<SingleWatchEvent<ImmutableList<String>>> watch) {
-        return watch.scan(this, (t, w) -> new IdListAccumulator(w.getValue()));
+        return watch.map(w -> new IdListAccumulator(w.getValue()));
     }
 
     @Override
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListBindingBuilder.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListBindingBuilder.java
index 988de7a..250069e 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListBindingBuilder.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/IdListBindingBuilder.java
@@ -11,6 +11,7 @@
 
 import io.v.rx.syncbase.SingleWatchEvent;
 import rx.Observable;
+import rx.schedulers.Schedulers;
 
 public class IdListBindingBuilder<A extends RangeAdapter>
         extends CollectionAdapterBuilder<IdListBindingBuilder<A>, String, A> {
@@ -29,6 +30,9 @@
         return this;
     }
 
+    /**
+     * This assumes that no IDs are null.
+     */
     public Observable<SingleWatchEvent<ImmutableList<String>>> buildIdListWatch() {
         return mBase.mRxTable.watch(mIdListRowName, new TypeToken<List<String>>() {
         }, ImmutableList.of()).map(w -> w.map(ImmutableList::copyOf));
@@ -39,4 +43,10 @@
         return new IdListAccumulator()
                 .scanFrom(buildIdListWatch());
     }
+
+    @Override
+    public Observable<? extends ListDeltaAccumulator<String>> buildListDeltaAccumulator() {
+        return DerivedListDeltaAccumulator.scanFrom(buildListAccumulator()
+                .observeOn(Schedulers.computation()), IdListAccumulator::new);
+    }
 }
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulator.java
index 2325e1b..24bd5d7 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulator.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulator.java
@@ -6,6 +6,17 @@
 
 import com.google.common.collect.ImmutableList;
 
+import java8.util.function.Function;
+import rx.Observable;
+
+/**
+ * This interface tracks list updates for use with {@link android.widget.ListView}s via
+ * {@link RxListAdapter} and {@link android.support.v7.widget.RecyclerView}s via
+ * {@link RxRecyclerAdapter}. To support granular update notifications for {@code RecyclerView},
+ * the {@link ListDeltaAccumulator} subinterface is required. A {@code ListAccumulator} can be
+ * converted to a {@code ListDeltaAccumulator} by wrapping it with
+ * {@link DerivedListDeltaAccumulator#scanFrom(Observable, Function)}.
+ */
 public interface ListAccumulator<T> {
     boolean containsRow(String rowName);
     int getCount();
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulators.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulators.java
index babf78f..95f7b03 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulators.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListAccumulators.java
@@ -13,8 +13,6 @@
 
 @UtilityClass
 public class ListAccumulators {
-
-
     public static final ListAccumulator<Object> EMPTY = new ListAccumulator<Object>(){
         @Override
         public int getCount() {
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListDeltaAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListDeltaAccumulator.java
new file mode 100644
index 0000000..2e55711
--- /dev/null
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/ListDeltaAccumulator.java
@@ -0,0 +1,16 @@
+// 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.baku.toolkit.bind;
+
+import android.support.v7.widget.RecyclerView;
+
+/**
+ * This interface adds delta notifications to {@link ListAccumulator} to allow granular updates to
+ * {@link RecyclerView}s via {@link RxRecyclerAdapter}.
+ */
+public interface ListDeltaAccumulator<T> extends ListAccumulator<T> {
+    void notifyDeltas(RecyclerView.Adapter<?> rva);
+    long getItemId(int position);
+}
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/NumericIdMapper.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/NumericIdMapper.java
new file mode 100644
index 0000000..6c062fb
--- /dev/null
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/NumericIdMapper.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.baku.toolkit.bind;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import java8.util.Maps;
+
+public class NumericIdMapper {
+    private final Map<Object, Integer> mMapping = new HashMap<>();
+
+    public int getNumericId(final Object key) {
+        return mMapping.get(key);
+    }
+
+    public int assignNumericId(final Object key) {
+        return Maps.computeIfAbsent(mMapping, key, k -> mMapping.size());
+    }
+}
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixBindingBuilder.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixBindingBuilder.java
index 57e8588..b1085f3 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixBindingBuilder.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixBindingBuilder.java
@@ -98,8 +98,14 @@
     }
 
     @Override
-    public Observable<PrefixListAccumulator<T>> buildListAccumulator() {
+    public Observable<? extends PrefixListAccumulator<T>> buildListAccumulator() {
         return new PrefixListAccumulator<>(getOrdering())
                 .scanFrom(buildPrefixWatch());
     }
+
+    @Override
+    public Observable<? extends PrefixListDeltaAccumulator<T>> buildListDeltaAccumulator() {
+        return new PrefixListDeltaAccumulator<>(getOrdering())
+                .scanFrom(buildPrefixWatch());
+    }
 }
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListAccumulator.java
index c03eece..9bd3c57 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListAccumulator.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListAccumulator.java
@@ -42,7 +42,7 @@
         mOrdering = ordering.compound(Ordering.natural().onResultOf(RxTable.Row::getRowName));
     }
 
-    public Observable<PrefixListAccumulator<T>> scanFrom(
+    public Observable<? extends PrefixListAccumulator<T>> scanFrom(
             final Observable<RangeWatchBatch<T>> watch) {
         return watch
                 .concatMap(RangeWatchBatch::collectChanges)
@@ -60,9 +60,10 @@
         }
     }
 
-    private PrefixListAccumulator<T> withUpdates(final Collection<RangeWatchEvent<T>> events) {
+    protected PrefixListAccumulator<T> withUpdates(final Collection<RangeWatchEvent<T>> events) {
         // TODO(rosswang): more efficient updates for larger batches
         // TODO(rosswang): allow option to copy on add (immutable accumulator)
+        // If we copy on add, don't forget to override the clone in PrefixListDeltaAccumulator.
         for (final RangeWatchEvent<T> e : events) {
             if (e.getChangeType() == ChangeType.DELETE_CHANGE) {
                 removeOne(e.getRow());
@@ -73,10 +74,10 @@
         return this;
     }
 
-    protected void removeOne(final RxTable.Row<T> entry) {
+    private void removeOne(final RxTable.Row<T> entry) {
         final T old = mRows.remove(entry.getRowName());
         if (old != null) {
-            mSorted.remove(findRowForEdit(entry.getRowName(), old));
+            remove(findRowForEdit(entry.getRowName(), old));
         }
     }
 
@@ -85,28 +86,38 @@
         return bs < 0 ? ~bs : bs;
     }
 
-    protected void updateOne(final RxTable.Row<T> entry) {
+    private void updateOne(final RxTable.Row<T> entry) {
         final T old = mRows.put(entry.getRowName(), entry.getValue());
         if (old == null) {
-            mSorted.add(insertionIndex(entry), entry);
+            insert(insertionIndex(entry), entry);
         } else {
             final int oldIndex = findRowForEdit(entry.getRowName(), old);
-            int newIndex = insertionIndex(entry);
-
-            if (newIndex >= oldIndex) {
-                newIndex--;
-                for (int i = oldIndex; i < newIndex; i++) {
-                    mSorted.set(i, mSorted.get(i + 1));
-                }
+            final int newIndex = insertionIndex(entry);
+            if (oldIndex == newIndex) {
+                change(newIndex, entry);
             } else {
-                for (int i = oldIndex; i > newIndex; i--) {
-                    mSorted.set(i, mSorted.get(i - 1));
-                }
+                move(oldIndex, newIndex, entry);
             }
-            mSorted.set(newIndex, entry);
         }
     }
 
+    protected void remove(final int index) {
+        mSorted.remove(index);
+    }
+
+    protected void insert(final int index, final RxTable.Row<T> entry) {
+        mSorted.add(index, entry);
+    }
+
+    protected void move(final int from, final int to, final RxTable.Row<T> entry) {
+        mSorted.remove(from);
+        mSorted.add(to, entry);
+    }
+
+    protected void change(final int index, final RxTable.Row<T> entry) {
+        mSorted.set(index, entry);
+    }
+
     @Override
     public int getCount() {
         return mRows.size();
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListDeltaAccumulator.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListDeltaAccumulator.java
new file mode 100644
index 0000000..d804799
--- /dev/null
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/PrefixListDeltaAccumulator.java
@@ -0,0 +1,75 @@
+// 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.baku.toolkit.bind;
+
+import android.support.v7.widget.RecyclerView;
+
+import com.google.common.collect.Ordering;
+
+import io.v.rx.syncbase.RangeWatchBatch;
+import io.v.rx.syncbase.RxTable;
+import java8.util.function.Consumer;
+import rx.Observable;
+
+/**
+ * This variant of {@link PrefixListAccumulator} notifies
+ * {@link android.support.v7.widget.RecyclerView.Adapter}s of granular data changes. For now, this
+ * implementation does not treat batches any differently, and derives reordering directly from
+ * one-by-one sorts. TODO(rosswang): Do we need to optimize this?
+ */
+public class PrefixListDeltaAccumulator<T> extends PrefixListAccumulator<T>
+        implements ListDeltaAccumulator<RxTable.Row<T>> {
+    private Consumer<RecyclerView.Adapter> mDeltas;
+    private final NumericIdMapper mIds = new NumericIdMapper();
+
+    public PrefixListDeltaAccumulator(final Ordering<? super RxTable.Row<T>> ordering) {
+        super(ordering);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public Observable<? extends PrefixListDeltaAccumulator<T>> scanFrom(
+            final Observable<RangeWatchBatch<T>> watch) {
+        return (Observable<? extends PrefixListDeltaAccumulator<T>>)super.scanFrom(watch);
+    }
+
+    @Override
+    protected void remove(final int index) {
+        super.remove(index);
+        mDeltas = a -> a.notifyItemRemoved(index);
+    }
+
+    @Override
+    protected void insert(final int index, final RxTable.Row<T> entry) {
+        super.insert(index, entry);
+        mDeltas = a -> a.notifyItemInserted(index);
+        mIds.assignNumericId(entry.getRowName());
+    }
+
+    @Override
+    protected void move(final int from, final int to, final RxTable.Row<T> entry) {
+        super.move(from, to, entry);
+        mDeltas = a -> a.notifyItemMoved(from, to);
+    }
+
+    @Override
+    protected void change(final int index, final RxTable.Row<T> entry) {
+        super.change(index, entry);
+        // TODO(rosswang): Can we do anything with passing the optional payload here?
+        mDeltas = a -> a.notifyItemChanged(index);
+    }
+
+    @Override
+    public void notifyDeltas(final RecyclerView.Adapter<?> rva) {
+        if (mDeltas != null) {
+            mDeltas.accept(rva);
+        }
+    }
+
+    @Override
+    public long getItemId(final int position) {
+        return mIds.getNumericId(getRowAt(position).getRowName());
+    }
+}
diff --git a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/RxRecyclerAdapter.java b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/RxRecyclerAdapter.java
index 34ee173..ea574b8 100644
--- a/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/RxRecyclerAdapter.java
+++ b/baku-toolkit/lib/src/main/java/io/v/baku/toolkit/bind/RxRecyclerAdapter.java
@@ -29,21 +29,39 @@
     }
 
     private final ViewAdapter<? super T, VH> mViewAdapter;
-    @Delegate
-    private ListAccumulator<T> mLatestState = ListAccumulators.empty();
+
+    /**
+     * The main purpose of this class is to capture the generic arg {@link T}. Otherwise, the
+     * {@link Delegate} annotation fails to capture it and cannot implement {@link ListAccumulator}
+     * with the right generic type.
+     * <p>
+     * While we're here, we might as well use it to override
+     * {@link android.support.v7.widget.RecyclerView.Adapter#getItemId(int)}.
+     */
+    private abstract class Delegated implements ListAccumulator<T> {
+        /**
+         * Overrides {@link android.support.v7.widget.RecyclerView.Adapter#getItemId(int)}.
+         */
+        public abstract long getItemId(int position);
+    }
+
+    @Delegate(types = Delegated.class)
+    private ListDeltaAccumulator<T> mLatestState =
+            new DerivedListDeltaAccumulator<>(null, ListAccumulators.empty());
+
     @Getter
     private final Subscription mSubscription;
 
-    public RxRecyclerAdapter(final Observable<? extends ListAccumulator<T>> data,
+    public RxRecyclerAdapter(final Observable<? extends ListDeltaAccumulator<T>> data,
                              final ViewAdapter<? super T, VH> viewAdapter,
                              final Action1<Throwable> onError) {
+        setHasStableIds(true);
         mViewAdapter = viewAdapter;
         mSubscription = data
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribe(d -> {
                     mLatestState = d;
-                    notifyDataSetChanged();
-                    // TODO(rosswang): Use higher-fidelity update notifications.
+                    d.notifyDeltas(this);
                 }, onError);
     }
 
@@ -68,12 +86,4 @@
     public int getItemCount() {
         return getCount();
     }
-
-    /**
-     * TODO(rosswang): If this can improve UX, allot numeric IDs to row keys.
-     */
-    @Override
-    public long getItemId(int i) {
-        return RecyclerView.NO_ID;
-    }
 }
diff --git a/baku-toolkit/lib/src/test/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulatorTest.java b/baku-toolkit/lib/src/test/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulatorTest.java
new file mode 100644
index 0000000..c3aea71
--- /dev/null
+++ b/baku-toolkit/lib/src/test/java/io/v/baku/toolkit/bind/DerivedListDeltaAccumulatorTest.java
@@ -0,0 +1,71 @@
+// 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.baku.toolkit.bind;
+
+import android.support.v7.widget.RecyclerView;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.powermock.modules.junit4.PowerMockRunner;
+
+import java.util.List;
+
+import java8.util.function.Consumer;
+import rx.Observable;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.verify;
+import static org.powermock.api.mockito.PowerMockito.mock;
+
+@RunWith(PowerMockRunner.class)
+@PrepareForTest(RecyclerView.Adapter.class)
+public class DerivedListDeltaAccumulatorTest {
+    @SafeVarargs
+    private static List<DerivedListDeltaAccumulator<String>> deriveDeltas(
+            final ImmutableList<String>... idLists) {
+        return ImmutableList.copyOf(DerivedListDeltaAccumulator.scanFrom(
+                Observable.from(idLists)
+                        .map(IdListAccumulator::new),
+                IdListAccumulator::new)
+                .toBlocking().toIterable());
+    }
+
+    private static void verifyDelta(final DerivedListDeltaAccumulator<String> delta,
+                               final ImmutableList<String> expectedStep,
+                               final Consumer<RecyclerView.Adapter<?>> expectedNotification) {
+        assertEquals(expectedStep, delta.getListSnapshot());
+        final RecyclerView.Adapter<?> rva = mock(RecyclerView.Adapter.class);
+        delta.notifyDeltas(rva);
+        expectedNotification.accept(verify(rva));
+    }
+
+    @Test
+    public void test() {
+        final List<DerivedListDeltaAccumulator<String>> deltas = deriveDeltas(
+                ImmutableList.of("a", "b", "c", "d", "e"),
+                ImmutableList.of("a", "c"),
+                ImmutableList.of("b", "l", "a", "c", "k"),
+                ImmutableList.of("k", "a", "c", "b", "l"));
+        verifyDelta(deltas.get(0), ImmutableList.of("a", "b", "c", "d", "e"),
+                RecyclerView.Adapter::notifyDataSetChanged);
+        verifyDelta(deltas.get(1), ImmutableList.of("a", "c", "d", "e"),
+                rva -> rva.notifyItemRangeRemoved(1, 1));
+        verifyDelta(deltas.get(2), ImmutableList.of("a", "c"),
+                rva -> rva.notifyItemRangeRemoved(2, 2));
+        verifyDelta(deltas.get(3), ImmutableList.of("b", "l", "a", "c"),
+                rva -> rva.notifyItemRangeInserted(0, 2));
+        verifyDelta(deltas.get(4), ImmutableList.of("b", "l", "a", "c", "k"),
+                rva -> rva.notifyItemRangeInserted(4, 1));
+        verifyDelta(deltas.get(5), ImmutableList.of("k", "b", "l", "a", "c"),
+                rva -> rva.notifyItemMoved(4, 0));
+        verifyDelta(deltas.get(6), ImmutableList.of("k", "b", "a", "c", "l"),
+                rva -> rva.notifyItemMoved(2, 4));
+        verifyDelta(deltas.get(7), ImmutableList.of("k", "a", "c", "b", "l"),
+                rva -> rva.notifyItemMoved(1, 3));
+    }
+}