Home | History | Annotate | Download | only in provider
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package android.support.provider;
     18 
     19 import android.content.Context;
     20 import android.content.res.AssetFileDescriptor;
     21 import android.database.Cursor;
     22 import android.database.MatrixCursor;
     23 import android.graphics.Point;
     24 import android.media.ExifInterface;
     25 import android.net.Uri;
     26 import android.os.Bundle;
     27 import android.os.CancellationSignal;
     28 import android.os.OperationCanceledException;
     29 import android.os.ParcelFileDescriptor;
     30 import android.provider.DocumentsContract;
     31 import android.provider.DocumentsContract.Document;
     32 import android.provider.DocumentsProvider;
     33 import android.support.annotation.Nullable;
     34 import android.util.Log;
     35 import android.webkit.MimeTypeMap;
     36 
     37 import java.io.Closeable;
     38 import java.io.File;
     39 import java.io.FileNotFoundException;
     40 import java.io.FileOutputStream;
     41 import java.io.IOException;
     42 import java.io.InputStream;
     43 import java.util.ArrayList;
     44 import java.lang.IllegalArgumentException;
     45 import java.lang.IllegalStateException;
     46 import java.lang.UnsupportedOperationException;
     47 import java.util.Collections;
     48 import java.util.HashMap;
     49 import java.util.Iterator;
     50 import java.util.List;
     51 import java.util.Locale;
     52 import java.util.Map;
     53 import java.util.Stack;
     54 import java.util.concurrent.ExecutorService;
     55 import java.util.concurrent.Executors;
     56 import java.util.zip.ZipEntry;
     57 import java.util.zip.ZipFile;
     58 import java.util.zip.ZipInputStream;
     59 
     60 /**
     61  * Provides basic implementation for creating, extracting and accessing
     62  * files within archives exposed by a document provider. The id delimiter
     63  * must be a character which is not used in document ids generated by the
     64  * document provider.
     65  *
     66  * <p>This class is thread safe.
     67  *
     68  * @hide
     69  */
     70 public class DocumentArchive implements Closeable {
     71     private static final String TAG = "DocumentArchive";
     72 
     73     private static final String[] DEFAULT_PROJECTION = new String[] {
     74             Document.COLUMN_DOCUMENT_ID,
     75             Document.COLUMN_DISPLAY_NAME,
     76             Document.COLUMN_MIME_TYPE,
     77             Document.COLUMN_SIZE,
     78             Document.COLUMN_FLAGS
     79     };
     80 
     81     private final Context mContext;
     82     private final String mDocumentId;
     83     private final char mIdDelimiter;
     84     private final Uri mNotificationUri;
     85     private final ZipFile mZipFile;
     86     private final ExecutorService mExecutor;
     87     private final Map<String, ZipEntry> mEntries;
     88     private final Map<String, List<ZipEntry>> mTree;
     89 
     90     private DocumentArchive(
     91             Context context,
     92             File file,
     93             String documentId,
     94             char idDelimiter,
     95             @Nullable Uri notificationUri)
     96             throws IOException {
     97         mContext = context;
     98         mDocumentId = documentId;
     99         mIdDelimiter = idDelimiter;
    100         mNotificationUri = notificationUri;
    101         mZipFile = new ZipFile(file);
    102         mExecutor = Executors.newSingleThreadExecutor();
    103 
    104         // Build the tree structure in memory.
    105         mTree = new HashMap<String, List<ZipEntry>>();
    106         mTree.put("/", new ArrayList<ZipEntry>());
    107 
    108         mEntries = new HashMap<String, ZipEntry>();
    109         ZipEntry entry;
    110         final List<? extends ZipEntry> entries = Collections.list(mZipFile.entries());
    111         final Stack<ZipEntry> stack = new Stack<>();
    112         for (int i = entries.size() - 1; i >= 0; i--) {
    113             entry = entries.get(i);
    114             if (entry.isDirectory() != entry.getName().endsWith("/")) {
    115                 throw new IOException(
    116                         "Directories must have a trailing slash, and files must not.");
    117             }
    118             if (mEntries.containsKey(entry.getName())) {
    119                 throw new IOException("Multiple entries with the same name are not supported.");
    120             }
    121             mEntries.put(entry.getName(), entry);
    122             if (entry.isDirectory()) {
    123                 mTree.put(entry.getName(), new ArrayList<ZipEntry>());
    124             }
    125             stack.push(entry);
    126         }
    127 
    128         int delimiterIndex;
    129         String parentPath;
    130         ZipEntry parentEntry;
    131         List<ZipEntry> parentList;
    132 
    133         while (stack.size() > 0) {
    134             entry = stack.pop();
    135 
    136             delimiterIndex = entry.getName().lastIndexOf('/', entry.isDirectory()
    137                     ? entry.getName().length() - 2 : entry.getName().length() - 1);
    138             parentPath =
    139                     delimiterIndex != -1 ? entry.getName().substring(0, delimiterIndex) + "/" : "/";
    140             parentList = mTree.get(parentPath);
    141 
    142             if (parentList == null) {
    143                 parentEntry = mEntries.get(parentPath);
    144                 if (parentEntry == null) {
    145                     // The ZIP file doesn't contain all directories leading to the entry.
    146                     // It's rare, but can happen in a valid ZIP archive. In such case create a
    147                     // fake ZipEntry and add it on top of the stack to process it next.
    148                     parentEntry = new ZipEntry(parentPath);
    149                     parentEntry.setSize(0);
    150                     parentEntry.setTime(entry.getTime());
    151                     mEntries.put(parentPath, parentEntry);
    152                     stack.push(parentEntry);
    153                 }
    154                 parentList = new ArrayList<ZipEntry>();
    155                 mTree.put(parentPath, parentList);
    156             }
    157 
    158             parentList.add(entry);
    159         }
    160     }
    161 
    162     /**
    163      * Creates a DocumentsArchive instance for opening, browsing and accessing
    164      * documents within the archive passed as a local file.
    165      *
    166      * @param context Context of the provider.
    167      * @param File Local file containing the archive.
    168      * @param documentId ID of the archive document.
    169      * @param idDelimiter Delimiter for constructing IDs of documents within the archive.
    170      *            The delimiter must never be used for IDs of other documents.
    171      * @param Uri notificationUri Uri for notifying that the archive file has changed.
    172      * @see createForParcelFileDescriptor(DocumentsProvider, ParcelFileDescriptor, String, char,
    173      *          Uri)
    174      */
    175     public static DocumentArchive createForLocalFile(
    176             Context context, File file, String documentId, char idDelimiter,
    177             @Nullable Uri notificationUri)
    178             throws IOException {
    179         return new DocumentArchive(context, file, documentId, idDelimiter, notificationUri);
    180     }
    181 
    182     /**
    183      * Creates a DocumentsArchive instance for opening, browsing and accessing
    184      * documents within the archive passed as a file descriptor.
    185      *
    186      * <p>Note, that this method should be used only if the document does not exist
    187      * on the local storage. A snapshot file will be created, which may be slower
    188      * and consume significant resources, in contrast to using
    189      * {@see createForLocalFile(Context, File, String, char, Uri}.
    190      *
    191      * @param context Context of the provider.
    192      * @param descriptor File descriptor for the archive's contents.
    193      * @param documentId ID of the archive document.
    194      * @param idDelimiter Delimiter for constructing IDs of documents within the archive.
    195      *            The delimiter must never be used for IDs of other documents.
    196      * @param Uri notificationUri Uri for notifying that the archive file has changed.
    197      * @see createForLocalFile(Context, File, String, char, Uri)
    198      */
    199     public static DocumentArchive createForParcelFileDescriptor(
    200             Context context, ParcelFileDescriptor descriptor, String documentId,
    201             char idDelimiter, @Nullable Uri notificationUri)
    202             throws IOException {
    203         File snapshotFile = null;
    204         try {
    205             // Create a copy of the archive, as ZipFile doesn't operate on streams.
    206             // Moreover, ZipInputStream would be inefficient for large files on
    207             // pipes.
    208             snapshotFile = File.createTempFile("android.support.provider.snapshot{",
    209                     "}.zip", context.getCacheDir());
    210 
    211             try (
    212                 final FileOutputStream outputStream =
    213                         new ParcelFileDescriptor.AutoCloseOutputStream(
    214                                 ParcelFileDescriptor.open(
    215                                         snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY));
    216                 final ParcelFileDescriptor.AutoCloseInputStream inputStream =
    217                         new ParcelFileDescriptor.AutoCloseInputStream(descriptor);
    218             ) {
    219                 final byte[] buffer = new byte[32 * 1024];
    220                 int bytes;
    221                 while ((bytes = inputStream.read(buffer)) != -1) {
    222                     outputStream.write(buffer, 0, bytes);
    223                 }
    224                 outputStream.flush();
    225                 return new DocumentArchive(context, snapshotFile, documentId, idDelimiter,
    226                         notificationUri);
    227             }
    228         } finally {
    229             // On UNIX the file will be still available for processes which opened it, even
    230             // after deleting it. Remove it ASAP, as it won't be used by anyone else.
    231             if (snapshotFile != null) {
    232                 snapshotFile.delete();
    233             }
    234         }
    235     }
    236 
    237     /**
    238      * Lists child documents of an archive or a directory within an
    239      * archive. Must be called only for archives with supported mime type,
    240      * or for documents within archives.
    241      *
    242      * @see DocumentsProvider.queryChildDocuments(String, String[], String)
    243      */
    244     public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
    245             @Nullable String sortOrder) throws FileNotFoundException {
    246         final ParsedDocumentId parsedParentId = ParsedDocumentId.fromDocumentId(
    247                 documentId, mIdDelimiter);
    248         Preconditions.checkArgumentEquals(mDocumentId, parsedParentId.mArchiveId,
    249                 "Mismatching document ID. Expected: %s, actual: %s.");
    250 
    251         final String parentPath = parsedParentId.mPath != null ? parsedParentId.mPath : "/";
    252         final MatrixCursor result = new MatrixCursor(
    253                 projection != null ? projection : DEFAULT_PROJECTION);
    254         if (mNotificationUri != null) {
    255             result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
    256         }
    257 
    258         final List<ZipEntry> parentList = mTree.get(parentPath);
    259         if (parentList == null) {
    260             throw new FileNotFoundException();
    261         }
    262         for (final ZipEntry entry : parentList) {
    263             addCursorRow(result, entry);
    264         }
    265         return result;
    266     }
    267 
    268     /**
    269      * Returns a MIME type of a document within an archive.
    270      *
    271      * @see DocumentsProvider.getDocumentType(String)
    272      */
    273     public String getDocumentType(String documentId) throws FileNotFoundException {
    274         final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
    275                 documentId, mIdDelimiter);
    276         Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
    277                 "Mismatching document ID. Expected: %s, actual: %s.");
    278         Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
    279 
    280         final ZipEntry entry = mEntries.get(parsedId.mPath);
    281         if (entry == null) {
    282             throw new FileNotFoundException();
    283         }
    284         return getMimeTypeForEntry(entry);
    285     }
    286 
    287     /**
    288      * Returns true if a document within an archive is a child or any descendant of the archive
    289      * document or another document within the archive.
    290      *
    291      * @see DocumentsProvider.isChildDocument(String, String)
    292      */
    293     public boolean isChildDocument(String parentDocumentId, String documentId) {
    294         final ParsedDocumentId parsedParentId = ParsedDocumentId.fromDocumentId(
    295                 parentDocumentId, mIdDelimiter);
    296         final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
    297                 documentId, mIdDelimiter);
    298         Preconditions.checkArgumentEquals(mDocumentId, parsedParentId.mArchiveId,
    299                 "Mismatching document ID. Expected: %s, actual: %s.");
    300         Preconditions.checkArgumentNotNull(parsedId.mPath,
    301                 "Not a document within an archive.");
    302 
    303         final ZipEntry entry = mEntries.get(parsedId.mPath);
    304         if (entry == null) {
    305             return false;
    306         }
    307 
    308         if (parsedParentId.mPath == null) {
    309             // No need to compare paths. Every file in the archive is a child of the archive
    310             // file.
    311             return true;
    312         }
    313 
    314         final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath);
    315         if (parentEntry == null || !parentEntry.isDirectory()) {
    316             return false;
    317         }
    318 
    319         final String parentPath = entry.getName();
    320 
    321         // Add a trailing slash even if it's not a directory, so it's easy to check if the
    322         // entry is a descendant.
    323         final String pathWithSlash = entry.isDirectory() ? entry.getName() : entry.getName() + "/";
    324         return pathWithSlash.startsWith(parentPath) && !parentPath.equals(pathWithSlash);
    325     }
    326 
    327     /**
    328      * Returns metadata of a document within an archive.
    329      *
    330      * @see DocumentsProvider.queryDocument(String, String[])
    331      */
    332     public Cursor queryDocument(String documentId, @Nullable String[] projection)
    333             throws FileNotFoundException {
    334         final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
    335                 documentId, mIdDelimiter);
    336         Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
    337                 "Mismatching document ID. Expected: %s, actual: %s.");
    338         Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
    339 
    340         final ZipEntry entry = mEntries.get(parsedId.mPath);
    341         if (entry == null) {
    342             throw new FileNotFoundException();
    343         }
    344 
    345         final MatrixCursor result = new MatrixCursor(
    346                 projection != null ? projection : DEFAULT_PROJECTION);
    347         if (mNotificationUri != null) {
    348             result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
    349         }
    350         addCursorRow(result, entry);
    351         return result;
    352     }
    353 
    354     /**
    355      * Opens a file within an archive.
    356      *
    357      * @see DocumentsProvider.openDocument(String, String, CancellationSignal))
    358      */
    359     public ParcelFileDescriptor openDocument(
    360             String documentId, String mode, @Nullable final CancellationSignal signal)
    361             throws FileNotFoundException {
    362         Preconditions.checkArgumentEquals("r", mode,
    363                 "Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
    364         final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(
    365                 documentId, mIdDelimiter);
    366         Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
    367                 "Mismatching document ID. Expected: %s, actual: %s.");
    368         Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
    369 
    370         final ZipEntry entry = mEntries.get(parsedId.mPath);
    371         if (entry == null) {
    372             throw new FileNotFoundException();
    373         }
    374 
    375         ParcelFileDescriptor[] pipe;
    376         InputStream inputStream = null;
    377         try {
    378             pipe = ParcelFileDescriptor.createReliablePipe();
    379             inputStream = mZipFile.getInputStream(entry);
    380         } catch (IOException e) {
    381             if (inputStream != null) {
    382                 IoUtils.closeQuietly(inputStream);
    383             }
    384             // Ideally we'd simply throw IOException to the caller, but for consistency
    385             // with DocumentsProvider::openDocument, converting it to IllegalStateException.
    386             throw new IllegalStateException("Failed to open the document.", e);
    387         }
    388         final ParcelFileDescriptor outputPipe = pipe[1];
    389         final InputStream finalInputStream = inputStream;
    390         mExecutor.execute(
    391                 new Runnable() {
    392                     @Override
    393                     public void run() {
    394                         try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream =
    395                                 new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) {
    396                             try {
    397                                 final byte buffer[] = new byte[32 * 1024];
    398                                 int bytes;
    399                                 while ((bytes = finalInputStream.read(buffer)) != -1) {
    400                                     if (Thread.interrupted()) {
    401                                         throw new InterruptedException();
    402                                     }
    403                                     if (signal != null) {
    404                                         signal.throwIfCanceled();
    405                                     }
    406                                     outputStream.write(buffer, 0, bytes);
    407                                 }
    408                             } catch (IOException | InterruptedException e) {
    409                                 // Catch the exception before the outer try-with-resource closes the
    410                                 // pipe with close() instead of closeWithError().
    411                                 try {
    412                                     outputPipe.closeWithError(e.getMessage());
    413                                 } catch (IOException e2) {
    414                                     Log.e(TAG, "Failed to close the pipe after an error.", e2);
    415                                 }
    416                             }
    417                         } catch (OperationCanceledException e) {
    418                             // Cancelled gracefully.
    419                         } catch (IOException e) {
    420                             Log.e(TAG, "Failed to close the output stream gracefully.", e);
    421                         } finally {
    422                             IoUtils.closeQuietly(finalInputStream);
    423                         }
    424                     }
    425                 });
    426 
    427         return pipe[0];
    428     }
    429 
    430     /**
    431      * Opens a thumbnail of a file within an archive.
    432      *
    433      * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal))
    434      */
    435     public AssetFileDescriptor openDocumentThumbnail(
    436             String documentId, Point sizeHint, final CancellationSignal signal)
    437             throws FileNotFoundException {
    438         final ParsedDocumentId parsedId = ParsedDocumentId.fromDocumentId(documentId, mIdDelimiter);
    439         Preconditions.checkArgumentEquals(mDocumentId, parsedId.mArchiveId,
    440                 "Mismatching document ID. Expected: %s, actual: %s.");
    441         Preconditions.checkArgumentNotNull(parsedId.mPath, "Not a document within an archive.");
    442         Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"),
    443                 "Thumbnails only supported for image/* MIME type.");
    444 
    445         final ZipEntry entry = mEntries.get(parsedId.mPath);
    446         if (entry == null) {
    447             throw new FileNotFoundException();
    448         }
    449 
    450         InputStream inputStream = null;
    451         try {
    452             inputStream = mZipFile.getInputStream(entry);
    453             final ExifInterface exif = new ExifInterface(inputStream);
    454             if (exif.hasThumbnail()) {
    455                 Bundle extras = null;
    456                 switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) {
    457                     case ExifInterface.ORIENTATION_ROTATE_90:
    458                         extras = new Bundle(1);
    459                         extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90);
    460                         break;
    461                     case ExifInterface.ORIENTATION_ROTATE_180:
    462                         extras = new Bundle(1);
    463                         extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180);
    464                         break;
    465                     case ExifInterface.ORIENTATION_ROTATE_270:
    466                         extras = new Bundle(1);
    467                         extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270);
    468                         break;
    469                 }
    470                 final long[] range = exif.getThumbnailRange();
    471                 return new AssetFileDescriptor(
    472                         openDocument(documentId, "r", signal), range[0], range[1], extras);
    473             }
    474         } catch (IOException e) {
    475             // Ignore the exception, as reading the EXIF may legally fail.
    476             Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e);
    477         } finally {
    478             IoUtils.closeQuietly(inputStream);
    479         }
    480 
    481         return new AssetFileDescriptor(
    482                 openDocument(documentId, "r", signal), 0, entry.getSize(), null);
    483     }
    484 
    485     /**
    486      * Schedules a gracefully close of the archive after any opened files are closed.
    487      *
    488      * <p>This method does not block until shutdown. Once called, other methods should not be
    489      * called.
    490      */
    491     @Override
    492     public void close() {
    493         mExecutor.execute(new Runnable() {
    494             @Override
    495             public void run() {
    496                 IoUtils.closeQuietly(mZipFile);
    497             }
    498         });
    499         mExecutor.shutdown();
    500     }
    501 
    502     private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
    503         final MatrixCursor.RowBuilder row = cursor.newRow();
    504         final ParsedDocumentId parsedId = new ParsedDocumentId(mDocumentId, entry.getName());
    505         row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId(mIdDelimiter));
    506 
    507         final File file = new File(entry.getName());
    508         row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
    509         row.add(Document.COLUMN_SIZE, entry.getSize());
    510 
    511         final String mimeType = getMimeTypeForEntry(entry);
    512         row.add(Document.COLUMN_MIME_TYPE, mimeType);
    513 
    514         final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0;
    515         row.add(Document.COLUMN_FLAGS, flags);
    516     }
    517 
    518     private String getMimeTypeForEntry(ZipEntry entry) {
    519         if (entry.isDirectory()) {
    520             return Document.MIME_TYPE_DIR;
    521         }
    522 
    523         final int lastDot = entry.getName().lastIndexOf('.');
    524         if (lastDot >= 0) {
    525             final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US);
    526             final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
    527             if (mimeType != null) {
    528                 return mimeType;
    529             }
    530         }
    531 
    532         return "application/octet-stream";
    533     }
    534 };
    535