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