ReadableArchive.java revision a903c2cdad65d0fe163a4152faa6bd05e2888b22
1/*
2 * Copyright (C) 2017 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 com.android.documentsui.archives;
18
19import android.content.Context;
20import android.content.res.AssetFileDescriptor;
21import android.graphics.Point;
22import android.media.ExifInterface;
23import android.net.Uri;
24import android.os.Bundle;
25import android.os.CancellationSignal;
26import android.os.OperationCanceledException;
27import android.os.ParcelFileDescriptor;
28import android.provider.DocumentsContract;
29import android.support.annotation.Nullable;
30import android.util.Log;
31import android.util.jar.StrictJarFile;
32
33import com.android.internal.annotations.GuardedBy;
34import com.android.internal.util.Preconditions;
35
36import libcore.io.IoUtils;
37
38import java.io.File;
39import java.io.FileDescriptor;
40import java.io.FileNotFoundException;
41import java.io.FileOutputStream;
42import java.io.IOException;
43import java.io.InputStream;
44import java.util.ArrayList;
45import java.util.HashSet;
46import java.util.Iterator;
47import java.util.List;
48import java.util.Set;
49import java.util.Stack;
50import java.util.concurrent.LinkedBlockingQueue;
51import java.util.concurrent.RejectedExecutionException;
52import java.util.concurrent.ThreadPoolExecutor;
53import java.util.concurrent.TimeUnit;
54import java.util.zip.ZipEntry;
55
56/**
57 * Provides basic implementation for extracting and accessing
58 * files within archives exposed by a document provider.
59 *
60 * <p>This class is thread safe.
61 */
62public class ReadableArchive extends Archive {
63    private static final String TAG = "ReadableArchive";
64
65    @GuardedBy("mEnqueuedOutputPipes")
66    private final Set<ParcelFileDescriptor> mEnqueuedOutputPipes = new HashSet<>();
67    private final ThreadPoolExecutor mExecutor;
68    private final StrictJarFile mZipFile;
69
70    private ReadableArchive(
71            Context context,
72            @Nullable File file,
73            @Nullable FileDescriptor fd,
74            Uri archiveUri,
75            int accessMode,
76            @Nullable Uri notificationUri)
77            throws IOException {
78        super(context, archiveUri, accessMode, notificationUri);
79        if (!supportsAccessMode(accessMode)) {
80            throw new IllegalStateException("Unsupported access mode.");
81        }
82
83        // At most 8 active threads. All threads idling for more than a minute will
84        // be closed.
85        mExecutor = new ThreadPoolExecutor(8, 8, 60, TimeUnit.SECONDS,
86                new LinkedBlockingQueue<Runnable>());
87        mExecutor.allowCoreThreadTimeOut(true);
88
89        mZipFile = file != null ?
90                new StrictJarFile(file.getPath(), false /* verify */,
91                        false /* signatures */) :
92                new StrictJarFile(fd, false /* verify */, false /* signatures */);
93
94        ZipEntry entry;
95        String entryPath;
96        final Iterator<ZipEntry> it = mZipFile.iterator();
97        final Stack<ZipEntry> stack = new Stack<>();
98        while (it.hasNext()) {
99            entry = it.next();
100            if (entry.isDirectory() != entry.getName().endsWith("/")) {
101                throw new IOException(
102                        "Directories must have a trailing slash, and files must not.");
103            }
104            entryPath = getEntryPath(entry);
105            if (mEntries.containsKey(entryPath)) {
106                throw new IOException("Multiple entries with the same name are not supported.");
107            }
108            mEntries.put(entryPath, entry);
109            if (entry.isDirectory()) {
110                mTree.put(entryPath, new ArrayList<ZipEntry>());
111            }
112            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
113                stack.push(entry);
114            }
115        }
116
117        int delimiterIndex;
118        String parentPath;
119        ZipEntry parentEntry;
120        List<ZipEntry> parentList;
121
122        // Go through all directories recursively and build a tree structure.
123        while (stack.size() > 0) {
124            entry = stack.pop();
125
126            entryPath = getEntryPath(entry);
127            delimiterIndex = entryPath.lastIndexOf('/', entry.isDirectory()
128                    ? entryPath.length() - 2 : entryPath.length() - 1);
129            parentPath = entryPath.substring(0, delimiterIndex) + "/";
130
131            parentList = mTree.get(parentPath);
132
133            if (parentList == null) {
134                // The ZIP file doesn't contain all directories leading to the entry.
135                // It's rare, but can happen in a valid ZIP archive. In such case create a
136                // fake ZipEntry and add it on top of the stack to process it next.
137                parentEntry = new ZipEntry(parentPath);
138                parentEntry.setSize(0);
139                parentEntry.setTime(entry.getTime());
140                mEntries.put(parentPath, parentEntry);
141
142                if (!"/".equals(parentPath)) {
143                    stack.push(parentEntry);
144                }
145
146                parentList = new ArrayList<>();
147                mTree.put(parentPath, parentList);
148            }
149
150            parentList.add(entry);
151        }
152    }
153
154    /**
155     * @see ParcelFileDescriptor
156     */
157    public static boolean supportsAccessMode(int accessMode) {
158        return accessMode == ParcelFileDescriptor.MODE_READ_ONLY;
159    }
160
161    /**
162     * Creates a DocumentsArchive instance for opening, browsing and accessing
163     * documents within the archive passed as a file descriptor.
164     *
165     * If the file descriptor is not seekable, then a snapshot will be created.
166     *
167     * This method takes ownership for the passed descriptor. The caller must
168     * not use it after passing.
169     *
170     * @param context Context of the provider.
171     * @param descriptor File descriptor for the archive's contents.
172     * @param archiveUri Uri of the archive document.
173     * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}.
174     * @param Uri notificationUri Uri for notifying that the archive file has changed.
175     */
176    public static ReadableArchive createForParcelFileDescriptor(
177            Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
178            @Nullable Uri notificationUri)
179            throws IOException {
180        FileDescriptor fd = null;
181        try {
182            if (canSeek(descriptor)) {
183                fd = new FileDescriptor();
184                fd.setInt$(descriptor.detachFd());
185                return new ReadableArchive(context, null, fd, archiveUri, accessMode,
186                        notificationUri);
187            }
188
189            // Fallback for non-seekable file descriptors.
190            File snapshotFile = null;
191            try {
192                // Create a copy of the archive, as ZipFile doesn't operate on streams.
193                // Moreover, ZipInputStream would be inefficient for large files on
194                // pipes.
195                snapshotFile = File.createTempFile("com.android.documentsui.snapshot{",
196                        "}.zip", context.getCacheDir());
197
198                try (
199                    final FileOutputStream outputStream =
200                            new ParcelFileDescriptor.AutoCloseOutputStream(
201                                    ParcelFileDescriptor.open(
202                                            snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY));
203                    final ParcelFileDescriptor.AutoCloseInputStream inputStream =
204                            new ParcelFileDescriptor.AutoCloseInputStream(descriptor);
205                ) {
206                    final byte[] buffer = new byte[32 * 1024];
207                    int bytes;
208                    while ((bytes = inputStream.read(buffer)) != -1) {
209                        outputStream.write(buffer, 0, bytes);
210                    }
211                    outputStream.flush();
212                }
213                return new ReadableArchive(context, snapshotFile, null, archiveUri, accessMode,
214                        notificationUri);
215            } finally {
216                // On UNIX the file will be still available for processes which opened it, even
217                // after deleting it. Remove it ASAP, as it won't be used by anyone else.
218                if (snapshotFile != null) {
219                    snapshotFile.delete();
220                }
221            }
222        } catch (Exception e) {
223            // Since the method takes ownership of the passed descriptor, close it
224            // on exception.
225            IoUtils.closeQuietly(descriptor);
226            IoUtils.closeQuietly(fd);
227            throw e;
228        }
229    }
230
231    @Override
232    public ParcelFileDescriptor openDocument(
233            String documentId, String mode, @Nullable final CancellationSignal signal)
234            throws FileNotFoundException {
235        MorePreconditions.checkArgumentEquals("r", mode,
236                "Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
237        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
238        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
239                "Mismatching archive Uri. Expected: %s, actual: %s.");
240
241        final ZipEntry entry = mEntries.get(parsedId.mPath);
242        if (entry == null) {
243            throw new FileNotFoundException();
244        }
245
246        ParcelFileDescriptor[] pipe;
247        try {
248            pipe = ParcelFileDescriptor.createReliablePipe();
249        } catch (IOException e) {
250            // Ideally we'd simply throw IOException to the caller, but for consistency
251            // with DocumentsProvider::openDocument, converting it to IllegalStateException.
252            throw new IllegalStateException("Failed to open the document.", e);
253        }
254        final InputStream inputStream = mZipFile.getInputStream(entry);
255        final ParcelFileDescriptor outputPipe = pipe[1];
256
257        synchronized (mEnqueuedOutputPipes) {
258            mEnqueuedOutputPipes.add(outputPipe);
259        }
260
261        try {
262            mExecutor.execute(
263                    new Runnable() {
264                        @Override
265                        public void run() {
266                            synchronized (mEnqueuedOutputPipes) {
267                                mEnqueuedOutputPipes.remove(outputPipe);
268                            }
269                            try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream =
270                                    new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) {
271                                try {
272                                    final byte buffer[] = new byte[32 * 1024];
273                                    int bytes;
274                                    while ((bytes = inputStream.read(buffer)) != -1) {
275                                        if (Thread.interrupted()) {
276                                            throw new InterruptedException();
277                                        }
278                                        if (signal != null) {
279                                            signal.throwIfCanceled();
280                                        }
281                                        outputStream.write(buffer, 0, bytes);
282                                    }
283                                } catch (IOException | InterruptedException e) {
284                                    // Catch the exception before the outer try-with-resource closes
285                                    // the pipe with close() instead of closeWithError().
286                                    try {
287                                        Log.e(TAG, "Failed while reading a file.", e);
288                                        outputPipe.closeWithError("Reading failure.");
289                                    } catch (IOException e2) {
290                                        Log.e(TAG, "Failed to close the pipe after an error.", e2);
291                                    }
292                                }
293                            } catch (OperationCanceledException e) {
294                                // Cancelled gracefully.
295                            } catch (IOException e) {
296                                Log.e(TAG, "Failed to close the output stream gracefully.", e);
297                            } finally {
298                                IoUtils.closeQuietly(inputStream);
299                            }
300                        }
301                    });
302        } catch (RejectedExecutionException e) {
303            IoUtils.closeQuietly(pipe[0]);
304            IoUtils.closeQuietly(pipe[1]);
305            synchronized (mEnqueuedOutputPipes) {
306                mEnqueuedOutputPipes.remove(outputPipe);
307            }
308            throw new IllegalStateException("Failed to initialize pipe.");
309        }
310
311        return pipe[0];
312    }
313
314    @Override
315    public AssetFileDescriptor openDocumentThumbnail(
316            String documentId, Point sizeHint, final CancellationSignal signal)
317            throws FileNotFoundException {
318        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
319        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
320                "Mismatching archive Uri. Expected: %s, actual: %s.");
321        Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"),
322                "Thumbnails only supported for image/* MIME type.");
323
324        final ZipEntry entry = mEntries.get(parsedId.mPath);
325        if (entry == null) {
326            throw new FileNotFoundException();
327        }
328
329        InputStream inputStream = null;
330        try {
331            inputStream = mZipFile.getInputStream(entry);
332            final ExifInterface exif = new ExifInterface(inputStream);
333            if (exif.hasThumbnail()) {
334                Bundle extras = null;
335                switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) {
336                    case ExifInterface.ORIENTATION_ROTATE_90:
337                        extras = new Bundle(1);
338                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90);
339                        break;
340                    case ExifInterface.ORIENTATION_ROTATE_180:
341                        extras = new Bundle(1);
342                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180);
343                        break;
344                    case ExifInterface.ORIENTATION_ROTATE_270:
345                        extras = new Bundle(1);
346                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270);
347                        break;
348                }
349                final long[] range = exif.getThumbnailRange();
350                return new AssetFileDescriptor(
351                        openDocument(documentId, "r", signal), range[0], range[1], extras);
352            }
353        } catch (IOException e) {
354            // Ignore the exception, as reading the EXIF may legally fail.
355            Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e);
356        } finally {
357            IoUtils.closeQuietly(inputStream);
358        }
359
360        return new AssetFileDescriptor(
361                openDocument(documentId, "r", signal), 0, entry.getSize(), null);
362    }
363
364    /**
365     * Closes an archive.
366     *
367     * <p>This method does not block until shutdown. Once called, other methods should not be
368     * called. Any active pipes will be terminated.
369     */
370    @Override
371    public void close() {
372        mExecutor.shutdownNow();
373        synchronized (mEnqueuedOutputPipes) {
374            for (ParcelFileDescriptor outputPipe : mEnqueuedOutputPipes) {
375                try {
376                    outputPipe.closeWithError("Archive closed.");
377                } catch (IOException e2) {
378                    // Silent close.
379                }
380            }
381        }
382        try {
383            mZipFile.close();
384        } catch (IOException e) {
385            // Silent close.
386        }
387    }
388};
389