Merge "Features around findPath API." into nyc-andromeda-dev
diff --git a/src/com/android/documentsui/archives/Archive.java b/src/com/android/documentsui/archives/Archive.java
index 35ecb54..4db6b4a 100644
--- a/src/com/android/documentsui/archives/Archive.java
+++ b/src/com/android/documentsui/archives/Archive.java
@@ -34,6 +34,10 @@
 import android.util.Log;
 import android.webkit.MimeTypeMap;
 
+import android.system.Os;
+import android.system.OsConstants;
+import android.system.ErrnoException;
+
 import libcore.io.IoUtils;
 
 import com.android.internal.util.Preconditions;
@@ -71,6 +75,10 @@
 public class Archive implements Closeable {
     private static final String TAG = "Archive";
 
+    // Stores file representations of file descriptors. Used to open pipes
+    // by path.
+    private static final String PROC_FD_PATH = "/proc/self/fd/";
+
     public static final String[] DEFAULT_PROJECTION = new String[] {
             Document.COLUMN_DOCUMENT_ID,
             Document.COLUMN_DISPLAY_NAME,
@@ -101,26 +109,29 @@
 
         // Build the tree structure in memory.
         mTree = new HashMap<String, List<ZipEntry>>();
-        mTree.put("/", new ArrayList<ZipEntry>());
 
         mEntries = new HashMap<String, ZipEntry>();
         ZipEntry entry;
         final List<? extends ZipEntry> entries = Collections.list(mZipFile.entries());
         final Stack<ZipEntry> stack = new Stack<>();
+        String entryPath;
         for (int i = entries.size() - 1; i >= 0; i--) {
             entry = entries.get(i);
             if (entry.isDirectory() != entry.getName().endsWith("/")) {
                 throw new IOException(
                         "Directories must have a trailing slash, and files must not.");
             }
-            if (mEntries.containsKey(entry.getName())) {
+            entryPath = getEntryPath(entry);
+            if (mEntries.containsKey(entryPath)) {
                 throw new IOException("Multiple entries with the same name are not supported.");
             }
-            mEntries.put(entry.getName(), entry);
+            mEntries.put(entryPath, entry);
             if (entry.isDirectory()) {
-                mTree.put(entry.getName(), new ArrayList<ZipEntry>());
+                mTree.put(entryPath, new ArrayList<ZipEntry>());
             }
-            stack.push(entry);
+            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
+                stack.push(entry);
+            }
         }
 
         int delimiterIndex;
@@ -128,27 +139,30 @@
         ZipEntry parentEntry;
         List<ZipEntry> parentList;
 
+        // Go through all directories recursively and build a tree structure.
         while (stack.size() > 0) {
             entry = stack.pop();
 
-            delimiterIndex = entry.getName().lastIndexOf('/', entry.isDirectory()
-                    ? entry.getName().length() - 2 : entry.getName().length() - 1);
-            parentPath =
-                    delimiterIndex != -1 ? entry.getName().substring(0, delimiterIndex) + "/" : "/";
+            entryPath = getEntryPath(entry);
+            delimiterIndex = entryPath.lastIndexOf('/', entry.isDirectory()
+                    ? entryPath.length() - 2 : entryPath.length() - 1);
+            parentPath = entryPath.substring(0, delimiterIndex) + "/";
+
             parentList = mTree.get(parentPath);
 
             if (parentList == null) {
-                parentEntry = mEntries.get(parentPath);
-                if (parentEntry == null) {
-                    // The ZIP file doesn't contain all directories leading to the entry.
-                    // It's rare, but can happen in a valid ZIP archive. In such case create a
-                    // fake ZipEntry and add it on top of the stack to process it next.
-                    parentEntry = new ZipEntry(parentPath);
-                    parentEntry.setSize(0);
-                    parentEntry.setTime(entry.getTime());
-                    mEntries.put(parentPath, parentEntry);
+                // The ZIP file doesn't contain all directories leading to the entry.
+                // It's rare, but can happen in a valid ZIP archive. In such case create a
+                // fake ZipEntry and add it on top of the stack to process it next.
+                parentEntry = new ZipEntry(parentPath);
+                parentEntry.setSize(0);
+                parentEntry.setTime(entry.getTime());
+                mEntries.put(parentPath, parentEntry);
+
+                if (!"/".equals(parentPath)) {
                     stack.push(parentEntry);
                 }
+
                 parentList = new ArrayList<ZipEntry>();
                 mTree.put(parentPath, parentList);
             }
@@ -158,9 +172,37 @@
     }
 
     /**
+     * Returns a valid, normalized path for an entry.
+     */
+    public static String getEntryPath(ZipEntry entry) {
+        Preconditions.checkArgument(entry.isDirectory() == entry.getName().endsWith("/"),
+                "Ill-formated ZIP-file.");
+        if (entry.getName().startsWith("/")) {
+            return entry.getName();
+        } else {
+            return "/" + entry.getName();
+        }
+    }
+
+    /**
+     * Returns true if the file descriptor is seekable.
+     * @param descriptor File descriptor to check.
+     */
+    public static boolean canSeek(ParcelFileDescriptor descriptor) {
+        try {
+            return Os.lseek(descriptor.getFileDescriptor(), 0,
+                    OsConstants.SEEK_SET) == 0;
+        } catch (ErrnoException e) {
+            return false;
+        }
+    }
+
+    /**
      * Creates a DocumentsArchive instance for opening, browsing and accessing
      * documents within the archive passed as a file descriptor.
      *
+     * If the file descriptor is not seekable, then a snapshot will be created.
+     *
      * @param context Context of the provider.
      * @param descriptor File descriptor for the archive's contents.
      * @param archiveUri Uri of the archive document.
@@ -170,6 +212,12 @@
             Context context, ParcelFileDescriptor descriptor, Uri archiveUri,
             @Nullable Uri notificationUri)
             throws IOException {
+        if (canSeek(descriptor)) {
+            return new Archive(context, new File(PROC_FD_PATH + descriptor.getFd()),
+                    archiveUri, notificationUri);
+        }
+
+        // Fallback for non-seekable file descriptors.
         File snapshotFile = null;
         try {
             // Create a copy of the archive, as ZipFile doesn't operate on streams.
@@ -272,12 +320,13 @@
             return false;
         }
 
-        final String parentPath = entry.getName();
-
         // Add a trailing slash even if it's not a directory, so it's easy to check if the
         // entry is a descendant.
-        final String pathWithSlash = entry.isDirectory() ? entry.getName() : entry.getName() + "/";
-        return pathWithSlash.startsWith(parentPath) && !parentPath.equals(pathWithSlash);
+        String pathWithSlash = entry.isDirectory() ? getEntryPath(entry)
+                : getEntryPath(entry) + "/";
+
+        return pathWithSlash.startsWith(parsedParentId.mPath) &&
+                !parsedParentId.mPath.equals(pathWithSlash);
     }
 
     /**
@@ -452,7 +501,7 @@
 
     private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
         final MatrixCursor.RowBuilder row = cursor.newRow();
-        final ArchiveId parsedId = new ArchiveId(mArchiveUri, entry.getName());
+        final ArchiveId parsedId = new ArchiveId(mArchiveUri, getEntryPath(entry));
         row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId());
 
         final File file = new File(entry.getName());
diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java
index 403be64..09f90d4 100644
--- a/src/com/android/documentsui/files/ActionHandler.java
+++ b/src/com/android/documentsui/files/ActionHandler.java
@@ -357,6 +357,7 @@
         }
 
         Intent intent = Intent.createChooser(buildViewIntent(doc), null);
+        intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false);
         try {
             mActivity.startActivity(intent);
         } catch (ActivityNotFoundException e) {
diff --git a/src/com/android/documentsui/files/ActivityInputHandler.java b/src/com/android/documentsui/files/ActivityInputHandler.java
new file mode 100644
index 0000000..a7b0e2c
--- /dev/null
+++ b/src/com/android/documentsui/files/ActivityInputHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.files;
+
+import android.view.KeyEvent;
+
+import com.android.documentsui.selection.SelectionManager;
+import com.android.documentsui.ActionHandler;
+
+/**
+ * Used by {@link FilesActivity} to manage global keyboard shortcuts tied to file actions
+ */
+final class ActivityInputHandler {
+
+    private final SelectionManager mSelectionMgr;
+    private final ActionHandler mActions;
+
+    ActivityInputHandler(SelectionManager selectionMgr, ActionHandler actionHandler) {
+        mSelectionMgr = selectionMgr;
+        mActions = actionHandler;
+    }
+
+    boolean onKeyDown(int keyCode, KeyEvent event) {
+        if ((keyCode == KeyEvent.KEYCODE_DEL && event.isAltPressed())
+                || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+            if (mSelectionMgr.hasSelection()) {
+                mActions.deleteSelectedDocuments();
+                return true;
+            } else {
+                return false;
+            }
+        }
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/documentsui/files/FilesActivity.java b/src/com/android/documentsui/files/FilesActivity.java
index e934d48..5777f9c 100644
--- a/src/com/android/documentsui/files/FilesActivity.java
+++ b/src/com/android/documentsui/files/FilesActivity.java
@@ -78,6 +78,7 @@
     private DialogController mDialogs;
     private DocumentClipper mClipper;
     private ActionModeController mActionModeController;
+    private ActivityInputHandler mActivityInputHandler;
 
     public FilesActivity() {
         super(R.layout.files_activity, TAG);
@@ -122,6 +123,8 @@
                 mClipper,
                 DocumentsApplication.getClipStore(this));
 
+        mActivityInputHandler = new ActivityInputHandler(mSelectionMgr, mActions);
+
         RootsFragment.show(getFragmentManager(), null);
 
         final Intent intent = getIntent();
@@ -289,16 +292,8 @@
     @CallSuper
     @Override
     public boolean onKeyDown(int keyCode, KeyEvent event) {
-        if ((keyCode == KeyEvent.KEYCODE_DEL && event.isAltPressed())
-                || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
-            if (mSelectionMgr.hasSelection()) {
-                mActions.deleteSelectedDocuments();
-                return true;
-            } else {
-                return false;
-            }
-        }
-        return super.onKeyDown(keyCode, event);
+        return mActivityInputHandler.onKeyDown(keyCode, event) ? true
+                : super.onKeyDown(keyCode, event);
     }
 
     @Override
diff --git a/tests/Android.mk b/tests/Android.mk
index 8c2896a..9d3b64e 100644
--- a/tests/Android.mk
+++ b/tests/Android.mk
@@ -1,6 +1,4 @@
-
 LOCAL_PATH := $(call my-dir)
-
 include $(CLEAR_VARS)
 
 # unittests
@@ -8,6 +6,10 @@
 LOCAL_SRC_FILES := $(call all-java-files-under, common) \
     $(call all-java-files-under, unit) \
     $(call all-java-files-under, functional)
+
+# For testing ZIP files.
+LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
+
 LOCAL_JAVA_LIBRARIES := android.test.runner
 LOCAL_STATIC_JAVA_LIBRARIES := mockito-target ub-uiautomator espresso-core guava
 LOCAL_JARJAR_RULES := $(LOCAL_PATH)/jarjar-rules.txt
@@ -16,4 +18,3 @@
 LOCAL_CERTIFICATE := platform
 
 include $(BUILD_PACKAGE)
-
diff --git a/tests/common/com/android/documentsui/testing/TestActionHandler.java b/tests/common/com/android/documentsui/testing/TestActionHandler.java
index bc4295a..5ad0563 100644
--- a/tests/common/com/android/documentsui/testing/TestActionHandler.java
+++ b/tests/common/com/android/documentsui/testing/TestActionHandler.java
@@ -28,6 +28,7 @@
     public final TestEventHandler<DocumentDetails> open = new TestEventHandler<>();
     public final TestEventHandler<DocumentDetails> view = new TestEventHandler<>();
     public final TestEventHandler<DocumentDetails> preview = new TestEventHandler<>();
+    public boolean mDeleteHappened;
 
     public TestActionHandler() {
         this(TestEnv.create());
@@ -61,6 +62,11 @@
     }
 
     @Override
+    public void deleteSelectedDocuments() {
+        mDeleteHappened = true;
+    }
+
+    @Override
     public void openRoot(RootInfo root) {
         throw new UnsupportedOperationException();
     }
diff --git a/tests/res/raw/archive.zip b/tests/res/raw/archive.zip
new file mode 100644
index 0000000..c3b8d22
--- /dev/null
+++ b/tests/res/raw/archive.zip
Binary files differ
diff --git a/tests/res/raw/empty_dirs.zip b/tests/res/raw/empty_dirs.zip
new file mode 100644
index 0000000..1dd2251
--- /dev/null
+++ b/tests/res/raw/empty_dirs.zip
Binary files differ
diff --git a/tests/res/raw/no_dirs.zip b/tests/res/raw/no_dirs.zip
new file mode 100644
index 0000000..e178ae1
--- /dev/null
+++ b/tests/res/raw/no_dirs.zip
Binary files differ
diff --git a/tests/unit/com/android/documentsui/archives/ArchiveTest.java b/tests/unit/com/android/documentsui/archives/ArchiveTest.java
new file mode 100644
index 0000000..4ea3a01
--- /dev/null
+++ b/tests/unit/com/android/documentsui/archives/ArchiveTest.java
@@ -0,0 +1,384 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.archives;
+
+import com.android.documentsui.archives.Archive;
+import com.android.documentsui.tests.R;
+
+import android.database.Cursor;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.support.test.InstrumentationRegistry;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Scanner;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+@MediumTest
+public class ArchiveTest extends AndroidTestCase {
+    private static final Uri ARCHIVE_URI = Uri.parse("content://i/love/strawberries");
+    private static final String NOTIFICATION_URI = "content://notification-uri";
+    private ExecutorService mExecutor = null;
+    private Context mContext = null;
+    private Archive mArchive = null;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        mContext = InstrumentationRegistry.getTargetContext();
+        mExecutor = Executors.newSingleThreadExecutor();
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        mExecutor.shutdown();
+        assertTrue(mExecutor.awaitTermination(3 /* timeout */, TimeUnit.SECONDS));
+        if (mArchive != null) {
+            mArchive.close();
+        }
+        super.tearDown();
+    }
+
+    public static ArchiveId createArchiveId(String path) {
+        return new ArchiveId(ARCHIVE_URI, path);
+    }
+
+    /**
+     * Opens a resource and returns the contents via file descriptor to a local
+     * snapshot file.
+     */
+    public ParcelFileDescriptor getSeekableDescriptor(int resource) {
+        // Extract the file from resources.
+        File file = null;
+        final Context testContext = InstrumentationRegistry.getContext();
+        try {
+            file = File.createTempFile("com.android.documentsui.archives.tests{",
+                    "}.zip", mContext.getCacheDir());
+            try (
+                final FileOutputStream outputStream =
+                        new ParcelFileDescriptor.AutoCloseOutputStream(
+                                ParcelFileDescriptor.open(
+                                        file, ParcelFileDescriptor.MODE_WRITE_ONLY));
+                final InputStream inputStream =
+                        testContext.getResources().openRawResource(resource);
+            ) {
+                final byte[] buffer = new byte[32 * 1024];
+                int bytes;
+                while ((bytes = inputStream.read(buffer)) != -1) {
+                    outputStream.write(buffer, 0, bytes);
+                }
+                outputStream.flush();
+
+            }
+            return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+        } catch (IOException e) {
+            fail(String.valueOf(e));
+            return null;
+        } finally {
+            // On UNIX the file will be still available for processes which opened it, even
+            // after deleting it. Remove it ASAP, as it won't be used by anyone else.
+            if (file != null) {
+                file.delete();
+            }
+        }
+    }
+
+    /**
+     * Opens a resource and returns the contents via a pipe.
+     */
+    public ParcelFileDescriptor getNonSeekableDescriptor(int resource) {
+        ParcelFileDescriptor[] pipe = null;
+        final Context testContext = InstrumentationRegistry.getContext();
+        try {
+            pipe = ParcelFileDescriptor.createPipe();
+            final ParcelFileDescriptor finalOutputPipe = pipe[1];
+            mExecutor.execute(
+                    new Runnable() {
+                        @Override
+                        public void run() {
+                            try (
+                                final ParcelFileDescriptor.AutoCloseOutputStream outputStream =
+                                        new ParcelFileDescriptor.
+                                                AutoCloseOutputStream(finalOutputPipe);
+                                final InputStream inputStream =
+                                        testContext.getResources().openRawResource(resource);
+                            ) {
+                                final byte[] buffer = new byte[32 * 1024];
+                                int bytes;
+                                while ((bytes = inputStream.read(buffer)) != -1) {
+                                    outputStream.write(buffer, 0, bytes);
+                                }
+                            } catch (IOException e) {
+                              fail(String.valueOf(e));
+                            }
+                        }
+                    });
+            return pipe[0];
+        } catch (IOException e) {
+            fail(String.valueOf(e));
+            return null;
+        }
+    }
+
+    public void loadArchive(ParcelFileDescriptor descriptor) throws IOException {
+        mArchive = Archive.createForParcelFileDescriptor(
+                mContext,
+                descriptor,
+                ARCHIVE_URI,
+                Uri.parse(NOTIFICATION_URI));
+    }
+
+    public void testQueryChildDocument() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.archive));
+        final Cursor cursor = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+
+        assertTrue(cursor.moveToFirst());
+        assertEquals(new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir1",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+
+        assertTrue(cursor.moveToNext());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir2/").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir2",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+
+        assertTrue(cursor.moveToNext());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/file1.txt").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("file1.txt",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals("text/plain",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(13,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+
+        assertFalse(cursor.moveToNext());
+
+        // Check if querying children works too.
+        final Cursor childCursor = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+
+        assertTrue(childCursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId(),
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("cherries.txt",
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DISPLAY_NAME)));
+        assertEquals("text/plain",
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_MIME_TYPE)));
+        assertEquals(17,
+                childCursor.getInt(childCursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+    }
+
+    public void testQueryChildDocument_NoDirs() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.no_dirs));
+        final Cursor cursor = mArchive.queryChildDocuments(
+            new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+
+        assertTrue(cursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir1",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+        assertFalse(cursor.moveToNext());
+
+        final Cursor childCursor = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+
+        assertTrue(childCursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir2",
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                childCursor.getInt(childCursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+        assertFalse(childCursor.moveToNext());
+
+        final Cursor childCursor2 = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                null, null);
+
+        assertTrue(childCursor2.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/cherries.txt").toDocumentId(),
+                childCursor2.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DOCUMENT_ID)));
+        assertFalse(childCursor2.moveToNext());
+    }
+
+    public void testQueryChildDocument_EmptyDirs() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.empty_dirs));
+        final Cursor cursor = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+
+        assertTrue(cursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir1",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+        assertFalse(cursor.moveToNext());
+
+        final Cursor childCursor = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+
+        assertTrue(childCursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir2",
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                childCursor.getInt(childCursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+
+        assertTrue(childCursor.moveToNext());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir3/").toDocumentId(),
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("dir3",
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_DISPLAY_NAME)));
+        assertEquals(Document.MIME_TYPE_DIR,
+                childCursor.getString(childCursor.getColumnIndexOrThrow(
+                        Document.COLUMN_MIME_TYPE)));
+        assertEquals(0,
+                childCursor.getInt(childCursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+        assertFalse(cursor.moveToNext());
+
+        final Cursor childCursor2 = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                null, null);
+        assertFalse(childCursor2.moveToFirst());
+
+        final Cursor childCursor3 = mArchive.queryChildDocuments(
+                new ArchiveId(ARCHIVE_URI, "/dir1/dir3/").toDocumentId(),
+                null, null);
+        assertFalse(childCursor3.moveToFirst());
+    }
+
+    public void testGetDocumentType() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.archive));
+        assertEquals(Document.MIME_TYPE_DIR, mArchive.getDocumentType(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId()));
+        assertEquals("text/plain", mArchive.getDocumentType(
+                new ArchiveId(ARCHIVE_URI, "/file1.txt").toDocumentId()));
+    }
+
+    public void testIsChildDocument() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.archive));
+        final String documentId = new ArchiveId(ARCHIVE_URI, "/").toDocumentId();
+        assertTrue(mArchive.isChildDocument(documentId,
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId()));
+        assertFalse(mArchive.isChildDocument(documentId,
+                new ArchiveId(ARCHIVE_URI, "/this-does-not-exist").toDocumentId()));
+        assertTrue(mArchive.isChildDocument(
+                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId()));
+        assertTrue(mArchive.isChildDocument(documentId,
+                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId()));
+    }
+
+    public void testQueryDocument() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.archive));
+        final Cursor cursor = mArchive.queryDocument(
+                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                null);
+
+        assertTrue(cursor.moveToFirst());
+        assertEquals(
+                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
+        assertEquals("strawberries.txt",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
+        assertEquals("text/plain",
+                cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)));
+        assertEquals(21,
+                cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
+    }
+
+    public void testOpenDocument() throws IOException {
+        loadArchive(getSeekableDescriptor(R.raw.archive));
+        commonTestOpenDocument();
+    }
+
+    public void testOpenDocument_NonSeekable() throws IOException {
+        loadArchive(getNonSeekableDescriptor(R.raw.archive));
+        commonTestOpenDocument();
+    }
+
+    // Common part of testOpenDocument and testOpenDocument_NonSeekable.
+    void commonTestOpenDocument() throws IOException {
+        final ParcelFileDescriptor descriptor = mArchive.openDocument(
+                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                "r", null /* signal */);
+        try (final ParcelFileDescriptor.AutoCloseInputStream inputStream =
+                new ParcelFileDescriptor.AutoCloseInputStream(descriptor)) {
+            assertEquals("I love strawberries!", new Scanner(inputStream).nextLine());
+        }
+    }
+
+    public void testCanSeek() throws IOException {
+        assertTrue(Archive.canSeek(getSeekableDescriptor(R.raw.archive)));
+        assertFalse(Archive.canSeek(getNonSeekableDescriptor(R.raw.archive)));
+    }
+}
diff --git a/tests/unit/com/android/documentsui/dirlist/DragStartListenerTest.java b/tests/unit/com/android/documentsui/dirlist/DragStartListenerTest.java
index f94e10f..f2e044b 100644
--- a/tests/unit/com/android/documentsui/dirlist/DragStartListenerTest.java
+++ b/tests/unit/com/android/documentsui/dirlist/DragStartListenerTest.java
@@ -92,11 +92,6 @@
                 .primary();
     }
 
-    @Override
-    protected void tearDown() throws Exception {
-        mMultiSelectManager.clearSelection();
-    }
-
     public void testDragStarted_OnMouseMove() {
         assertTrue(mListener.onMouseDragEvent(mEvent.build()));
         assertTrue(mDragStarted);
diff --git a/tests/unit/com/android/documentsui/files/ActivityInputHandlerTest.java b/tests/unit/com/android/documentsui/files/ActivityInputHandlerTest.java
new file mode 100644
index 0000000..37a696f
--- /dev/null
+++ b/tests/unit/com/android/documentsui/files/ActivityInputHandlerTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.files;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.support.test.filters.MediumTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import com.android.documentsui.dirlist.TestData;
+import com.android.documentsui.selection.SelectionManager;
+import com.android.documentsui.selection.SelectionProbe;
+import com.android.documentsui.testing.SelectionManagers;
+import com.android.documentsui.testing.TestActionHandler;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class ActivityInputHandlerTest {
+
+    private static final List<String> ITEMS = TestData.create(100);
+
+    private SelectionProbe mSelection;
+    private TestActionHandler mActionHandler;
+    private ActivityInputHandler mActivityInputHandler;
+
+    @Before
+    public void setUp() {
+        SelectionManager selectionMgr = SelectionManagers.createTestInstance(ITEMS);
+        mSelection = new SelectionProbe(selectionMgr);
+        mActionHandler = new TestActionHandler();
+        mActivityInputHandler = new ActivityInputHandler(selectionMgr, mActionHandler);
+    }
+
+    @Test
+    public void testDelete_noSelection() {
+        KeyEvent event = new KeyEvent(0, 0, MotionEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL, 0,
+                KeyEvent.META_ALT_ON);
+        assertFalse(mActivityInputHandler.onKeyDown(event.getKeyCode(), event));
+        assertFalse(mActionHandler.mDeleteHappened);
+    }
+
+    @Test
+    public void testDelete_hasSelection() {
+        mSelection.select(1);
+        KeyEvent event = new KeyEvent(0, 0, MotionEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL, 0,
+                KeyEvent.META_ALT_ON);
+        assertTrue(mActivityInputHandler.onKeyDown(event.getKeyCode(), event));
+        assertTrue(mActionHandler.mDeleteHappened);
+    }
+}