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