Consolidate user input handling in single class.

But separate mouse and touch handling into independent (internal) handlers.
Ensure we don't do band select on right click + drag.

Bug: 29575607, 29548676
Change-Id: I247e3ba002751f2cda010125e0e7b4bdd745ac23
diff --git a/packages/DocumentsUI/src/com/android/documentsui/Events.java b/packages/DocumentsUI/src/com/android/documentsui/Events.java
index 691f95a..2d0dbe8 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/Events.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/Events.java
@@ -115,7 +115,8 @@
      * A facade over MotionEvent primarily designed to permit for unit testing
      * of related code.
      */
-    public interface InputEvent {
+    public interface InputEvent extends AutoCloseable {
+        boolean isTouchEvent();
         boolean isMouseEvent();
         boolean isPrimaryButtonPressed();
         boolean isSecondaryButtonPressed();
@@ -127,9 +128,15 @@
         /** Returns true if the action is the final release of a mouse or touch. */
         boolean isActionUp();
 
+        // Eliminate the checked Exception from Autoclosable.
+        @Override
+        public void close();
+
         Point getOrigin();
         float getX();
         float getY();
+        float getRawX();
+        float getRawY();
 
         /** Returns true if the there is an item under the finger/cursor. */
         boolean isOverItem();
@@ -138,7 +145,7 @@
         int getItemPosition();
     }
 
-    public static final class MotionInputEvent implements InputEvent, AutoCloseable {
+    public static final class MotionInputEvent implements InputEvent {
         private static final String TAG = "MotionInputEvent";
 
         private static final Pools.SimplePool<MotionInputEvent> sPool = new Pools.SimplePool<>(1);
@@ -205,6 +212,11 @@
         }
 
         @Override
+        public boolean isTouchEvent() {
+            return Events.isTouchEvent(mEvent);
+        }
+
+        @Override
         public boolean isMouseEvent() {
             return Events.isMouseEvent(mEvent);
         }
@@ -250,6 +262,16 @@
         }
 
         @Override
+        public float getRawX() {
+            return mEvent.getRawX();
+        }
+
+        @Override
+        public float getRawY() {
+            return mEvent.getRawY();
+        }
+
+        @Override
         public boolean isOverItem() {
             return getItemPosition() != RecyclerView.NO_POSITION;
         }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java
index 7320dc0..8f52036 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java
@@ -178,6 +178,11 @@
     }
 
     private boolean handleEvent(MotionInputEvent e) {
+        // Don't start, or extend bands on right click.
+        if (e.isSecondaryButtonPressed()) {
+            return false;
+        }
+
         if (!e.isMouseEvent() && isActive()) {
             // Weird things happen if we keep up band select
             // when touch events happen.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index ce4e993..3980cfc 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -63,6 +63,7 @@
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
+import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ImageView;
@@ -75,6 +76,7 @@
 import com.android.documentsui.DocumentClipper;
 import com.android.documentsui.DocumentsActivity;
 import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.Events.InputEvent;
 import com.android.documentsui.Events.MotionInputEvent;
 import com.android.documentsui.ItemDragListener;
 import com.android.documentsui.MenuManager;
@@ -92,6 +94,7 @@
 import com.android.documentsui.State.ViewMode;
 import com.android.documentsui.UrisSupplier;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
+import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.services.FileOperation;
@@ -106,6 +109,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.function.Function;
 
 import javax.annotation.Nullable;
 
@@ -136,9 +140,9 @@
     private static final int LOADER_ID = 42;
 
     private Model mModel;
-    private MultiSelectManager mSelectionManager;
+    private MultiSelectManager mSelectionMgr;
     private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener();
-    private ItemEventListener mItemEventListener;
+    private UserInputHandler mInputHandler;
     private SelectionModeListener mSelectionModeListener;
     private FocusManager mFocusManager;
 
@@ -240,7 +244,7 @@
 
     @Override
     public void onDestroyView() {
-        mSelectionManager.clearSelection();
+        mSelectionMgr.clearSelection();
 
         // Cancel any outstanding thumbnail requests
         final int count = mRecView.getChildCount();
@@ -296,46 +300,49 @@
         // TODO: instead of inserting the view into the constructor, extract listener-creation code
         // and set the listener on the view after the fact.  Then the view doesn't need to be passed
         // into the selection manager.
-        mSelectionManager = new MultiSelectManager(
+        mSelectionMgr = new MultiSelectManager(
                 mAdapter,
                 state.allowMultiple
                     ? MultiSelectManager.MODE_MULTIPLE
                     : MultiSelectManager.MODE_SINGLE);
 
-        GestureListener gestureListener = new GestureListener(
-                mSelectionManager,
-                mRecView,
+        // Make sure this is done after the RecyclerView is set up.
+        mFocusManager = new FocusManager(context, mRecView, mModel);
+
+        mInputHandler = new UserInputHandler(
+                mSelectionMgr,
+                mFocusManager,
+                new Function<MotionEvent, InputEvent>() {
+                    @Override
+                    public InputEvent apply(MotionEvent t) {
+                        return MotionInputEvent.obtain(t, mRecView);
+                    }
+                },
                 this::getTarget,
-                this::onDoubleTap,
-                this::onRightClick);
+                this::canSelect,
+                this::onRightClick,
+                this::onActivate,
+                (DocumentDetails ignored) -> {
+                    return onDeleteSelectedDocuments();
+                });
 
         mGestureDetector =
-                new ListeningGestureDetector(this.getContext(), mDragHelper, gestureListener);
+                new ListeningGestureDetector(this.getContext(), mDragHelper, mInputHandler);
 
         mRecView.addOnItemTouchListener(mGestureDetector);
         mEmptyView.setOnTouchListener(mGestureDetector);
 
         if (state.allowMultiple) {
-            mBandController = new BandController(mRecView, mAdapter, mSelectionManager);
+            mBandController = new BandController(mRecView, mAdapter, mSelectionMgr);
         }
 
         mSelectionModeListener = new SelectionModeListener();
-        mSelectionManager.addCallback(mSelectionModeListener);
+        mSelectionMgr.addCallback(mSelectionModeListener);
 
         mModel = new Model();
         mModel.addUpdateListener(mAdapter);
         mModel.addUpdateListener(mModelUpdateListener);
 
-        // Make sure this is done after the RecyclerView is set up.
-        mFocusManager = new FocusManager(context, mRecView, mModel);
-
-        mItemEventListener = new ItemEventListener(
-                mSelectionManager,
-                mFocusManager,
-                this::handleViewItem,
-                this::deleteDocuments,
-                this::canSelect);
-
         final BaseActivity activity = getBaseActivity();
         mTuner = activity.createFragmentTuner();
         mMenuManager = activity.getMenuManager();
@@ -351,7 +358,7 @@
     }
 
     public void retainState(RetainedState state) {
-        state.selection = mSelectionManager.getSelection(new Selection());
+        state.selection = mSelectionMgr.getSelection(new Selection());
     }
 
     @Override
@@ -419,49 +426,37 @@
         FileOperations.start(getContext(), operation, mFileOpCallback);
     }
 
-    protected boolean onDoubleTap(MotionInputEvent event) {
-        if (event.isMouseEvent()) {
-            String id = getModelId(event);
-            if (id != null) {
-                return handleViewItem(id);
-            }
-        }
-        return false;
-    }
-
-    protected boolean onRightClick(MotionInputEvent e) {
+    protected boolean onRightClick(InputEvent e) {
         if (e.getItemPosition() != RecyclerView.NO_POSITION) {
-            final DocumentHolder holder = getTarget(e);
-            String modelId = getModelId(holder.itemView);
-            if (!mSelectionManager.getSelection().contains(modelId)) {
-                mSelectionManager.clearSelection();
-                // Set selection on the one single item
-                List<String> ids = Collections.singletonList(modelId);
-                mSelectionManager.setItemsSelected(ids, true);
+            final DocumentHolder doc = getTarget(e);
+            if (!mSelectionMgr.getSelection().contains(doc.modelId)) {
+                mSelectionMgr.replaceSelection(Collections.singleton(doc.modelId));
             }
 
             // We are registering for context menu here so long-press doesn't trigger this
             // floating context menu, and then quickly unregister right afterwards
-            registerForContextMenu(holder.itemView);
-            mRecView.showContextMenuForChild(holder.itemView,
-                    e.getX() - holder.itemView.getLeft(), e.getY() - holder.itemView.getTop());
-            unregisterForContextMenu(holder.itemView);
+            registerForContextMenu(doc.itemView);
+            mRecView.showContextMenuForChild(doc.itemView,
+                    e.getX() - doc.itemView.getLeft(), e.getY() - doc.itemView.getTop());
+            unregisterForContextMenu(doc.itemView);
+            return true;
         }
+
         // If there was no corresponding item pos, that means user right-clicked on the blank
         // pane
         // We would want to show different options then, and not select any item
         // The blank pane could be the recyclerView or the emptyView, so we need to register
         // according to whichever one is visible
-        else if (mEmptyView.getVisibility() == View.VISIBLE) {
+        if (mEmptyView.getVisibility() == View.VISIBLE) {
             registerForContextMenu(mEmptyView);
             mEmptyView.showContextMenu(e.getX(), e.getY());
             unregisterForContextMenu(mEmptyView);
             return true;
-        } else {
-            registerForContextMenu(mRecView);
-            mRecView.showContextMenu(e.getX(), e.getY());
-            unregisterForContextMenu(mRecView);
         }
+
+        registerForContextMenu(mRecView);
+        mRecView.showContextMenu(e.getX(), e.getY());
+        unregisterForContextMenu(mRecView);
         return true;
     }
 
@@ -478,7 +473,7 @@
         if (mTuner.isDocumentEnabled(docMimeType, docFlags)) {
             final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
             getBaseActivity().onDocumentPicked(doc, mModel);
-            mSelectionManager.clearSelection();
+            mSelectionMgr.clearSelection();
             return true;
         }
         return false;
@@ -643,7 +638,7 @@
 
         @Override
         public void onSelectionChanged() {
-            mSelectionManager.getSelection(mSelected);
+            mSelectionMgr.getSelection(mSelected);
             if (mSelected.size() > 0) {
                 if (DEBUG) Log.d(TAG, "Maybe starting action mode.");
                 if (mActionMode == null) {
@@ -673,7 +668,7 @@
             if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
             mActionMode = null;
             // clear selection
-            mSelectionManager.clearSelection();
+            mSelectionMgr.clearSelection();
             mSelected.clear();
 
             mDirectoryCount = 0;
@@ -704,7 +699,7 @@
                 mRecView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
             }
 
-            int size = mSelectionManager.getSelection().size();
+            int size = mSelectionMgr.getSelection().size();
             mode.getMenuInflater().inflate(R.menu.mode_directory, menu);
             mode.setTitle(TextUtils.formatSelectedCount(size));
 
@@ -752,7 +747,7 @@
 
         @Override
         public boolean canRename() {
-            return mNoRenameCount == 0 && mSelectionManager.getSelection().size() == 1;
+            return mNoRenameCount == 0 && mSelectionMgr.getSelection().size() == 1;
         }
 
         private void updateActionMenu() {
@@ -768,7 +763,7 @@
     }
 
     private boolean handleMenuItemClick(MenuItem item) {
-        Selection selection = mSelectionManager.getSelection(new Selection());
+        Selection selection = mSelectionMgr.getSelection(new Selection());
 
         switch (item.getItemId()) {
             case R.id.menu_open:
@@ -835,9 +830,9 @@
     }
 
     public final boolean onBackPressed() {
-        if (mSelectionManager.hasSelection()) {
+        if (mSelectionMgr.hasSelection()) {
             if (DEBUG) Log.d(TAG, "Clearing selection on selection manager.");
-            mSelectionManager.clearSelection();
+            mSelectionMgr.clearSelection();
             return true;
         }
         return false;
@@ -949,6 +944,29 @@
         return message;
     }
 
+    private boolean onDeleteSelectedDocuments() {
+        if (mSelectionMgr.hasSelection()) {
+            deleteDocuments(mSelectionMgr.getSelection(new Selection()));
+        }
+        return false;
+    }
+
+    private boolean onActivate(DocumentDetails doc) {
+        // Toggle selection if we're in selection mode, othewise, view item.
+        if (mSelectionMgr.hasSelection()) {
+            mSelectionMgr.toggleSelection(doc.getModelId());
+        } else {
+            handleViewItem(doc.getModelId());
+        }
+        return true;
+    }
+
+//    private boolean onSelect(DocumentDetails doc) {
+//        mSelectionMgr.toggleSelection(doc.getModelId());
+//        mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
+//        return true;
+//    }
+
     private void deleteDocuments(final Selection selected) {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_DELETE);
 
@@ -1100,7 +1118,7 @@
 
     @Override
     public void initDocumentHolder(DocumentHolder holder) {
-        holder.addEventListener(mItemEventListener);
+        holder.addKeyEventListener(mInputHandler);
         holder.itemView.setOnFocusChangeListener(mFocusManager);
     }
 
@@ -1186,11 +1204,11 @@
     public void copySelectedToClipboard() {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD);
 
-        Selection selection = mSelectionManager.getSelection(new Selection());
+        Selection selection = mSelectionMgr.getSelection(new Selection());
         if (selection.isEmpty()) {
             return;
         }
-        mSelectionManager.clearSelection();
+        mSelectionMgr.clearSelection();
 
         mClipper.clipDocumentsForCopy(mModel::getItemUri, selection);
 
@@ -1200,11 +1218,11 @@
     public void cutSelectedToClipboard() {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_CUT_CLIPBOARD);
 
-        Selection selection = mSelectionManager.getSelection(new Selection());
+        Selection selection = mSelectionMgr.getSelection(new Selection());
         if (selection.isEmpty()) {
             return;
         }
-        mSelectionManager.clearSelection();
+        mSelectionMgr.clearSelection();
 
         mClipper.clipDocumentsForCut(mModel::getItemUri, selection, getDisplayState().stack.peek());
 
@@ -1239,7 +1257,7 @@
         }
 
         // Only select things currently visible in the adapter.
-        boolean changed = mSelectionManager.setItemsSelected(enabled, true);
+        boolean changed = mSelectionMgr.setItemsSelected(enabled, true);
         if (changed) {
             updateDisplayState();
         }
@@ -1277,7 +1295,7 @@
 
     void dragStopped(boolean result) {
         if (result) {
-            mSelectionManager.clearSelection();
+            mSelectionMgr.clearSelection();
         }
     }
 
@@ -1363,19 +1381,7 @@
         }
     }
 
-    /**
-     * Gets the model ID for a given motion event (using the event position)
-     */
-    private String getModelId(MotionInputEvent e) {
-        RecyclerView.ViewHolder vh = getTarget(e);
-        if (vh instanceof DocumentHolder) {
-            return ((DocumentHolder) vh).modelId;
-        } else {
-            return null;
-        }
-    }
-
-    private @Nullable DocumentHolder getTarget(MotionInputEvent e) {
+    private @Nullable DocumentHolder getTarget(InputEvent e) {
         View childView = mRecView.findChildViewUnder(e.getX(), e.getY());
         if (childView != null) {
             return (DocumentHolder) mRecView.getChildViewHolder(childView);
@@ -1423,7 +1429,7 @@
 
     @Override
     public boolean isSelected(String modelId) {
-        return mSelectionManager.getSelection().contains(modelId);
+        return mSelectionMgr.getSelection().contains(modelId);
     }
 
     private final class ModelUpdateListener implements Model.UpdateListener {
@@ -1480,7 +1486,7 @@
 
     private DocumentInfo getSingleSelectedDocument(Selection selection) {
         assert (selection.size() == 1);
-        final List<DocumentInfo> docs = mModel.getDocuments(mSelectionManager.getSelection());
+        final List<DocumentInfo> docs = mModel.getDocuments(mSelectionMgr.getSelection());
         assert (docs.size() == 1);
         return docs.get(0);
     }
@@ -1489,7 +1495,7 @@
             new DragStartHelper.OnDragStartListener() {
                 @Override
                 public boolean onDragStart(View v, DragStartHelper helper) {
-                    Selection selection = mSelectionManager.getSelection();
+                    Selection selection = mSelectionMgr.getSelection();
 
                     if (v == null) {
                         Log.d(TAG, "Ignoring drag event, null view");
@@ -1532,6 +1538,10 @@
         }
     };
 
+    private boolean canSelect(DocumentDetails doc) {
+        return canSelect(doc.getModelId());
+    }
+
     private boolean canSelect(String modelId) {
 
         // TODO: Combine this method with onBeforeItemStateChange, as both of them are almost
@@ -1662,7 +1672,7 @@
         updateLayout(state.derivedMode);
 
         if (mRestoredSelection != null) {
-            mSelectionManager.restoreSelection(mRestoredSelection);
+            mSelectionMgr.restoreSelection(mRestoredSelection);
             // Note, we'll take care of cleaning up retained selection
             // in the selection handler where we already have some
             // specialized code to handle when selection was restored.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DocumentHolder.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DocumentHolder.java
index 2288fe74..c2b0bf2 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DocumentHolder.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DocumentHolder.java
@@ -24,28 +24,31 @@
 import android.support.v7.widget.RecyclerView;
 import android.view.KeyEvent;
 import android.view.LayoutInflater;
-import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
 
-import com.android.documentsui.Events;
+import com.android.documentsui.Events.InputEvent;
 import com.android.documentsui.R;
 import com.android.documentsui.State;
+import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
 
 public abstract class DocumentHolder
         extends RecyclerView.ViewHolder
-        implements View.OnKeyListener {
+        implements View.OnKeyListener,
+        DocumentDetails {
 
     static final float DISABLED_ALPHA = 0.3f;
 
+    @Deprecated  // Public access is deprecated, use #getModelId.
     public @Nullable String modelId;
 
     final Context mContext;
     final @ColorInt int mDefaultBgColor;
     final @ColorInt int mSelectedBgColor;
 
-    DocumentHolder.EventListener mEventListener;
-    private View.OnKeyListener mKeyListener;
+    // See #addKeyEventListener for details on the need for this field.
+    KeyboardEventListener mKeyEventListener;
+
     private View mSelectionHotspot;
 
 
@@ -74,6 +77,11 @@
      */
     public abstract void bind(Cursor cursor, String modelId, State state);
 
+    @Override
+    public String getModelId() {
+        return modelId;
+    }
+
     /**
      * Makes the associated item view appear selected. Note that this merely affects the appearance
      * of the view, it doesn't actually select the item.
@@ -107,54 +115,36 @@
 
     @Override
     public boolean onKey(View v, int keyCode, KeyEvent event) {
-        // Event listener should always be set.
-        assert(mEventListener != null);
-
-        return mEventListener.onKey(this,  keyCode,  event);
+        assert(mKeyEventListener != null);
+        return mKeyEventListener.onKey(this,  keyCode,  event);
     }
 
-    public void addEventListener(DocumentHolder.EventListener listener) {
-        // Just handle one for now; switch to a list if necessary.
-        assert(mEventListener == null);
-        mEventListener = listener;
+    /**
+     * Installs a delegate to receive keyboard input events. This arrangement is necessitated
+     * by the fact that a single listener cannot listen to all keyboard events
+     * on RecyclerView (our parent view). Not sure why this is, but have been
+     * assured it is the case.
+     *
+     * <p>Ideally we'd not involve DocumentHolder in propagation of events like this.
+     */
+    public void addKeyEventListener(KeyboardEventListener listener) {
+        assert(mKeyEventListener == null);
+        mKeyEventListener = listener;
     }
 
-    public void addOnKeyListener(View.OnKeyListener listener) {
-        // Just handle one for now; switch to a list if necessary.
-        assert(mKeyListener == null);
-        mKeyListener = listener;
+    @Override
+    public boolean isInSelectionHotspot(InputEvent event) {
+        // Do everything in global coordinates - it makes things simpler.
+        int[] coords = new int[2];
+        mSelectionHotspot.getLocationOnScreen(coords);
+        Rect rect = new Rect(coords[0], coords[1], coords[0] + mSelectionHotspot.getWidth(),
+                coords[1] + mSelectionHotspot.getHeight());
+
+        // If the tap occurred within the icon rect, consider it a selection.
+        return rect.contains((int) event.getRawX(), (int) event.getRawY());
     }
 
-    public boolean onSingleTapUp(MotionEvent event) {
-        if (Events.isMouseEvent(event)) {
-            // Mouse clicks select.
-            // TODO:  && input.isPrimaryButtonPressed(), but it is returning false.
-            if (mEventListener != null) {
-                return mEventListener.onSelect(this);
-            }
-        } else if (Events.isTouchEvent(event)) {
-            // Touch events select if they occur in the selection hotspot, otherwise they activate.
-            if (mEventListener == null) {
-                return false;
-            }
-
-            // Do everything in global coordinates - it makes things simpler.
-            int[] coords = new int[2];
-            mSelectionHotspot.getLocationOnScreen(coords);
-            Rect rect = new Rect(coords[0], coords[1], coords[0] + mSelectionHotspot.getWidth(),
-                    coords[1] + mSelectionHotspot.getHeight());
-
-            // If the tap occurred within the icon rect, consider it a selection.
-            if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {
-                return mEventListener.onSelect(this);
-            } else {
-                return mEventListener.onActivate(this);
-            }
-        }
-        return false;
-    }
-
-    static void setEnabledRecursive(View itemView, boolean enabled) {
+        static void setEnabledRecursive(View itemView, boolean enabled) {
         if (itemView == null) return;
         if (itemView.isEnabled() == enabled) return;
         itemView.setEnabled(enabled);
@@ -174,23 +164,9 @@
 
     /**
      * Implement this in order to be able to respond to events coming from DocumentHolders.
+     * TODO: Make this bubble up logic events rather than having imperative commands.
      */
-    interface EventListener {
-        /**
-         * Handles activation events on the document holder.
-         *
-         * @param doc The target DocumentHolder
-         * @return Whether the event was handled.
-         */
-        public boolean onActivate(DocumentHolder doc);
-
-        /**
-         * Handles selection events on the document holder.
-         *
-         * @param doc The target DocumentHolder
-         * @return Whether the event was handled.
-         */
-        public boolean onSelect(DocumentHolder doc);
+    interface KeyboardEventListener {
 
         /**
          * Handles key events on the document holder.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusHandler.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusHandler.java
new file mode 100644
index 0000000..ba26d65
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusHandler.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2016 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.dirlist;
+
+import android.view.KeyEvent;
+import android.view.View;
+
+/**
+ * A class that handles navigation and focus within the DirectoryFragment.
+ */
+interface FocusHandler extends View.OnFocusChangeListener {
+
+    /**
+     * Handles navigation (setting focus, adjusting selection if needed) arising from incoming key
+     * events.
+     *
+     * @param doc The DocumentHolder receiving the key event.
+     * @param keyCode
+     * @param event
+     * @return Whether the event was handled.
+     */
+    boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event);
+
+    @Override
+    void onFocusChange(View v, boolean hasFocus);
+
+    /**
+     * Requests focus on the item that last had focus. Scrolls to that item if necessary.
+     */
+    void restoreLastFocus();
+
+    /**
+     * @return The adapter position of the last focused item.
+     */
+    int getFocusPosition();
+
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
index f274df3..1be2f65 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
@@ -49,7 +49,7 @@
 /**
  * A class that handles navigation and focus within the DirectoryFragment.
  */
-class FocusManager implements View.OnFocusChangeListener {
+final class FocusManager implements FocusHandler {
     private static final String TAG = "FocusManager";
 
     private RecyclerView mView;
@@ -70,15 +70,7 @@
         mSearchHelper = new TitleSearchHelper(context);
     }
 
-    /**
-     * Handles navigation (setting focus, adjusting selection if needed) arising from incoming key
-     * events.
-     *
-     * @param doc The DocumentHolder receiving the key event.
-     * @param keyCode
-     * @param event
-     * @return Whether the event was handled.
-     */
+    @Override
     public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
         // Search helper gets first crack, for doing type-to-focus.
         if (mSearchHelper.handleKey(doc, keyCode, event)) {
@@ -116,9 +108,7 @@
         }
     }
 
-    /**
-     * Requests focus on the item that last had focus. Scrolls to that item if necessary.
-     */
+    @Override
     public void restoreLastFocus() {
         if (mAdapter.getItemCount() == 0) {
             // Nothing to focus.
@@ -134,9 +124,7 @@
         }
     }
 
-    /**
-     * @return The adapter position of the last focused item.
-     */
+    @Override
     public int getFocusPosition() {
         return mLastFocusPosition;
     }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GestureListener.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/GestureListener.java
deleted file mode 100644
index 1af26d0..0000000
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GestureListener.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Copyright (C) 2016 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.dirlist;
-
-import android.support.v7.widget.RecyclerView;
-import android.view.GestureDetector;
-import android.view.MotionEvent;
-
-import com.android.documentsui.Events;
-import com.android.documentsui.Events.MotionInputEvent;
-
-import java.util.function.Function;
-import java.util.function.Predicate;
-
-/**
- * The gesture listener for items in the directly list, interprets gestures, and sends the
- * events to the target DocumentHolder, whence they are routed to the appropriate listener.
- */
-final class GestureListener extends GestureDetector.SimpleOnGestureListener {
-    // From the RecyclerView, we get two events sent to
-    // ListeningGestureDetector#onInterceptTouchEvent on a mouse click; we first get an
-    // ACTION_DOWN Event for clicking on the mouse, and then an ACTION_UP event from releasing
-    // the mouse click. ACTION_UP event doesn't have information regarding the button (primary
-    // vs. secondary), so we have to save that somewhere first from ACTION_DOWN, and then reuse
-    // it later. The ACTION_DOWN event doesn't get forwarded to GestureListener, so we have open
-    // up a public set method to set it.
-    private int mLastButtonState = -1;
-    private MultiSelectManager mSelectionMgr;
-    private RecyclerView mRecView;
-    private Function<MotionInputEvent, DocumentHolder> mDocFinder;
-    private Predicate<MotionInputEvent> mDoubleTapHandler;
-    private Predicate<MotionInputEvent> mRightClickHandler;
-
-    public GestureListener(
-            MultiSelectManager selectionMgr,
-            RecyclerView recView,
-            Function<MotionInputEvent, DocumentHolder> docFinder,
-            Predicate<MotionInputEvent> doubleTapHandler,
-            Predicate<MotionInputEvent> rightClickHandler) {
-        mSelectionMgr = selectionMgr;
-        mRecView = recView;
-        mDocFinder = docFinder;
-        mDoubleTapHandler = doubleTapHandler;
-        mRightClickHandler = rightClickHandler;
-    }
-
-    public void setLastButtonState(int state) {
-        mLastButtonState = state;
-    }
-
-    @Override
-    public boolean onSingleTapUp(MotionEvent e) {
-        // Single tap logic:
-        // We first see if it's a mouse event, and if it was right click by checking on
-        // @{code ListeningGestureDetector#mLastButtonState}
-        // If the selection manager is active, it gets first whack at handling tap
-        // events. Otherwise, tap events are routed to the target DocumentHolder.
-        if (Events.isMouseEvent(e) && mLastButtonState == MotionEvent.BUTTON_SECONDARY) {
-            mLastButtonState = -1;
-            return onRightClick(e);
-        }
-
-        try (MotionInputEvent event = MotionInputEvent.obtain(e, mRecView)) {
-            boolean handled = mSelectionMgr.onSingleTapUp(event);
-
-            if (handled) {
-                return handled;
-            }
-
-            // Give the DocumentHolder a crack at the event.
-            DocumentHolder holder = mDocFinder.apply(event);
-            if (holder != null) {
-                handled = holder.onSingleTapUp(e);
-            }
-
-            return handled;
-        }
-    }
-
-    @Override
-    public void onLongPress(MotionEvent e) {
-        // Long-press events get routed directly to the selection manager. They can be
-        // changed to route through the DocumentHolder if necessary.
-        try (MotionInputEvent event = MotionInputEvent.obtain(e, mRecView)) {
-            mSelectionMgr.onLongPress(event);
-        }
-    }
-
-    @Override
-    public boolean onDoubleTap(MotionEvent e) {
-        // Double-tap events are handled directly by the DirectoryFragment. They can be changed
-        // to route through the DocumentHolder if necessary.
-
-        try (MotionInputEvent event = MotionInputEvent.obtain(e, mRecView)) {
-            return mDoubleTapHandler.test(event);
-        }
-    }
-
-    public boolean onRightClick(MotionEvent e) {
-        try (MotionInputEvent event = MotionInputEvent.obtain(e, mRecView)) {
-            return mRightClickHandler.test(event);
-        }
-    }
-}
\ No newline at end of file
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ItemEventListener.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ItemEventListener.java
deleted file mode 100644
index cffba8d..0000000
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ItemEventListener.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright (C) 2016 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.dirlist;
-
-import android.view.KeyEvent;
-
-import com.android.documentsui.Events;
-import com.android.documentsui.dirlist.MultiSelectManager.Selection;
-
-import java.util.function.Consumer;
-import java.util.function.Predicate;
-
-/**
- * Handles click/tap/key events on individual DocumentHolders.
- */
-class ItemEventListener implements DocumentHolder.EventListener {
-    private MultiSelectManager mSelectionManager;
-    private FocusManager mFocusManager;
-
-    private Consumer<String> mViewItemCallback;
-    private Consumer<Selection> mDeleteDocumentsCallback;
-    private Predicate<String> mCanSelectPredicate;
-
-    public ItemEventListener(
-            MultiSelectManager selectionManager,
-            FocusManager focusManager,
-            Consumer<String> viewItemCallback,
-            Consumer<Selection> deleteDocumentsCallback,
-            Predicate<String> canSelectPredicate) {
-
-        mSelectionManager = selectionManager;
-        mFocusManager = focusManager;
-        mViewItemCallback = viewItemCallback;
-        mDeleteDocumentsCallback = deleteDocumentsCallback;
-        mCanSelectPredicate = canSelectPredicate;
-    }
-
-    @Override
-    public boolean onActivate(DocumentHolder doc) {
-        // Toggle selection if we're in selection mode, othewise, view item.
-        if (mSelectionManager.hasSelection()) {
-            mSelectionManager.toggleSelection(doc.modelId);
-        } else {
-            mViewItemCallback.accept(doc.modelId);
-        }
-        return true;
-    }
-
-    @Override
-    public boolean onSelect(DocumentHolder doc) {
-        mSelectionManager.toggleSelection(doc.modelId);
-        mSelectionManager.setSelectionRangeBegin(doc.getAdapterPosition());
-        return true;
-    }
-
-    @Override
-    public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
-        // Only handle key-down events. This is simpler, consistent with most other UIs, and
-        // enables the handling of repeated key events from holding down a key.
-        if (event.getAction() != KeyEvent.ACTION_DOWN) {
-            return false;
-        }
-
-        // Ignore tab key events.  Those should be handled by the top-level key handler.
-        if (keyCode == KeyEvent.KEYCODE_TAB) {
-            return false;
-        }
-
-        if (mFocusManager.handleKey(doc, keyCode, event)) {
-            // Handle range selection adjustments. Extending the selection will adjust the
-            // bounds of the in-progress range selection. Each time an unshifted navigation
-            // event is received, the range selection is restarted.
-            if (shouldExtendSelection(doc, event)) {
-                if (!mSelectionManager.isRangeSelectionActive()) {
-                    // Start a range selection if one isn't active
-                    mSelectionManager.startRangeSelection(doc.getAdapterPosition());
-                }
-                mSelectionManager.snapRangeSelection(mFocusManager.getFocusPosition());
-            } else {
-                mSelectionManager.endRangeSelection();
-            }
-            return true;
-        }
-
-        // Handle enter key events
-        switch (keyCode) {
-            case KeyEvent.KEYCODE_ENTER:
-                if (event.isShiftPressed()) {
-                    return onSelect(doc);
-                }
-                // For non-shifted enter keypresses, fall through.
-            case KeyEvent.KEYCODE_DPAD_CENTER:
-            case KeyEvent.KEYCODE_BUTTON_A:
-                return onActivate(doc);
-            case KeyEvent.KEYCODE_FORWARD_DEL:
-                // This has to be handled here instead of in a keyboard shortcut, because
-                // keyboard shortcuts all have to be modified with the 'Ctrl' key.
-                if (mSelectionManager.hasSelection()) {
-                    Selection selection = mSelectionManager.getSelection(new Selection());
-                    mDeleteDocumentsCallback.accept(selection);
-                }
-                // Always handle the key, even if there was nothing to delete. This is a
-                // precaution to prevent other handlers from potentially picking up the event
-                // and triggering extra behaviours.
-                return true;
-        }
-
-        return false;
-    }
-
-    private boolean shouldExtendSelection(DocumentHolder doc, KeyEvent event) {
-        if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) {
-            return false;
-        }
-
-        return mCanSelectPredicate.test(doc.modelId);
-    }
-}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListeningGestureDetector.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListeningGestureDetector.java
index 50e595d..85ff6ed 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListeningGestureDetector.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListeningGestureDetector.java
@@ -34,20 +34,21 @@
         implements OnItemTouchListener, OnTouchListener {
 
     private DragStartHelper mDragHelper;
-    private GestureListener mGestureListener;
+    private UserInputHandler mInputHandler;
 
     public ListeningGestureDetector(
-            Context context, DragStartHelper dragHelper, GestureListener listener) {
-        super(context, listener);
+            Context context, DragStartHelper dragHelper, UserInputHandler handler) {
+        super(context, handler);
         mDragHelper = dragHelper;
-        mGestureListener = listener;
-        setOnDoubleTapListener(listener);
+        mInputHandler = handler;
+        setOnDoubleTapListener(handler);
     }
 
     @Override
     public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
+        // TODO: If possible, move this into UserInputHandler.
         if (e.getAction() == MotionEvent.ACTION_DOWN && Events.isMouseEvent(e)) {
-            mGestureListener.setLastButtonState(e.getButtonState());
+            mInputHandler.setLastButtonState(e.getButtonState());
         }
 
         // Detect drag events. When a drag is detected, intercept the rest of the gesture.
@@ -78,7 +79,7 @@
     @Override
     public boolean onTouch(View v, MotionEvent event) {
         if (event.getButtonState() == MotionEvent.BUTTON_SECONDARY) {
-            return mGestureListener.onRightClick(event);
+            return mInputHandler.onSingleRightClickUp(event);
         }
         return false;
     }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
index e0fc541..e58971a 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
@@ -158,6 +158,11 @@
         return dest;
     }
 
+    public void replaceSelection(Iterable<String> ids) {
+        clearSelection();
+        setItemsSelected(ids, true);
+    }
+
     /**
      * Returns an unordered array of selected positions, including any
      * provisional selection currently in effect.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/UserInputHandler.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/UserInputHandler.java
new file mode 100644
index 0000000..943815c
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/UserInputHandler.java
@@ -0,0 +1,337 @@
+/*
+ * Copyright (C) 2016 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.dirlist;
+
+import android.view.GestureDetector;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import com.android.documentsui.Events;
+import com.android.documentsui.Events.InputEvent;
+import com.android.documentsui.dirlist.DocumentHolder.KeyboardEventListener;
+
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * Grand unified-ish gesture/event listener for items in the directory list.
+ */
+final class UserInputHandler extends GestureDetector.SimpleOnGestureListener
+        implements KeyboardEventListener {
+
+    private final MultiSelectManager mSelectionMgr;
+    private final FocusHandler mFocusHandler;
+    private final Function<MotionEvent, InputEvent> mEventConverter;
+    private final Function<InputEvent, DocumentDetails> mDocFinder;
+    private final Predicate<DocumentDetails> mSelectable;
+    private final EventHandler mRightClickHandler;
+    private final DocumentHandler mActivateHandler;
+    private final DocumentHandler mDeleteHandler;
+    private final TouchInputDelegate mTouchDelegate;
+    private final MouseInputDelegate mMouseDelegate;
+
+    public UserInputHandler(
+            MultiSelectManager selectionMgr,
+            FocusHandler focusHandler,
+            Function<MotionEvent, InputEvent> eventConverter,
+            Function<InputEvent, DocumentDetails> docFinder,
+            Predicate<DocumentDetails> selectable,
+            EventHandler rightClickHandler,
+            DocumentHandler activateHandler,
+            DocumentHandler deleteHandler) {
+
+        mSelectionMgr = selectionMgr;
+        mFocusHandler = focusHandler;
+        mEventConverter = eventConverter;
+        mDocFinder = docFinder;
+        mSelectable = selectable;
+        mRightClickHandler = rightClickHandler;
+        mActivateHandler = activateHandler;
+        mDeleteHandler = deleteHandler;
+
+        mTouchDelegate = new TouchInputDelegate();
+        mMouseDelegate = new MouseInputDelegate();
+    }
+
+    @Override
+    public boolean onSingleTapUp(MotionEvent e) {
+        try (InputEvent event = mEventConverter.apply(e)) {
+            return event.isMouseEvent()
+                    ? mMouseDelegate.onSingleTapUp(event)
+                    : mTouchDelegate.onSingleTapUp(event);
+        }
+    }
+
+    @Override
+    public boolean onSingleTapConfirmed(MotionEvent e) {
+        try (InputEvent event = mEventConverter.apply(e)) {
+            return event.isMouseEvent()
+                    ? mMouseDelegate.onSingleTapConfirmed(event)
+                    : mTouchDelegate.onSingleTapConfirmed(event);
+        }
+    }
+
+    @Override
+    public boolean onDoubleTap(MotionEvent e) {
+        try (InputEvent event = mEventConverter.apply(e)) {
+            return event.isMouseEvent()
+                    ? mMouseDelegate.onDoubleTap(event)
+                    : mTouchDelegate.onDoubleTap(event);
+        }
+    }
+
+    @Override
+    public void onLongPress(MotionEvent e) {
+        try (InputEvent event = mEventConverter.apply(e)) {
+            if (event.isMouseEvent()) {
+                mMouseDelegate.onLongPress(event);
+            }
+            mTouchDelegate.onLongPress(event);
+        }
+    }
+
+    private boolean onSelect(DocumentDetails doc) {
+        mSelectionMgr.toggleSelection(doc.getModelId());
+        mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
+        return true;
+    }
+
+    private final class TouchInputDelegate {
+
+        public boolean onSingleTapUp(InputEvent event) {
+            if (mSelectionMgr.onSingleTapUp(event)) {
+                return true;
+            }
+
+            // Give the DocumentHolder a crack at the event.
+            DocumentDetails doc = mDocFinder.apply(event);
+            if (doc != null) {
+                // Touch events select if they occur in the selection hotspot,
+                // otherwise they activate.
+                return doc.isInSelectionHotspot(event)
+                        ? onSelect(doc)
+                        : mActivateHandler.accept(doc);
+            }
+
+            return false;
+        }
+
+        public boolean onSingleTapConfirmed(InputEvent event) {
+            return false;
+        }
+
+        public boolean onDoubleTap(InputEvent event) {
+            return false;
+        }
+
+        public void onLongPress(InputEvent event) {
+            mSelectionMgr.onLongPress(event);
+        }
+    }
+
+    private final class MouseInputDelegate {
+
+        // From the RecyclerView, we get two events sent to
+        // ListeningGestureDetector#onInterceptTouchEvent on a mouse click; we first get an
+        // ACTION_DOWN Event for clicking on the mouse, and then an ACTION_UP event from releasing
+        // the mouse click. ACTION_UP event doesn't have information regarding the button (primary
+        // vs. secondary), so we have to save that somewhere first from ACTION_DOWN, and then reuse
+        // it later. The ACTION_DOWN event doesn't get forwarded to UserInputListener,
+        // so we have open up a public set method to set it.
+        private int mLastButtonState = -1;
+
+        // true when the previous event has consumed a right click motion event
+        private boolean ateRightClick;
+
+        // The event has been handled in onSingleTapUp
+        private boolean handledTapUp;
+
+        public boolean onSingleTapUp(InputEvent event) {
+            if (eatRightClick()) {
+                return onSingleRightClickUp(event);
+            }
+
+            if (mSelectionMgr.onSingleTapUp(event)) {
+                handledTapUp = true;
+                return true;
+            }
+
+            // We'll toggle selection in onSingleTapConfirmed
+            // This avoids flickering on/off action mode when an item is double clicked.
+            if (!mSelectionMgr.hasSelection()) {
+                return false;
+            }
+
+            DocumentDetails doc = mDocFinder.apply(event);
+            if (doc == null) {
+                return false;
+            }
+
+            handledTapUp = true;
+            return onSelect(doc);
+        }
+
+        public boolean onSingleTapConfirmed(InputEvent event) {
+            if (ateRightClick) {
+                ateRightClick = false;
+                return false;
+            }
+            if (handledTapUp) {
+                handledTapUp = false;
+                return false;
+            }
+
+            if (mSelectionMgr.hasSelection()) {
+                return false;  // should have been handled by onSingleTapUp.
+            }
+
+            DocumentDetails doc = mDocFinder.apply(event);
+            if (doc == null) {
+                return false;
+            }
+
+            return onSelect(doc);
+        }
+
+        public boolean onDoubleTap(InputEvent event) {
+            handledTapUp = false;
+            DocumentDetails doc = mDocFinder.apply(event);
+            if (doc != null) {
+                return mSelectionMgr.hasSelection()
+                        ? onSelect(doc)
+                        : mActivateHandler.accept(doc);
+            }
+            return false;
+        }
+
+        public void onLongPress(InputEvent event) {
+            mSelectionMgr.onLongPress(event);
+        }
+
+        private boolean onSingleRightClickUp(InputEvent event) {
+            return mRightClickHandler.apply(event);
+        }
+
+        // hack alert from here through end of class.
+        private void setLastButtonState(int state) {
+            mLastButtonState = state;
+        }
+
+        private boolean eatRightClick() {
+            if (mLastButtonState == MotionEvent.BUTTON_SECONDARY) {
+                mLastButtonState = -1;
+                ateRightClick = true;
+                return true;
+            }
+            return false;
+        }
+    }
+
+    public boolean onSingleRightClickUp(MotionEvent e) {
+        try (InputEvent event = mEventConverter.apply(e)) {
+            return mMouseDelegate.onSingleRightClickUp(event);
+        }
+    }
+
+    // TODO: Isolate this hack...see if we can't get this solved at the platform level.
+    public void setLastButtonState(int state) {
+        mMouseDelegate.setLastButtonState(state);
+    }
+
+    // TODO: Refactor FocusManager to depend only on DocumentDetails so we can eliminate
+    // difficult to test dependency on DocumentHolder.
+    @Override
+    public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
+        // Only handle key-down events. This is simpler, consistent with most other UIs, and
+        // enables the handling of repeated key events from holding down a key.
+        if (event.getAction() != KeyEvent.ACTION_DOWN) {
+            return false;
+        }
+
+        // Ignore tab key events.  Those should be handled by the top-level key handler.
+        if (keyCode == KeyEvent.KEYCODE_TAB) {
+            return false;
+        }
+
+        if (mFocusHandler.handleKey(doc, keyCode, event)) {
+            // Handle range selection adjustments. Extending the selection will adjust the
+            // bounds of the in-progress range selection. Each time an unshifted navigation
+            // event is received, the range selection is restarted.
+            if (shouldExtendSelection(doc, event)) {
+                if (!mSelectionMgr.isRangeSelectionActive()) {
+                    // Start a range selection if one isn't active
+                    mSelectionMgr.startRangeSelection(doc.getAdapterPosition());
+                }
+                mSelectionMgr.snapRangeSelection(mFocusHandler.getFocusPosition());
+            } else {
+                mSelectionMgr.endRangeSelection();
+            }
+            return true;
+        }
+
+        // Handle enter key events
+        switch (keyCode) {
+            case KeyEvent.KEYCODE_ENTER:
+                if (event.isShiftPressed()) {
+                    onSelect(doc);
+                }
+                // For non-shifted enter keypresses, fall through.
+            case KeyEvent.KEYCODE_DPAD_CENTER:
+            case KeyEvent.KEYCODE_BUTTON_A:
+                return mActivateHandler.accept(doc);
+            case KeyEvent.KEYCODE_FORWARD_DEL:
+                // This has to be handled here instead of in a keyboard shortcut, because
+                // keyboard shortcuts all have to be modified with the 'Ctrl' key.
+                if (mSelectionMgr.hasSelection()) {
+                    mDeleteHandler.accept(doc);
+                }
+                // Always handle the key, even if there was nothing to delete. This is a
+                // precaution to prevent other handlers from potentially picking up the event
+                // and triggering extra behaviors.
+                return true;
+        }
+
+        return false;
+    }
+
+    private boolean shouldExtendSelection(DocumentDetails doc, KeyEvent event) {
+        if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) {
+            return false;
+        }
+
+        return mSelectable.test(doc);
+    }
+
+    @FunctionalInterface
+    interface EventHandler {
+        boolean apply(InputEvent input);
+    }
+
+    @FunctionalInterface
+    interface DocumentHandler {
+        boolean accept(DocumentDetails doc);
+    }
+
+    /**
+     * Class providing limited access to document view info.
+     */
+    public interface DocumentDetails {
+        String getModelId();
+        int getAdapterPosition();
+        boolean isInSelectionHotspot(InputEvent event);
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/TestInputEvent.java b/packages/DocumentsUI/tests/src/com/android/documentsui/TestInputEvent.java
index a215488..36e7c1b 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/TestInputEvent.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/TestInputEvent.java
@@ -12,6 +12,7 @@
     public boolean actionDown;
     public boolean actionUp;
     public Point location;
+    public Point rawLocation;
     public int position = Integer.MIN_VALUE;
 
     public TestInputEvent() {}
@@ -21,6 +22,11 @@
     }
 
     @Override
+    public boolean isTouchEvent() {
+        return !mouseEvent;
+    }
+
+    @Override
     public boolean isMouseEvent() {
         return mouseEvent;
     }
@@ -66,6 +72,16 @@
     }
 
     @Override
+    public float getRawX() {
+        return rawLocation.x;
+    }
+
+    @Override
+    public float getRawY() {
+        return rawLocation.y;
+    }
+
+    @Override
     public boolean isOverItem() {
         return position != Integer.MIN_VALUE && position != RecyclerView.NO_POSITION;
     }
@@ -75,6 +91,9 @@
         return position;
     }
 
+    @Override
+    public void close() {}
+
     public static TestInputEvent tap(int position) {
         return new TestInputEvent(position);
     }
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java
index 87cd42f..949f6b7 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java
@@ -20,6 +20,7 @@
 import android.database.Cursor;
 import android.graphics.Rect;
 import android.os.SystemClock;
+import android.support.test.filters.Suppress;
 import android.test.AndroidTestCase;
 import android.test.suitebuilder.annotation.SmallTest;
 import android.view.KeyEvent;
@@ -37,6 +38,7 @@
     DocumentHolder mHolder;
     TestListener mListener;
 
+    @Override
     public void setUp() throws Exception {
         Context context = getContext();
         LayoutInflater inflater = LayoutInflater.from(context);
@@ -46,28 +48,20 @@
         };
 
         mListener = new TestListener();
-        mHolder.addEventListener(mListener);
+        mHolder.addKeyEventListener(mListener);
 
         mHolder.itemView.requestLayout();
         mHolder.itemView.invalidate();
     }
 
-    public void testClickActivates() {
-        click();
-        mListener.assertSelected();
+    @Suppress
+    public void testIsInSelectionHotspot() {
+        fail();
     }
 
-    public void testTapActivates() {
-        tap();
-        mListener.assertActivated();
-    }
-
-    public void click() {
-        mHolder.onSingleTapUp(createEvent(MotionEvent.TOOL_TYPE_MOUSE));
-    }
-
-    public void tap() {
-        mHolder.onSingleTapUp(createEvent(MotionEvent.TOOL_TYPE_FINGER));
+    @Suppress
+    public void testDelegatesKeyEvents() {
+        fail();
     }
 
     public MotionEvent createEvent(int tooltype) {
@@ -105,32 +99,7 @@
                 );
     }
 
-    private class TestListener implements DocumentHolder.EventListener {
-        private boolean mActivated = false;
-        private boolean mSelected = false;
-
-        public void assertActivated() {
-            assertTrue(mActivated);
-            assertFalse(mSelected);
-        }
-
-        public void assertSelected() {
-            assertTrue(mSelected);
-            assertFalse(mActivated);
-        }
-
-        @Override
-        public boolean onActivate(DocumentHolder doc) {
-            mActivated = true;
-            return true;
-        }
-
-        @Override
-        public boolean onSelect(DocumentHolder doc) {
-            mSelected = true;
-            return true;
-        }
-
+    private class TestListener implements DocumentHolder.KeyboardEventListener {
         @Override
         public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
             return false;
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
index 7864e98..7eb3c2e 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
@@ -26,7 +26,6 @@
 
 import com.google.common.collect.Lists;
 
-import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -34,13 +33,7 @@
 @SmallTest
 public class MultiSelectManagerTest extends AndroidTestCase {
 
-    private static final List<String> items;
-    static {
-        items = new ArrayList<String>();
-        for (int i = 0; i < 100; ++i) {
-            items.add(Integer.toString(i));
-        }
-    }
+    private static final List<String> items = TestData.create(100);
 
     private MultiSelectManager mManager;
     private TestCallback mCallback;
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestData.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestData.java
new file mode 100644
index 0000000..5c1d987
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestData.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2016 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.dirlist;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TestData {
+    public static List<String> create(int num) {
+        List<String> items = new ArrayList<String>(num);
+        for (int i = 0; i < num; ++i) {
+            items.add(Integer.toString(i));
+        }
+        return items;
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/UserInputHandler_MouseTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/UserInputHandler_MouseTest.java
new file mode 100644
index 0000000..d808fe8
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/UserInputHandler_MouseTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2016 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.dirlist;
+
+import static org.junit.Assert.*;
+
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+
+import com.android.documentsui.Events.InputEvent;
+import com.android.documentsui.TestInputEvent;
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
+import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
+import com.android.documentsui.testing.TestPredicate;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public final class UserInputHandler_MouseTest {
+
+    private static final List<String> ITEMS = TestData.create(100);
+
+    private TestDocumentsAdapter mAdapter;
+    private MultiSelectManager mSelectionMgr;
+    private TestPredicate<DocumentDetails> mCanSelect;
+    private TestPredicate<InputEvent> mRightClickHandler;
+    private TestPredicate<DocumentDetails> mActivateHandler;
+    private TestPredicate<DocumentDetails> mDeleteHandler;
+
+    private TestInputEvent mTestEvent;
+    private TestDocDetails mTestDoc;
+
+    private UserInputHandler mInputHandler;
+
+    @Before
+    public void setUp() {
+
+        mAdapter = new TestDocumentsAdapter(ITEMS);
+        mSelectionMgr = new MultiSelectManager(mAdapter, MultiSelectManager.MODE_MULTIPLE);
+        mCanSelect = new TestPredicate<>();
+        mRightClickHandler = new TestPredicate<>();
+        mActivateHandler = new TestPredicate<>();
+        mDeleteHandler = new TestPredicate<>();
+
+        mInputHandler = new UserInputHandler(
+                mSelectionMgr,
+                new TestFocusHandler(),
+                (MotionEvent event) -> {
+                    return mTestEvent;
+                },
+                (InputEvent event) -> {
+                    return mTestDoc;
+                },
+                mCanSelect,
+                mRightClickHandler::test,
+                mActivateHandler::test,
+                mDeleteHandler::test);
+
+        mTestEvent = new TestInputEvent();
+        mTestEvent.mouseEvent = true;
+        mTestDoc = new TestDocDetails();
+    }
+
+    @Test
+    public void testConfirmedClick_StartsSelection() {
+        mTestDoc.modelId = "11";
+        mInputHandler.onSingleTapConfirmed(null);
+        assertSelected("11");
+    }
+
+    @Test
+    public void testDoubleClick_Activates() {
+        mTestDoc.modelId = "11";
+        mInputHandler.onDoubleTap(null);
+        mActivateHandler.assertLastArgument(mTestDoc);
+    }
+
+    void assertSelected(String id) {
+        Selection sel = mSelectionMgr.getSelection();
+        assertTrue(sel.contains(id));
+    }
+
+    private final class TestDocDetails implements DocumentDetails {
+
+        private String modelId;
+        private int position;
+        private boolean inHotspot;
+
+        @Override
+        public String getModelId() {
+            return modelId;
+        }
+
+        @Override
+        public int getAdapterPosition() {
+            return position;
+        }
+
+        @Override
+        public boolean isInSelectionHotspot(InputEvent event) {
+            return inHotspot;
+        }
+
+    }
+
+    private final class TestFocusHandler implements FocusHandler {
+
+        @Override
+        public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
+            return false;
+        }
+
+        @Override
+        public void onFocusChange(View v, boolean hasFocus) {
+        }
+
+        @Override
+        public void restoreLastFocus() {
+        }
+
+        @Override
+        public int getFocusPosition() {
+            return 0;
+        }
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestPredicate.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestPredicate.java
new file mode 100644
index 0000000..f8ee21e
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestPredicate.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2016 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.testing;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.function.Predicate;
+
+import javax.annotation.Nullable;
+
+/**
+ * Test predicate that can be used to spy control responses and make
+ * assertions against values tested.
+ */
+public class TestPredicate<T> implements Predicate<T> {
+
+    private @Nullable T lastValue;
+    private boolean nextReturnValue;
+
+    @Override
+    public boolean test(T t) {
+        lastValue = t;
+        return nextReturnValue;
+    }
+
+    public void assertLastArgument(@Nullable T expected) {
+        assertEquals(expected, lastValue);
+    }
+
+    public void nextReturn(boolean value) {
+        nextReturnValue = value;
+    }
+}