DocumentArchive.java revision 735fc5f89ffb4c2c9e96e370cda7208b222f0773
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
17package android.support.provider;
18
19import android.content.Context;
20import android.content.res.AssetFileDescriptor;
21import android.database.Cursor;
22import android.database.MatrixCursor;
23import android.graphics.Point;
24import android.media.ExifInterface;
25import android.net.Uri;
26import android.os.Bundle;
27import android.os.CancellationSignal;
28import android.os.OperationCanceledException;
29import android.os.ParcelFileDescriptor;
30import android.provider.DocumentsContract;
31import android.provider.DocumentsContract.Document;
32import android.provider.DocumentsProvider;
33import android.support.annotation.Nullable;
34import android.util.Log;
35import android.webkit.MimeTypeMap;
36
37import java.io.Closeable;
38import java.io.File;
39import java.io.FileNotFoundException;
40import java.io.FileOutputStream;
41import java.io.IOException;
42import java.io.InputStream;
43import java.util.ArrayList;
44import java.lang.IllegalArgumentException;
45import java.lang.IllegalStateException;
46import java.lang.UnsupportedOperationException;
47import java.util.Collections;
48import java.util.HashMap;
49import java.util.Iterator;
50import java.util.List;
51import java.util.Locale;
52import java.util.Map;
53import java.util.Stack;
54import java.util.concurrent.ExecutorService;
55import java.util.concurrent.Executors;
56import java.util.zip.ZipEntry;
57import java.util.zip.ZipFile;
58import 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 */
70public 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