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