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