Start selection mode with single mouse click...

Double clicking an item opens it.
BandSelectManager tells MultiSelectManager where its selection begins
    so Shift+Click behavior can be complimentary.
BandSelectManager more actively manages selection...so it doesn't
    clear existing selection on mouse down.

Change-Id: Ibe65e793e84463d333a19f363dfb0d42c37480e3
diff --git a/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
index aa63837..f2bde0e 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
@@ -16,7 +16,9 @@
 
 package com.android.documentsui;
 
+import static com.android.documentsui.Events.isMouseEvent;
 import static com.android.internal.util.Preconditions.checkState;
+import static java.lang.String.format;
 
 import android.graphics.Point;
 import android.graphics.Rect;
@@ -24,6 +26,7 @@
 import android.support.v7.widget.RecyclerView;
 import android.support.v7.widget.RecyclerView.ViewHolder;
 import android.util.Log;
+import android.util.SparseBooleanArray;
 import android.view.MotionEvent;
 import android.view.View;
 
@@ -34,6 +37,8 @@
  */
 public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
 
+    private static final int NOT_SELECTED = -1;
+
     // For debugging purposes.
     private static final String TAG = "BandSelectManager";
     private static final boolean DEBUG = false;
@@ -41,10 +46,17 @@
     private final RecyclerView mRecyclerView;
     private final MultiSelectManager mSelectManager;
     private final Drawable mRegionSelectorDrawable;
+    private final SparseBooleanArray mSelectedByBand = new SparseBooleanArray();
 
     private boolean mIsBandSelectActive = false;
     private Point mOrigin;
-    private Rect mRegionBounds;
+    private Rect mBounds;
+    // Maintain the last selection made by band, so if bounds shink back, we can unselect
+    // the respective items.
+
+    // Track information
+    private int mCursorDeltaY = 0;
+    private int mFirstSelected = NOT_SELECTED;
 
     /**
      * @param recyclerView
@@ -109,11 +121,17 @@
      * @param pointerPosition
      */
     private void resizeBandSelectRectangle(Point pointerPosition) {
-        mRegionBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),
+
+        if (mBounds != null) {
+            mCursorDeltaY = pointerPosition.y - mBounds.bottom;
+        }
+
+        mBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),
                 Math.min(mOrigin.y, pointerPosition.y),
                 Math.max(mOrigin.x, pointerPosition.x),
                 Math.max(mOrigin.y, pointerPosition.y));
-        mRegionSelectorDrawable.setBounds(mRegionBounds);
+
+        mRegionSelectorDrawable.setBounds(mBounds);
     }
 
     /**
@@ -122,14 +140,53 @@
      * Final optimized implementation, with support for managing offscreen selection to come.
      */
     private void selectChildrenCoveredBySelection() {
+
+        // track top and bottom selections. Details on why this is useful below.
+        int first = NOT_SELECTED;
+        int last = NOT_SELECTED;
+
         for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
+
             View child = mRecyclerView.getChildAt(i);
             ViewHolder holder = mRecyclerView.getChildViewHolder(child);
             Rect childRect = new Rect();
             child.getHitRect(childRect);
 
-            boolean doRectsOverlap = Rect.intersects(childRect, mRegionBounds);
-            mSelectManager.setItemSelected(holder.getAdapterPosition(), doRectsOverlap);
+            boolean shouldSelect = Rect.intersects(childRect, mBounds);
+            int position = holder.getAdapterPosition();
+
+            // This also allows us to clear the selection of elements
+            // that only temporarily entered the bounds of the band.
+            if (mSelectedByBand.get(position) && !shouldSelect) {
+                mSelectManager.setItemSelected(position, false);
+                mSelectedByBand.delete(position);
+            }
+
+            // We need to keep track of the first and last items selected.
+            // We'll use this information along with cursor direction
+            // to determine the starting point of the selection.
+            // We provide this information to selection manager
+            // to enable more natural user interaction when working
+            // with Shift+Click and multiple contiguous selection ranges.
+            if (shouldSelect) {
+                if (first == NOT_SELECTED) {
+                    first = position;
+                } else {
+                    last = position;
+                }
+                mSelectManager.setItemSelected(position, true);
+                mSelectedByBand.put(position, true);
+            }
+        }
+
+        // Remember which is the last selected item, so we can
+        // share that with selection manager when band select ends.
+        // It'll use that as it's begin selection point when
+        // user SHIFT+Clicks.
+        if (mCursorDeltaY < 0 && last != NOT_SELECTED) {
+            mFirstSelected = last;
+        } else if (mCursorDeltaY > 0 && first != NOT_SELECTED) {
+            mFirstSelected = first;
         }
     }
 
@@ -139,16 +196,10 @@
     private void endBandSelect() {
         if (DEBUG) Log.d(TAG, "Ending band select.");
         mIsBandSelectActive = false;
+        mSelectedByBand.clear();
         mRecyclerView.getOverlay().remove(mRegionSelectorDrawable);
-    }
-
-    /**
-     * Determines whether the provided event was triggered by a mouse (as opposed to a finger or
-     * stylus).
-     * @param e
-     * @return
-     */
-    private static boolean isMouseEvent(MotionEvent e) {
-        return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
+        if (mFirstSelected != NOT_SELECTED) {
+            mSelectManager.setSelectionFocusBegin(mFirstSelected);
+        }
     }
 }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
index 308375e7..9468cfd 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
@@ -286,6 +286,11 @@
                     public boolean onSingleTapUp(MotionEvent e) {
                         return DirectoryFragment.this.onSingleTapUp(e);
                     }
+                    @Override
+                    public boolean onDoubleTap(MotionEvent e) {
+                        Log.d(TAG, "Handling double tap.");
+                        return DirectoryFragment.this.onDoubleTap(e);
+                    }
                 };
 
         mSelectionManager = new MultiSelectManager(mRecView, listener);
@@ -425,20 +430,37 @@
     }
 
     private boolean onSingleTapUp(MotionEvent e) {
-        int position = getEventAdapterPosition(e);
-
-        if (position != RecyclerView.NO_POSITION) {
-            final Cursor cursor = mAdapter.getItem(position);
-            checkNotNull(cursor, "Cursor cannot be null.");
-            final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
-            final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
-            if (isDocumentEnabled(docMimeType, docFlags)) {
-                final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
-                ((BaseActivity) getActivity()).onDocumentPicked(doc);
-                return true;
+        if (!Events.isMouseEvent(e)) {
+            int position = getEventAdapterPosition(e);
+            if (position != RecyclerView.NO_POSITION) {
+                return handleViewItem(position);
             }
         }
+        return false;
+    }
 
+    protected boolean onDoubleTap(MotionEvent e) {
+        if (Events.isMouseEvent(e)) {
+            Log.d(TAG, "Handling double tap from mouse.");
+            int position = getEventAdapterPosition(e);
+            if (position != RecyclerView.NO_POSITION) {
+                return handleViewItem(position);
+            }
+        }
+        return false;
+    }
+
+    private boolean handleViewItem(int position) {
+        final Cursor cursor = mAdapter.getItem(position);
+        checkNotNull(cursor, "Cursor cannot be null.");
+        final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
+        final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
+        if (isDocumentEnabled(docMimeType, docFlags)) {
+            final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
+            ((BaseActivity) getActivity()).onDocumentPicked(doc);
+            mSelectionManager.clearSelection();
+            return true;
+        }
         return false;
     }
 
diff --git a/packages/DocumentsUI/src/com/android/documentsui/Events.java b/packages/DocumentsUI/src/com/android/documentsui/Events.java
new file mode 100644
index 0000000..2e06903
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/Events.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui;
+
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+/**
+ * Utility code for dealing with MotionEvents.
+ */
+final class Events {
+
+    /**
+     * Returns true if event was triggered by a mouse.
+     */
+    static boolean isMouseEvent(MotionEvent e) {
+        return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
+    }
+
+    /**
+     * Returns true if event was triggered by a mouse.
+     */
+    static boolean isMouseType(int toolType) {
+        return toolType == MotionEvent.TOOL_TYPE_MOUSE;
+    }
+
+    /**
+     * Returns true if the shift is pressed.
+     */
+    boolean isShiftPressed(MotionEvent e) {
+        return hasShiftBit(e.getMetaState());
+    }
+
+    /**
+     * Returns true if the "SHIFT" bit is set.
+     */
+    static boolean hasShiftBit(int metaState) {
+        return (metaState & KeyEvent.META_SHIFT_ON) != 0;
+    }
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
index a962abd..91b4456 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
@@ -26,13 +26,11 @@
 import android.util.Log;
 import android.util.SparseBooleanArray;
 import android.view.GestureDetector;
+import android.view.GestureDetector.OnDoubleTapListener;
 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;
@@ -51,7 +49,7 @@
     // Only created when selection is cleared.
     private Selection mIntermediateSelection;
 
-    private Ranger mRanger;
+    private Range mRanger;
     private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
 
     private Adapter<?> mAdapter;
@@ -60,8 +58,11 @@
     /**
      * @param recyclerView
      * @param gestureDelegate Option delage gesture listener.
+     * @template A gestureDelegate that implements both {@link OnGestureListener}
+     *     and {@link OnDoubleTapListener}
      */
-    public MultiSelectManager(final RecyclerView recyclerView, OnGestureListener gestureDelegate) {
+    public <L extends OnGestureListener & OnDoubleTapListener> MultiSelectManager(
+            final RecyclerView recyclerView, L gestureDelegate) {
         this(
                 recyclerView.getAdapter(),
                 new RecyclerViewHelper() {
@@ -86,11 +87,15 @@
                     }
                 };
 
+        CompositeOnGestureListener<? extends Object> compositeListener =
+                new CompositeOnGestureListener<>(listener, gestureDelegate);
         final GestureDetector detector = new GestureDetector(
                 recyclerView.getContext(),
                 gestureDelegate == null
                         ? listener
-                        : new CompositeOnGestureListener(listener, gestureDelegate));
+                        : compositeListener);
+
+        detector.setOnDoubleTapListener(compositeListener);
 
         recyclerView.addOnItemTouchListener(
                 new RecyclerView.OnItemTouchListener() {
@@ -236,52 +241,6 @@
         }
     }
 
-    /**
-     * @param e
-     * @return true if the event was consumed.
-     */
-    private boolean onSingleTapUp(MotionEvent e) {
-        if (DEBUG) Log.d(TAG, "Handling tap event.");
-        if (mSelection.isEmpty()) {
-            return false;
-        }
-
-        return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState());
-    }
-
-    /**
-     * TODO: Roll this into {@link #onSingleTapUp(MotionEvent)} once MotionEvent
-     * 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, int metaState) {
-        if (mSelection.isEmpty()) {
-            return false;
-        }
-
-        if (position == RecyclerView.NO_POSITION) {
-            if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
-            clearSelection();
-            return true;
-        }
-
-        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.");
 
@@ -310,6 +269,50 @@
     }
 
     /**
+     * @param e
+     * @return true if the event was consumed.
+     */
+    private boolean onSingleTapUp(MotionEvent e) {
+        if (DEBUG) Log.d(TAG, "Handling tap event.");
+        return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState(), e.getToolType(0));
+    }
+
+    /**
+     * TODO: Roll this into {@link #onSingleTapUp(MotionEvent)} once MotionEvent
+     * can be mocked.
+     *
+     * @param position
+     * @param metaState as returned from {@link MotionEvent#getMetaState()}.
+     * @param toolType
+     * @return true if the event was consumed.
+     * @hide
+     */
+    @VisibleForTesting
+    boolean onSingleTapUp(int position, int metaState, int toolType) {
+        if (mSelection.isEmpty()) {
+            // if this is a mouse click on an item, start selection mode.
+            if (position != RecyclerView.NO_POSITION && Events.isMouseType(toolType)) {
+                toggleSelection(position);
+            }
+            return false;
+        }
+
+        if (position == RecyclerView.NO_POSITION) {
+            if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
+            clearSelection();
+            return false;
+        }
+
+        if (Events.hasShiftBit(metaState) && mRanger != null) {
+            mRanger.snapSelection(position);
+        } else {
+            toggleSelection(position);
+        }
+
+        return false;
+    }
+
+    /**
      * Toggles the selection state at position. If an item does end up selected
      * a new Ranger (range selection manager) at that point is created.
      *
@@ -334,13 +337,26 @@
             // 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);
+                setSelectionFocusBegin(position);
             }
             return selected;
         }
     }
 
     /**
+     * Sets the magic location at which a selection range begins. This
+     * value is consulted when determining how to extend, and modify
+     * selection ranges.
+     *
+     * @throws IllegalStateException if {@code position} is not already be selected
+     * @param position
+     */
+    void setSelectionFocusBegin(int position) {
+        checkState(mSelection.contains(position));
+        mRanger = new Range(position);
+    }
+
+    /**
      * 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.
@@ -420,18 +436,18 @@
     /**
      * Class providing support for managing range selections.
      */
-    private final class Ranger {
+    private final class Range {
         private static final int UNDEFINED = -1;
 
         final int mBegin;
         int mEnd = UNDEFINED;
 
-        public Ranger(int begin) {
+        public Range(int begin) {
             if (DEBUG) Log.d(TAG, String.format("New Ranger(%d) created.", begin));
             mBegin = begin;
         }
 
-        void snapSelection(int position) {
+        private void snapSelection(int position) {
             checkState(mRanger != null);
             checkArgument(position != RecyclerView.NO_POSITION);
 
@@ -720,12 +736,17 @@
     /**
      * A composite {@code OnGestureDetector} that allows us to delegate unhandled
      * events to an outside party (presumably DirectoryFragment).
+     * @template A gestureDelegate that implements both {@link OnGestureListener}
+     *     and {@link OnDoubleTapListener}
      */
-    private static final class CompositeOnGestureListener implements OnGestureListener {
+    private static final class
+            CompositeOnGestureListener<L extends OnGestureListener & OnDoubleTapListener>
+            implements OnGestureListener, OnDoubleTapListener {
 
-        private OnGestureListener[] mListeners;
+        private L[] mListeners;
 
-        public CompositeOnGestureListener(OnGestureListener... listeners) {
+        @SafeVarargs
+        public CompositeOnGestureListener(L... listeners) {
             mListeners = listeners;
         }
 
@@ -782,5 +803,35 @@
             }
             return false;
         }
+
+        @Override
+        public boolean onSingleTapConfirmed(MotionEvent e) {
+            for (int i = 0; i < mListeners.length; i++) {
+                if (mListeners[i].onSingleTapConfirmed(e)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public boolean onDoubleTap(MotionEvent e) {
+            for (int i = 0; i < mListeners.length; i++) {
+                if (mListeners[i].onDoubleTap(e)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public boolean onDoubleTapEvent(MotionEvent e) {
+            for (int i = 0; i < mListeners.length; i++) {
+                if (mListeners[i].onDoubleTapEvent(e)) {
+                    return true;
+                }
+            }
+            return false;
+        }
     }
 }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
index aabec9a..d9f2261 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
@@ -65,6 +65,35 @@
     }
 
     @Test
+    public void mouseClick_StartsSelectionMode() {
+        click(7);
+        assertSelection(7);
+    }
+
+    @Test
+    public void mouseClick_ShiftClickExtendsSelection() {
+        click(7);
+        shiftClick(11);
+        assertRangeSelection(7, 11);
+    }
+
+    @Test
+    public void mouseClick_NoPosition_ClearsSelection() {
+        mManager.onLongPress(7);
+        click(11);
+        click(RecyclerView.NO_POSITION);
+        assertSelection();
+    }
+
+    @Test
+    public void setSelectionFocusBegin() {
+        mManager.setItemSelected(7, true);
+        mManager.setSelectionFocusBegin(7);
+        shiftClick(11);
+        assertRangeSelection(7, 11);
+    }
+
+    @Test
     public void longPress_StartsSelectionMode() {
         mManager.onLongPress(7);
         assertSelection(7);
@@ -78,63 +107,57 @@
     }
 
     @Test
-    public void singleTapUp_DoesNotSelectBeforeLongPress() {
-        mManager.onSingleTapUp(99, 0);
-        assertSelection();
-    }
-
-    @Test
     public void singleTapUp_UnselectsSelectedItem() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(7, 0);
+        tap(7);
         assertSelection();
     }
 
     @Test
-    public void singleTapUp_NoPositionClearsSelection() {
+    public void singleTapUp_NoPosition_ClearsSelection() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(11, 0);
-        mManager.onSingleTapUp(RecyclerView.NO_POSITION, 0);
+        tap(11);
+        tap(RecyclerView.NO_POSITION);
         assertSelection();
     }
 
     @Test
     public void singleTapUp_ExtendsSelection() {
         mManager.onLongPress(99);
-        mManager.onSingleTapUp(7, 0);
-        mManager.onSingleTapUp(13, 0);
-        mManager.onSingleTapUp(129899, 0);
+        tap(7);
+        tap(13);
+        tap(129899);
         assertSelection(7, 99, 13, 129899);
     }
 
     @Test
     public void singleTapUp_ShiftCreatesRangeSelection() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+        shiftTap(17);
         assertRangeSelection(7, 17);
     }
 
     @Test
     public void singleTapUp_ShiftCreatesRangeSeletion_Backwards() {
         mManager.onLongPress(17);
-        mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
+        shiftTap(7);
         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);
+        shiftTap(11);
+        shiftTap(17);
         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);
+        shiftTap(11);
+        tap(20);
+        shiftTap(25);
         assertRangeSelected(7, 11);
         assertRangeSelected(20, 25);
         assertSelectionSize(11);
@@ -143,16 +166,16 @@
     @Test
     public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
-        mManager.onSingleTapUp(10, KeyEvent.META_SHIFT_ON);
+        shiftTap(17);
+        shiftTap(10);
         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);
+        shiftTap(7);
+        shiftTap(14);
         assertRangeSelection(14, 17);
     }
 
@@ -160,11 +183,27 @@
     @Test
     public void singleTapUp_ShiftReversesSelectionDirection() {
         mManager.onLongPress(7);
-        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
-        mManager.onSingleTapUp(0, KeyEvent.META_SHIFT_ON);
+        shiftTap(17);
+        shiftTap(0);
         assertRangeSelection(0, 7);
     }
 
+    private void tap(int position) {
+        mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
+    }
+
+    private void shiftTap(int position) {
+        mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_FINGER);
+    }
+
+    private void click(int position) {
+        mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
+    }
+
+    private void shiftClick(int position) {
+        mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_MOUSE);
+    }
+
     private void assertSelected(int... expected) {
         for (int i = 0; i < expected.length; i++) {
             Selection selection = mManager.getSelection();