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