Add SHIFT+Click selection support.

Bug: 22799741
Change-Id: Ia1221675db8c2813667bfde561c23304e4d0b31f
diff --git a/src/com/android/documentsui/MultiSelectManager.java b/src/com/android/documentsui/MultiSelectManager.java
index a725dfd..a962abd 100644
--- a/src/com/android/documentsui/MultiSelectManager.java
+++ b/src/com/android/documentsui/MultiSelectManager.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui;
 
+import static com.android.internal.util.Preconditions.checkArgument;
 import static com.android.internal.util.Preconditions.checkNotNull;
 import static com.android.internal.util.Preconditions.checkState;
 
@@ -26,9 +27,12 @@
 import android.util.SparseBooleanArray;
 import android.view.GestureDetector;
 import android.view.GestureDetector.OnGestureListener;
+import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.View;
 
+import com.android.internal.util.Preconditions;
+
 import com.google.common.annotations.VisibleForTesting;
 
 import java.util.ArrayList;
@@ -43,9 +47,11 @@
     private static final boolean DEBUG = false;
 
     private final Selection mSelection = new Selection();
+
     // Only created when selection is cleared.
     private Selection mIntermediateSelection;
 
+    private Ranger mRanger;
     private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
 
     private Adapter<?> mAdapter;
@@ -213,6 +219,8 @@
      * Clears the selection.
      */
     public void clearSelection() {
+        mRanger = null;
+
         if (mSelection.isEmpty()) {
             return;
         }
@@ -238,7 +246,7 @@
             return false;
         }
 
-        return onSingleTapUp(mHelper.findEventPosition(e));
+        return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState());
     }
 
     /**
@@ -246,11 +254,12 @@
      * can be mocked.
      *
      * @param position
+     * @param metaState as returned from {@link MotionEvent#getMetaState()}.
      * @return true if the event was consumed.
      * @hide
      */
     @VisibleForTesting
-    boolean onSingleTapUp(int position) {
+    boolean onSingleTapUp(int position, int metaState) {
         if (mSelection.isEmpty()) {
             return false;
         }
@@ -261,10 +270,18 @@
             return true;
         }
 
-        toggleSelection(position);
+        if (isShiftPressed(metaState) && mRanger != null) {
+            mRanger.snapSelection(position);
+        } else {
+            toggleSelection(position);
+        }
         return true;
     }
 
+    private static boolean isShiftPressed(int metaState) {
+        return (metaState & KeyEvent.META_SHIFT_ON) != 0;
+    }
+
     private void onLongPress(MotionEvent e) {
         if (DEBUG) Log.d(TAG, "Handling long press event.");
 
@@ -273,7 +290,7 @@
             if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
         }
 
-        toggleSelection(position);
+        onLongPress(position);
     }
 
     /**
@@ -292,22 +309,87 @@
         toggleSelection(position);
     }
 
-    private void toggleSelection(int position) {
+    /**
+     * Toggles the selection state at position. If an item does end up selected
+     * a new Ranger (range selection manager) at that point is created.
+     *
+     * @param position
+     * @return True if state changed.
+     */
+    private boolean toggleSelection(int position) {
         // Position may be special "no position" during certain
         // transitional phases. If so, skip handling of the event.
         if (position == RecyclerView.NO_POSITION) {
             if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
-            return;
+            return false;
         }
 
-        if (DEBUG) Log.d(TAG, "Handling long press on view: " + position);
-        boolean nextState = !mSelection.contains(position);
-        if (notifyBeforeItemStateChange(position, nextState)) {
-            boolean selected = mSelection.flip(position);
-            notifyItemStateChanged(position, selected);
-            if (DEBUG) Log.d(TAG, "Selection after long press: " + mSelection);
+        if (mSelection.contains(position)) {
+            return attemptDeselect(position);
         } else {
-            Log.i(TAG, "Selection change cancelled by listener.");
+            boolean selected = attemptSelect(position);
+            // Here we're already in selection mode. In that case
+            // When a simple click/tap (without SHIFT) creates causes
+            // an item to be selected.
+            // By recreating Ranger at this point, we allow the user to create
+            // multiple separate contiguous ranges with SHIFT+Click & Click.
+            if (selected) {
+                mRanger = new Ranger(position);
+            }
+            return selected;
+        }
+    }
+
+    /**
+     * Try to select all elements in range. Not that callbacks can cancel selection
+     * of specific items, so some or even all items may not reflect the desired
+     * state after the update is complete.
+     *
+     * @param begin inclusive
+     * @param end inclusive
+     * @param selected
+     */
+    private void updateRange(int begin, int end, boolean selected) {
+        checkState(end >= begin);
+        if (DEBUG) Log.i(TAG, String.format("Updating range begin=%d, end=%d, selected=%b.", begin, end, selected));
+        for (int i = begin; i <= end; i++) {
+            if (selected) {
+                attemptSelect(i);
+            } else {
+                attemptDeselect(i);
+            }
+        }
+    }
+
+    /**
+     * @param position
+     * @return True if the update was applied.
+     */
+    private boolean attemptSelect(int position) {
+        if (notifyBeforeItemStateChange(position, true)) {
+            mSelection.add(position);
+            notifyItemStateChanged(position, true);
+            if (DEBUG) Log.d(TAG, "Selection after select: " + mSelection);
+            return true;
+        } else {
+            if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
+            return false;
+        }
+    }
+
+    /**
+     * @param position
+     * @return True if the update was applied.
+     */
+    private boolean attemptDeselect(int position) {
+        if (notifyBeforeItemStateChange(position, false)) {
+            mSelection.remove(position);
+            notifyItemStateChanged(position, false);
+            if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
+            return true;
+        } else {
+            if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
+            return false;
         }
     }
 
@@ -336,6 +418,106 @@
     }
 
     /**
+     * Class providing support for managing range selections.
+     */
+    private final class Ranger {
+        private static final int UNDEFINED = -1;
+
+        final int mBegin;
+        int mEnd = UNDEFINED;
+
+        public Ranger(int begin) {
+            if (DEBUG) Log.d(TAG, String.format("New Ranger(%d) created.", begin));
+            mBegin = begin;
+        }
+
+        void snapSelection(int position) {
+            checkState(mRanger != null);
+            checkArgument(position != RecyclerView.NO_POSITION);
+
+            if (mEnd == UNDEFINED || mEnd == mBegin) {
+                // Reset mEnd so it can be established in establishRange.
+                mEnd = UNDEFINED;
+                establishRange(position);
+            } else {
+                reviseRange(position);
+            }
+        }
+
+        private void establishRange(int position) {
+            checkState(mRanger.mEnd == UNDEFINED);
+
+            if (position == mBegin) {
+                mEnd = position;
+            }
+
+            if (position > mBegin) {
+                updateRange(mBegin + 1, position, true);
+            } else if (position < mBegin) {
+                updateRange(position, mBegin - 1, true);
+            }
+
+            mEnd = position;
+        }
+
+        private void reviseRange(int position) {
+            checkState(mEnd != UNDEFINED);
+            checkState(mBegin != mEnd);
+
+            if (position == mEnd) {
+                if (DEBUG) Log.i(TAG, "Skipping no-op revision click on mEndRange.");
+            }
+
+            if (mEnd > mBegin) {
+                reviseAscendingRange(position);
+            } else if (mEnd < mBegin) {
+                reviseDescendingRange(position);
+            }
+            // the "else" case is covered by checkState at beginning of method.
+
+            mEnd = position;
+        }
+
+        /**
+         * Updates an existing ascending seleciton.
+         * @param position
+         */
+        private void reviseAscendingRange(int position) {
+            // Reducing or reversing the range....
+            if (position < mEnd) {
+                if (position < mBegin) {
+                    updateRange(mBegin + 1, mEnd, false);
+                    updateRange(position, mBegin -1, true);
+                } else {
+                    updateRange(position + 1, mEnd, false);
+                }
+            }
+
+            // Extending the range...
+            else if (position > mEnd) {
+                updateRange(mEnd + 1, position, true);
+            }
+        }
+
+        private void reviseDescendingRange(int position) {
+            // Reducing or reversing the range....
+            if (position > mEnd) {
+                if (position > mBegin) {
+                    updateRange(mEnd, mBegin - 1, false);
+                    updateRange(mBegin + 1, position, true);
+                } else {
+                    updateRange(mEnd, position - 1, false);
+                }
+            }
+
+            // Extending the range...
+            else if (position < mEnd) {
+                updateRange(position, mEnd - 1, true);
+            }
+        }
+    }
+
+    /**
      * Object representing the current selection. Provides read only access
      * public access, and private write access.
      */
diff --git a/tests/src/com/android/documentsui/MultiSelectManagerTest.java b/tests/src/com/android/documentsui/MultiSelectManagerTest.java
index 20fb07e..aabec9a 100644
--- a/tests/src/com/android/documentsui/MultiSelectManagerTest.java
+++ b/tests/src/com/android/documentsui/MultiSelectManagerTest.java
@@ -19,6 +19,7 @@
 import static org.junit.Assert.*;
 
 import android.support.v7.widget.RecyclerView;
+import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
@@ -78,34 +79,92 @@
 
     @Test
     public void singleTapUp_DoesNotSelectBeforeLongPress() {
-        mManager.onSingleTapUp(99);
+        mManager.onSingleTapUp(99, 0);
         assertSelection();
     }
 
     @Test
     public void singleTapUp_UnselectsSelectedItem() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(7);
+        mManager.onSingleTapUp(7, 0);
         assertSelection();
     }
 
     @Test
     public void singleTapUp_NoPositionClearsSelection() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(11);
-        mManager.onSingleTapUp(RecyclerView.NO_POSITION);
+        mManager.onSingleTapUp(11, 0);
+        mManager.onSingleTapUp(RecyclerView.NO_POSITION, 0);
         assertSelection();
     }
 
     @Test
     public void singleTapUp_ExtendsSelection() {
         mManager.onLongPress(99);
-        mManager.onSingleTapUp(7);
-        mManager.onSingleTapUp(13);
-        mManager.onSingleTapUp(129899);
+        mManager.onSingleTapUp(7, 0);
+        mManager.onSingleTapUp(13, 0);
+        mManager.onSingleTapUp(129899, 0);
         assertSelection(7, 99, 13, 129899);
     }
 
+    @Test
+    public void singleTapUp_ShiftCreatesRangeSelection() {
+        mManager.onLongPress(7);
+        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(7, 17);
+    }
+
+    @Test
+    public void singleTapUp_ShiftCreatesRangeSeletion_Backwards() {
+        mManager.onLongPress(17);
+        mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(7, 17);
+    }
+
+    @Test
+    public void singleTapUp_SecondShiftClickExtendsSelection() {
+        mManager.onLongPress(7);
+        mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
+        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(7, 17);
+    }
+
+    @Test
+    public void singleTapUp_MultipleContiguousRangesSelected() {
+        mManager.onLongPress(7);
+        mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
+        mManager.onSingleTapUp(20, 0);
+        mManager.onSingleTapUp(25, KeyEvent.META_SHIFT_ON);
+        assertRangeSelected(7, 11);
+        assertRangeSelected(20, 25);
+        assertSelectionSize(11);
+    }
+
+    @Test
+    public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick() {
+        mManager.onLongPress(7);
+        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+        mManager.onSingleTapUp(10, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(7, 10);
+    }
+
+    @Test
+    public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick_Backwards() {
+        mManager.onLongPress(17);
+        mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
+        mManager.onSingleTapUp(14, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(14, 17);
+    }
+
+
+    @Test
+    public void singleTapUp_ShiftReversesSelectionDirection() {
+        mManager.onLongPress(7);
+        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+        mManager.onSingleTapUp(0, KeyEvent.META_SHIFT_ON);
+        assertRangeSelection(0, 7);
+    }
+
     private void assertSelected(int... expected) {
         for (int i = 0; i < expected.length; i++) {
             Selection selection = mManager.getSelection();
@@ -120,9 +179,20 @@
         assertSelected(expected);
     }
 
+    private void assertRangeSelected(int begin, int end) {
+        for (int i = begin; i <= end; i++) {
+            assertSelected(i);
+        }
+    }
+
+    private void assertRangeSelection(int begin, int end) {
+        assertSelectionSize(end - begin + 1);
+        assertRangeSelected(begin, end);
+    }
+
     private void assertSelectionSize(int expected) {
         Selection selection = mManager.getSelection();
-        assertEquals(expected, selection.size());
+        assertEquals(selection.toString(), expected, selection.size());
     }
 
     private static final class EventHelper implements RecyclerViewHelper {