Merge "Adding ActivityInputHandler and tests." into nyc-andromeda-dev
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9ea7b54..2e678b7 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -108,6 +108,13 @@
             android:authorities="com.android.documentsui.lastAccessed"
             android:exported="false"/>
 
+        <provider
+            android:name=".archives.ArchivesProvider"
+            android:authorities="com.android.documentsui.archives"
+            android:grantUriPermissions="true"
+            android:permission="android.permission.MANAGE_DOCUMENTS"
+            android:exported="true"/>
+
         <receiver android:name=".PackageReceiver">
             <intent-filter>
                 <action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" />
diff --git a/res/layout/item_doc_grid.xml b/res/layout/item_doc_grid.xml
index 9ffccad..0fa9685 100644
--- a/res/layout/item_doc_grid.xml
+++ b/res/layout/item_doc_grid.xml
@@ -98,7 +98,7 @@
             android:textColor="@color/item_title" />
 
         <TextView
-            android:id="@+id/size"
+            android:id="@+id/details"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_toEndOf="@id/icon_mime_sm"
@@ -115,7 +115,7 @@
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_below="@android:id/title"
-            android:layout_toEndOf="@id/size"
+            android:layout_toEndOf="@id/details"
             android:singleLine="true"
             android:ellipsize="end"
             android:textAlignment="viewStart"
diff --git a/res/values-kn-rIN/strings.xml b/res/values-kn-rIN/strings.xml
index d2031ac..3062896 100644
--- a/res/values-kn-rIN/strings.xml
+++ b/res/values-kn-rIN/strings.xml
@@ -23,7 +23,7 @@
     <string name="menu_create_dir" msgid="2413624798689091042">"ಹೊಸ ಫೋಲ್ಡರ್"</string>
     <string name="menu_grid" msgid="1453636521731880680">"ಗ್ರಿಡ್ ವೀಕ್ಷಣೆ"</string>
     <string name="menu_list" msgid="6714267452146410402">"ಪಟ್ಟಿ ವೀಕ್ಷಣೆ"</string>
-    <string name="menu_search" msgid="1876699106790719849">"ಹುಡುಕು"</string>
+    <string name="menu_search" msgid="1876699106790719849">"ಹುಡುಕಿ"</string>
     <string name="menu_settings" msgid="6520844520117939047">"ಸಂಗ್ರಹಣೆ ಸೆಟ್ಟಿಂಗ್‌ಗಳು"</string>
     <string name="menu_open" msgid="9092138100049759315">"ತೆರೆ"</string>
     <string name="menu_open_with" msgid="5507647065467520229">"ಇದರ ಮೂಲಕ ತೆರೆಯಿರಿ"</string>
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index 0809a37..f541775 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -25,6 +25,7 @@
 import android.support.annotation.VisibleForTesting;
 
 import com.android.documentsui.AbstractActionHandler.CommonAddons;
+import com.android.documentsui.archives.ArchivesProvider;
 import com.android.documentsui.base.BooleanConsumer;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
@@ -32,8 +33,8 @@
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.base.State;
-import com.android.documentsui.dirlist.AnimationView;
 import com.android.documentsui.dirlist.AnimationView.AnimationType;
+import com.android.documentsui.dirlist.AnimationView;
 import com.android.documentsui.dirlist.DocumentDetails;
 import com.android.documentsui.files.LauncherActivity;
 import com.android.documentsui.files.OpenUriForViewTask;
@@ -158,10 +159,20 @@
     @Override
     public void openContainerDocument(DocumentInfo doc) {
         assert(doc.isContainer());
+        DocumentInfo currentDoc = null;
 
-        mActivity.notifyDirectoryNavigated(doc.derivedUri);
+        if (doc.isDirectory()) {
+            // Regular directory.
+            currentDoc = doc;
+        } else if (doc.isArchive()) {
+            // Archive.
+            currentDoc = mDocs.getArchiveDocument(doc.derivedUri);
+        }
 
-        mState.pushDocument(doc);
+        assert(currentDoc != null);
+        mActivity.notifyDirectoryNavigated(currentDoc.derivedUri);
+        mState.pushDocument(currentDoc);
+
         // Show an opening animation only if pressing "back" would get us back to the
         // previous directory. Especially after opening a root document, pressing
         // back, wouldn't go to the previous root, but close the activity.
diff --git a/src/com/android/documentsui/DocumentsAccess.java b/src/com/android/documentsui/DocumentsAccess.java
index cd9c4c9..5d72ab8 100644
--- a/src/com/android/documentsui/DocumentsAccess.java
+++ b/src/com/android/documentsui/DocumentsAccess.java
@@ -22,6 +22,7 @@
 import android.provider.DocumentsContract;
 import android.util.Log;
 
+import com.android.documentsui.archives.ArchivesProvider;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.RootInfo;
 
@@ -35,6 +36,7 @@
     @Nullable DocumentInfo getRootDocument(RootInfo root);
     @Nullable DocumentInfo getRootDocument(Uri uri);
     @Nullable DocumentInfo getDocument(Uri uri);
+    @Nullable DocumentInfo getArchiveDocument(Uri uri);
 
     public static DocumentsAccess create(Context context) {
         return new RuntimeDocumentAccess(context);
@@ -76,5 +78,10 @@
 
             return null;
         }
+
+        @Override
+        public DocumentInfo getArchiveDocument(Uri uri) {
+            return getDocument(ArchivesProvider.buildUriForArchive(uri));
+        }
     }
 }
diff --git a/src/com/android/documentsui/archives/Archive.java b/src/com/android/documentsui/archives/Archive.java
new file mode 100644
index 0000000..35ecb54
--- /dev/null
+++ b/src/com/android/documentsui/archives/Archive.java
@@ -0,0 +1,501 @@
+/*
+ * 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 android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.graphics.Point;
+import android.media.ExifInterface;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.OperationCanceledException;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsProvider;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import libcore.io.IoUtils;
+
+import com.android.internal.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.lang.IllegalArgumentException;
+import java.lang.IllegalStateException;
+import java.lang.UnsupportedOperationException;
+import android.text.TextUtils;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Stack;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipInputStream;
+
+/**
+ * Provides basic implementation for creating, extracting and accessing
+ * files within archives exposed by a document provider.
+ *
+ * <p>This class is thread safe.
+ */
+public class Archive implements Closeable {
+    private static final String TAG = "Archive";
+
+    public static final String[] DEFAULT_PROJECTION = new String[] {
+            Document.COLUMN_DOCUMENT_ID,
+            Document.COLUMN_DISPLAY_NAME,
+            Document.COLUMN_MIME_TYPE,
+            Document.COLUMN_SIZE,
+            Document.COLUMN_FLAGS
+    };
+
+    private final Context mContext;
+    private final Uri mArchiveUri;
+    private final Uri mNotificationUri;
+    private final ZipFile mZipFile;
+    private final ExecutorService mExecutor;
+    private final Map<String, ZipEntry> mEntries;
+    private final Map<String, List<ZipEntry>> mTree;
+
+    private Archive(
+            Context context,
+            File file,
+            Uri archiveUri,
+            @Nullable Uri notificationUri)
+            throws IOException {
+        mContext = context;
+        mArchiveUri = archiveUri;
+        mNotificationUri = notificationUri;
+        mZipFile = new ZipFile(file);
+        mExecutor = Executors.newSingleThreadExecutor();
+
+        // 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<>();
+        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())) {
+                throw new IOException("Multiple entries with the same name are not supported.");
+            }
+            mEntries.put(entry.getName(), entry);
+            if (entry.isDirectory()) {
+                mTree.put(entry.getName(), new ArrayList<ZipEntry>());
+            }
+            stack.push(entry);
+        }
+
+        int delimiterIndex;
+        String parentPath;
+        ZipEntry parentEntry;
+        List<ZipEntry> parentList;
+
+        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) + "/" : "/";
+            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);
+                    stack.push(parentEntry);
+                }
+                parentList = new ArrayList<ZipEntry>();
+                mTree.put(parentPath, parentList);
+            }
+
+            parentList.add(entry);
+        }
+    }
+
+    /**
+     * Creates a DocumentsArchive instance for opening, browsing and accessing
+     * documents within the archive passed as a file descriptor.
+     *
+     * @param context Context of the provider.
+     * @param descriptor File descriptor for the archive's contents.
+     * @param archiveUri Uri of the archive document.
+     * @param Uri notificationUri Uri for notifying that the archive file has changed.
+     */
+    public static Archive createForParcelFileDescriptor(
+            Context context, ParcelFileDescriptor descriptor, Uri archiveUri,
+            @Nullable Uri notificationUri)
+            throws IOException {
+        File snapshotFile = null;
+        try {
+            // Create a copy of the archive, as ZipFile doesn't operate on streams.
+            // Moreover, ZipInputStream would be inefficient for large files on
+            // pipes.
+            snapshotFile = File.createTempFile("com.android.documentsui.snapshot{",
+                    "}.zip", context.getCacheDir());
+
+            try (
+                final FileOutputStream outputStream =
+                        new ParcelFileDescriptor.AutoCloseOutputStream(
+                                ParcelFileDescriptor.open(
+                                        snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY));
+                final ParcelFileDescriptor.AutoCloseInputStream inputStream =
+                        new ParcelFileDescriptor.AutoCloseInputStream(descriptor);
+            ) {
+                final byte[] buffer = new byte[32 * 1024];
+                int bytes;
+                while ((bytes = inputStream.read(buffer)) != -1) {
+                    outputStream.write(buffer, 0, bytes);
+                }
+                outputStream.flush();
+                return new Archive(context, snapshotFile, archiveUri,
+                        notificationUri);
+            }
+        } 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 (snapshotFile != null) {
+                snapshotFile.delete();
+            }
+        }
+    }
+
+    /**
+     * Lists child documents of an archive or a directory within an
+     * archive. Must be called only for archives with supported mime type,
+     * or for documents within archives.
+     *
+     * @see DocumentsProvider.queryChildDocuments(String, String[], String)
+     */
+    public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
+            @Nullable String sortOrder) throws FileNotFoundException {
+        final ArchiveId parsedParentId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+        final MatrixCursor result = new MatrixCursor(
+                projection != null ? projection : DEFAULT_PROJECTION);
+        if (mNotificationUri != null) {
+            result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
+        }
+
+        final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath);
+        if (parentList == null) {
+            throw new FileNotFoundException();
+        }
+        for (final ZipEntry entry : parentList) {
+            addCursorRow(result, entry);
+        }
+        return result;
+    }
+
+    /**
+     * Returns a MIME type of a document within an archive.
+     *
+     * @see DocumentsProvider.getDocumentType(String)
+     */
+    public String getDocumentType(String documentId) throws FileNotFoundException {
+        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+        final ZipEntry entry = mEntries.get(parsedId.mPath);
+        if (entry == null) {
+            throw new FileNotFoundException();
+        }
+        return getMimeTypeForEntry(entry);
+    }
+
+    /**
+     * Returns true if a document within an archive is a child or any descendant of the archive
+     * document or another document within the archive.
+     *
+     * @see DocumentsProvider.isChildDocument(String, String)
+     */
+    public boolean isChildDocument(String parentDocumentId, String documentId) {
+        final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId);
+        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+        final ZipEntry entry = mEntries.get(parsedId.mPath);
+        if (entry == null) {
+            return false;
+        }
+
+        final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath);
+        if (parentEntry == null || !parentEntry.isDirectory()) {
+            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);
+    }
+
+    /**
+     * Returns metadata of a document within an archive.
+     *
+     * @see DocumentsProvider.queryDocument(String, String[])
+     */
+    public Cursor queryDocument(String documentId, @Nullable String[] projection)
+            throws FileNotFoundException {
+        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+        final ZipEntry entry = mEntries.get(parsedId.mPath);
+        if (entry == null) {
+            throw new FileNotFoundException();
+        }
+
+        final MatrixCursor result = new MatrixCursor(
+                projection != null ? projection : DEFAULT_PROJECTION);
+        if (mNotificationUri != null) {
+            result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
+        }
+        addCursorRow(result, entry);
+        return result;
+    }
+
+    /**
+     * Opens a file within an archive.
+     *
+     * @see DocumentsProvider.openDocument(String, String, CancellationSignal))
+     */
+    public ParcelFileDescriptor openDocument(
+            String documentId, String mode, @Nullable final CancellationSignal signal)
+            throws FileNotFoundException {
+        MorePreconditions.checkArgumentEquals("r", mode,
+                "Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
+        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+        final ZipEntry entry = mEntries.get(parsedId.mPath);
+        if (entry == null) {
+            throw new FileNotFoundException();
+        }
+
+        ParcelFileDescriptor[] pipe;
+        InputStream inputStream = null;
+        try {
+            pipe = ParcelFileDescriptor.createReliablePipe();
+            inputStream = mZipFile.getInputStream(entry);
+        } catch (IOException e) {
+            if (inputStream != null) {
+                IoUtils.closeQuietly(inputStream);
+            }
+            // Ideally we'd simply throw IOException to the caller, but for consistency
+            // with DocumentsProvider::openDocument, converting it to IllegalStateException.
+            throw new IllegalStateException("Failed to open the document.", e);
+        }
+        final ParcelFileDescriptor outputPipe = pipe[1];
+        final InputStream finalInputStream = inputStream;
+        mExecutor.execute(
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream =
+                                new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) {
+                            try {
+                                final byte buffer[] = new byte[32 * 1024];
+                                int bytes;
+                                while ((bytes = finalInputStream.read(buffer)) != -1) {
+                                    if (Thread.interrupted()) {
+                                        throw new InterruptedException();
+                                    }
+                                    if (signal != null) {
+                                        signal.throwIfCanceled();
+                                    }
+                                    outputStream.write(buffer, 0, bytes);
+                                }
+                            } catch (IOException | InterruptedException e) {
+                                // Catch the exception before the outer try-with-resource closes the
+                                // pipe with close() instead of closeWithError().
+                                try {
+                                    outputPipe.closeWithError(e.getMessage());
+                                } catch (IOException e2) {
+                                    Log.e(TAG, "Failed to close the pipe after an error.", e2);
+                                }
+                            }
+                        } catch (OperationCanceledException e) {
+                            // Cancelled gracefully.
+                        } catch (IOException e) {
+                            Log.e(TAG, "Failed to close the output stream gracefully.", e);
+                        } finally {
+                            IoUtils.closeQuietly(finalInputStream);
+                        }
+                    }
+                });
+
+        return pipe[0];
+    }
+
+    /**
+     * Opens a thumbnail of a file within an archive.
+     *
+     * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal))
+     */
+    public AssetFileDescriptor openDocumentThumbnail(
+            String documentId, Point sizeHint, final CancellationSignal signal)
+            throws FileNotFoundException {
+        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+                "Mismatching archive Uri. Expected: %s, actual: %s.");
+        Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"),
+                "Thumbnails only supported for image/* MIME type.");
+
+        final ZipEntry entry = mEntries.get(parsedId.mPath);
+        if (entry == null) {
+            throw new FileNotFoundException();
+        }
+
+        InputStream inputStream = null;
+        try {
+            inputStream = mZipFile.getInputStream(entry);
+            final ExifInterface exif = new ExifInterface(inputStream);
+            if (exif.hasThumbnail()) {
+                Bundle extras = null;
+                switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) {
+                    case ExifInterface.ORIENTATION_ROTATE_90:
+                        extras = new Bundle(1);
+                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90);
+                        break;
+                    case ExifInterface.ORIENTATION_ROTATE_180:
+                        extras = new Bundle(1);
+                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180);
+                        break;
+                    case ExifInterface.ORIENTATION_ROTATE_270:
+                        extras = new Bundle(1);
+                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270);
+                        break;
+                }
+                final long[] range = exif.getThumbnailRange();
+                return new AssetFileDescriptor(
+                        openDocument(documentId, "r", signal), range[0], range[1], extras);
+            }
+        } catch (IOException e) {
+            // Ignore the exception, as reading the EXIF may legally fail.
+            Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e);
+        } finally {
+            IoUtils.closeQuietly(inputStream);
+        }
+
+        return new AssetFileDescriptor(
+                openDocument(documentId, "r", signal), 0, entry.getSize(), null);
+    }
+
+    /**
+     * Schedules a gracefully close of the archive after any opened files are closed.
+     *
+     * <p>This method does not block until shutdown. Once called, other methods should not be
+     * called.
+     */
+    @Override
+    public void close() {
+        mExecutor.execute(new Runnable() {
+            @Override
+            public void run() {
+                IoUtils.closeQuietly(mZipFile);
+            }
+        });
+        mExecutor.shutdown();
+    }
+
+    private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
+        final MatrixCursor.RowBuilder row = cursor.newRow();
+        final ArchiveId parsedId = new ArchiveId(mArchiveUri, entry.getName());
+        row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId());
+
+        final File file = new File(entry.getName());
+        row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
+        row.add(Document.COLUMN_SIZE, entry.getSize());
+
+        final String mimeType = getMimeTypeForEntry(entry);
+        row.add(Document.COLUMN_MIME_TYPE, mimeType);
+
+        final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0;
+        row.add(Document.COLUMN_FLAGS, flags);
+    }
+
+    private String getMimeTypeForEntry(ZipEntry entry) {
+        if (entry.isDirectory()) {
+            return Document.MIME_TYPE_DIR;
+        }
+
+        final int lastDot = entry.getName().lastIndexOf('.');
+        if (lastDot >= 0) {
+            final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US);
+            final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+            if (mimeType != null) {
+                return mimeType;
+            }
+        }
+
+        return "application/octet-stream";
+    }
+
+    // TODO: Upstream to the Preconditions class.
+    private static class MorePreconditions {
+        static void checkArgumentEquals(String expected, @Nullable String actual,
+                String message) {
+            if (!TextUtils.equals(expected, actual)) {
+                throw new IllegalArgumentException(String.format(message,
+                        String.valueOf(expected), String.valueOf(actual)));
+            }
+        }
+
+        static void checkArgumentEquals(Uri expected, @Nullable Uri actual,
+                String message) {
+            checkArgumentEquals(expected.toString(), actual.toString(), message);
+        }
+    }
+};
diff --git a/src/com/android/documentsui/archives/ArchiveId.java b/src/com/android/documentsui/archives/ArchiveId.java
new file mode 100644
index 0000000..d136de1
--- /dev/null
+++ b/src/com/android/documentsui/archives/ArchiveId.java
@@ -0,0 +1,43 @@
+/*
+ * 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.archives;
+
+import android.net.Uri;
+
+class ArchiveId {
+    private final static char DELIMITER = '#';
+
+    public final Uri mArchiveUri;
+    public final String mPath;
+
+    public ArchiveId(Uri archiveUri, String path) {
+        mArchiveUri = archiveUri;
+        mPath = path;
+        assert(!mPath.isEmpty());
+    }
+
+    static public ArchiveId fromDocumentId(String documentId) {
+        final int delimiterPosition = documentId.indexOf(DELIMITER);
+        assert(delimiterPosition != -1);
+        return new ArchiveId(Uri.parse(documentId.substring(0, delimiterPosition)),
+                documentId.substring((delimiterPosition + 1)));
+    }
+
+    public String toDocumentId() {
+        return mArchiveUri.toString() + DELIMITER + mPath;
+    }
+};
diff --git a/src/com/android/documentsui/archives/ArchivesProvider.java b/src/com/android/documentsui/archives/ArchivesProvider.java
new file mode 100644
index 0000000..1ebe427
--- /dev/null
+++ b/src/com/android/documentsui/archives/ArchivesProvider.java
@@ -0,0 +1,315 @@
+/*
+ * 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 android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Configuration;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.graphics.Point;
+import android.net.Uri;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsProvider;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.LruCache;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+/**
+ * Provides basic implementation for creating, extracting and accessing
+ * files within archives exposed by a document provider.
+ *
+ * <p>This class is thread safe. All methods can be called on any thread without
+ * synchronization.
+ */
+public class ArchivesProvider extends DocumentsProvider implements Closeable {
+    public static final String AUTHORITY = "com.android.documentsui.archives";
+
+    private static final String TAG = "ArchivesProvider";
+    private static final int OPENED_ARCHIVES_CACHE_SIZE = 4;
+    private static final String[] ZIP_MIME_TYPES = {
+            "application/zip", "application/x-zip", "application/x-zip-compressed"
+    };
+
+    @GuardedBy("mArchives")
+    private final LruCache<Uri, Loader> mArchives =
+            new LruCache<Uri, Loader>(OPENED_ARCHIVES_CACHE_SIZE) {
+                @Override
+                public void entryRemoved(boolean evicted, Uri key,
+                        Loader oldValue, Loader newValue) {
+                    oldValue.getWriteLock().lock();
+                    try {
+                        oldValue.get().close();
+                    } catch (FileNotFoundException e) {
+                        Log.e(TAG, "Failed to close an archive as it no longer exists.");
+                    } finally {
+                        oldValue.getWriteLock().unlock();
+                    }
+                }
+            };
+
+    @Override
+    public boolean onCreate() {
+        return true;
+    }
+
+    @Override
+    public Cursor queryRoots(String[] projection) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
+            @Nullable String sortOrder)
+            throws FileNotFoundException {
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().queryChildDocuments(documentId, projection, sortOrder);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    @Override
+    public String getDocumentType(String documentId) throws FileNotFoundException {
+        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
+        if (archiveId.mPath.equals("/")) {
+            return Document.MIME_TYPE_DIR;
+        }
+
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().getDocumentType(documentId);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    @Override
+    public boolean isChildDocument(String parentDocumentId, String documentId) {
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().isChildDocument(parentDocumentId, documentId);
+        } catch (FileNotFoundException e) {
+            throw new IllegalStateException(e);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    @Override
+    public Cursor queryDocument(String documentId, @Nullable String[] projection)
+            throws FileNotFoundException {
+        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
+        if (archiveId.mPath.equals("/")) {
+            // For the archive's root directory return hard-coded cursor, so clients know that
+            // it's actually a directory and queryChildDocuments() can be called on it.
+            //
+            // TODO: Move this code to the Archive class, once opening archives is moved to
+            // background.
+            final MatrixCursor cursor = new MatrixCursor(
+                    projection != null ? projection : Archive.DEFAULT_PROJECTION);
+            final RowBuilder row = cursor.newRow();
+            row.add(Document.COLUMN_DOCUMENT_ID, documentId);
+            row.add(Document.COLUMN_DISPLAY_NAME, "Archive");  // TODO: Fix.
+            row.add(Document.COLUMN_SIZE, 0);
+            row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
+            return cursor;
+        }
+
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().queryDocument(documentId, projection);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    @Override
+    public ParcelFileDescriptor openDocument(
+            String documentId, String mode, final CancellationSignal signal)
+            throws FileNotFoundException {
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().openDocument(documentId, mode, signal);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    @Override
+    public AssetFileDescriptor openDocumentThumbnail(
+            String documentId, Point sizeHint, final CancellationSignal signal)
+            throws FileNotFoundException {
+        Loader loader = null;
+        try {
+            loader = obtainInstance(documentId);
+            return loader.get().openDocumentThumbnail(documentId, sizeHint, signal);
+        } finally {
+            releaseInstance(loader);
+        }
+    }
+
+    /**
+     * Returns true if the passed mime type is supported by the helper.
+     */
+    public static boolean isSupportedArchiveType(String mimeType) {
+        for (final String zipMimeType : ZIP_MIME_TYPES) {
+            if (zipMimeType.equals(mimeType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static Uri buildUriForArchive(Uri archiveUri) {
+        return DocumentsContract.buildDocumentUri(
+                AUTHORITY, new ArchiveId(archiveUri, "/").toDocumentId());
+    }
+
+    /**
+     * Closes the helper and disposes all existing archives. It will block until all ongoing
+     * operations on each opened archive are finished.
+     */
+    @Override
+    // TODO: Wire close() to call().
+    public void close() {
+        synchronized (mArchives) {
+            mArchives.evictAll();
+        }
+    }
+
+    private Loader obtainInstance(String documentId) throws FileNotFoundException {
+        Loader loader;
+        synchronized (mArchives) {
+            loader = getInstanceUncheckedLocked(documentId);
+            loader.getReadLock().lock();
+        }
+        return loader;
+    }
+
+    private void releaseInstance(@Nullable Loader loader) {
+        if (loader != null) {
+            loader.getReadLock().unlock();
+        }
+    }
+
+    private Loader getInstanceUncheckedLocked(String documentId)
+            throws FileNotFoundException {
+        final ArchiveId id = ArchiveId.fromDocumentId(documentId);
+        if (mArchives.get(id.mArchiveUri) != null) {
+            return mArchives.get(id.mArchiveUri);
+        }
+
+        final Cursor cursor = getContext().getContentResolver().query(
+                id.mArchiveUri, new String[] { Document.COLUMN_MIME_TYPE }, null, null, null);
+        cursor.moveToFirst();
+        final String mimeType = cursor.getString(cursor.getColumnIndex(
+                Document.COLUMN_MIME_TYPE));
+        Preconditions.checkArgument(isSupportedArchiveType(mimeType));
+        final Uri notificationUri = cursor.getNotificationUri();
+        final Loader loader = new Loader(getContext(), id.mArchiveUri, notificationUri);
+
+        // Remove the instance from mArchives collection once the archive file changes.
+        if (notificationUri != null) {
+            final LruCache<Uri, Loader> finalArchives = mArchives;
+            getContext().getContentResolver().registerContentObserver(notificationUri,
+                    false,
+                    new ContentObserver(null) {
+                        @Override
+                        public void onChange(boolean selfChange, Uri uri) {
+                            synchronized (mArchives) {
+                                final Loader currentLoader = mArchives.get(id.mArchiveUri);
+                                if (currentLoader == loader) {
+                                    mArchives.remove(id.mArchiveUri);
+                                }
+                            }
+                        }
+                    });
+        }
+
+        mArchives.put(id.mArchiveUri, loader);
+        return loader;
+    }
+
+    /**
+     * Loads an instance of Archive lazily.
+     */
+    private static final class Loader {
+        private final Context mContext;
+        private final Uri mArchiveUri;
+        private final Uri mNotificationUri;
+        private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
+        private Archive mArchive = null;
+
+        Loader(Context context, Uri archiveUri, Uri notificationUri) {
+            this.mContext = context;
+            this.mArchiveUri = archiveUri;
+            this.mNotificationUri = notificationUri;
+        }
+
+        synchronized Archive get() throws FileNotFoundException {
+            if (mArchive != null) {
+                return mArchive;
+            }
+
+            try {
+                mArchive = Archive.createForParcelFileDescriptor(
+                        mContext,
+                        mContext.getContentResolver().openFileDescriptor(
+                                mArchiveUri, "r", null /* signal */),
+                        mArchiveUri, mNotificationUri);
+            } catch (IOException e) {
+                throw new IllegalStateException(e);
+            }
+
+            return mArchive;
+        }
+
+        Lock getReadLock() {
+            return mLock.readLock();
+        }
+
+        Lock getWriteLock() {
+            return mLock.writeLock();
+        }
+    }
+}
diff --git a/src/com/android/documentsui/base/DocumentInfo.java b/src/com/android/documentsui/base/DocumentInfo.java
index e7932c4..f9e9b5c 100644
--- a/src/com/android/documentsui/base/DocumentInfo.java
+++ b/src/com/android/documentsui/base/DocumentInfo.java
@@ -28,6 +28,7 @@
 import android.support.annotation.VisibleForTesting;
 
 import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.archives.ArchivesProvider;
 import com.android.documentsui.roots.RootCursorWrapper;
 
 import java.io.DataInputStream;
@@ -256,7 +257,7 @@
     }
 
     public boolean isArchive() {
-        return (flags & Document.FLAG_ARCHIVE) != 0;
+        return ArchivesProvider.isSupportedArchiveType(mimeType);
     }
 
     public boolean isPartial() {
diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index f9a78d8..051114d 100644
--- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -44,7 +44,7 @@
 
     final TextView mTitle;
     final TextView mDate;
-    final TextView mSize;
+    final TextView mDetails;
     final ImageView mIconMimeLg;
     final ImageView mIconMimeSm;
     final ImageView mIconThumb;
@@ -60,7 +60,7 @@
 
         mTitle = (TextView) itemView.findViewById(android.R.id.title);
         mDate = (TextView) itemView.findViewById(R.id.date);
-        mSize = (TextView) itemView.findViewById(R.id.size);
+        mDetails = (TextView) itemView.findViewById(R.id.details);
         mIconMimeLg = (ImageView) itemView.findViewById(R.id.icon_mime_lg);
         mIconMimeSm = (ImageView) itemView.findViewById(R.id.icon_mime_sm);
         mIconThumb = (ImageView) itemView.findViewById(R.id.icon_thumb);
@@ -135,7 +135,6 @@
         final long docLastModified = getCursorLong(cursor, Document.COLUMN_LAST_MODIFIED);
         final int docIcon = getCursorInt(cursor, Document.COLUMN_ICON);
         final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
-        final long docSize = getCursorLong(cursor, Document.COLUMN_SIZE);
 
         mIconHelper.stopLoading(mIconThumb);
 
@@ -155,17 +154,27 @@
             mTitle.setVisibility(View.VISIBLE);
         }
 
-        if (docLastModified == -1) {
+        // If file is partial, we want to show summary field as that's more relevant than fileSize
+        // and date
+        if ((docFlags & Document.FLAG_PARTIAL) != 0) {
+            final String docSummary = getCursorString(cursor, Document.COLUMN_SUMMARY);
+            mDetails.setVisibility(View.VISIBLE);
             mDate.setText(null);
+            mDetails.setText(docSummary);
         } else {
-            mDate.setText(Shared.formatTime(mContext, docLastModified));
-        }
+            if (docLastModified == -1) {
+                mDate.setText(null);
+            } else {
+                mDate.setText(Shared.formatTime(mContext, docLastModified));
+            }
 
-        if (Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
-            mSize.setVisibility(View.GONE);
-        } else {
-            mSize.setVisibility(View.VISIBLE);
-            mSize.setText(Formatter.formatFileSize(mContext, docSize));
+            final long docSize = getCursorLong(cursor, Document.COLUMN_SIZE);
+            if (Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
+                mDetails.setVisibility(View.GONE);
+            } else {
+                mDetails.setVisibility(View.VISIBLE);
+                mDetails.setText(Formatter.formatFileSize(mContext, docSize));
+            }
         }
     }
 }
diff --git a/src/com/android/documentsui/picker/SaveFragment.java b/src/com/android/documentsui/picker/SaveFragment.java
index 3ce88be..7de983d 100644
--- a/src/com/android/documentsui/picker/SaveFragment.java
+++ b/src/com/android/documentsui/picker/SaveFragment.java
@@ -40,7 +40,7 @@
 /**
  * Display document title editor and save button.
  */
-class SaveFragment extends Fragment {
+public class SaveFragment extends Fragment {
     public static final String TAG = "SaveFragment";
 
     private DocumentInfo mReplaceTarget;
diff --git a/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java b/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
index 40f799e..d5c317f 100644
--- a/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
+++ b/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
@@ -42,4 +42,9 @@
     public DocumentInfo getDocument(Uri uri) {
         return nextDocument;
     }
+
+    @Override
+    public DocumentInfo getArchiveDocument(Uri uri) {
+        return nextDocument;
+    }
 }
diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
index 7de09b1..bd58263 100644
--- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
+++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
@@ -220,6 +220,7 @@
     @Test
     public void testDocumentPicked_OpensArchives() throws Exception {
         mActivity.currentRoot = TestRootsAccess.HOME;
+        mEnv.docs.nextDocument = TestEnv.FILE_ARCHIVE;
 
         mHandler.onDocumentPicked(TestEnv.FILE_ARCHIVE);
         assertEquals(TestEnv.FILE_ARCHIVE, mEnv.state.stack.peek());