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