Loading AndroidManifest.xml +7 −0 Original line number Diff line number Diff line Loading @@ -108,6 +108,13 @@ android:authorities="com.android.documentsui.lastAccessed" android:exported="false"/> <provider android:name=".archives.ArchivesProvider" android:authorities="com.android.documentsui.archives" android:grantUriPermissions="true" android:permission="android.permission.MANAGE_DOCUMENTS" android:exported="true"/> <receiver android:name=".PackageReceiver"> <intent-filter> <action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" /> Loading src/com/android/documentsui/AbstractActionHandler.java +14 −3 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import android.os.Parcelable; import android.support.annotation.VisibleForTesting; import com.android.documentsui.AbstractActionHandler.CommonAddons; import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.BooleanConsumer; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; Loading @@ -32,8 +33,8 @@ import com.android.documentsui.base.Lookup; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.Shared; import com.android.documentsui.base.State; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.AnimationView.AnimationType; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.DocumentDetails; import com.android.documentsui.files.LauncherActivity; import com.android.documentsui.files.OpenUriForViewTask; Loading Loading @@ -158,10 +159,20 @@ public abstract class AbstractActionHandler<T extends Activity & CommonAddons> @Override public void openContainerDocument(DocumentInfo doc) { assert(doc.isContainer()); DocumentInfo currentDoc = null; if (doc.isDirectory()) { // Regular directory. currentDoc = doc; } else if (doc.isArchive()) { // Archive. currentDoc = mDocs.getArchiveDocument(doc.derivedUri); } mActivity.notifyDirectoryNavigated(doc.derivedUri); assert(currentDoc != null); mActivity.notifyDirectoryNavigated(currentDoc.derivedUri); mState.pushDocument(currentDoc); mState.pushDocument(doc); // Show an opening animation only if pressing "back" would get us back to the // previous directory. Especially after opening a root document, pressing // back, wouldn't go to the previous root, but close the activity. Loading src/com/android/documentsui/DocumentsAccess.java +7 −0 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ import android.net.Uri; import android.provider.DocumentsContract; import android.util.Log; import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.RootInfo; Loading @@ -35,6 +36,7 @@ public interface DocumentsAccess { @Nullable DocumentInfo getRootDocument(RootInfo root); @Nullable DocumentInfo getRootDocument(Uri uri); @Nullable DocumentInfo getDocument(Uri uri); @Nullable DocumentInfo getArchiveDocument(Uri uri); public static DocumentsAccess create(Context context) { return new RuntimeDocumentAccess(context); Loading Loading @@ -76,5 +78,10 @@ public interface DocumentsAccess { return null; } @Override public DocumentInfo getArchiveDocument(Uri uri) { return getDocument(ArchivesProvider.buildUriForArchive(uri)); } } } src/com/android/documentsui/archives/Archive.java 0 → 100644 +501 −0 Original line number Diff line number Diff line /* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.archives; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Point; import android.media.ExifInterface; import android.net.Uri; import android.os.Bundle; import android.os.CancellationSignal; import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.provider.DocumentsProvider; import android.support.annotation.Nullable; import android.util.Log; import android.webkit.MimeTypeMap; import libcore.io.IoUtils; import com.android.internal.util.Preconditions; import java.io.Closeable; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.lang.IllegalArgumentException; import java.lang.IllegalStateException; import java.lang.UnsupportedOperationException; import android.text.TextUtils; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Stack; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; /** * Provides basic implementation for creating, extracting and accessing * files within archives exposed by a document provider. * * <p>This class is thread safe. */ public class Archive implements Closeable { private static final String TAG = "Archive"; public static final String[] DEFAULT_PROJECTION = new String[] { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, Document.COLUMN_FLAGS }; private final Context mContext; private final Uri mArchiveUri; private final Uri mNotificationUri; private final ZipFile mZipFile; private final ExecutorService mExecutor; private final Map<String, ZipEntry> mEntries; private final Map<String, List<ZipEntry>> mTree; private Archive( Context context, File file, Uri archiveUri, @Nullable Uri notificationUri) throws IOException { mContext = context; mArchiveUri = archiveUri; mNotificationUri = notificationUri; mZipFile = new ZipFile(file); mExecutor = Executors.newSingleThreadExecutor(); // Build the tree structure in memory. mTree = new HashMap<String, List<ZipEntry>>(); mTree.put("/", new ArrayList<ZipEntry>()); mEntries = new HashMap<String, ZipEntry>(); ZipEntry entry; final List<? extends ZipEntry> entries = Collections.list(mZipFile.entries()); final Stack<ZipEntry> stack = new Stack<>(); for (int i = entries.size() - 1; i >= 0; i--) { entry = entries.get(i); if (entry.isDirectory() != entry.getName().endsWith("/")) { throw new IOException( "Directories must have a trailing slash, and files must not."); } if (mEntries.containsKey(entry.getName())) { throw new IOException("Multiple entries with the same name are not supported."); } mEntries.put(entry.getName(), entry); if (entry.isDirectory()) { mTree.put(entry.getName(), new ArrayList<ZipEntry>()); } stack.push(entry); } int delimiterIndex; String parentPath; ZipEntry parentEntry; List<ZipEntry> parentList; while (stack.size() > 0) { entry = stack.pop(); delimiterIndex = entry.getName().lastIndexOf('/', entry.isDirectory() ? entry.getName().length() - 2 : entry.getName().length() - 1); parentPath = delimiterIndex != -1 ? entry.getName().substring(0, delimiterIndex) + "/" : "/"; parentList = mTree.get(parentPath); if (parentList == null) { parentEntry = mEntries.get(parentPath); if (parentEntry == null) { // The ZIP file doesn't contain all directories leading to the entry. // It's rare, but can happen in a valid ZIP archive. In such case create a // fake ZipEntry and add it on top of the stack to process it next. parentEntry = new ZipEntry(parentPath); parentEntry.setSize(0); parentEntry.setTime(entry.getTime()); mEntries.put(parentPath, parentEntry); stack.push(parentEntry); } parentList = new ArrayList<ZipEntry>(); mTree.put(parentPath, parentList); } parentList.add(entry); } } /** * Creates a DocumentsArchive instance for opening, browsing and accessing * documents within the archive passed as a file descriptor. * * @param context Context of the provider. * @param descriptor File descriptor for the archive's contents. * @param archiveUri Uri of the archive document. * @param Uri notificationUri Uri for notifying that the archive file has changed. */ public static Archive createForParcelFileDescriptor( Context context, ParcelFileDescriptor descriptor, Uri archiveUri, @Nullable Uri notificationUri) throws IOException { File snapshotFile = null; try { // Create a copy of the archive, as ZipFile doesn't operate on streams. // Moreover, ZipInputStream would be inefficient for large files on // pipes. snapshotFile = File.createTempFile("com.android.documentsui.snapshot{", "}.zip", context.getCacheDir()); try ( final FileOutputStream outputStream = new ParcelFileDescriptor.AutoCloseOutputStream( ParcelFileDescriptor.open( snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY)); final ParcelFileDescriptor.AutoCloseInputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(descriptor); ) { final byte[] buffer = new byte[32 * 1024]; int bytes; while ((bytes = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytes); } outputStream.flush(); return new Archive(context, snapshotFile, archiveUri, notificationUri); } } finally { // On UNIX the file will be still available for processes which opened it, even // after deleting it. Remove it ASAP, as it won't be used by anyone else. if (snapshotFile != null) { snapshotFile.delete(); } } } /** * Lists child documents of an archive or a directory within an * archive. Must be called only for archives with supported mime type, * or for documents within archives. * * @see DocumentsProvider.queryChildDocuments(String, String[], String) */ public Cursor queryChildDocuments(String documentId, @Nullable String[] projection, @Nullable String sortOrder) throws FileNotFoundException { final ArchiveId parsedParentId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final MatrixCursor result = new MatrixCursor( projection != null ? projection : DEFAULT_PROJECTION); if (mNotificationUri != null) { result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); } final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath); if (parentList == null) { throw new FileNotFoundException(); } for (final ZipEntry entry : parentList) { addCursorRow(result, entry); } return result; } /** * Returns a MIME type of a document within an archive. * * @see DocumentsProvider.getDocumentType(String) */ public String getDocumentType(String documentId) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } return getMimeTypeForEntry(entry); } /** * Returns true if a document within an archive is a child or any descendant of the archive * document or another document within the archive. * * @see DocumentsProvider.isChildDocument(String, String) */ public boolean isChildDocument(String parentDocumentId, String documentId) { final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId); final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { return false; } final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath); if (parentEntry == null || !parentEntry.isDirectory()) { return false; } final String parentPath = entry.getName(); // Add a trailing slash even if it's not a directory, so it's easy to check if the // entry is a descendant. final String pathWithSlash = entry.isDirectory() ? entry.getName() : entry.getName() + "/"; return pathWithSlash.startsWith(parentPath) && !parentPath.equals(pathWithSlash); } /** * Returns metadata of a document within an archive. * * @see DocumentsProvider.queryDocument(String, String[]) */ public Cursor queryDocument(String documentId, @Nullable String[] projection) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } final MatrixCursor result = new MatrixCursor( projection != null ? projection : DEFAULT_PROJECTION); if (mNotificationUri != null) { result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); } addCursorRow(result, entry); return result; } /** * Opens a file within an archive. * * @see DocumentsProvider.openDocument(String, String, CancellationSignal)) */ public ParcelFileDescriptor openDocument( String documentId, String mode, @Nullable final CancellationSignal signal) throws FileNotFoundException { MorePreconditions.checkArgumentEquals("r", mode, "Invalid mode. Only reading \"r\" supported, but got: \"%s\"."); final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } ParcelFileDescriptor[] pipe; InputStream inputStream = null; try { pipe = ParcelFileDescriptor.createReliablePipe(); inputStream = mZipFile.getInputStream(entry); } catch (IOException e) { if (inputStream != null) { IoUtils.closeQuietly(inputStream); } // Ideally we'd simply throw IOException to the caller, but for consistency // with DocumentsProvider::openDocument, converting it to IllegalStateException. throw new IllegalStateException("Failed to open the document.", e); } final ParcelFileDescriptor outputPipe = pipe[1]; final InputStream finalInputStream = inputStream; mExecutor.execute( new Runnable() { @Override public void run() { try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream = new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) { try { final byte buffer[] = new byte[32 * 1024]; int bytes; while ((bytes = finalInputStream.read(buffer)) != -1) { if (Thread.interrupted()) { throw new InterruptedException(); } if (signal != null) { signal.throwIfCanceled(); } outputStream.write(buffer, 0, bytes); } } catch (IOException | InterruptedException e) { // Catch the exception before the outer try-with-resource closes the // pipe with close() instead of closeWithError(). try { outputPipe.closeWithError(e.getMessage()); } catch (IOException e2) { Log.e(TAG, "Failed to close the pipe after an error.", e2); } } } catch (OperationCanceledException e) { // Cancelled gracefully. } catch (IOException e) { Log.e(TAG, "Failed to close the output stream gracefully.", e); } finally { IoUtils.closeQuietly(finalInputStream); } } }); return pipe[0]; } /** * Opens a thumbnail of a file within an archive. * * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal)) */ public AssetFileDescriptor openDocumentThumbnail( String documentId, Point sizeHint, final CancellationSignal signal) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"), "Thumbnails only supported for image/* MIME type."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } InputStream inputStream = null; try { inputStream = mZipFile.getInputStream(entry); final ExifInterface exif = new ExifInterface(inputStream); if (exif.hasThumbnail()) { Bundle extras = null; switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) { case ExifInterface.ORIENTATION_ROTATE_90: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90); break; case ExifInterface.ORIENTATION_ROTATE_180: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180); break; case ExifInterface.ORIENTATION_ROTATE_270: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270); break; } final long[] range = exif.getThumbnailRange(); return new AssetFileDescriptor( openDocument(documentId, "r", signal), range[0], range[1], extras); } } catch (IOException e) { // Ignore the exception, as reading the EXIF may legally fail. Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e); } finally { IoUtils.closeQuietly(inputStream); } return new AssetFileDescriptor( openDocument(documentId, "r", signal), 0, entry.getSize(), null); } /** * Schedules a gracefully close of the archive after any opened files are closed. * * <p>This method does not block until shutdown. Once called, other methods should not be * called. */ @Override public void close() { mExecutor.execute(new Runnable() { @Override public void run() { IoUtils.closeQuietly(mZipFile); } }); mExecutor.shutdown(); } private void addCursorRow(MatrixCursor cursor, ZipEntry entry) { final MatrixCursor.RowBuilder row = cursor.newRow(); final ArchiveId parsedId = new ArchiveId(mArchiveUri, entry.getName()); row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId()); final File file = new File(entry.getName()); row.add(Document.COLUMN_DISPLAY_NAME, file.getName()); row.add(Document.COLUMN_SIZE, entry.getSize()); final String mimeType = getMimeTypeForEntry(entry); row.add(Document.COLUMN_MIME_TYPE, mimeType); final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0; row.add(Document.COLUMN_FLAGS, flags); } private String getMimeTypeForEntry(ZipEntry entry) { if (entry.isDirectory()) { return Document.MIME_TYPE_DIR; } final int lastDot = entry.getName().lastIndexOf('.'); if (lastDot >= 0) { final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US); final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if (mimeType != null) { return mimeType; } } return "application/octet-stream"; } // TODO: Upstream to the Preconditions class. private static class MorePreconditions { static void checkArgumentEquals(String expected, @Nullable String actual, String message) { if (!TextUtils.equals(expected, actual)) { throw new IllegalArgumentException(String.format(message, String.valueOf(expected), String.valueOf(actual))); } } static void checkArgumentEquals(Uri expected, @Nullable Uri actual, String message) { checkArgumentEquals(expected.toString(), actual.toString(), message); } } }; Loading
AndroidManifest.xml +7 −0 Original line number Diff line number Diff line Loading @@ -108,6 +108,13 @@ android:authorities="com.android.documentsui.lastAccessed" android:exported="false"/> <provider android:name=".archives.ArchivesProvider" android:authorities="com.android.documentsui.archives" android:grantUriPermissions="true" android:permission="android.permission.MANAGE_DOCUMENTS" android:exported="true"/> <receiver android:name=".PackageReceiver"> <intent-filter> <action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" /> Loading
src/com/android/documentsui/AbstractActionHandler.java +14 −3 Original line number Diff line number Diff line Loading @@ -25,6 +25,7 @@ import android.os.Parcelable; import android.support.annotation.VisibleForTesting; import com.android.documentsui.AbstractActionHandler.CommonAddons; import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.BooleanConsumer; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; Loading @@ -32,8 +33,8 @@ import com.android.documentsui.base.Lookup; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.Shared; import com.android.documentsui.base.State; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.AnimationView.AnimationType; import com.android.documentsui.dirlist.AnimationView; import com.android.documentsui.dirlist.DocumentDetails; import com.android.documentsui.files.LauncherActivity; import com.android.documentsui.files.OpenUriForViewTask; Loading Loading @@ -158,10 +159,20 @@ public abstract class AbstractActionHandler<T extends Activity & CommonAddons> @Override public void openContainerDocument(DocumentInfo doc) { assert(doc.isContainer()); DocumentInfo currentDoc = null; if (doc.isDirectory()) { // Regular directory. currentDoc = doc; } else if (doc.isArchive()) { // Archive. currentDoc = mDocs.getArchiveDocument(doc.derivedUri); } mActivity.notifyDirectoryNavigated(doc.derivedUri); assert(currentDoc != null); mActivity.notifyDirectoryNavigated(currentDoc.derivedUri); mState.pushDocument(currentDoc); mState.pushDocument(doc); // Show an opening animation only if pressing "back" would get us back to the // previous directory. Especially after opening a root document, pressing // back, wouldn't go to the previous root, but close the activity. Loading
src/com/android/documentsui/DocumentsAccess.java +7 −0 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ import android.net.Uri; import android.provider.DocumentsContract; import android.util.Log; import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.RootInfo; Loading @@ -35,6 +36,7 @@ public interface DocumentsAccess { @Nullable DocumentInfo getRootDocument(RootInfo root); @Nullable DocumentInfo getRootDocument(Uri uri); @Nullable DocumentInfo getDocument(Uri uri); @Nullable DocumentInfo getArchiveDocument(Uri uri); public static DocumentsAccess create(Context context) { return new RuntimeDocumentAccess(context); Loading Loading @@ -76,5 +78,10 @@ public interface DocumentsAccess { return null; } @Override public DocumentInfo getArchiveDocument(Uri uri) { return getDocument(ArchivesProvider.buildUriForArchive(uri)); } } }
src/com/android/documentsui/archives/Archive.java 0 → 100644 +501 −0 Original line number Diff line number Diff line /* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.archives; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Point; import android.media.ExifInterface; import android.net.Uri; import android.os.Bundle; import android.os.CancellationSignal; import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.provider.DocumentsProvider; import android.support.annotation.Nullable; import android.util.Log; import android.webkit.MimeTypeMap; import libcore.io.IoUtils; import com.android.internal.util.Preconditions; import java.io.Closeable; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.lang.IllegalArgumentException; import java.lang.IllegalStateException; import java.lang.UnsupportedOperationException; import android.text.TextUtils; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Stack; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; /** * Provides basic implementation for creating, extracting and accessing * files within archives exposed by a document provider. * * <p>This class is thread safe. */ public class Archive implements Closeable { private static final String TAG = "Archive"; public static final String[] DEFAULT_PROJECTION = new String[] { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, Document.COLUMN_FLAGS }; private final Context mContext; private final Uri mArchiveUri; private final Uri mNotificationUri; private final ZipFile mZipFile; private final ExecutorService mExecutor; private final Map<String, ZipEntry> mEntries; private final Map<String, List<ZipEntry>> mTree; private Archive( Context context, File file, Uri archiveUri, @Nullable Uri notificationUri) throws IOException { mContext = context; mArchiveUri = archiveUri; mNotificationUri = notificationUri; mZipFile = new ZipFile(file); mExecutor = Executors.newSingleThreadExecutor(); // Build the tree structure in memory. mTree = new HashMap<String, List<ZipEntry>>(); mTree.put("/", new ArrayList<ZipEntry>()); mEntries = new HashMap<String, ZipEntry>(); ZipEntry entry; final List<? extends ZipEntry> entries = Collections.list(mZipFile.entries()); final Stack<ZipEntry> stack = new Stack<>(); for (int i = entries.size() - 1; i >= 0; i--) { entry = entries.get(i); if (entry.isDirectory() != entry.getName().endsWith("/")) { throw new IOException( "Directories must have a trailing slash, and files must not."); } if (mEntries.containsKey(entry.getName())) { throw new IOException("Multiple entries with the same name are not supported."); } mEntries.put(entry.getName(), entry); if (entry.isDirectory()) { mTree.put(entry.getName(), new ArrayList<ZipEntry>()); } stack.push(entry); } int delimiterIndex; String parentPath; ZipEntry parentEntry; List<ZipEntry> parentList; while (stack.size() > 0) { entry = stack.pop(); delimiterIndex = entry.getName().lastIndexOf('/', entry.isDirectory() ? entry.getName().length() - 2 : entry.getName().length() - 1); parentPath = delimiterIndex != -1 ? entry.getName().substring(0, delimiterIndex) + "/" : "/"; parentList = mTree.get(parentPath); if (parentList == null) { parentEntry = mEntries.get(parentPath); if (parentEntry == null) { // The ZIP file doesn't contain all directories leading to the entry. // It's rare, but can happen in a valid ZIP archive. In such case create a // fake ZipEntry and add it on top of the stack to process it next. parentEntry = new ZipEntry(parentPath); parentEntry.setSize(0); parentEntry.setTime(entry.getTime()); mEntries.put(parentPath, parentEntry); stack.push(parentEntry); } parentList = new ArrayList<ZipEntry>(); mTree.put(parentPath, parentList); } parentList.add(entry); } } /** * Creates a DocumentsArchive instance for opening, browsing and accessing * documents within the archive passed as a file descriptor. * * @param context Context of the provider. * @param descriptor File descriptor for the archive's contents. * @param archiveUri Uri of the archive document. * @param Uri notificationUri Uri for notifying that the archive file has changed. */ public static Archive createForParcelFileDescriptor( Context context, ParcelFileDescriptor descriptor, Uri archiveUri, @Nullable Uri notificationUri) throws IOException { File snapshotFile = null; try { // Create a copy of the archive, as ZipFile doesn't operate on streams. // Moreover, ZipInputStream would be inefficient for large files on // pipes. snapshotFile = File.createTempFile("com.android.documentsui.snapshot{", "}.zip", context.getCacheDir()); try ( final FileOutputStream outputStream = new ParcelFileDescriptor.AutoCloseOutputStream( ParcelFileDescriptor.open( snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY)); final ParcelFileDescriptor.AutoCloseInputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(descriptor); ) { final byte[] buffer = new byte[32 * 1024]; int bytes; while ((bytes = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytes); } outputStream.flush(); return new Archive(context, snapshotFile, archiveUri, notificationUri); } } finally { // On UNIX the file will be still available for processes which opened it, even // after deleting it. Remove it ASAP, as it won't be used by anyone else. if (snapshotFile != null) { snapshotFile.delete(); } } } /** * Lists child documents of an archive or a directory within an * archive. Must be called only for archives with supported mime type, * or for documents within archives. * * @see DocumentsProvider.queryChildDocuments(String, String[], String) */ public Cursor queryChildDocuments(String documentId, @Nullable String[] projection, @Nullable String sortOrder) throws FileNotFoundException { final ArchiveId parsedParentId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final MatrixCursor result = new MatrixCursor( projection != null ? projection : DEFAULT_PROJECTION); if (mNotificationUri != null) { result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); } final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath); if (parentList == null) { throw new FileNotFoundException(); } for (final ZipEntry entry : parentList) { addCursorRow(result, entry); } return result; } /** * Returns a MIME type of a document within an archive. * * @see DocumentsProvider.getDocumentType(String) */ public String getDocumentType(String documentId) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } return getMimeTypeForEntry(entry); } /** * Returns true if a document within an archive is a child or any descendant of the archive * document or another document within the archive. * * @see DocumentsProvider.isChildDocument(String, String) */ public boolean isChildDocument(String parentDocumentId, String documentId) { final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId); final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { return false; } final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath); if (parentEntry == null || !parentEntry.isDirectory()) { return false; } final String parentPath = entry.getName(); // Add a trailing slash even if it's not a directory, so it's easy to check if the // entry is a descendant. final String pathWithSlash = entry.isDirectory() ? entry.getName() : entry.getName() + "/"; return pathWithSlash.startsWith(parentPath) && !parentPath.equals(pathWithSlash); } /** * Returns metadata of a document within an archive. * * @see DocumentsProvider.queryDocument(String, String[]) */ public Cursor queryDocument(String documentId, @Nullable String[] projection) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } final MatrixCursor result = new MatrixCursor( projection != null ? projection : DEFAULT_PROJECTION); if (mNotificationUri != null) { result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); } addCursorRow(result, entry); return result; } /** * Opens a file within an archive. * * @see DocumentsProvider.openDocument(String, String, CancellationSignal)) */ public ParcelFileDescriptor openDocument( String documentId, String mode, @Nullable final CancellationSignal signal) throws FileNotFoundException { MorePreconditions.checkArgumentEquals("r", mode, "Invalid mode. Only reading \"r\" supported, but got: \"%s\"."); final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } ParcelFileDescriptor[] pipe; InputStream inputStream = null; try { pipe = ParcelFileDescriptor.createReliablePipe(); inputStream = mZipFile.getInputStream(entry); } catch (IOException e) { if (inputStream != null) { IoUtils.closeQuietly(inputStream); } // Ideally we'd simply throw IOException to the caller, but for consistency // with DocumentsProvider::openDocument, converting it to IllegalStateException. throw new IllegalStateException("Failed to open the document.", e); } final ParcelFileDescriptor outputPipe = pipe[1]; final InputStream finalInputStream = inputStream; mExecutor.execute( new Runnable() { @Override public void run() { try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream = new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) { try { final byte buffer[] = new byte[32 * 1024]; int bytes; while ((bytes = finalInputStream.read(buffer)) != -1) { if (Thread.interrupted()) { throw new InterruptedException(); } if (signal != null) { signal.throwIfCanceled(); } outputStream.write(buffer, 0, bytes); } } catch (IOException | InterruptedException e) { // Catch the exception before the outer try-with-resource closes the // pipe with close() instead of closeWithError(). try { outputPipe.closeWithError(e.getMessage()); } catch (IOException e2) { Log.e(TAG, "Failed to close the pipe after an error.", e2); } } } catch (OperationCanceledException e) { // Cancelled gracefully. } catch (IOException e) { Log.e(TAG, "Failed to close the output stream gracefully.", e); } finally { IoUtils.closeQuietly(finalInputStream); } } }); return pipe[0]; } /** * Opens a thumbnail of a file within an archive. * * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal)) */ public AssetFileDescriptor openDocumentThumbnail( String documentId, Point sizeHint, final CancellationSignal signal) throws FileNotFoundException { final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, "Mismatching archive Uri. Expected: %s, actual: %s."); Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"), "Thumbnails only supported for image/* MIME type."); final ZipEntry entry = mEntries.get(parsedId.mPath); if (entry == null) { throw new FileNotFoundException(); } InputStream inputStream = null; try { inputStream = mZipFile.getInputStream(entry); final ExifInterface exif = new ExifInterface(inputStream); if (exif.hasThumbnail()) { Bundle extras = null; switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) { case ExifInterface.ORIENTATION_ROTATE_90: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90); break; case ExifInterface.ORIENTATION_ROTATE_180: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180); break; case ExifInterface.ORIENTATION_ROTATE_270: extras = new Bundle(1); extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270); break; } final long[] range = exif.getThumbnailRange(); return new AssetFileDescriptor( openDocument(documentId, "r", signal), range[0], range[1], extras); } } catch (IOException e) { // Ignore the exception, as reading the EXIF may legally fail. Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e); } finally { IoUtils.closeQuietly(inputStream); } return new AssetFileDescriptor( openDocument(documentId, "r", signal), 0, entry.getSize(), null); } /** * Schedules a gracefully close of the archive after any opened files are closed. * * <p>This method does not block until shutdown. Once called, other methods should not be * called. */ @Override public void close() { mExecutor.execute(new Runnable() { @Override public void run() { IoUtils.closeQuietly(mZipFile); } }); mExecutor.shutdown(); } private void addCursorRow(MatrixCursor cursor, ZipEntry entry) { final MatrixCursor.RowBuilder row = cursor.newRow(); final ArchiveId parsedId = new ArchiveId(mArchiveUri, entry.getName()); row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId()); final File file = new File(entry.getName()); row.add(Document.COLUMN_DISPLAY_NAME, file.getName()); row.add(Document.COLUMN_SIZE, entry.getSize()); final String mimeType = getMimeTypeForEntry(entry); row.add(Document.COLUMN_MIME_TYPE, mimeType); final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0; row.add(Document.COLUMN_FLAGS, flags); } private String getMimeTypeForEntry(ZipEntry entry) { if (entry.isDirectory()) { return Document.MIME_TYPE_DIR; } final int lastDot = entry.getName().lastIndexOf('.'); if (lastDot >= 0) { final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US); final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if (mimeType != null) { return mimeType; } } return "application/octet-stream"; } // TODO: Upstream to the Preconditions class. private static class MorePreconditions { static void checkArgumentEquals(String expected, @Nullable String actual, String message) { if (!TextUtils.equals(expected, actual)) { throw new IllegalArgumentException(String.format(message, String.valueOf(expected), String.valueOf(actual))); } } static void checkArgumentEquals(Uri expected, @Nullable Uri actual, String message) { checkArgumentEquals(expected.toString(), actual.toString(), message); } } };