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