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