1 /* 2 * Copyright (C) 2017 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 17 package com.android.internal.content; 18 19 import android.annotation.CallSuper; 20 import android.annotation.Nullable; 21 import android.content.ContentResolver; 22 import android.content.ContentValues; 23 import android.content.Intent; 24 import android.content.res.AssetFileDescriptor; 25 import android.database.Cursor; 26 import android.database.MatrixCursor; 27 import android.database.MatrixCursor.RowBuilder; 28 import android.graphics.Point; 29 import android.net.Uri; 30 import android.os.Binder; 31 import android.os.Bundle; 32 import android.os.CancellationSignal; 33 import android.os.FileObserver; 34 import android.os.FileUtils; 35 import android.os.Handler; 36 import android.os.ParcelFileDescriptor; 37 import android.provider.DocumentsContract; 38 import android.provider.DocumentsContract.Document; 39 import android.provider.DocumentsProvider; 40 import android.provider.MediaStore; 41 import android.provider.MetadataReader; 42 import android.text.TextUtils; 43 import android.util.ArrayMap; 44 import android.util.Log; 45 import android.webkit.MimeTypeMap; 46 47 import com.android.internal.annotations.GuardedBy; 48 49 import libcore.io.IoUtils; 50 51 import java.io.File; 52 import java.io.FileInputStream; 53 import java.io.FileNotFoundException; 54 import java.io.IOException; 55 import java.io.InputStream; 56 import java.util.LinkedList; 57 import java.util.List; 58 import java.util.Set; 59 60 /** 61 * A helper class for {@link android.provider.DocumentsProvider} to perform file operations on local 62 * files. 63 */ 64 public abstract class FileSystemProvider extends DocumentsProvider { 65 66 private static final String TAG = "FileSystemProvider"; 67 68 private static final boolean LOG_INOTIFY = false; 69 70 private String[] mDefaultProjection; 71 72 @GuardedBy("mObservers") 73 private final ArrayMap<File, DirectoryObserver> mObservers = new ArrayMap<>(); 74 75 private Handler mHandler; 76 77 private static final String MIMETYPE_JPEG = "image/jpeg"; 78 private static final String MIMETYPE_JPG = "image/jpg"; 79 private static final String MIMETYPE_OCTET_STREAM = "application/octet-stream"; 80 81 protected abstract File getFileForDocId(String docId, boolean visible) 82 throws FileNotFoundException; 83 84 protected abstract String getDocIdForFile(File file) throws FileNotFoundException; 85 86 protected abstract Uri buildNotificationUri(String docId); 87 88 /** 89 * Callback indicating that the given document has been modified. This gives 90 * the provider a hook to invalidate cached data, such as {@code sdcardfs}. 91 */ 92 protected void onDocIdChanged(String docId) { 93 // Default is no-op 94 } 95 96 @Override 97 public boolean onCreate() { 98 throw new UnsupportedOperationException( 99 "Subclass should override this and call onCreate(defaultDocumentProjection)"); 100 } 101 102 @CallSuper 103 protected void onCreate(String[] defaultProjection) { 104 mHandler = new Handler(); 105 mDefaultProjection = defaultProjection; 106 } 107 108 @Override 109 public boolean isChildDocument(String parentDocId, String docId) { 110 try { 111 final File parent = getFileForDocId(parentDocId).getCanonicalFile(); 112 final File doc = getFileForDocId(docId).getCanonicalFile(); 113 return FileUtils.contains(parent, doc); 114 } catch (IOException e) { 115 throw new IllegalArgumentException( 116 "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e); 117 } 118 } 119 120 @Override 121 public @Nullable Bundle getDocumentMetadata(String documentId) 122 throws FileNotFoundException { 123 File file = getFileForDocId(documentId); 124 125 if (!file.exists()) { 126 throw new FileNotFoundException("Can't find the file for documentId: " + documentId); 127 } 128 129 if (!file.isFile()) { 130 Log.w(TAG, "Can't stream non-regular file. Returning empty metadata."); 131 return null; 132 } 133 134 if (!file.canRead()) { 135 Log.w(TAG, "Can't stream non-readable file. Returning empty metadata."); 136 return null; 137 } 138 139 String mimeType = getTypeForFile(file); 140 if (!MetadataReader.isSupportedMimeType(mimeType)) { 141 return null; 142 } 143 144 InputStream stream = null; 145 try { 146 Bundle metadata = new Bundle(); 147 stream = new FileInputStream(file.getAbsolutePath()); 148 MetadataReader.getMetadata(metadata, stream, mimeType, null); 149 return metadata; 150 } catch (IOException e) { 151 Log.e(TAG, "An error occurred retrieving the metadata", e); 152 return null; 153 } finally { 154 IoUtils.closeQuietly(stream); 155 } 156 } 157 158 protected final List<String> findDocumentPath(File parent, File doc) 159 throws FileNotFoundException { 160 161 if (!doc.exists()) { 162 throw new FileNotFoundException(doc + " is not found."); 163 } 164 165 if (!FileUtils.contains(parent, doc)) { 166 throw new FileNotFoundException(doc + " is not found under " + parent); 167 } 168 169 LinkedList<String> path = new LinkedList<>(); 170 while (doc != null && FileUtils.contains(parent, doc)) { 171 path.addFirst(getDocIdForFile(doc)); 172 173 doc = doc.getParentFile(); 174 } 175 176 return path; 177 } 178 179 @Override 180 public String createDocument(String docId, String mimeType, String displayName) 181 throws FileNotFoundException { 182 displayName = FileUtils.buildValidFatFilename(displayName); 183 184 final File parent = getFileForDocId(docId); 185 if (!parent.isDirectory()) { 186 throw new IllegalArgumentException("Parent document isn't a directory"); 187 } 188 189 final File file = FileUtils.buildUniqueFile(parent, mimeType, displayName); 190 final String childId; 191 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 192 if (!file.mkdir()) { 193 throw new IllegalStateException("Failed to mkdir " + file); 194 } 195 childId = getDocIdForFile(file); 196 onDocIdChanged(childId); 197 addFolderToMediaStore(getFileForDocId(childId, true)); 198 } else { 199 try { 200 if (!file.createNewFile()) { 201 throw new IllegalStateException("Failed to touch " + file); 202 } 203 childId = getDocIdForFile(file); 204 onDocIdChanged(childId); 205 } catch (IOException e) { 206 throw new IllegalStateException("Failed to touch " + file + ": " + e); 207 } 208 } 209 210 return childId; 211 } 212 213 private void addFolderToMediaStore(@Nullable File visibleFolder) { 214 // visibleFolder is null if we're adding a folder to external thumb drive or SD card. 215 if (visibleFolder != null) { 216 assert (visibleFolder.isDirectory()); 217 218 final long token = Binder.clearCallingIdentity(); 219 220 try { 221 final ContentResolver resolver = getContext().getContentResolver(); 222 final Uri uri = MediaStore.Files.getDirectoryUri("external"); 223 ContentValues values = new ContentValues(); 224 values.put(MediaStore.Files.FileColumns.DATA, visibleFolder.getAbsolutePath()); 225 resolver.insert(uri, values); 226 } finally { 227 Binder.restoreCallingIdentity(token); 228 } 229 } 230 } 231 232 @Override 233 public String renameDocument(String docId, String displayName) throws FileNotFoundException { 234 // Since this provider treats renames as generating a completely new 235 // docId, we're okay with letting the MIME type change. 236 displayName = FileUtils.buildValidFatFilename(displayName); 237 238 final File before = getFileForDocId(docId); 239 final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName); 240 if (!before.renameTo(after)) { 241 throw new IllegalStateException("Failed to rename to " + after); 242 } 243 244 final String afterDocId = getDocIdForFile(after); 245 onDocIdChanged(docId); 246 onDocIdChanged(afterDocId); 247 248 final File beforeVisibleFile = getFileForDocId(docId, true); 249 final File afterVisibleFile = getFileForDocId(afterDocId, true); 250 moveInMediaStore(beforeVisibleFile, afterVisibleFile); 251 252 if (!TextUtils.equals(docId, afterDocId)) { 253 scanFile(afterVisibleFile); 254 return afterDocId; 255 } else { 256 return null; 257 } 258 } 259 260 @Override 261 public String moveDocument(String sourceDocumentId, String sourceParentDocumentId, 262 String targetParentDocumentId) 263 throws FileNotFoundException { 264 final File before = getFileForDocId(sourceDocumentId); 265 final File after = new File(getFileForDocId(targetParentDocumentId), before.getName()); 266 final File visibleFileBefore = getFileForDocId(sourceDocumentId, true); 267 268 if (after.exists()) { 269 throw new IllegalStateException("Already exists " + after); 270 } 271 if (!before.renameTo(after)) { 272 throw new IllegalStateException("Failed to move to " + after); 273 } 274 275 final String docId = getDocIdForFile(after); 276 onDocIdChanged(sourceDocumentId); 277 onDocIdChanged(docId); 278 moveInMediaStore(visibleFileBefore, getFileForDocId(docId, true)); 279 280 return docId; 281 } 282 283 private void moveInMediaStore(@Nullable File oldVisibleFile, @Nullable File newVisibleFile) { 284 // visibleFolders are null if we're moving a document in external thumb drive or SD card. 285 // 286 // They should be all null or not null at the same time. File#renameTo() doesn't work across 287 // volumes so an exception will be thrown before calling this method. 288 if (oldVisibleFile != null && newVisibleFile != null) { 289 final long token = Binder.clearCallingIdentity(); 290 291 try { 292 final ContentResolver resolver = getContext().getContentResolver(); 293 final Uri externalUri = newVisibleFile.isDirectory() 294 ? MediaStore.Files.getDirectoryUri("external") 295 : MediaStore.Files.getContentUri("external"); 296 297 ContentValues values = new ContentValues(); 298 values.put(MediaStore.Files.FileColumns.DATA, newVisibleFile.getAbsolutePath()); 299 300 // Logic borrowed from MtpDatabase. 301 // note - we are relying on a special case in MediaProvider.update() to update 302 // the paths for all children in the case where this is a directory. 303 final String path = oldVisibleFile.getAbsolutePath(); 304 resolver.update(externalUri, 305 values, 306 "_data LIKE ? AND lower(_data)=lower(?)", 307 new String[]{path, path}); 308 } finally { 309 Binder.restoreCallingIdentity(token); 310 } 311 } 312 } 313 314 @Override 315 public void deleteDocument(String docId) throws FileNotFoundException { 316 final File file = getFileForDocId(docId); 317 final File visibleFile = getFileForDocId(docId, true); 318 319 final boolean isDirectory = file.isDirectory(); 320 if (isDirectory) { 321 FileUtils.deleteContents(file); 322 } 323 if (!file.delete()) { 324 throw new IllegalStateException("Failed to delete " + file); 325 } 326 327 onDocIdChanged(docId); 328 removeFromMediaStore(visibleFile, isDirectory); 329 } 330 331 private void removeFromMediaStore(@Nullable File visibleFile, boolean isFolder) 332 throws FileNotFoundException { 333 // visibleFolder is null if we're removing a document from external thumb drive or SD card. 334 if (visibleFile != null) { 335 final long token = Binder.clearCallingIdentity(); 336 337 try { 338 final ContentResolver resolver = getContext().getContentResolver(); 339 final Uri externalUri = MediaStore.Files.getContentUri("external"); 340 341 // Remove media store entries for any files inside this directory, using 342 // path prefix match. Logic borrowed from MtpDatabase. 343 if (isFolder) { 344 final String path = visibleFile.getAbsolutePath() + "/"; 345 resolver.delete(externalUri, 346 "_data LIKE ?1 AND lower(substr(_data,1,?2))=lower(?3)", 347 new String[]{path + "%", Integer.toString(path.length()), path}); 348 } 349 350 // Remove media store entry for this exact file. 351 final String path = visibleFile.getAbsolutePath(); 352 resolver.delete(externalUri, 353 "_data LIKE ?1 AND lower(_data)=lower(?2)", 354 new String[]{path, path}); 355 } finally { 356 Binder.restoreCallingIdentity(token); 357 } 358 } 359 } 360 361 @Override 362 public Cursor queryDocument(String documentId, String[] projection) 363 throws FileNotFoundException { 364 final MatrixCursor result = new MatrixCursor(resolveProjection(projection)); 365 includeFile(result, documentId, null); 366 return result; 367 } 368 369 @Override 370 public Cursor queryChildDocuments( 371 String parentDocumentId, String[] projection, String sortOrder) 372 throws FileNotFoundException { 373 374 final File parent = getFileForDocId(parentDocumentId); 375 final MatrixCursor result = new DirectoryCursor( 376 resolveProjection(projection), parentDocumentId, parent); 377 for (File file : parent.listFiles()) { 378 includeFile(result, null, file); 379 } 380 return result; 381 } 382 383 /** 384 * Searches documents under the given folder. 385 * 386 * To avoid runtime explosion only returns the at most 23 items. 387 * 388 * @param folder the root folder where recursive search begins 389 * @param query the search condition used to match file names 390 * @param projection projection of the returned cursor 391 * @param exclusion absolute file paths to exclude from result 392 * @return cursor containing search result 393 * @throws FileNotFoundException when root folder doesn't exist or search fails 394 */ 395 protected final Cursor querySearchDocuments( 396 File folder, String query, String[] projection, Set<String> exclusion) 397 throws FileNotFoundException { 398 399 query = query.toLowerCase(); 400 final MatrixCursor result = new MatrixCursor(resolveProjection(projection)); 401 final LinkedList<File> pending = new LinkedList<>(); 402 pending.add(folder); 403 while (!pending.isEmpty() && result.getCount() < 24) { 404 final File file = pending.removeFirst(); 405 if (file.isDirectory()) { 406 for (File child : file.listFiles()) { 407 pending.add(child); 408 } 409 } 410 if (file.getName().toLowerCase().contains(query) 411 && !exclusion.contains(file.getAbsolutePath())) { 412 includeFile(result, null, file); 413 } 414 } 415 return result; 416 } 417 418 @Override 419 public String getDocumentType(String documentId) throws FileNotFoundException { 420 final File file = getFileForDocId(documentId); 421 return getTypeForFile(file); 422 } 423 424 @Override 425 public ParcelFileDescriptor openDocument( 426 String documentId, String mode, CancellationSignal signal) 427 throws FileNotFoundException { 428 final File file = getFileForDocId(documentId); 429 final File visibleFile = getFileForDocId(documentId, true); 430 431 final int pfdMode = ParcelFileDescriptor.parseMode(mode); 432 if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY || visibleFile == null) { 433 return ParcelFileDescriptor.open(file, pfdMode); 434 } else { 435 try { 436 // When finished writing, kick off media scanner 437 return ParcelFileDescriptor.open( 438 file, pfdMode, mHandler, (IOException e) -> { 439 onDocIdChanged(documentId); 440 scanFile(visibleFile); 441 }); 442 } catch (IOException e) { 443 throw new FileNotFoundException("Failed to open for writing: " + e); 444 } 445 } 446 } 447 448 private void scanFile(File visibleFile) { 449 final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); 450 intent.setData(Uri.fromFile(visibleFile)); 451 getContext().sendBroadcast(intent); 452 } 453 454 @Override 455 public AssetFileDescriptor openDocumentThumbnail( 456 String documentId, Point sizeHint, CancellationSignal signal) 457 throws FileNotFoundException { 458 final File file = getFileForDocId(documentId); 459 return DocumentsContract.openImageThumbnail(file); 460 } 461 462 protected RowBuilder includeFile(MatrixCursor result, String docId, File file) 463 throws FileNotFoundException { 464 if (docId == null) { 465 docId = getDocIdForFile(file); 466 } else { 467 file = getFileForDocId(docId); 468 } 469 470 int flags = 0; 471 472 if (file.canWrite()) { 473 if (file.isDirectory()) { 474 flags |= Document.FLAG_DIR_SUPPORTS_CREATE; 475 flags |= Document.FLAG_SUPPORTS_DELETE; 476 flags |= Document.FLAG_SUPPORTS_RENAME; 477 flags |= Document.FLAG_SUPPORTS_MOVE; 478 } else { 479 flags |= Document.FLAG_SUPPORTS_WRITE; 480 flags |= Document.FLAG_SUPPORTS_DELETE; 481 flags |= Document.FLAG_SUPPORTS_RENAME; 482 flags |= Document.FLAG_SUPPORTS_MOVE; 483 } 484 } 485 486 final String mimeType = getTypeForFile(file); 487 final String displayName = file.getName(); 488 if (mimeType.startsWith("image/")) { 489 flags |= Document.FLAG_SUPPORTS_THUMBNAIL; 490 } 491 492 if (typeSupportsMetadata(mimeType)) { 493 flags |= Document.FLAG_SUPPORTS_METADATA; 494 } 495 496 final RowBuilder row = result.newRow(); 497 row.add(Document.COLUMN_DOCUMENT_ID, docId); 498 row.add(Document.COLUMN_DISPLAY_NAME, displayName); 499 row.add(Document.COLUMN_SIZE, file.length()); 500 row.add(Document.COLUMN_MIME_TYPE, mimeType); 501 row.add(Document.COLUMN_FLAGS, flags); 502 503 // Only publish dates reasonably after epoch 504 long lastModified = file.lastModified(); 505 if (lastModified > 31536000000L) { 506 row.add(Document.COLUMN_LAST_MODIFIED, lastModified); 507 } 508 509 // Return the row builder just in case any subclass want to add more stuff to it. 510 return row; 511 } 512 513 private static String getTypeForFile(File file) { 514 if (file.isDirectory()) { 515 return Document.MIME_TYPE_DIR; 516 } else { 517 return getTypeForName(file.getName()); 518 } 519 } 520 521 protected boolean typeSupportsMetadata(String mimeType) { 522 return MetadataReader.isSupportedMimeType(mimeType); 523 } 524 525 private static String getTypeForName(String name) { 526 final int lastDot = name.lastIndexOf('.'); 527 if (lastDot >= 0) { 528 final String extension = name.substring(lastDot + 1).toLowerCase(); 529 final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 530 if (mime != null) { 531 return mime; 532 } 533 } 534 535 return MIMETYPE_OCTET_STREAM; 536 } 537 538 protected final File getFileForDocId(String docId) throws FileNotFoundException { 539 return getFileForDocId(docId, false); 540 } 541 542 private String[] resolveProjection(String[] projection) { 543 return projection == null ? mDefaultProjection : projection; 544 } 545 546 private void startObserving(File file, Uri notifyUri) { 547 synchronized (mObservers) { 548 DirectoryObserver observer = mObservers.get(file); 549 if (observer == null) { 550 observer = new DirectoryObserver( 551 file, getContext().getContentResolver(), notifyUri); 552 observer.startWatching(); 553 mObservers.put(file, observer); 554 } 555 observer.mRefCount++; 556 557 if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer); 558 } 559 } 560 561 private void stopObserving(File file) { 562 synchronized (mObservers) { 563 DirectoryObserver observer = mObservers.get(file); 564 if (observer == null) return; 565 566 observer.mRefCount--; 567 if (observer.mRefCount == 0) { 568 mObservers.remove(file); 569 observer.stopWatching(); 570 } 571 572 if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer); 573 } 574 } 575 576 private static class DirectoryObserver extends FileObserver { 577 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO 578 | CREATE | DELETE | DELETE_SELF | MOVE_SELF; 579 580 private final File mFile; 581 private final ContentResolver mResolver; 582 private final Uri mNotifyUri; 583 584 private int mRefCount = 0; 585 586 public DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) { 587 super(file.getAbsolutePath(), NOTIFY_EVENTS); 588 mFile = file; 589 mResolver = resolver; 590 mNotifyUri = notifyUri; 591 } 592 593 @Override 594 public void onEvent(int event, String path) { 595 if ((event & NOTIFY_EVENTS) != 0) { 596 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path); 597 mResolver.notifyChange(mNotifyUri, null, false); 598 } 599 } 600 601 @Override 602 public String toString() { 603 return "DirectoryObserver{file=" + mFile.getAbsolutePath() + ", ref=" + mRefCount + "}"; 604 } 605 } 606 607 private class DirectoryCursor extends MatrixCursor { 608 private final File mFile; 609 610 public DirectoryCursor(String[] columnNames, String docId, File file) { 611 super(columnNames); 612 613 final Uri notifyUri = buildNotificationUri(docId); 614 setNotificationUri(getContext().getContentResolver(), notifyUri); 615 616 mFile = file; 617 startObserving(mFile, notifyUri); 618 } 619 620 @Override 621 public void close() { 622 super.close(); 623 stopObserving(mFile); 624 } 625 } 626 } 627