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