diff options
author | 2017-01-24 18:54:42 +0900 | |
---|---|---|
committer | 2017-02-03 00:46:37 +0000 | |
commit | c8fb30f1b909e2c6178ac7ad9980b48ed6b6d2c8 (patch) | |
tree | 6a5893f12d3ba13b1cef9a71f8433f7e5d22e01e | |
parent | 05376628da19c7247c909f86cf2324c32f08c25b (diff) |
Refactor archives to support creating archives.
Archive class used StrictJarFile, which only supports reading.
This CL makes Archive a base class which is going to have two
subclasses: ReadableArchive (current Archive) and WriteableArchive.
ReadableArchive will be used to open archives with StrictJarFile.
WriteableArchive will be used to create archives with ZipOutputStream.
Test: Unit tests.
Bug: 20822019
Change-Id: I40174c8d970bc3098929854231622e3006f6263e
(cherry picked from commit d683f9756b0c56d28693c4d0269217e8fcebf76a)
(cherry picked from commit c8d3007d5ce12d0bc04bbda018c47889fabc8fd3)
-rw-r--r-- | src/com/android/documentsui/DocumentsAccess.java | 4 | ||||
-rw-r--r-- | src/com/android/documentsui/archives/Archive.java | 310 | ||||
-rw-r--r-- | src/com/android/documentsui/archives/ArchiveId.java | 24 | ||||
-rw-r--r-- | src/com/android/documentsui/archives/ArchivesProvider.java | 20 | ||||
-rw-r--r-- | src/com/android/documentsui/archives/Loader.java | 22 | ||||
-rw-r--r-- | src/com/android/documentsui/archives/ReadableArchive.java | 347 | ||||
-rw-r--r-- | tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java | 6 | ||||
-rw-r--r-- | tests/unit/com/android/documentsui/archives/ReadableArchiveTest.java (renamed from tests/unit/com/android/documentsui/archives/ArchiveTest.java) | 71 |
8 files changed, 462 insertions, 342 deletions
diff --git a/src/com/android/documentsui/DocumentsAccess.java b/src/com/android/documentsui/DocumentsAccess.java index 63f235c55..5d09aa610 100644 --- a/src/com/android/documentsui/DocumentsAccess.java +++ b/src/com/android/documentsui/DocumentsAccess.java @@ -22,6 +22,7 @@ import android.content.ContentResolver; 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 @@ public interface DocumentsAccess { @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 496ffdc00..8e889ff6d 100644 --- a/src/com/android/documentsui/archives/Archive.java +++ b/src/com/android/documentsui/archives/Archive.java @@ -21,44 +21,26 @@ 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.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 @@ import java.util.zip.ZipEntry; * * <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 @@ public class Archive implements Closeable { 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 @@ public class Archive implements Closeable { 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 @@ public class Archive implements Closeable { * 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 @@ public class Archive implements Closeable { } /** - * 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 @@ public class Archive implements Closeable { * * @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 @@ public class Archive implements Closeable { @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 @@ public class Archive implements Closeable { 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 @@ public class Archive implements Closeable { } // 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 ae23bcee7..1ce0ede3e 100644 --- a/src/com/android/documentsui/archives/ArchiveId.java +++ b/src/com/android/documentsui/archives/ArchiveId.java @@ -22,22 +22,36 @@ public class ArchiveId { 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 8d5552bf8..e0ff6728a 100644 --- a/src/com/android/documentsui/archives/ArchivesProvider.java +++ b/src/com/android/documentsui/archives/ArchivesProvider.java @@ -121,7 +121,7 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable { cursor.setExtras(bundle); cursor.setNotificationUri(getContext().getContentResolver(), - buildUriForArchive(archiveId.mArchiveUri)); + buildUriForArchive(archiveId.mArchiveUri, archiveId.mAccessMode)); return cursor; } finally { releaseInstance(loader); @@ -231,9 +231,15 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable { 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 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable { } } - 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 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable { 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 2e03c3933..6ad601149 100644 --- a/src/com/android/documentsui/archives/Loader.java +++ b/src/com/android/documentsui/archives/Loader.java @@ -42,6 +42,7 @@ public class Loader { 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 @@ public class Loader { 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 @@ public class Loader { } 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 @@ public class Loader { // 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 000000000..26e8eec64 --- /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 c196c0c72..373ba4646 100644 --- a/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java +++ b/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java @@ -70,7 +70,8 @@ public class ArchivesProviderTest extends AndroidTestCase { 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 class ArchivesProviderTest extends AndroidTestCase { 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 index e4356e525..9f12186d9 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.Executors; 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 class ArchiveTest extends AndroidTestCase { } 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 @@ public class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { // 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 class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { 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 class ArchiveTest extends AndroidTestCase { 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 @@ public class ArchiveTest extends AndroidTestCase { // 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 class ArchiveTest extends AndroidTestCase { 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); } } |