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