Merge "Follow up changes from ag/1190582." into nyc-andromeda-dev
diff --git a/src/com/android/documentsui/ClipStorage.java b/src/com/android/documentsui/ClipStorage.java
deleted file mode 100644
index 5102718..0000000
--- a/src/com/android/documentsui/ClipStorage.java
+++ /dev/null
@@ -1,217 +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;
-
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.support.annotation.VisibleForTesting;
-import android.util.Log;
-
-import java.io.Closeable;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.nio.channels.FileLock;
-import java.util.Scanner;
-
-/**
- * Provides support for storing lists of documents identified by Uri.
- *
- * <li>Access to this object *must* be synchronized externally.
- * <li>All calls to this class are I/O intensive and must be wrapped in an AsyncTask.
- */
-public final class ClipStorage {
-
-    private static final String TAG = "ClipStorage";
-
-    private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
-    public static final long NO_SELECTION_TAG = -1;
-
-    private final File mOutDir;
-
-    /**
-     * @param outDir see {@link #prepareStorage(File)}.
-     */
-    public ClipStorage(File outDir) {
-        assert(outDir.isDirectory());
-        mOutDir = outDir;
-    }
-
-    /**
-     * Creates a clip tag.
-     *
-     * NOTE: this tag doesn't guarantee perfect uniqueness, but should work well unless user creates
-     * clips more than hundreds of times per second.
-     */
-    public long createTag() {
-        return System.currentTimeMillis();
-    }
-
-    /**
-     * Returns a writer. Callers must close the writer when finished.
-     */
-    public Writer createWriter(long tag) throws IOException {
-        File file = toTagFile(tag);
-        return new Writer(file);
-    }
-
-    @VisibleForTesting
-    public Reader createReader(long tag) throws IOException {
-        File file = toTagFile(tag);
-        return new Reader(file);
-    }
-
-    @VisibleForTesting
-    public void delete(long tag) throws IOException {
-        toTagFile(tag).delete();
-    }
-
-    private File toTagFile(long tag) {
-        return new File(mOutDir, String.valueOf(tag));
-    }
-
-    /**
-     * Provides initialization of the clip data storage directory.
-     */
-    static File prepareStorage(File cacheDir) {
-        File clipDir = getClipDir(cacheDir);
-        clipDir.mkdir();
-
-        assert(clipDir.isDirectory());
-        return clipDir;
-    }
-
-    public static boolean hasDocList(long tag) {
-        return tag != NO_SELECTION_TAG;
-    }
-
-    private static File getClipDir(File cacheDir) {
-        return new File(cacheDir, "clippings");
-    }
-
-    static final class Reader implements Iterable<Uri>, Closeable {
-
-        private final Scanner mScanner;
-        private final FileLock mLock;
-
-        private Reader(File file) throws IOException {
-            FileInputStream inStream = new FileInputStream(file);
-
-            // Lock the file here so it won't pass this line until the corresponding writer is done
-            // writing.
-            mLock = inStream.getChannel().lock(0L, Long.MAX_VALUE, true);
-
-            mScanner = new Scanner(inStream);
-        }
-
-        @Override
-        public Iterator iterator() {
-            return new Iterator(mScanner);
-        }
-
-        @Override
-        public void close() throws IOException {
-            if (mLock != null) {
-                mLock.release();
-            }
-
-            if (mScanner != null) {
-                mScanner.close();
-            }
-        }
-    }
-
-    private static final class Iterator implements java.util.Iterator {
-        private final Scanner mScanner;
-
-        private Iterator(Scanner scanner) {
-            mScanner = scanner;
-        }
-
-        @Override
-        public boolean hasNext() {
-            return mScanner.hasNextLine();
-        }
-
-        @Override
-        public Uri next() {
-            String line = mScanner.nextLine();
-            return Uri.parse(line);
-        }
-    }
-
-    private static final class Writer implements Closeable {
-
-        private final FileOutputStream mOut;
-        private final FileLock mLock;
-
-        private Writer(File file) throws IOException {
-            mOut = new FileOutputStream(file);
-
-            // Lock the file here so copy tasks would wait until everything is flushed to disk
-            // before start to run.
-            mLock = mOut.getChannel().lock();
-        }
-
-        public void write(Uri uri) throws IOException {
-            mOut.write(uri.toString().getBytes());
-            mOut.write(LINE_SEPARATOR);
-        }
-
-        @Override
-        public void close() throws IOException {
-            if (mLock != null) {
-                mLock.release();
-            }
-
-            if (mOut != null) {
-                mOut.close();
-            }
-        }
-    }
-
-    /**
-     * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
-     */
-    static final class PersistTask extends AsyncTask<Void, Void, Void> {
-
-        private final ClipStorage mClipStorage;
-        private final Iterable<Uri> mUris;
-        private final long mTag;
-
-        PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, long tag) {
-            mClipStorage = clipStorage;
-            mUris = uris;
-            mTag = tag;
-        }
-
-        @Override
-        protected Void doInBackground(Void... params) {
-            try (ClipStorage.Writer writer = mClipStorage.createWriter(mTag)) {
-                for (Uri uri: mUris) {
-                    assert(uri != null);
-                    writer.write(uri);
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e);
-            }
-
-            return null;
-        }
-    }
-}
diff --git a/src/com/android/documentsui/DocumentsApplication.java b/src/com/android/documentsui/DocumentsApplication.java
index 3d3902d..3b2529f 100644
--- a/src/com/android/documentsui/DocumentsApplication.java
+++ b/src/com/android/documentsui/DocumentsApplication.java
@@ -28,6 +28,9 @@
 import android.os.RemoteException;
 import android.text.format.DateUtils;
 
+import com.android.documentsui.clipping.ClipStorage;
+import com.android.documentsui.clipping.DocumentClipper;
+
 public class DocumentsApplication extends Application {
     private static final long PROVIDER_ANR_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS;
 
@@ -77,7 +80,9 @@
 
         mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4);
 
-        mClipStorage = new ClipStorage(ClipStorage.prepareStorage(getCacheDir()));
+        mClipStorage = new ClipStorage(
+                ClipStorage.prepareStorage(getCacheDir()),
+                getSharedPreferences(ClipStorage.PREF_NAME, 0));
         mClipper = new DocumentClipper(this, mClipStorage);
 
         final IntentFilter packageFilter = new IntentFilter();
diff --git a/src/com/android/documentsui/Events.java b/src/com/android/documentsui/Events.java
index 691f95a..2d0dbe8 100644
--- a/src/com/android/documentsui/Events.java
+++ b/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/src/com/android/documentsui/Files.java b/src/com/android/documentsui/Files.java
index 38f98be..009fecb 100644
--- a/src/com/android/documentsui/Files.java
+++ b/src/com/android/documentsui/Files.java
@@ -26,13 +26,11 @@
     private Files() {}  // no initialization for utility classes.
 
     public static void deleteRecursively(File file) {
-        if (file.exists()) {
-            if (file.isDirectory()) {
-                for (File child : file.listFiles()) {
-                    deleteRecursively(child);
-                }
+        if (file.isDirectory()) {
+            for (File child : file.listFiles()) {
+                deleteRecursively(child);
             }
-            file.delete();
         }
+        file.delete();
     }
 }
diff --git a/src/com/android/documentsui/FilesActivity.java b/src/com/android/documentsui/FilesActivity.java
index f875aaa..41f5d24 100644
--- a/src/com/android/documentsui/FilesActivity.java
+++ b/src/com/android/documentsui/FilesActivity.java
@@ -39,6 +39,7 @@
 import com.android.documentsui.MenuManager.DirectoryDetails;
 import com.android.documentsui.OperationDialogFragment.DialogType;
 import com.android.documentsui.RecentsProvider.ResumeColumns;
+import com.android.documentsui.clipping.DocumentClipper;
 import com.android.documentsui.dirlist.AnimationView;
 import com.android.documentsui.dirlist.DirectoryFragment;
 import com.android.documentsui.dirlist.FragmentTuner;
diff --git a/src/com/android/documentsui/RootsCache.java b/src/com/android/documentsui/RootsCache.java
index 88eeb49..117bb01 100644
--- a/src/com/android/documentsui/RootsCache.java
+++ b/src/com/android/documentsui/RootsCache.java
@@ -36,6 +36,7 @@
 import android.os.SystemClock;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Root;
+import android.provider.DocumentsProvider;
 import android.support.annotation.VisibleForTesting;
 import android.util.Log;
 
@@ -350,11 +351,20 @@
      * waiting for all the other roots to come back.
      */
     public RootInfo getRootOneshot(String authority, String rootId) {
+        return getRootOneshot(authority, rootId, false);
+    }
+
+    /**
+     * Return the requested {@link RootInfo}, but only loading the roots of the requested authority.
+     * It always fetches from {@link DocumentsProvider} if forceRefresh is true, which is used to
+     * get the most up-to-date free space before starting copy operations.
+     */
+    public RootInfo getRootOneshot(String authority, String rootId, boolean forceRefresh) {
         synchronized (mLock) {
-            RootInfo root = getRootLocked(authority, rootId);
+            RootInfo root = forceRefresh ? null : getRootLocked(authority, rootId);
             if (root == null) {
-                mRoots.putAll(authority,
-                        loadRootsForAuthority(mContext.getContentResolver(), authority, false));
+                mRoots.putAll(authority, loadRootsForAuthority(
+                                mContext.getContentResolver(), authority, forceRefresh));
                 root = getRootLocked(authority, rootId);
             }
             return root;
diff --git a/src/com/android/documentsui/SaveFragment.java b/src/com/android/documentsui/SaveFragment.java
index d830c61..a37590d 100644
--- a/src/com/android/documentsui/SaveFragment.java
+++ b/src/com/android/documentsui/SaveFragment.java
@@ -22,6 +22,7 @@
 import android.content.Context;
 import android.os.Bundle;
 import android.text.Editable;
+import android.text.TextUtils;
 import android.text.TextWatcher;
 import android.view.KeyEvent;
 import android.view.LayoutInflater;
@@ -91,6 +92,14 @@
                             return false;
                         }
 
+                        // Returning false in this method will bubble the event up to
+                        // {@link BaseActivity#onKeyDown}. In order to prevent backspace popping
+                        // documents once the textView is empty, we are going to trap it here.
+                        if (keyCode == KeyEvent.KEYCODE_DEL
+                                && TextUtils.isEmpty(mDisplayName.getText())) {
+                            return true;
+                        }
+
                         if (keyCode == KeyEvent.KEYCODE_ENTER && mSave.isEnabled()) {
                             performSave();
                             return true;
diff --git a/src/com/android/documentsui/clipping/ClipStorage.java b/src/com/android/documentsui/clipping/ClipStorage.java
new file mode 100644
index 0000000..b9fc93e
--- /dev/null
+++ b/src/com/android/documentsui/clipping/ClipStorage.java
@@ -0,0 +1,260 @@
+/*
+ * 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.clipping;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.support.annotation.VisibleForTesting;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+
+import com.android.documentsui.Files;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.channels.FileLock;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Provides support for storing lists of documents identified by Uri.
+ *
+ * This class uses a ring buffer to recycle clip file slots, to mitigate the issue of clip file
+ * deletions.
+ */
+public final class ClipStorage {
+
+    public static final int NO_SELECTION_TAG = -1;
+
+    public static final String PREF_NAME = "ClipStoragePref";
+
+    @VisibleForTesting
+    static final int NUM_OF_SLOTS = 20;
+
+    private static final String TAG = "ClipStorage";
+
+    private static final long STALENESS_THRESHOLD = TimeUnit.DAYS.toMillis(2);
+
+    private static final String NEXT_POS_TAG = "NextPosTag";
+    private static final String PRIMARY_DATA_FILE_NAME = "primary";
+
+    private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
+
+    private final File mOutDir;
+    private final SharedPreferences mPref;
+
+    private final File[] mSlots = new File[NUM_OF_SLOTS];
+    private int mNextPos;
+
+    /**
+     * @param outDir see {@link #prepareStorage(File)}.
+     */
+    public ClipStorage(File outDir, SharedPreferences pref) {
+        assert(outDir.isDirectory());
+        mOutDir = outDir;
+        mPref = pref;
+
+        mNextPos = mPref.getInt(NEXT_POS_TAG, 0);
+    }
+
+    /**
+     * Tries to get the next available clip slot. It's guaranteed to return one. If none of
+     * slots is available, it returns the next slot of the most recently returned slot by this
+     * method.
+     *
+     * <p>This is not a perfect solution, but should be enough for most regular use. There are
+     * several situations this method may not work:
+     * <ul>
+     *     <li>Making {@link #NUM_OF_SLOTS} - 1 times of large drag and drop or moveTo/copyTo/delete
+     *     operations after cutting a primary clip, then the primary clip is overwritten.</li>
+     *     <li>Having more than {@link #NUM_OF_SLOTS} queued jumbo file operations, one or more clip
+     *     file may be overwritten.</li>
+     * </ul>
+     */
+    synchronized int claimStorageSlot() {
+        int curPos = mNextPos;
+        for (int i = 0; i < NUM_OF_SLOTS; ++i, curPos = (curPos + 1) % NUM_OF_SLOTS) {
+            createSlotFile(curPos);
+
+            if (!mSlots[curPos].exists()) {
+                break;
+            }
+
+            // No file or only primary file exists, we deem it available.
+            if (mSlots[curPos].list().length <= 1) {
+                break;
+            }
+            // This slot doesn't seem available, but still need to check if it's a legacy of
+            // service being killed or a service crash etc. If it's stale, it's available.
+            else if(checkStaleFiles(curPos)) {
+                break;
+            }
+        }
+
+        prepareSlot(curPos);
+
+        mNextPos = (curPos + 1) % NUM_OF_SLOTS;
+        mPref.edit().putInt(NEXT_POS_TAG, mNextPos).commit();
+        return curPos;
+    }
+
+    private boolean checkStaleFiles(int pos) {
+        File slotData = toSlotDataFile(pos);
+
+        // No need to check if the file exists. File.lastModified() returns 0L if the file doesn't
+        // exist.
+        return slotData.lastModified() + STALENESS_THRESHOLD <= System.currentTimeMillis();
+    }
+
+    private void prepareSlot(int pos) {
+        assert(mSlots[pos] != null);
+
+        Files.deleteRecursively(mSlots[pos]);
+        mSlots[pos].mkdir();
+        assert(mSlots[pos].isDirectory());
+    }
+
+    /**
+     * Returns a writer. Callers must close the writer when finished.
+     */
+    private Writer createWriter(int tag) throws IOException {
+        File file = toSlotDataFile(tag);
+        return new Writer(file);
+    }
+
+    /**
+     * Gets a {@link File} instance given a tag.
+     *
+     * This method creates a symbolic link in the slot folder to the data file as a reference
+     * counting method. When someone is done using this symlink, it's responsible to delete it.
+     * Therefore we can have a neat way to track how many things are still using this slot.
+     */
+    public File getFile(int tag) throws IOException {
+        createSlotFile(tag);
+
+        File primary = toSlotDataFile(tag);
+
+        String linkFileName = Integer.toString(mSlots[tag].list().length);
+        File link = new File(mSlots[tag], linkFileName);
+
+        try {
+            Os.symlink(primary.getAbsolutePath(), link.getAbsolutePath());
+        } catch (ErrnoException e) {
+            e.rethrowAsIOException();
+        }
+        return link;
+    }
+
+    /**
+     * Returns a Reader. Callers must close the reader when finished.
+     */
+    ClipStorageReader createReader(File file) throws IOException {
+        assert(file.getParentFile().getParentFile().equals(mOutDir));
+        return new ClipStorageReader(file);
+    }
+
+    private File toSlotDataFile(int pos) {
+        assert(mSlots[pos] != null);
+        return new File(mSlots[pos], PRIMARY_DATA_FILE_NAME);
+    }
+
+    private void createSlotFile(int pos) {
+        if (mSlots[pos] == null) {
+            mSlots[pos] = new File(mOutDir, Integer.toString(pos));
+        }
+    }
+
+    /**
+     * Provides initialization of the clip data storage directory.
+     */
+    public static File prepareStorage(File cacheDir) {
+        File clipDir = getClipDir(cacheDir);
+        clipDir.mkdir();
+
+        assert(clipDir.isDirectory());
+        return clipDir;
+    }
+
+    private static File getClipDir(File cacheDir) {
+        return new File(cacheDir, "clippings");
+    }
+
+    private static final class Writer implements Closeable {
+
+        private final FileOutputStream mOut;
+        private final FileLock mLock;
+
+        private Writer(File file) throws IOException {
+            assert(!file.exists());
+
+            mOut = new FileOutputStream(file);
+
+            // Lock the file here so copy tasks would wait until everything is flushed to disk
+            // before start to run.
+            mLock = mOut.getChannel().lock();
+        }
+
+        public void write(Uri uri) throws IOException {
+            mOut.write(uri.toString().getBytes());
+            mOut.write(LINE_SEPARATOR);
+        }
+
+        @Override
+        public void close() throws IOException {
+            if (mLock != null) {
+                mLock.release();
+            }
+
+            if (mOut != null) {
+                mOut.close();
+            }
+        }
+    }
+
+    /**
+     * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
+     */
+    static final class PersistTask extends AsyncTask<Void, Void, Void> {
+
+        private final ClipStorage mClipStorage;
+        private final Iterable<Uri> mUris;
+        private final int mTag;
+
+        PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, int tag) {
+            mClipStorage = clipStorage;
+            mUris = uris;
+            mTag = tag;
+        }
+
+        @Override
+        protected Void doInBackground(Void... params) {
+            try(Writer writer = mClipStorage.createWriter(mTag)){
+                for (Uri uri: mUris) {
+                    assert(uri != null);
+                    writer.write(uri);
+                }
+            } catch (IOException e) {
+                Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e);
+            }
+
+            return null;
+        }
+    }
+}
diff --git a/src/com/android/documentsui/clipping/ClipStorageReader.java b/src/com/android/documentsui/clipping/ClipStorageReader.java
new file mode 100644
index 0000000..2bae0f8
--- /dev/null
+++ b/src/com/android/documentsui/clipping/ClipStorageReader.java
@@ -0,0 +1,125 @@
+/*
+ * 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.clipping;
+
+import android.net.Uri;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.channels.FileLock;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+/**
+ * Reader class used to read uris from clip files stored in {@link ClipStorage}. It provides
+ * synchronization within a single process as an addition to {@link FileLock} which is for
+ * cross-process synchronization.
+ */
+class ClipStorageReader implements Iterable<Uri>, Closeable {
+
+    /**
+     * FileLock can't be held multiple times in a single JVM, but it's possible to have multiple
+     * readers reading the same clip file. Share the FileLock here so that it can be released
+     * when it's not needed.
+     */
+    private static final Map<String, FileLockEntry> sLocks = new HashMap<>();
+
+    private final String mCanonicalPath;
+    private final Scanner mScanner;
+
+    ClipStorageReader(File file) throws IOException {
+        FileInputStream inStream = new FileInputStream(file);
+        mScanner = new Scanner(inStream);
+
+        mCanonicalPath = file.getCanonicalPath(); // Resolve symlink
+        synchronized (sLocks) {
+            if (sLocks.containsKey(mCanonicalPath)) {
+                // Read lock is already held by someone in this JVM, just increment the ref
+                // count.
+                sLocks.get(mCanonicalPath).mCount++;
+            } else {
+                // No map entry, need to lock the file so it won't pass this line until the
+                // corresponding writer is done writing.
+                FileLock lock = inStream.getChannel().lock(0L, Long.MAX_VALUE, true);
+                sLocks.put(mCanonicalPath, new FileLockEntry(1, lock, mScanner));
+            }
+        }
+    }
+
+    @Override
+    public Iterator iterator() {
+        return new Iterator(mScanner);
+    }
+
+    @Override
+    public void close() throws IOException {
+        FileLockEntry ref;
+        synchronized (sLocks) {
+            ref = sLocks.get(mCanonicalPath);
+
+            assert(ref.mCount > 0);
+            if (--ref.mCount == 0) {
+                // If ref count is 0 now, then there is no one who needs to hold the read lock.
+                // Release the lock, and remove the entry.
+                ref.mLock.release();
+                ref.mScanner.close();
+                sLocks.remove(mCanonicalPath);
+            }
+        }
+
+        if (mScanner != ref.mScanner) {
+            mScanner.close();
+        }
+    }
+
+    private static final class Iterator implements java.util.Iterator {
+        private final Scanner mScanner;
+
+        private Iterator(Scanner scanner) {
+            mScanner = scanner;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return mScanner.hasNextLine();
+        }
+
+        @Override
+        public Uri next() {
+            String line = mScanner.nextLine();
+            return Uri.parse(line);
+        }
+    }
+
+    private static final class FileLockEntry {
+        private final FileLock mLock;
+        // We need to keep this scanner here because if the scanner is closed, the file lock is
+        // closed too.
+        private final Scanner mScanner;
+
+        private int mCount;
+
+        private FileLockEntry(int count, FileLock lock, Scanner scanner) {
+            mCount = count;
+            mLock = lock;
+            mScanner = scanner;
+        }
+    }
+}
diff --git a/src/com/android/documentsui/DocumentClipper.java b/src/com/android/documentsui/clipping/DocumentClipper.java
similarity index 63%
rename from src/com/android/documentsui/DocumentClipper.java
rename to src/com/android/documentsui/clipping/DocumentClipper.java
index 72413bd..0b6355a 100644
--- a/src/com/android/documentsui/DocumentClipper.java
+++ b/src/com/android/documentsui/clipping/DocumentClipper.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2015 The Android Open Source Project
+ * 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.
@@ -14,21 +14,20 @@
  * limitations under the License.
  */
 
-package com.android.documentsui;
+package com.android.documentsui.clipping;
 
 import android.content.ClipData;
 import android.content.ClipDescription;
 import android.content.ClipboardManager;
 import android.content.ContentResolver;
 import android.content.Context;
-import android.content.SharedPreferences;
 import android.net.Uri;
-import android.os.BaseBundle;
 import android.os.PersistableBundle;
 import android.provider.DocumentsContract;
 import android.support.annotation.Nullable;
 import android.util.Log;
 
+import com.android.documentsui.Shared;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
@@ -48,7 +47,7 @@
  * ClipboardManager wrapper class providing higher level logical
  * support for dealing with Documents.
  */
-public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChangedListener {
+public final class DocumentClipper {
 
     private static final String TAG = "DocumentClipper";
 
@@ -57,34 +56,14 @@
     static final String OP_JUMBO_SELECTION_SIZE = "jumboSelection-size";
     static final String OP_JUMBO_SELECTION_TAG = "jumboSelection-tag";
 
-    // Use shared preference to store last seen primary clip tag, so that we can delete the file
-    // when we realize primary clip has been changed when we're not running.
-    private static final String PREF_NAME = "DocumentClipperPref";
-    private static final String LAST_PRIMARY_CLIP_TAG = "lastPrimaryClipTag";
-
     private final Context mContext;
     private final ClipStorage mClipStorage;
     private final ClipboardManager mClipboard;
 
-    // Here we're tracking the last clipped tag ids so we can delete them later.
-    private long mLastDragClipTag = ClipStorage.NO_SELECTION_TAG;
-    private long mLastUnusedPrimaryClipTag = ClipStorage.NO_SELECTION_TAG;
-
-    private final SharedPreferences mPref;
-
-    DocumentClipper(Context context, ClipStorage storage) {
+    public DocumentClipper(Context context, ClipStorage storage) {
         mContext = context;
         mClipStorage = storage;
         mClipboard = context.getSystemService(ClipboardManager.class);
-
-        mClipboard.addPrimaryClipChangedListener(this);
-
-        // Primary clips may be changed when we're not running, now it's time to clean up the
-        // remnant.
-        mPref = context.getSharedPreferences(PREF_NAME, 0);
-        mLastUnusedPrimaryClipTag =
-                mPref.getLong(LAST_PRIMARY_CLIP_TAG, ClipStorage.NO_SELECTION_TAG);
-        deleteLastUnusedPrimaryClip();
     }
 
     public boolean hasItemsToPaste() {
@@ -112,24 +91,8 @@
     /**
      * Returns {@link ClipData} representing the selection, or null if selection is empty,
      * or cannot be converted.
-     *
-     * This is specialized for drag and drop so that we know which file to delete if nobody accepts
-     * the drop.
      */
-    public @Nullable ClipData getClipDataForDrag(
-            Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {
-        ClipData data = getClipDataForDocuments(uriBuilder, selection, opType);
-
-        mLastDragClipTag = getTag(data);
-
-        return data;
-    }
-
-    /**
-     * Returns {@link ClipData} representing the selection, or null if selection is empty,
-     * or cannot be converted.
-     */
-    private @Nullable ClipData getClipDataForDocuments(
+    public ClipData getClipDataForDocuments(
         Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {
 
         assert(selection != null);
@@ -147,7 +110,7 @@
     /**
      * Returns ClipData representing the selection.
      */
-    private @Nullable ClipData createStandardClipData(
+    private ClipData createStandardClipData(
             Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {
 
         assert(!selection.isEmpty());
@@ -178,7 +141,7 @@
     /**
      * Returns ClipData representing the list of docs
      */
-    private @Nullable ClipData createJumboClipData(
+    private ClipData createJumboClipData(
             Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {
 
         assert(!selection.isEmpty());
@@ -210,8 +173,8 @@
         bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size());
 
         // Creates a clip tag
-        long tag = mClipStorage.createTag();
-        bundle.putLong(OP_JUMBO_SELECTION_TAG, tag);
+        int tag = mClipStorage.claimStorageSlot();
+        bundle.putInt(OP_JUMBO_SELECTION_TAG, tag);
 
         ClipDescription description = new ClipDescription(
                 "", // Currently "label" is not displayed anywhere in the UI.
@@ -232,7 +195,7 @@
                 getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY);
         assert(data != null);
 
-        setPrimaryClip(data);
+        mClipboard.setPrimaryClip(data);
     }
 
     /**
@@ -250,68 +213,10 @@
         PersistableBundle bundle = data.getDescription().getExtras();
         bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString());
 
-        setPrimaryClip(data);
-    }
-
-    private void setPrimaryClip(ClipData data) {
-        deleteLastPrimaryClip();
-
-        long tag = getTag(data);
-        setLastUnusedPrimaryClipTag(tag);
-
         mClipboard.setPrimaryClip(data);
     }
 
     /**
-     * Sets this primary tag to both class variable and shared preference.
-     */
-    private void setLastUnusedPrimaryClipTag(long tag) {
-        mLastUnusedPrimaryClipTag = tag;
-        mPref.edit().putLong(LAST_PRIMARY_CLIP_TAG, tag).commit();
-    }
-
-    /**
-     * This is a good chance for us to remove previous clip file for cut/copy because we know a new
-     * primary clip is set.
-     */
-    @Override
-    public void onPrimaryClipChanged() {
-        deleteLastUnusedPrimaryClip();
-    }
-
-    private void deleteLastUnusedPrimaryClip() {
-        ClipData primary = mClipboard.getPrimaryClip();
-        long primaryTag = getTag(primary);
-
-        // onPrimaryClipChanged is also called after we call setPrimaryClip(), so make sure we don't
-        // delete the clip file we just created.
-        if (mLastUnusedPrimaryClipTag != primaryTag) {
-            deleteLastPrimaryClip();
-        }
-    }
-
-    private void deleteLastPrimaryClip() {
-        deleteClip(mLastUnusedPrimaryClipTag);
-        setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG);
-    }
-
-    /**
-     * Deletes the last seen drag clip file.
-     */
-    public void deleteDragClip() {
-        deleteClip(mLastDragClipTag);
-        mLastDragClipTag = ClipStorage.NO_SELECTION_TAG;
-    }
-
-    private void deleteClip(long tag) {
-        try {
-            mClipStorage.delete(tag);
-        } catch (IOException e) {
-            Log.w(TAG, "Error deleting clip file with tag: " + tag, e);
-        }
-    }
-
-    /**
      * Copies documents from clipboard. It's the same as {@link #copyFromClipData} with clipData
      * returned from {@link ClipboardManager#getPrimaryClip()}.
      *
@@ -324,10 +229,6 @@
             DocumentStack docStack,
             FileOperations.Callback callback) {
 
-        // The primary clip has been claimed by a file operation. It's now the operation's duty
-        // to make sure the clip file is deleted after use.
-        setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG);
-
         copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback);
     }
 
@@ -352,33 +253,38 @@
 
         PersistableBundle bundle = clipData.getDescription().getExtras();
         @OpType int opType = getOpType(bundle);
-        UrisSupplier uris = UrisSupplier.create(clipData);
+        try {
+            UrisSupplier uris = UrisSupplier.create(clipData, mContext);
+            if (!canCopy(destination)) {
+                callback.onOperationResult(
+                        FileOperations.Callback.STATUS_REJECTED, opType, 0);
+                return;
+            }
 
-        if (!canCopy(destination)) {
-            callback.onOperationResult(
-                    FileOperations.Callback.STATUS_REJECTED, opType, 0);
+            if (uris.getItemCount() == 0) {
+                callback.onOperationResult(
+                        FileOperations.Callback.STATUS_ACCEPTED, opType, 0);
+                return;
+            }
+
+            DocumentStack dstStack = new DocumentStack(docStack, destination);
+
+            String srcParentString = bundle.getString(SRC_PARENT_KEY);
+            Uri srcParent = srcParentString == null ? null : Uri.parse(srcParentString);
+
+            FileOperation operation = new FileOperation.Builder()
+                    .withOpType(opType)
+                    .withSrcParent(srcParent)
+                    .withDestination(dstStack)
+                    .withSrcs(uris)
+                    .build();
+
+            FileOperations.start(mContext, operation, callback);
+        } catch(IOException e) {
+            Log.e(TAG, "Cannot create uris supplier.", e);
+            callback.onOperationResult(FileOperations.Callback.STATUS_REJECTED, opType, 0);
             return;
         }
-
-        if (uris.getItemCount() == 0) {
-            callback.onOperationResult(
-                    FileOperations.Callback.STATUS_ACCEPTED, opType, 0);
-            return;
-        }
-
-        DocumentStack dstStack = new DocumentStack(docStack, destination);
-
-        String srcParentString = bundle.getString(SRC_PARENT_KEY);
-        Uri srcParent = srcParentString == null ? null : Uri.parse(srcParentString);
-
-        FileOperation operation = new FileOperation.Builder()
-                .withOpType(opType)
-                .withSrcParent(srcParent)
-                .withDestination(dstStack)
-                .withSrcs(uris)
-                .build();
-
-        FileOperations.start(mContext, operation, callback);
     }
 
     /**
@@ -397,28 +303,6 @@
         return true;
     }
 
-    /**
-     * Obtains tag from {@link ClipData}. Returns {@link ClipStorage#NO_SELECTION_TAG}
-     * if it's not a jumbo clip.
-     */
-    private static long getTag(@Nullable ClipData data) {
-        if (data == null) {
-            return ClipStorage.NO_SELECTION_TAG;
-        }
-
-        ClipDescription description = data.getDescription();
-        if (description == null) {
-            return ClipStorage.NO_SELECTION_TAG;
-        }
-
-        BaseBundle bundle = description.getExtras();
-        if (bundle == null) {
-            return ClipStorage.NO_SELECTION_TAG;
-        }
-
-        return bundle.getLong(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG);
-    }
-
     public static @OpType int getOpType(ClipData data) {
         PersistableBundle bundle = data.getDescription().getExtras();
         return getOpType(bundle);
diff --git a/src/com/android/documentsui/UrisSupplier.java b/src/com/android/documentsui/clipping/UrisSupplier.java
similarity index 73%
rename from src/com/android/documentsui/UrisSupplier.java
rename to src/com/android/documentsui/clipping/UrisSupplier.java
index c5d30aa..7b1ba34 100644
--- a/src/com/android/documentsui/UrisSupplier.java
+++ b/src/com/android/documentsui/clipping/UrisSupplier.java
@@ -14,10 +14,10 @@
  * limitations under the License.
  */
 
-package com.android.documentsui;
+package com.android.documentsui.clipping;
 
-import static com.android.documentsui.DocumentClipper.OP_JUMBO_SELECTION_SIZE;
-import static com.android.documentsui.DocumentClipper.OP_JUMBO_SELECTION_TAG;
+import static com.android.documentsui.clipping.DocumentClipper.OP_JUMBO_SELECTION_SIZE;
+import static com.android.documentsui.clipping.DocumentClipper.OP_JUMBO_SELECTION_TAG;
 
 import android.content.ClipData;
 import android.content.Context;
@@ -28,9 +28,12 @@
 import android.support.annotation.VisibleForTesting;
 import android.util.Log;
 
+import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.Shared;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.services.FileOperation;
 
+import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -54,31 +57,25 @@
      *
      * @param context We need context to obtain {@link ClipStorage}. It can't be sent in a parcel.
      */
-    public Iterable<Uri> getDocs(Context context) throws IOException {
-        return getDocs(DocumentsApplication.getClipStorage(context));
+    public Iterable<Uri> getUris(Context context) throws IOException {
+        return getUris(DocumentsApplication.getClipStorage(context));
     }
 
     @VisibleForTesting
-    abstract Iterable<Uri> getDocs(ClipStorage storage) throws IOException;
+    abstract Iterable<Uri> getUris(ClipStorage storage) throws IOException;
 
-    public void dispose(Context context) {
-        ClipStorage storage = DocumentsApplication.getClipStorage(context);
-        dispose(storage);
-    }
-
-    @VisibleForTesting
-    void dispose(ClipStorage storage) {}
+    public void dispose() {}
 
     @Override
     public int describeContents() {
         return 0;
     }
 
-    public static UrisSupplier create(ClipData clipData) {
+    public static UrisSupplier create(ClipData clipData, Context context) throws IOException {
         UrisSupplier uris;
         PersistableBundle bundle = clipData.getDescription().getExtras();
         if (bundle.containsKey(OP_JUMBO_SELECTION_TAG)) {
-            uris = new JumboUrisSupplier(clipData);
+            uris = new JumboUrisSupplier(clipData, context);
         } else {
             uris = new StandardUrisSupplier(clipData);
         }
@@ -87,7 +84,9 @@
     }
 
     public static UrisSupplier create(
-            Selection selection, Function<String, Uri> uriBuilder, Context context) {
+            Selection selection, Function<String, Uri> uriBuilder, Context context)
+            throws IOException {
+
         ClipStorage storage = DocumentsApplication.getClipStorage(context);
 
         List<Uri> uris = new ArrayList<>(selection.size());
@@ -99,7 +98,7 @@
     }
 
     @VisibleForTesting
-    static UrisSupplier create(List<Uri> uris, ClipStorage storage) {
+    static UrisSupplier create(List<Uri> uris, ClipStorage storage) throws IOException {
         UrisSupplier urisSupplier = (uris.size() > Shared.MAX_DOCS_IN_INTENT)
                 ? new JumboUrisSupplier(uris, storage)
                 : new StandardUrisSupplier(uris);
@@ -110,24 +109,32 @@
     private static class JumboUrisSupplier extends UrisSupplier {
         private static final String TAG = "JumboUrisSupplier";
 
-        private final long mSelectionTag;
+        private final File mFile;
         private final int mSelectionSize;
 
-        private final transient AtomicReference<ClipStorage.Reader> mReader =
+        private final transient AtomicReference<ClipStorageReader> mReader =
                 new AtomicReference<>();
 
-        private JumboUrisSupplier(ClipData clipData) {
+        private JumboUrisSupplier(ClipData clipData, Context context) throws IOException {
             PersistableBundle bundle = clipData.getDescription().getExtras();
-            mSelectionTag = bundle.getLong(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG);
-            assert(mSelectionTag != ClipStorage.NO_SELECTION_TAG);
+            final int tag = bundle.getInt(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG);
+            assert(tag != ClipStorage.NO_SELECTION_TAG);
+            mFile = DocumentsApplication.getClipStorage(context).getFile(tag);
+            assert(mFile.exists());
 
             mSelectionSize = bundle.getInt(OP_JUMBO_SELECTION_SIZE);
             assert(mSelectionSize > Shared.MAX_DOCS_IN_INTENT);
         }
 
-        private JumboUrisSupplier(Collection<Uri> uris, ClipStorage storage) {
-            mSelectionTag = storage.createTag();
-            new ClipStorage.PersistTask(storage, uris, mSelectionTag).execute();
+        private JumboUrisSupplier(Collection<Uri> uris, ClipStorage storage) throws IOException {
+            final int tag = storage.claimStorageSlot();
+            new ClipStorage.PersistTask(storage, uris, tag).execute();
+
+            // There is a tiny race condition here. A job may starts to read before persist task
+            // starts to write, but it has to beat an IPC and background task schedule, which is
+            // pretty rare. Creating a symlink doesn't need that file to exist, but we can't assert
+            // on its existence.
+            mFile = storage.getFile(tag);
             mSelectionSize = uris.size();
         }
 
@@ -137,8 +144,8 @@
         }
 
         @Override
-        Iterable<Uri> getDocs(ClipStorage storage) throws IOException {
-            ClipStorage.Reader reader = mReader.getAndSet(storage.createReader(mSelectionTag));
+        Iterable<Uri> getUris(ClipStorage storage) throws IOException {
+            ClipStorageReader reader = mReader.getAndSet(storage.createReader(mFile));
             if (reader != null) {
                 reader.close();
                 mReader.get().close();
@@ -149,27 +156,27 @@
         }
 
         @Override
-        void dispose(ClipStorage storage) {
+        public void dispose() {
             try {
-                ClipStorage.Reader reader = mReader.get();
+                ClipStorageReader reader = mReader.get();
                 if (reader != null) {
                     reader.close();
                 }
             } catch (IOException e) {
                 Log.w(TAG, "Failed to close the reader.", e);
             }
-            try {
-                storage.delete(mSelectionTag);
-            } catch(IOException e) {
-                Log.w(TAG, "Failed to delete clip with tag: " + mSelectionTag + ".", e);
-            }
+
+            // mFile is a symlink to the actual data file. Delete the symlink here so that we know
+            // there is one fewer referrer that needs the data file. The actual data file will be
+            // cleaned up during file slot rotation. See ClipStorage for more details.
+            mFile.delete();
         }
 
         @Override
         public String toString() {
             StringBuilder builder = new StringBuilder();
             builder.append("JumboUrisSupplier{");
-            builder.append("selectionTag=").append(mSelectionTag);
+            builder.append("file=").append(mFile.getAbsolutePath());
             builder.append(", selectionSize=").append(mSelectionSize);
             builder.append("}");
             return builder.toString();
@@ -177,12 +184,12 @@
 
         @Override
         public void writeToParcel(Parcel dest, int flags) {
-            dest.writeLong(mSelectionTag);
+            dest.writeString(mFile.getAbsolutePath());
             dest.writeInt(mSelectionSize);
         }
 
         private JumboUrisSupplier(Parcel in) {
-            mSelectionTag = in.readLong();
+            mFile = new File(in.readString());
             mSelectionSize = in.readInt();
         }
 
@@ -236,7 +243,7 @@
         }
 
         @Override
-        Iterable<Uri> getDocs(ClipStorage storage) {
+        Iterable<Uri> getUris(ClipStorage storage) {
             return mDocs;
         }
 
diff --git a/src/com/android/documentsui/dirlist/BandController.java b/src/com/android/documentsui/dirlist/BandController.java
index 7320dc0..8f52036 100644
--- a/src/com/android/documentsui/dirlist/BandController.java
+++ b/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/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 21f772e..32e16f2 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/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;
@@ -72,9 +73,10 @@
 import com.android.documentsui.BaseActivity;
 import com.android.documentsui.DirectoryLoader;
 import com.android.documentsui.DirectoryResult;
-import com.android.documentsui.DocumentClipper;
+import com.android.documentsui.clipping.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;
@@ -90,8 +92,9 @@
 import com.android.documentsui.Snackbars;
 import com.android.documentsui.State;
 import com.android.documentsui.State.ViewMode;
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.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;
@@ -99,12 +102,14 @@
 import com.android.documentsui.services.FileOperationService.OpType;
 import com.android.documentsui.services.FileOperations;
 
+import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.function.Function;
 
 import javax.annotation.Nullable;
 
@@ -135,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;
 
@@ -239,7 +244,7 @@
 
     @Override
     public void onDestroyView() {
-        mSelectionManager.clearSelection();
+        mSelectionMgr.clearSelection();
 
         // Cancel any outstanding thumbnail requests
         final int count = mRecView.getChildCount();
@@ -295,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();
@@ -350,7 +358,7 @@
     }
 
     public void retainState(RetainedState state) {
-        state.selection = mSelectionManager.getSelection(new Selection());
+        state.selection = mSelectionMgr.getSelection(new Selection());
     }
 
     @Override
@@ -409,7 +417,7 @@
         if (resultCode == Activity.RESULT_CANCELED || data == null) {
             // User pressed the back button or otherwise cancelled the destination pick. Don't
             // proceed with the copy.
-            operation.dispose(getContext());
+            operation.dispose();
             return;
         }
 
@@ -418,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;
     }
 
@@ -477,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;
@@ -494,26 +490,6 @@
         state.dirState.put(mStateKey, container);
     }
 
-    void dragStarted() {
-        // When files are selected for dragging, ActionMode is started. This obscures the breadcrumb
-        // with an ActionBar. In order to make drag and drop to the breadcrumb possible, we first
-        // end ActionMode so the breadcrumb is visible to the user.
-        if (mActionMode != null) {
-            mActionMode.finish();
-        }
-    }
-
-    void dragStopped(boolean result) {
-        if (result) {
-            clearSelection();
-        } else {
-            // When drag starts we might write a new clip file to disk.
-            // No drop event happens, remove clip file here. This may be called multiple times,
-            // but it should be OK because deletion is idempotent and cheap.
-            deleteDragClipFile();
-        }
-    }
-
     public void onDisplayStateChanged() {
         updateDisplayState();
     }
@@ -662,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) {
@@ -692,7 +668,7 @@
             if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
             mActionMode = null;
             // clear selection
-            mSelectionManager.clearSelection();
+            mSelectionMgr.clearSelection();
             mSelected.clear();
 
             mDirectoryCount = 0;
@@ -723,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));
 
@@ -771,7 +747,7 @@
 
         @Override
         public boolean canRename() {
-            return mNoRenameCount == 0 && mSelectionManager.getSelection().size() == 1;
+            return mNoRenameCount == 0 && mSelectionMgr.getSelection().size() == 1;
         }
 
         private void updateActionMenu() {
@@ -787,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:
@@ -854,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;
@@ -968,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);
 
@@ -1005,10 +1004,15 @@
                                     Log.w(TAG, "Action mode is null before deleting documents.");
                                 }
 
-                                UrisSupplier srcs = UrisSupplier.create(
-                                        selected,
-                                        mModel::getItemUri,
-                                        getContext());
+                                UrisSupplier srcs;
+                                try {
+                                    srcs = UrisSupplier.create(
+                                            selected,
+                                            mModel::getItemUri,
+                                            getContext());
+                                } catch(IOException e) {
+                                    throw new RuntimeException("Failed to create uri supplier.", e);
+                                }
 
                                 FileOperation operation = new FileOperation.Builder()
                                         .withOpType(FileOperationService.OPERATION_DELETE)
@@ -1041,8 +1045,12 @@
                 getActivity(),
                 DocumentsActivity.class);
 
-        UrisSupplier srcs =
-                UrisSupplier.create(selected, mModel::getItemUri, getContext());
+        UrisSupplier srcs;
+        try {
+            srcs = UrisSupplier.create(selected, mModel::getItemUri, getContext());
+        } catch(IOException e) {
+            throw new RuntimeException("Failed to create uri supplier.", e);
+        }
 
         Uri srcParent = getDisplayState().stack.peek().derivedUri;
         mPendingOperation = new FileOperation.Builder()
@@ -1110,7 +1118,7 @@
 
     @Override
     public void initDocumentHolder(DocumentHolder holder) {
-        holder.addEventListener(mItemEventListener);
+        holder.addKeyEventListener(mInputHandler);
         holder.itemView.setOnFocusChangeListener(mFocusManager);
     }
 
@@ -1196,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);
 
@@ -1210,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());
 
@@ -1249,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();
         }
@@ -1276,8 +1284,19 @@
         }
     }
 
-    public void clearSelection() {
-        mSelectionManager.clearSelection();
+    void dragStarted() {
+        // When files are selected for dragging, ActionMode is started. This obscures the breadcrumb
+        // with an ActionBar. In order to make drag and drop to the breadcrumb possible, we first
+        // end ActionMode so the breadcrumb is visible to the user.
+        if (mActionMode != null) {
+            mActionMode.finish();
+        }
+    }
+
+    void dragStopped(boolean result) {
+        if (result) {
+            mSelectionMgr.clearSelection();
+        }
     }
 
     @Override
@@ -1300,10 +1319,6 @@
         activity.setRootsDrawerOpen(false);
     }
 
-    private void deleteDragClipFile() {
-        mClipper.deleteDragClip();
-    }
-
     boolean handleDropEvent(View v, DragEvent event) {
         BaseActivity activity = (BaseActivity) getActivity();
         activity.setRootsDrawerOpen(false);
@@ -1366,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);
@@ -1426,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 {
@@ -1483,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);
     }
@@ -1492,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");
@@ -1508,7 +1511,7 @@
                     // the current code layout and framework assumptions don't support
                     // this. So for now, we could end up doing a bunch of i/o on main thread.
                     v.startDragAndDrop(
-                            mClipper.getClipDataForDrag(
+                            mClipper.getClipDataForDocuments(
                                     mModel::getItemUri,
                                     selection,
                                     FileOperationService.OPERATION_COPY),
@@ -1535,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
@@ -1665,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/src/com/android/documentsui/dirlist/DocumentHolder.java b/src/com/android/documentsui/dirlist/DocumentHolder.java
index 2288fe7..c2b0bf2 100644
--- a/src/com/android/documentsui/dirlist/DocumentHolder.java
+++ b/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/src/com/android/documentsui/dirlist/FocusHandler.java b/src/com/android/documentsui/dirlist/FocusHandler.java
new file mode 100644
index 0000000..ba26d65
--- /dev/null
+++ b/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/src/com/android/documentsui/dirlist/FocusManager.java b/src/com/android/documentsui/dirlist/FocusManager.java
index f274df3..1be2f65 100644
--- a/src/com/android/documentsui/dirlist/FocusManager.java
+++ b/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/src/com/android/documentsui/dirlist/GestureListener.java b/src/com/android/documentsui/dirlist/GestureListener.java
deleted file mode 100644
index 1af26d0..0000000
--- a/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/src/com/android/documentsui/dirlist/ItemEventListener.java b/src/com/android/documentsui/dirlist/ItemEventListener.java
deleted file mode 100644
index cffba8d..0000000
--- a/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/src/com/android/documentsui/dirlist/ListeningGestureDetector.java b/src/com/android/documentsui/dirlist/ListeningGestureDetector.java
index 50e595d..85ff6ed 100644
--- a/src/com/android/documentsui/dirlist/ListeningGestureDetector.java
+++ b/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/src/com/android/documentsui/dirlist/MultiSelectManager.java b/src/com/android/documentsui/dirlist/MultiSelectManager.java
index e0fc541..e58971a 100644
--- a/src/com/android/documentsui/dirlist/MultiSelectManager.java
+++ b/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/src/com/android/documentsui/dirlist/UserInputHandler.java b/src/com/android/documentsui/dirlist/UserInputHandler.java
new file mode 100644
index 0000000..943815c
--- /dev/null
+++ b/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/src/com/android/documentsui/services/CopyJob.java b/src/com/android/documentsui/services/CopyJob.java
index 390656c..c8f6a64 100644
--- a/src/com/android/documentsui/services/CopyJob.java
+++ b/src/com/android/documentsui/services/CopyJob.java
@@ -51,9 +51,11 @@
 import android.util.Log;
 import android.webkit.MimeTypeMap;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.R;
+import com.android.documentsui.RootsCache;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 import com.android.documentsui.model.RootInfo;
@@ -210,7 +212,6 @@
 
     @Override
     boolean setUp() {
-
         try {
             buildDocumentList();
         } catch (ResourceException e) {
@@ -218,6 +219,7 @@
             return false;
         }
 
+        // Check if user has canceled this task.
         if (isCanceled()) {
             return false;
         }
@@ -229,7 +231,15 @@
             mBatchSize = -1;
         }
 
-        return true;
+        // Check if user has canceled this task. We should check it again here as user cancels
+        // tasks in main thread, but this is running in a worker thread. calculateSize() may
+        // take a long time during which user can cancel this task, and we don't want to waste
+        // resources doing useless large chunk of work.
+        if (isCanceled()) {
+            return false;
+        }
+
+        return checkSpace();
     }
 
     @Override
@@ -262,7 +272,7 @@
     private void buildDocumentList() throws ResourceException {
         try {
             final ContentResolver resolver = appContext.getContentResolver();
-            final Iterable<Uri> uris = srcs.getDocs(appContext);
+            final Iterable<Uri> uris = srcs.getUris(appContext);
             for (Uri uri : uris) {
                 DocumentInfo doc = DocumentInfo.fromUri(resolver, uri);
                 if (canCopy(doc, stack.root)) {
@@ -286,6 +296,44 @@
         return !root.isDownloads() || !doc.isDirectory();
     }
 
+    /**
+     * Checks whether the destination folder has enough space to take all source files.
+     * @return true if the root has enough space or doesn't provide free space info; otherwise false
+     */
+    boolean checkSpace() {
+        return checkSpace(mBatchSize);
+    }
+
+    /**
+     * Checks whether the destination folder has enough space to take files of batchSize
+     * @param batchSize the total size of files
+     * @return true if the root has enough space or doesn't provide free space info; otherwise false
+     */
+    final boolean checkSpace(long batchSize) {
+        // Default to be true because if batchSize or available space is invalid, we still let the
+        // copy start anyway.
+        boolean result = true;
+        if (batchSize >= 0) {
+            RootsCache cache = DocumentsApplication.getRootsCache(appContext);
+
+            // Query root info here instead of using stack.root because the number there may be
+            // stale.
+            RootInfo root = cache.getRootOneshot(stack.root.authority, stack.root.rootId, true);
+            if (root.availableBytes >= 0) {
+                result = (batchSize <= root.availableBytes);
+            } else {
+                Log.w(TAG, root.toString() + " doesn't provide available bytes.");
+            }
+        }
+
+        if (!result) {
+            failedFileCount += mSrcs.size();
+            failedFiles.addAll(mSrcs);
+        }
+
+        return result;
+    }
+
     @Override
     boolean hasWarnings() {
         return !convertedFiles.isEmpty();
@@ -585,7 +633,7 @@
                     result += calculateFileSizesRecursively(getClient(src), src.derivedUri);
                 } catch (RemoteException e) {
                     throw new ResourceException("Failed to obtain the client for %s.",
-                            src.derivedUri);
+                            src.derivedUri, e);
                 }
             } else {
                 result += src.size;
@@ -603,7 +651,7 @@
      *
      * @throws ResourceException
      */
-    private long calculateFileSizesRecursively(
+    long calculateFileSizesRecursively(
             ContentProviderClient client, Uri uri) throws ResourceException {
         final String authority = uri.getAuthority();
         final Uri queryUri = buildChildDocumentsUri(authority, getDocumentId(uri));
diff --git a/src/com/android/documentsui/services/DeleteJob.java b/src/com/android/documentsui/services/DeleteJob.java
index f6202c5..64bc1a7 100644
--- a/src/com/android/documentsui/services/DeleteJob.java
+++ b/src/com/android/documentsui/services/DeleteJob.java
@@ -26,7 +26,7 @@
 import android.net.Uri;
 import android.util.Log;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.R;
 import com.android.documentsui.model.DocumentInfo;
@@ -97,7 +97,7 @@
         try {
             final List<DocumentInfo> srcs = new ArrayList<>(this.srcs.getItemCount());
 
-            final Iterable<Uri> uris = this.srcs.getDocs(appContext);
+            final Iterable<Uri> uris = this.srcs.getUris(appContext);
 
             final ContentResolver resolver = appContext.getContentResolver();
             final DocumentInfo srcParent = DocumentInfo.fromUri(resolver, mSrcParent);
diff --git a/src/com/android/documentsui/services/FileOperation.java b/src/com/android/documentsui/services/FileOperation.java
index ce63864..43c3bd7 100644
--- a/src/com/android/documentsui/services/FileOperation.java
+++ b/src/com/android/documentsui/services/FileOperation.java
@@ -27,7 +27,7 @@
 import android.os.Parcelable;
 import android.support.annotation.VisibleForTesting;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.model.DocumentStack;
 import com.android.documentsui.services.FileOperationService.OpType;
 
@@ -71,8 +71,8 @@
         mDestination = destination;
     }
 
-    public void dispose(Context context) {
-        mSrcs.dispose(context);
+    public void dispose() {
+        mSrcs.dispose();
     }
 
     abstract Job createJob(Context service, Job.Listener listener, String id);
diff --git a/src/com/android/documentsui/services/Job.java b/src/com/android/documentsui/services/Job.java
index 29e0210..14ae66e 100644
--- a/src/com/android/documentsui/services/Job.java
+++ b/src/com/android/documentsui/services/Job.java
@@ -40,7 +40,7 @@
 import android.provider.DocumentsContract;
 import android.util.Log;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.FilesActivity;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.OperationDialogFragment;
@@ -151,7 +151,7 @@
 
             // NOTE: If this details is a JumboClipDetails, and it's still referred in primary clip
             // at this point, user won't be able to paste it to anywhere else because the underlying
-            srcs.dispose(appContext);
+            srcs.dispose();
         }
     }
 
diff --git a/src/com/android/documentsui/services/MoveJob.java b/src/com/android/documentsui/services/MoveJob.java
index 5e9d5cc..ab0fae1 100644
--- a/src/com/android/documentsui/services/MoveJob.java
+++ b/src/com/android/documentsui/services/MoveJob.java
@@ -29,8 +29,8 @@
 import android.provider.DocumentsContract.Document;
 import android.util.Log;
 
-import com.android.documentsui.UrisSupplier;
 import com.android.documentsui.R;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 
@@ -96,9 +96,35 @@
         return super.setUp();
     }
 
+    /**
+     * {@inheritDoc}
+     *
+     * Only check space for moves across authorities. For now we don't know if the doc in
+     * {@link #mSrcs} is in the same root of destination, and if it's optimized move in the same
+     * root it should succeed regardless of free space, but it's for sure a failure if there is no
+     * enough free space if docs are moved from another authority.
+     */
     @Override
-    public void start() {
-        super.start();
+    boolean checkSpace() {
+        long size = 0;
+        for (DocumentInfo src : mSrcs) {
+            if (!src.authority.equals(stack.root.authority)) {
+                if (src.isDirectory()) {
+                    try {
+                        size += calculateFileSizesRecursively(getClient(src), src.derivedUri);
+                    } catch (RemoteException|ResourceException e) {
+                        Log.w(TAG, "Failed to obtain client for %s" + src.derivedUri + ".", e);
+
+                        // Failed to calculate size, but move may still succeed.
+                        return true;
+                    }
+                } else {
+                    size += src.size;
+                }
+            }
+        }
+
+        return checkSpace(size);
     }
 
     void processDocument(DocumentInfo src, DocumentInfo srcParent, DocumentInfo dest)
diff --git a/tests/src/com/android/documentsui/ClipStorageTest.java b/tests/src/com/android/documentsui/ClipStorageTest.java
deleted file mode 100644
index 851000b..0000000
--- a/tests/src/com/android/documentsui/ClipStorageTest.java
+++ /dev/null
@@ -1,123 +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;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.support.test.filters.SmallTest;
-import android.support.test.runner.AndroidJUnit4;
-
-import com.android.documentsui.ClipStorage.Reader;
-import com.android.documentsui.dirlist.TestModel;
-import com.android.documentsui.testing.TestScheduledExecutorService;
-
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.TemporaryFolder;
-import org.junit.runner.RunWith;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-@RunWith(AndroidJUnit4.class)
-@SmallTest
-public class ClipStorageTest {
-    private static final List<Uri> TEST_URIS = createList(
-            "content://ham/fancy",
-            "content://poodle/monkey/giraffe");
-
-    @Rule
-    public TemporaryFolder folder = new TemporaryFolder();
-
-    private TestScheduledExecutorService mExecutor;
-
-    private ClipStorage mStorage;
-    private TestModel mModel;
-
-    private long mTag;
-
-    @Before
-    public void setUp() {
-        File clipDir = ClipStorage.prepareStorage(folder.getRoot());
-        mStorage = new ClipStorage(clipDir);
-
-        mExecutor = new TestScheduledExecutorService();
-        AsyncTask.setDefaultExecutor(mExecutor);
-
-        mTag = mStorage.createTag();
-    }
-
-    @AfterClass
-    public static void tearDownOnce() {
-        AsyncTask.setDefaultExecutor(AsyncTask.SERIAL_EXECUTOR);
-    }
-
-    @Test
-    public void testWrite() throws Exception {
-        writeAll(mTag, TEST_URIS);
-    }
-
-    @Test
-    public void testRead() throws Exception {
-        writeAll(mTag, TEST_URIS);
-        List<Uri> uris = new ArrayList<>();
-        try(Reader provider = mStorage.createReader(mTag)) {
-            for (Uri uri : provider) {
-                uris.add(uri);
-            }
-        }
-        assertEquals(TEST_URIS, uris);
-    }
-
-    @Test
-    public void testDelete() throws Exception {
-        writeAll(mTag, TEST_URIS);
-        mStorage.delete(mTag);
-        try {
-            mStorage.createReader(mTag);
-        } catch (IOException expected) {}
-    }
-
-    @Test
-    public void testPrepareStorage_CreatesDir() throws Exception {
-        File clipDir = ClipStorage.prepareStorage(folder.getRoot());
-        assertTrue(clipDir.exists());
-        assertTrue(clipDir.isDirectory());
-        assertFalse(clipDir.equals(folder.getRoot()));
-    }
-
-    private void writeAll(long tag, List<Uri> uris) {
-        new ClipStorage.PersistTask(mStorage, uris, tag).execute();
-        mExecutor.runAll();
-    }
-
-    private static List<Uri> createList(String... values) {
-        List<Uri> uris = new ArrayList<>(values.length);
-        for (int i = 0; i < values.length; i++) {
-            uris.add(i, Uri.parse(values[i]));
-        }
-        return uris;
-    }
-}
diff --git a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
index b23dd7a..ec03173 100644
--- a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
+++ b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
@@ -222,11 +222,8 @@
         DocumentsMenuManager mgr = new DocumentsMenuManager(testSearchManager, state);
         mgr.updateRootContextMenu(testMenu, testRootInfo);
 
-        eject.assertVisible();
-        eject.assertDisabled();
-
-        settings.assertVisible();
-        settings.assertDisabled();
+        eject.assertInvisible();
+        settings.assertInvisible();
     }
 
     @Test
diff --git a/tests/src/com/android/documentsui/TestInputEvent.java b/tests/src/com/android/documentsui/TestInputEvent.java
index a215488..36e7c1b 100644
--- a/tests/src/com/android/documentsui/TestInputEvent.java
+++ b/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/tests/src/com/android/documentsui/clipping/ClipStorageTest.java b/tests/src/com/android/documentsui/clipping/ClipStorageTest.java
new file mode 100644
index 0000000..73366f8
--- /dev/null
+++ b/tests/src/com/android/documentsui/clipping/ClipStorageTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.clipping;
+
+import static com.android.documentsui.clipping.ClipStorage.NUM_OF_SLOTS;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.documentsui.testing.TestScheduledExecutorService;
+
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ClipStorageTest {
+    private static final String PREF_NAME = "pref";
+    private static final List<Uri> TEST_URIS = createList(
+            "content://ham/fancy",
+            "content://poodle/monkey/giraffe");
+
+    @Rule
+    public TemporaryFolder folder = new TemporaryFolder();
+
+    private SharedPreferences mPref;
+    private TestScheduledExecutorService mExecutor;
+    private ClipStorage mStorage;
+
+    private int mTag;
+
+    @Before
+    public void setUp() {
+        mPref = InstrumentationRegistry.getContext().getSharedPreferences(PREF_NAME, 0);
+        File clipDir = ClipStorage.prepareStorage(folder.getRoot());
+        mStorage = new ClipStorage(clipDir, mPref);
+
+        mExecutor = new TestScheduledExecutorService();
+        AsyncTask.setDefaultExecutor(mExecutor);
+
+        mTag = mStorage.claimStorageSlot();
+    }
+
+    @AfterClass
+    public static void tearDownOnce() {
+        AsyncTask.setDefaultExecutor(AsyncTask.SERIAL_EXECUTOR);
+    }
+
+    @Test
+    public void testWrite() throws Exception {
+        writeAll(mTag, TEST_URIS);
+    }
+
+    @Test
+    public void testRead() throws Exception {
+        writeAll(mTag, TEST_URIS);
+        List<Uri> uris = new ArrayList<>();
+
+        File copy = mStorage.getFile(mTag);
+        try(ClipStorageReader provider = mStorage.createReader(copy)) {
+            for (Uri uri : provider) {
+                uris.add(uri);
+            }
+        }
+        assertEquals(TEST_URIS, uris);
+    }
+
+    @Test
+    public void testClaimStorageSlot_NoAvailableSlot() throws Exception {
+        int firstTag = mStorage.claimStorageSlot();
+        writeAll(firstTag, TEST_URIS);
+        mStorage.getFile(firstTag);
+        for (int i = 0; i < NUM_OF_SLOTS - 1; ++i) {
+            int tag = mStorage.claimStorageSlot();
+            writeAll(tag, TEST_URIS);
+            mStorage.getFile(tag);
+        }
+
+        assertEquals(firstTag, mStorage.claimStorageSlot());
+    }
+
+    @Test
+    public void testReadConcurrently() throws Exception {
+        writeAll(mTag, TEST_URIS);
+        List<Uri> uris = new ArrayList<>();
+        List<Uri> uris2 = new ArrayList<>();
+
+        File copy = mStorage.getFile(mTag);
+        File copy2 = mStorage.getFile(mTag);
+        try(ClipStorageReader reader = mStorage.createReader(copy)) {
+            try(ClipStorageReader reader2 = mStorage.createReader(copy2)){
+                Iterator<Uri> iter = reader.iterator();
+                Iterator<Uri> iter2 = reader2.iterator();
+
+                while (iter.hasNext() && iter2.hasNext()) {
+                    uris.add(iter.next());
+                    uris2.add(iter2.next());
+                }
+
+                assertFalse(iter.hasNext());
+                assertFalse(iter2.hasNext());
+            }
+        }
+        assertEquals(TEST_URIS, uris);
+        assertEquals(TEST_URIS, uris2);
+    }
+
+    @Test
+    public void testPrepareStorage_CreatesDir() throws Exception {
+        File clipDir = ClipStorage.prepareStorage(folder.getRoot());
+        assertTrue(clipDir.exists());
+        assertTrue(clipDir.isDirectory());
+        assertFalse(clipDir.equals(folder.getRoot()));
+    }
+
+    private void writeAll(int tag, List<Uri> uris) {
+        new ClipStorage.PersistTask(mStorage, uris, tag).execute();
+        mExecutor.runAll();
+    }
+
+    private static List<Uri> createList(String... values) {
+        List<Uri> uris = new ArrayList<>(values.length);
+        for (int i = 0; i < values.length; i++) {
+            uris.add(i, Uri.parse(values[i]));
+        }
+        return uris;
+    }
+}
diff --git a/tests/src/com/android/documentsui/UrisSupplierTest.java b/tests/src/com/android/documentsui/clipping/UrisSupplierTest.java
similarity index 81%
rename from tests/src/com/android/documentsui/UrisSupplierTest.java
rename to tests/src/com/android/documentsui/clipping/UrisSupplierTest.java
index 719f0e2..9815d0e 100644
--- a/tests/src/com/android/documentsui/UrisSupplierTest.java
+++ b/tests/src/com/android/documentsui/clipping/UrisSupplierTest.java
@@ -14,17 +14,20 @@
  * limitations under the License.
  */
 
-package com.android.documentsui;
+package com.android.documentsui.clipping;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 
+import android.content.SharedPreferences;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.provider.DocumentsContract;
+import android.support.test.InstrumentationRegistry;
 import android.support.test.filters.MediumTest;
 import android.support.test.runner.AndroidJUnit4;
 
+import com.android.documentsui.Shared;
 import com.android.documentsui.testing.TestScheduledExecutorService;
 
 import org.junit.AfterClass;
@@ -42,6 +45,7 @@
 @MediumTest
 public class UrisSupplierTest {
 
+    private static final String PREF_NAME = "pref";
     private static final String AUTHORITY = "foo";
     private static final List<Uri> SHORT_URI_LIST = createList(3);
     private static final List<Uri> LONG_URI_LIST = createList(Shared.MAX_DOCS_IN_INTENT + 5);
@@ -49,6 +53,7 @@
     @Rule
     public TemporaryFolder folder = new TemporaryFolder();
 
+    private SharedPreferences mPref;
     private TestScheduledExecutorService mExecutor;
     private ClipStorage mStorage;
 
@@ -57,7 +62,8 @@
         mExecutor = new TestScheduledExecutorService();
         AsyncTask.setDefaultExecutor(mExecutor);
 
-        mStorage = new ClipStorage(folder.getRoot());
+        mPref = InstrumentationRegistry.getContext().getSharedPreferences(PREF_NAME, 0);
+        mStorage = new ClipStorage(folder.getRoot(), mPref);
     }
 
     @AfterClass
@@ -66,14 +72,14 @@
     }
 
     @Test
-    public void testItemCountEquals_shortList() {
+    public void testItemCountEquals_shortList() throws Exception {
         UrisSupplier uris = createWithShortList();
 
         assertEquals(SHORT_URI_LIST.size(), uris.getItemCount());
     }
 
     @Test
-    public void testItemCountEquals_longList() {
+    public void testItemCountEquals_longList() throws Exception {
         UrisSupplier uris = createWithLongList();
 
         assertEquals(LONG_URI_LIST.size(), uris.getItemCount());
@@ -83,35 +89,35 @@
     public void testGetDocsEquals_shortList() throws Exception {
         UrisSupplier uris = createWithShortList();
 
-        assertIterableEquals(SHORT_URI_LIST, uris.getDocs(mStorage));
+        assertIterableEquals(SHORT_URI_LIST, uris.getUris(mStorage));
     }
 
     @Test
     public void testGetDocsEquals_longList() throws Exception {
         UrisSupplier uris = createWithLongList();
 
-        assertIterableEquals(LONG_URI_LIST, uris.getDocs(mStorage));
+        assertIterableEquals(LONG_URI_LIST, uris.getUris(mStorage));
     }
 
     @Test
     public void testDispose_shortList() throws Exception {
         UrisSupplier uris = createWithShortList();
 
-        uris.dispose(mStorage);
+        uris.dispose();
     }
 
     @Test
     public void testDispose_longList() throws Exception {
         UrisSupplier uris = createWithLongList();
 
-        uris.dispose(mStorage);
+        uris.dispose();
     }
 
-    private UrisSupplier createWithShortList() {
+    private UrisSupplier createWithShortList() throws Exception {
         return UrisSupplier.create(SHORT_URI_LIST, mStorage);
     }
 
-    private UrisSupplier createWithLongList() {
+    private UrisSupplier createWithLongList() throws Exception {
         UrisSupplier uris =
                 UrisSupplier.create(LONG_URI_LIST, mStorage);
 
diff --git a/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java b/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java
index 87cd42f..949f6b7 100644
--- a/tests/src/com/android/documentsui/dirlist/DocumentHolderTest.java
+++ b/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/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java b/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
index 7864e98..7eb3c2e 100644
--- a/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
+++ b/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/tests/src/com/android/documentsui/dirlist/TestData.java b/tests/src/com/android/documentsui/dirlist/TestData.java
new file mode 100644
index 0000000..5c1d987
--- /dev/null
+++ b/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/tests/src/com/android/documentsui/dirlist/UserInputHandler_MouseTest.java b/tests/src/com/android/documentsui/dirlist/UserInputHandler_MouseTest.java
new file mode 100644
index 0000000..d808fe8
--- /dev/null
+++ b/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/tests/src/com/android/documentsui/services/AbstractJobTest.java b/tests/src/com/android/documentsui/services/AbstractJobTest.java
index 053942b..6e4e534 100644
--- a/tests/src/com/android/documentsui/services/AbstractJobTest.java
+++ b/tests/src/com/android/documentsui/services/AbstractJobTest.java
@@ -27,7 +27,7 @@
 import android.test.AndroidTestCase;
 import android.test.suitebuilder.annotation.MediumTest;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.DocumentsProviderHelper;
 import com.android.documentsui.StubProvider;
 import com.android.documentsui.model.DocumentInfo;
diff --git a/tests/src/com/android/documentsui/services/FileOperationServiceTest.java b/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
index b49d15d..d6bbe5a 100644
--- a/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
+++ b/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
@@ -32,7 +32,7 @@
 import android.test.ServiceTestCase;
 import android.test.suitebuilder.annotation.MediumTest;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 import com.android.documentsui.testing.DocsProviders;
diff --git a/tests/src/com/android/documentsui/services/TestJob.java b/tests/src/com/android/documentsui/services/TestJob.java
index 0c273c0..1fbcf37 100644
--- a/tests/src/com/android/documentsui/services/TestJob.java
+++ b/tests/src/com/android/documentsui/services/TestJob.java
@@ -23,7 +23,7 @@
 import android.app.Notification.Builder;
 import android.content.Context;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.R;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
diff --git a/tests/src/com/android/documentsui/testing/DocsProviders.java b/tests/src/com/android/documentsui/testing/DocsProviders.java
index d438892..c08157f 100644
--- a/tests/src/com/android/documentsui/testing/DocsProviders.java
+++ b/tests/src/com/android/documentsui/testing/DocsProviders.java
@@ -18,7 +18,7 @@
 
 import android.net.Uri;
 
-import com.android.documentsui.UrisSupplier;
+import com.android.documentsui.clipping.UrisSupplier;
 
 import java.util.List;
 
diff --git a/tests/src/com/android/documentsui/testing/TestPredicate.java b/tests/src/com/android/documentsui/testing/TestPredicate.java
new file mode 100644
index 0000000..f8ee21e
--- /dev/null
+++ b/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;
+    }
+}