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