Merge "Refactor archives to support creating archives." into arc-apps
diff --git a/src/com/android/documentsui/DocumentsAccess.java b/src/com/android/documentsui/DocumentsAccess.java
index 63f235c..5d09aa6 100644
--- a/src/com/android/documentsui/DocumentsAccess.java
+++ b/src/com/android/documentsui/DocumentsAccess.java
@@ -22,6 +22,7 @@
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
+import android.os.ParcelFileDescriptor;
 import android.os.RemoteException;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Path;
@@ -107,7 +108,8 @@
 
         @Override
         public DocumentInfo getArchiveDocument(Uri uri) {
-            return getDocument(ArchivesProvider.buildUriForArchive(uri));
+            return getDocument(ArchivesProvider.buildUriForArchive(uri,
+                    ParcelFileDescriptor.MODE_READ_ONLY));
         }
 
         @Override
diff --git a/src/com/android/documentsui/archives/Archive.java b/src/com/android/documentsui/archives/Archive.java
index 496ffdc..8e889ff 100644
--- a/src/com/android/documentsui/archives/Archive.java
+++ b/src/com/android/documentsui/archives/Archive.java
@@ -21,44 +21,26 @@
 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.support.annotation.Nullable;
-import android.support.annotation.VisibleForTesting;
 import android.system.ErrnoException;
 import android.system.Os;
 import android.system.OsConstants;
 import android.text.TextUtils;
-import android.util.Log;
-import android.util.jar.StrictJarFile;
 import android.webkit.MimeTypeMap;
 
 import com.android.internal.util.Preconditions;
 
-import libcore.io.IoUtils;
-
 import java.io.Closeable;
 import java.io.File;
-import java.io.FileDescriptor;
 import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-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.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -70,7 +52,7 @@
  *
  * <p>This class is thread safe.
  */
-public class Archive implements Closeable {
+public abstract class Archive implements Closeable {
     private static final String TAG = "Archive";
 
     public static final String[] DEFAULT_PROJECTION = new String[] {
@@ -81,27 +63,23 @@
             Document.COLUMN_FLAGS
     };
 
-    private final Context mContext;
-    private final Uri mArchiveUri;
-    private final Uri mNotificationUri;
-    private final StrictJarFile mZipFile;
-    private final ThreadPoolExecutor mExecutor;
-    private final Map<String, ZipEntry> mEntries;
-    private final Map<String, List<ZipEntry>> mTree;
+    final Context mContext;
+    final Uri mArchiveUri;
+    final int mArchiveMode;
+    final Uri mNotificationUri;
+    final ThreadPoolExecutor mExecutor;
+    final Map<String, ZipEntry> mEntries;
+    final Map<String, List<ZipEntry>> mTree;
 
-    private Archive(
+    Archive(
             Context context,
-            @Nullable File file,
-            @Nullable FileDescriptor fd,
             Uri archiveUri,
-            @Nullable Uri notificationUri)
-            throws IOException {
+            int archiveMode,
+            @Nullable Uri notificationUri) {
         mContext = context;
         mArchiveUri = archiveUri;
+        mArchiveMode = archiveMode;
         mNotificationUri = notificationUri;
-        mZipFile = file != null ?
-                new StrictJarFile(file.getPath(), false /* verify */, false /* signatures */) :
-                new StrictJarFile(fd, false /* verify */, false /* signatures */);
 
         // At most 8 active threads. All threads idling for more than a minute will
         // be closed.
@@ -109,68 +87,8 @@
                 new LinkedBlockingQueue<Runnable>());
         mExecutor.allowCoreThreadTimeOut(true);
 
-        // Build the tree structure in memory.
         mTree = new HashMap<>();
-
         mEntries = new HashMap<>();
-        ZipEntry entry;
-        String entryPath;
-        final Iterator<ZipEntry> it = mZipFile.iterator();
-        final Stack<ZipEntry> stack = new Stack<>();
-        while (it.hasNext()) {
-            entry = it.next();
-            if (entry.isDirectory() != entry.getName().endsWith("/")) {
-                throw new IOException(
-                        "Directories must have a trailing slash, and files must not.");
-            }
-            entryPath = getEntryPath(entry);
-            if (mEntries.containsKey(entryPath)) {
-                throw new IOException("Multiple entries with the same name are not supported.");
-            }
-            mEntries.put(entryPath, entry);
-            if (entry.isDirectory()) {
-                mTree.put(entryPath, new ArrayList<ZipEntry>());
-            }
-            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
-                stack.push(entry);
-            }
-        }
-
-        int delimiterIndex;
-        String parentPath;
-        ZipEntry parentEntry;
-        List<ZipEntry> parentList;
-
-        // Go through all directories recursively and build a tree structure.
-        while (stack.size() > 0) {
-            entry = stack.pop();
-
-            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) {
-                // 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<>();
-                mTree.put(parentPath, parentList);
-            }
-
-            parentList.add(entry);
-        }
     }
 
     /**
@@ -190,7 +108,6 @@
      * Returns true if the file descriptor is seekable.
      * @param descriptor File descriptor to check.
      */
-    @VisibleForTesting
     public static boolean canSeek(ParcelFileDescriptor descriptor) {
         try {
             return Os.lseek(descriptor.getFileDescriptor(), 0,
@@ -201,75 +118,6 @@
     }
 
     /**
-     * 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.
-     *
-     * This method takes ownership for the passed descriptor. The caller must
-     * not close it.
-     *
-     * @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 {
-        FileDescriptor fd = null;
-        try {
-            if (canSeek(descriptor)) {
-                fd = new FileDescriptor();
-                fd.setInt$(descriptor.detachFd());
-                return new Archive(context, null, fd, 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.
-                // 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, null, 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();
-                }
-            }
-        } catch (Exception e) {
-            // Since the method takes ownership of the passed descriptor, close it
-            // on exception.
-            IoUtils.closeQuietly(descriptor);
-            IoUtils.closeQuietly(fd);
-            throw e;
-        }
-    }
-
-    /**
      * 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.
@@ -376,127 +224,24 @@
      *
      * @see DocumentsProvider.openDocument(String, String, CancellationSignal))
      */
-    public ParcelFileDescriptor openDocument(
+    abstract 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];
-    }
+            throws FileNotFoundException;
 
     /**
      * Opens a thumbnail of a file within an archive.
      *
      * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal))
      */
-    public AssetFileDescriptor openDocumentThumbnail(
+    abstract 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.");
+            throws FileNotFoundException;
 
-        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);
+    /**
+     * Creates an archive id for the passed path.
+     */
+    public ArchiveId createArchiveId(String path) {
+        return new ArchiveId(mArchiveUri, mArchiveMode, path);
     }
 
     /**
@@ -508,16 +253,11 @@
     @Override
     public void close() {
         mExecutor.shutdownNow();
-        try {
-            mZipFile.close();
-        } catch (IOException e) {
-            // Silent close.
-        }
     }
 
-    private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
+    void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
         final MatrixCursor.RowBuilder row = cursor.newRow();
-        final ArchiveId parsedId = new ArchiveId(mArchiveUri, getEntryPath(entry));
+        final ArchiveId parsedId = createArchiveId(getEntryPath(entry));
         row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId());
 
         final File file = new File(entry.getName());
@@ -531,7 +271,7 @@
         row.add(Document.COLUMN_FLAGS, flags);
     }
 
-    private String getMimeTypeForEntry(ZipEntry entry) {
+    static String getMimeTypeForEntry(ZipEntry entry) {
         if (entry.isDirectory()) {
             return Document.MIME_TYPE_DIR;
         }
@@ -549,7 +289,7 @@
     }
 
     // TODO: Upstream to the Preconditions class.
-    private static class MorePreconditions {
+    static class MorePreconditions {
         static void checkArgumentEquals(String expected, @Nullable String actual,
                 String message) {
             if (!TextUtils.equals(expected, actual)) {
diff --git a/src/com/android/documentsui/archives/ArchiveId.java b/src/com/android/documentsui/archives/ArchiveId.java
index ae23bce..1ce0ede 100644
--- a/src/com/android/documentsui/archives/ArchiveId.java
+++ b/src/com/android/documentsui/archives/ArchiveId.java
@@ -22,22 +22,36 @@
     private final static char DELIMITER = '#';
 
     public final Uri mArchiveUri;
+    public final int mAccessMode;
     public final String mPath;
 
-    public ArchiveId(Uri archiveUri, String path) {
+    public ArchiveId(Uri archiveUri, int accessMode, String path) {
+        assert(archiveUri.toString().indexOf(DELIMITER) == -1);
+        assert(!path.isEmpty());
+
         mArchiveUri = archiveUri;
+        mAccessMode = accessMode;
         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)));
+
+        final int secondDelimiterPosition = documentId.indexOf(DELIMITER, delimiterPosition + 1);
+        assert(secondDelimiterPosition != -1);
+
+        final String archiveUriPart = documentId.substring(0, delimiterPosition);
+        final String accessModePart = documentId.substring(delimiterPosition + 1,
+                secondDelimiterPosition);
+
+        final String pathPart = documentId.substring(secondDelimiterPosition + 1);
+
+        return new ArchiveId(Uri.parse(archiveUriPart), Integer.parseInt(accessModePart),
+                pathPart);
     }
 
     public String toDocumentId() {
-        return mArchiveUri.toString() + DELIMITER + mPath;
+        return mArchiveUri.toString() + DELIMITER + mAccessMode + DELIMITER + mPath;
     }
 };
diff --git a/src/com/android/documentsui/archives/ArchivesProvider.java b/src/com/android/documentsui/archives/ArchivesProvider.java
index 8d5552b..e0ff672 100644
--- a/src/com/android/documentsui/archives/ArchivesProvider.java
+++ b/src/com/android/documentsui/archives/ArchivesProvider.java
@@ -121,7 +121,7 @@
 
             cursor.setExtras(bundle);
             cursor.setNotificationUri(getContext().getContentResolver(),
-                    buildUriForArchive(archiveId.mArchiveUri));
+                    buildUriForArchive(archiveId.mArchiveUri, archiveId.mAccessMode));
             return cursor;
         } finally {
             releaseInstance(loader);
@@ -231,9 +231,15 @@
         return false;
     }
 
-    public static Uri buildUriForArchive(Uri archiveUri) {
-        return DocumentsContract.buildDocumentUri(
-                AUTHORITY, new ArchiveId(archiveUri, "/").toDocumentId());
+    /**
+     * Creates a Uri for accessing an archive with the specified access mode.
+     *
+     * @see ParcelFileDescriptor#MODE_READ
+     * @see ParcelFileDescriptor#MODE_WRITE
+     */
+    public static Uri buildUriForArchive(Uri archiveUri, int accessMode) {
+        return DocumentsContract.buildDocumentUri(AUTHORITY,
+                new ArchiveId(archiveUri, accessMode, "/").toDocumentId());
     }
 
     /**
@@ -263,8 +269,7 @@
         }
     }
 
-    private Loader getInstanceUncheckedLocked(String documentId)
-            throws FileNotFoundException {
+    private Loader getInstanceUncheckedLocked(String documentId) throws FileNotFoundException {
         final ArchiveId id = ArchiveId.fromDocumentId(documentId);
         if (mArchives.get(id.mArchiveUri) != null) {
             return mArchives.get(id.mArchiveUri);
@@ -281,7 +286,8 @@
                 Document.COLUMN_MIME_TYPE));
         Preconditions.checkArgument(isSupportedArchiveType(mimeType));
         final Uri notificationUri = cursor.getNotificationUri();
-        final Loader loader = new Loader(getContext(), id.mArchiveUri, notificationUri);
+        final Loader loader = new Loader(getContext(), id.mArchiveUri, id.mAccessMode,
+                notificationUri);
 
         // Remove the instance from mArchives collection once the archive file changes.
         if (notificationUri != null) {
diff --git a/src/com/android/documentsui/archives/Loader.java b/src/com/android/documentsui/archives/Loader.java
index 2e03c39..6ad6011 100644
--- a/src/com/android/documentsui/archives/Loader.java
+++ b/src/com/android/documentsui/archives/Loader.java
@@ -42,6 +42,7 @@
 
     private final Context mContext;
     private final Uri mArchiveUri;
+    private final int mAccessMode;
     private final Uri mNotificationUri;
     private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
     private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
@@ -50,9 +51,10 @@
     private int mStatus = STATUS_OPENING;
     private Archive mArchive = null;
 
-    Loader(Context context, Uri archiveUri, Uri notificationUri) {
+    Loader(Context context, Uri archiveUri, int accessMode, Uri notificationUri) {
         this.mContext = context;
         this.mArchiveUri = archiveUri;
+        this.mAccessMode = accessMode;
         this.mNotificationUri = notificationUri;
 
         // Start loading the archive immediately in the background.
@@ -77,11 +79,17 @@
         }
 
         try {
-            mArchive = Archive.createForParcelFileDescriptor(
-                    mContext,
-                    mContext.getContentResolver().openFileDescriptor(
-                            mArchiveUri, "r", null /* signal */),
-                    mArchiveUri, mNotificationUri);
+            if (ReadableArchive.supportsAccessMode(mAccessMode)) {
+                mArchive = ReadableArchive.createForParcelFileDescriptor(
+                        mContext,
+                        mContext.getContentResolver().openFileDescriptor(
+                                mArchiveUri, "r", null /* signal */),
+                        mArchiveUri, mAccessMode, mNotificationUri);
+            // TODO:
+            // } else if (WriteableArchive.supportsAccessMode(mAccessMode)) {
+            } else {
+                throw new IllegalStateException("Access mode not supported.");
+            }
             synchronized (mStatusLock) {
                 mStatus = STATUS_OPENED;
             }
@@ -95,7 +103,7 @@
             // Notify observers that the root directory is loaded (or failed)
             // so clients reload it.
             mContext.getContentResolver().notifyChange(
-                    ArchivesProvider.buildUriForArchive(mArchiveUri),
+                    ArchivesProvider.buildUriForArchive(mArchiveUri, mAccessMode),
                     null /* observer */, false /* syncToNetwork */);
         }
 
diff --git a/src/com/android/documentsui/archives/ReadableArchive.java b/src/com/android/documentsui/archives/ReadableArchive.java
new file mode 100644
index 0000000..26e8eec
--- /dev/null
+++ b/src/com/android/documentsui/archives/ReadableArchive.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright (C) 2017 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.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.support.annotation.Nullable;
+import android.util.Log;
+import android.util.jar.StrictJarFile;
+
+import com.android.internal.util.Preconditions;
+
+import libcore.io.IoUtils;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Stack;
+import java.util.zip.ZipEntry;
+
+/**
+ * Provides basic implementation for extracting and accessing
+ * files within archives exposed by a document provider.
+ *
+ * <p>This class is thread safe.
+ */
+public class ReadableArchive extends Archive {
+    private static final String TAG = "Archive";
+
+    private final StrictJarFile mZipFile;
+
+    private ReadableArchive(
+            Context context,
+            @Nullable File file,
+            @Nullable FileDescriptor fd,
+            Uri archiveUri,
+            int accessMode,
+            @Nullable Uri notificationUri)
+            throws IOException {
+        super(context, archiveUri, accessMode, notificationUri);
+        if (!supportsAccessMode(accessMode)) {
+            throw new IllegalStateException("Unsupported access mode.");
+        }
+
+        mZipFile = file != null ?
+                new StrictJarFile(file.getPath(), false /* verify */,
+                        false /* signatures */) :
+                new StrictJarFile(fd, false /* verify */, false /* signatures */);
+
+        ZipEntry entry;
+        String entryPath;
+        final Iterator<ZipEntry> it = mZipFile.iterator();
+        final Stack<ZipEntry> stack = new Stack<>();
+        while (it.hasNext()) {
+            entry = it.next();
+            if (entry.isDirectory() != entry.getName().endsWith("/")) {
+                throw new IOException(
+                        "Directories must have a trailing slash, and files must not.");
+            }
+            entryPath = getEntryPath(entry);
+            if (mEntries.containsKey(entryPath)) {
+                throw new IOException("Multiple entries with the same name are not supported.");
+            }
+            mEntries.put(entryPath, entry);
+            if (entry.isDirectory()) {
+                mTree.put(entryPath, new ArrayList<ZipEntry>());
+            }
+            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
+                stack.push(entry);
+            }
+        }
+
+        int delimiterIndex;
+        String parentPath;
+        ZipEntry parentEntry;
+        List<ZipEntry> parentList;
+
+        // Go through all directories recursively and build a tree structure.
+        while (stack.size() > 0) {
+            entry = stack.pop();
+
+            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) {
+                // 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<>();
+                mTree.put(parentPath, parentList);
+            }
+
+            parentList.add(entry);
+        }
+    }
+
+    /**
+     * @see ParcelFileDescriptor
+     */
+    public static boolean supportsAccessMode(int accessMode) {
+        return accessMode == ParcelFileDescriptor.MODE_READ_ONLY;
+    }
+
+    /**
+     * 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.
+     *
+     * This method takes ownership for the passed descriptor. The caller must
+     * not close it.
+     *
+     * @param context Context of the provider.
+     * @param descriptor File descriptor for the archive's contents.
+     * @param archiveUri Uri of the archive document.
+     * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}.
+     * @param Uri notificationUri Uri for notifying that the archive file has changed.
+     */
+    public static ReadableArchive createForParcelFileDescriptor(
+            Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
+            @Nullable Uri notificationUri)
+            throws IOException {
+        FileDescriptor fd = null;
+        try {
+            if (canSeek(descriptor)) {
+                fd = new FileDescriptor();
+                fd.setInt$(descriptor.detachFd());
+                return new ReadableArchive(context, null, fd, archiveUri, accessMode,
+                        notificationUri);
+            }
+
+            // Fallback for non-seekable file descriptors.
+            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 ReadableArchive(context, snapshotFile, null, archiveUri, accessMode,
+                        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();
+                }
+            }
+        } catch (Exception e) {
+            // Since the method takes ownership of the passed descriptor, close it
+            // on exception.
+            IoUtils.closeQuietly(descriptor);
+            IoUtils.closeQuietly(fd);
+            throw e;
+        }
+    }
+
+    /**
+     * 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;
+        try {
+            pipe = ParcelFileDescriptor.createReliablePipe();
+        } catch (IOException e) {
+            // 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 InputStream inputStream = mZipFile.getInputStream(entry);
+        final ParcelFileDescriptor outputPipe = pipe[1];
+        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 = inputStream.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(inputStream);
+                        }
+                    }
+                });
+
+        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);
+    }
+
+    @Override
+    public void close() {
+        super.close();
+        try {
+            mZipFile.close();
+        } catch (IOException e) {
+            // Silent close.
+        }
+    }
+};
diff --git a/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java b/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
index c196c0c..373ba46 100644
--- a/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
+++ b/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
@@ -70,7 +70,8 @@
     public void testOpen_Success() throws InterruptedException {
         final Uri sourceUri = DocumentsContract.buildDocumentUri(
                 ResourcesProvider.AUTHORITY, "archive.zip");
-        final Uri archiveUri = ArchivesProvider.buildUriForArchive(sourceUri);
+        final Uri archiveUri = ArchivesProvider.buildUriForArchive(sourceUri,
+                ParcelFileDescriptor.MODE_READ_ONLY);
 
         final Uri childrenUri = DocumentsContract.buildChildDocumentsUri(
                 ArchivesProvider.AUTHORITY, DocumentsContract.getDocumentId(archiveUri));
@@ -113,7 +114,8 @@
     public void testOpen_Failure() throws InterruptedException {
         final Uri sourceUri = DocumentsContract.buildDocumentUri(
                 ResourcesProvider.AUTHORITY, "broken.zip");
-        final Uri archiveUri = ArchivesProvider.buildUriForArchive(sourceUri);
+        final Uri archiveUri = ArchivesProvider.buildUriForArchive(sourceUri,
+                ParcelFileDescriptor.MODE_READ_ONLY);
 
         final Uri childrenUri = DocumentsContract.buildChildDocumentsUri(
                 ArchivesProvider.AUTHORITY, DocumentsContract.getDocumentId(archiveUri));
diff --git a/tests/unit/com/android/documentsui/archives/ArchiveTest.java b/tests/unit/com/android/documentsui/archives/ReadableArchiveTest.java
similarity index 82%
rename from tests/unit/com/android/documentsui/archives/ArchiveTest.java
rename to tests/unit/com/android/documentsui/archives/ReadableArchiveTest.java
index e4356e5..9f12186 100644
--- a/tests/unit/com/android/documentsui/archives/ArchiveTest.java
+++ b/tests/unit/com/android/documentsui/archives/ReadableArchiveTest.java
@@ -16,7 +16,7 @@
 
 package com.android.documentsui.archives;
 
-import com.android.documentsui.archives.Archive;
+import com.android.documentsui.archives.ReadableArchive;
 import com.android.documentsui.tests.R;
 
 import android.database.Cursor;
@@ -38,7 +38,7 @@
 import java.util.concurrent.TimeUnit;
 
 @MediumTest
-public class ArchiveTest extends AndroidTestCase {
+public class ReadableArchiveTest 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;
@@ -64,25 +64,26 @@
     }
 
     public static ArchiveId createArchiveId(String path) {
-        return new ArchiveId(ARCHIVE_URI, path);
+        return new ArchiveId(ARCHIVE_URI, ParcelFileDescriptor.MODE_READ_ONLY, path);
     }
 
     public void loadArchive(ParcelFileDescriptor descriptor) throws IOException {
-        mArchive = Archive.createForParcelFileDescriptor(
+        mArchive = ReadableArchive.createForParcelFileDescriptor(
                 InstrumentationRegistry.getTargetContext(),
                 descriptor,
                 ARCHIVE_URI,
+                ParcelFileDescriptor.MODE_READ_ONLY,
                 Uri.parse(NOTIFICATION_URI));
     }
 
     public void testQueryChildDocument() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.archive));
         final Cursor cursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+                createArchiveId("/").toDocumentId(), null, null);
 
         assertTrue(cursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/file1.txt").toDocumentId(),
+                createArchiveId("/file1.txt").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("file1.txt",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -92,7 +93,7 @@
                 cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)));
 
         assertTrue(cursor.moveToNext());
-        assertEquals(new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+        assertEquals(createArchiveId("/dir1/").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir1",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -103,7 +104,7 @@
 
         assertTrue(cursor.moveToNext());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir2/").toDocumentId(),
+                createArchiveId("/dir2/").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir2",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -116,11 +117,11 @@
 
         // Check if querying children works too.
         final Cursor childCursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+                createArchiveId("/dir1/").toDocumentId(), null, null);
 
         assertTrue(childCursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId(),
+                createArchiveId("/dir1/cherries.txt").toDocumentId(),
                 childCursor.getString(childCursor.getColumnIndexOrThrow(
                         Document.COLUMN_DOCUMENT_ID)));
         assertEquals("cherries.txt",
@@ -136,11 +137,11 @@
     public void testQueryChildDocument_NoDirs() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.no_dirs));
         final Cursor cursor = mArchive.queryChildDocuments(
-            new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+            createArchiveId("/").toDocumentId(), null, null);
 
         assertTrue(cursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                createArchiveId("/dir1/").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir1",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -151,11 +152,11 @@
         assertFalse(cursor.moveToNext());
 
         final Cursor childCursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+                createArchiveId("/dir1/").toDocumentId(), null, null);
 
         assertTrue(childCursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                createArchiveId("/dir1/dir2/").toDocumentId(),
                 childCursor.getString(childCursor.getColumnIndexOrThrow(
                         Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir2",
@@ -169,12 +170,12 @@
         assertFalse(childCursor.moveToNext());
 
         final Cursor childCursor2 = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                createArchiveId("/dir1/dir2/").toDocumentId(),
                 null, null);
 
         assertTrue(childCursor2.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/cherries.txt").toDocumentId(),
+                createArchiveId("/dir1/dir2/cherries.txt").toDocumentId(),
                 childCursor2.getString(childCursor.getColumnIndexOrThrow(
                         Document.COLUMN_DOCUMENT_ID)));
         assertFalse(childCursor2.moveToNext());
@@ -183,11 +184,11 @@
     public void testQueryChildDocument_EmptyDirs() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.empty_dirs));
         final Cursor cursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+                createArchiveId("/").toDocumentId(), null, null);
 
         assertTrue(cursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
+                createArchiveId("/dir1/").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir1",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -198,11 +199,11 @@
         assertFalse(cursor.moveToNext());
 
         final Cursor childCursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(), null, null);
+                createArchiveId("/dir1/").toDocumentId(), null, null);
 
         assertTrue(childCursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                createArchiveId("/dir1/dir2/").toDocumentId(),
                 childCursor.getString(childCursor.getColumnIndexOrThrow(
                         Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir2",
@@ -216,7 +217,7 @@
 
         assertTrue(childCursor.moveToNext());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir3/").toDocumentId(),
+                createArchiveId("/dir1/dir3/").toDocumentId(),
                 childCursor.getString(childCursor.getColumnIndexOrThrow(
                         Document.COLUMN_DOCUMENT_ID)));
         assertEquals("dir3",
@@ -230,12 +231,12 @@
         assertFalse(cursor.moveToNext());
 
         final Cursor childCursor2 = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir2/").toDocumentId(),
+                createArchiveId("/dir1/dir2/").toDocumentId(),
                 null, null);
         assertFalse(childCursor2.moveToFirst());
 
         final Cursor childCursor3 = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/dir1/dir3/").toDocumentId(),
+                createArchiveId("/dir1/dir3/").toDocumentId(),
                 null, null);
         assertFalse(childCursor3.moveToFirst());
     }
@@ -243,34 +244,34 @@
     public void testGetDocumentType() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.archive));
         assertEquals(Document.MIME_TYPE_DIR, mArchive.getDocumentType(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId()));
+                createArchiveId("/dir1/").toDocumentId()));
         assertEquals("text/plain", mArchive.getDocumentType(
-                new ArchiveId(ARCHIVE_URI, "/file1.txt").toDocumentId()));
+                createArchiveId("/file1.txt").toDocumentId()));
     }
 
     public void testIsChildDocument() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.archive));
-        final String documentId = new ArchiveId(ARCHIVE_URI, "/").toDocumentId();
+        final String documentId = createArchiveId("/").toDocumentId();
         assertTrue(mArchive.isChildDocument(documentId,
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId()));
+                createArchiveId("/dir1/").toDocumentId()));
         assertFalse(mArchive.isChildDocument(documentId,
-                new ArchiveId(ARCHIVE_URI, "/this-does-not-exist").toDocumentId()));
+                createArchiveId("/this-does-not-exist").toDocumentId()));
         assertTrue(mArchive.isChildDocument(
-                new ArchiveId(ARCHIVE_URI, "/dir1/").toDocumentId(),
-                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId()));
+                createArchiveId("/dir1/").toDocumentId(),
+                createArchiveId("/dir1/cherries.txt").toDocumentId()));
         assertTrue(mArchive.isChildDocument(documentId,
-                new ArchiveId(ARCHIVE_URI, "/dir1/cherries.txt").toDocumentId()));
+                createArchiveId("/dir1/cherries.txt").toDocumentId()));
     }
 
     public void testQueryDocument() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.archive));
         final Cursor cursor = mArchive.queryDocument(
-                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                createArchiveId("/dir2/strawberries.txt").toDocumentId(),
                 null);
 
         assertTrue(cursor.moveToFirst());
         assertEquals(
-                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                createArchiveId("/dir2/strawberries.txt").toDocumentId(),
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)));
         assertEquals("strawberries.txt",
                 cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)));
@@ -293,7 +294,7 @@
     // Common part of testOpenDocument and testOpenDocument_NonSeekable.
     void commonTestOpenDocument() throws IOException {
         final ParcelFileDescriptor descriptor = mArchive.openDocument(
-                new ArchiveId(ARCHIVE_URI, "/dir2/strawberries.txt").toDocumentId(),
+                createArchiveId("/dir2/strawberries.txt").toDocumentId(),
                 "r", null /* signal */);
         try (final ParcelFileDescriptor.AutoCloseInputStream inputStream =
                 new ParcelFileDescriptor.AutoCloseInputStream(descriptor)) {
@@ -309,6 +310,6 @@
     public void testBrokenArchive() throws IOException {
         loadArchive(mTestUtils.getNonSeekableDescriptor(R.raw.archive));
         final Cursor cursor = mArchive.queryChildDocuments(
-                new ArchiveId(ARCHIVE_URI, "/").toDocumentId(), null, null);
+                createArchiveId("/").toDocumentId(), null, null);
     }
 }