• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
DocumentArchive( Context context, File file, String documentId, char idDelimiter, @Nullable Uri notificationUri)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      */
createForLocalFile( Context context, File file, String documentId, char idDelimiter, @Nullable Uri notificationUri)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      */
createForParcelFileDescriptor( Context context, ParcelFileDescriptor descriptor, String documentId, char idDelimiter, @Nullable Uri notificationUri)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      */
queryChildDocuments(String documentId, @Nullable String[] projection, @Nullable String sortOrder)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      */
getDocumentType(String documentId)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      */
isChildDocument(String parentDocumentId, String documentId)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      */
queryDocument(String documentId, @Nullable String[] projection)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      */
openDocument( String documentId, String mode, @Nullable final CancellationSignal signal)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      */
openDocumentThumbnail( String documentId, Point sizeHint, final CancellationSignal signal)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
close()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 
addCursorRow(MatrixCursor cursor, ZipEntry entry)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 
getMimeTypeForEntry(ZipEntry entry)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