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