blob: 5db5b38b53e0099f5a5028a7efc2ce65ef7d470f [file] [log] [blame]
/*
* 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 android.support.provider;
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 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 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. The id delimiter
* must be a character which is not used in document ids generated by the
* document provider.
*
* <p>This class is thread safe.
*
* @hide
*/
public class DocumentArchive implements Closeable {
private static final String TAG = "DocumentArchive";
private 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 String mDocumentId;
private final char mIdDelimiter;
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 DocumentArchive(
Context context,
File file,
String documentId,
char idDelimiter,
@Nullable Uri notificationUri)
throws IOException {
mContext = context;
mDocumentId = documentId;
mIdDelimiter = idDelimiter;
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 local file.
*
* @param context Context of the provider.
* @param File Local file containing the archive.
* @param documentId ID of the archive document.
* @param idDelimiter Delimiter for constructing IDs of documents within the archive.
* The delimiter must never be used for IDs of other documents.
* @param Uri notificationUri Uri for notifying that the archive file has changed.
* @see createForParcelFileDescriptor(DocumentsProvider, ParcelFileDescriptor, String, char,
* Uri)
*/
public static DocumentArchive createForLocalFile(
Context context, File file, String documentId, char idDelimiter,
@Nullable Uri notificationUri)
throws IOException {
return new DocumentArchive(context, file, documentId, idDelimiter, notificationUri);
}
/**
* Creates a DocumentsArchive instance for opening, browsing and accessing
* documents within the archive passed as a file descriptor.
*
* <p>Note, that this method should be used only if the document does not exist
* on the local storage. A snapshot file will be created, which may be slower
* and consume significant resources, in contrast to using
* {@see createForLocalFile(Context, File, String, char, Uri}.
*
* @param context Context of the provider.
* @param descriptor File descriptor for the archive's contents.
* @param documentId ID of the archive document.
* @param idDelimiter Delimiter for constructing IDs of documents within the archive.
* The delimiter must never be used for IDs of other documents.
* @param Uri notificationUri Uri for notifying that the archive file has changed.
* @see createForLocalFile(Context, File, String, char, Uri)
*/
public static DocumentArchive createForParcelFileDescriptor(
Context context, ParcelFileDescriptor descriptor, String documentId,
char idDelimiter, @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("android.support.provider.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 DocumentArchive(context, snapshotFile, documentId, idDelimiter,
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 ParsedDocumentId parsedParentId = ParsedDocumentId.fromDocumentId(
documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedParentId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
final String parentPath = parsedParentId.mPath != null ? parsedParentId.mPath : "/";
final MatrixCursor result = new MatrixCursor(
projection != null ? projection : DEFAULT_PROJECTION);
if (mNotificationUri != null) {
result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
}
final List<ZipEntry> parentList = mTree.get(parentPath);
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 ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
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 ParsedDocumentId parsedParentId = ParsedDocumentId.fromDocumentId(
parentDocumentId, mIdDelimiter);
final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedParentId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
Preconditions.checkArgumentNotNull(parsedId.mPath,
"Not a document within an archive.");
final ZipEntry entry = mEntries.get(parsedId.mPath);
if (entry == null) {
return false;
}
if (parsedParentId.mPath == null) {
// No need to compare paths. Every file in the archive is a child of the archive
// file.
return true;
}
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 ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
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 {
Preconditions.checkArgumentEquals("r", mode,
"Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
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 ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(documentId, mIdDelimiter);
Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
"Mismatching document ID. Expected: %s, actual: %s.");
Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
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 ParsedDocumentId parsedId = new ParsedDocumentId(mDocumentId, entry.getName());
row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId(mIdDelimiter));
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";
}
};