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