1 /* 2 * Copyright (C) 2013 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.providers.downloads; 18 19 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload; 20 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreIdString; 21 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreUriForQuery; 22 import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownload; 23 import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownloadDir; 24 25 import android.annotation.NonNull; 26 import android.annotation.Nullable; 27 import android.app.DownloadManager; 28 import android.app.DownloadManager.Query; 29 import android.content.ContentResolver; 30 import android.content.ContentUris; 31 import android.content.Context; 32 import android.content.UriPermission; 33 import android.database.Cursor; 34 import android.database.MatrixCursor; 35 import android.database.MatrixCursor.RowBuilder; 36 import android.media.MediaFile; 37 import android.net.Uri; 38 import android.os.Binder; 39 import android.os.Bundle; 40 import android.os.CancellationSignal; 41 import android.os.Environment; 42 import android.os.FileObserver; 43 import android.os.FileUtils; 44 import android.os.ParcelFileDescriptor; 45 import android.provider.DocumentsContract; 46 import android.provider.DocumentsContract.Document; 47 import android.provider.DocumentsContract.Path; 48 import android.provider.DocumentsContract.Root; 49 import android.provider.Downloads; 50 import android.provider.MediaStore; 51 import android.provider.MediaStore.DownloadColumns; 52 import android.text.TextUtils; 53 import android.util.Log; 54 import android.util.Pair; 55 56 import com.android.internal.annotations.GuardedBy; 57 import com.android.internal.content.FileSystemProvider; 58 59 import libcore.io.IoUtils; 60 61 import java.io.File; 62 import java.io.FileNotFoundException; 63 import java.text.NumberFormat; 64 import java.util.ArrayList; 65 import java.util.Arrays; 66 import java.util.Collections; 67 import java.util.HashSet; 68 import java.util.List; 69 import java.util.Locale; 70 import java.util.Set; 71 72 /** 73 * Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from 74 * {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed 75 * downloads added by other applications using 76 * {@link DownloadManager#addCompletedDownload(String, String, boolean, String, String, long, boolean, boolean, Uri, Uri)} 77 * . 78 */ 79 public class DownloadStorageProvider extends FileSystemProvider { 80 private static final String TAG = "DownloadStorageProvider"; 81 private static final boolean DEBUG = false; 82 83 private static final String AUTHORITY = Constants.STORAGE_AUTHORITY; 84 private static final String DOC_ID_ROOT = Constants.STORAGE_ROOT_ID; 85 86 private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 87 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, 88 Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_QUERY_ARGS 89 }; 90 91 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 92 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, 93 Document.COLUMN_SUMMARY, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, 94 Document.COLUMN_SIZE, 95 }; 96 97 private DownloadManager mDm; 98 99 private static final int NO_LIMIT = -1; 100 101 @Override onCreate()102 public boolean onCreate() { 103 super.onCreate(DEFAULT_DOCUMENT_PROJECTION); 104 mDm = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE); 105 mDm.setAccessAllDownloads(true); 106 mDm.setAccessFilename(true); 107 108 return true; 109 } 110 resolveRootProjection(String[] projection)111 private static String[] resolveRootProjection(String[] projection) { 112 return projection != null ? projection : DEFAULT_ROOT_PROJECTION; 113 } 114 resolveDocumentProjection(String[] projection)115 private static String[] resolveDocumentProjection(String[] projection) { 116 return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; 117 } 118 copyNotificationUri(@onNull MatrixCursor result, @NonNull Cursor cursor)119 private void copyNotificationUri(@NonNull MatrixCursor result, @NonNull Cursor cursor) { 120 final List<Uri> notifyUris = cursor.getNotificationUris(); 121 if (notifyUris != null) { 122 result.setNotificationUris(getContext().getContentResolver(), notifyUris); 123 } 124 } 125 126 /** 127 * Called by {@link DownloadProvider} when deleting a row in the {@link DownloadManager} 128 * database. 129 */ onDownloadProviderDelete(Context context, long id)130 static void onDownloadProviderDelete(Context context, long id) { 131 final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, Long.toString(id)); 132 context.revokeUriPermission(uri, ~0); 133 } 134 onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes)135 static void onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes) { 136 for (int i = 0; i < ids.length; ++i) { 137 final boolean isDir = mimeTypes[i] == null; 138 final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, 139 MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload(ids[i], isDir)); 140 context.revokeUriPermission(uri, ~0); 141 } 142 } 143 revokeAllMediaStoreUriPermissions(Context context)144 static void revokeAllMediaStoreUriPermissions(Context context) { 145 final List<UriPermission> uriPermissions = 146 context.getContentResolver().getOutgoingUriPermissions(); 147 final int size = uriPermissions.size(); 148 final StringBuilder sb = new StringBuilder("Revoking permissions for uris: "); 149 for (int i = 0; i < size; ++i) { 150 final Uri uri = uriPermissions.get(i).getUri(); 151 if (AUTHORITY.equals(uri.getAuthority()) 152 && isMediaStoreDownload(DocumentsContract.getDocumentId(uri))) { 153 context.revokeUriPermission(uri, ~0); 154 sb.append(uri + ","); 155 } 156 } 157 Log.d(TAG, sb.toString()); 158 } 159 160 @Override queryRoots(String[] projection)161 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 162 // It's possible that the folder does not exist on disk, so we will create the folder if 163 // that is the case. If user decides to delete the folder later, then it's OK to fail on 164 // subsequent queries. 165 getPublicDownloadsDirectory().mkdirs(); 166 167 final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); 168 final RowBuilder row = result.newRow(); 169 row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT); 170 row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS 171 | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH); 172 row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download); 173 row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads)); 174 row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); 175 row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS); 176 return result; 177 } 178 179 @Override findDocumentPath(@ullable String parentDocId, String docId)180 public Path findDocumentPath(@Nullable String parentDocId, String docId) throws FileNotFoundException { 181 182 // parentDocId is null if the client is asking for the path to the root of a doc tree. 183 // Don't share root information with those who shouldn't know it. 184 final String rootId = (parentDocId == null) ? DOC_ID_ROOT : null; 185 186 if (parentDocId == null) { 187 parentDocId = DOC_ID_ROOT; 188 } 189 190 final File parent = getFileForDocId(parentDocId); 191 192 final File doc = getFileForDocId(docId); 193 194 return new Path(rootId, findDocumentPath(parent, doc)); 195 } 196 197 /** 198 * Calls on {@link FileSystemProvider#createDocument(String, String, String)}, and then creates 199 * a new database entry in {@link DownloadManager} if it is not a raw file and not a folder. 200 */ 201 @Override createDocument(String parentDocId, String mimeType, String displayName)202 public String createDocument(String parentDocId, String mimeType, String displayName) 203 throws FileNotFoundException { 204 // Delegate to real provider 205 final long token = Binder.clearCallingIdentity(); 206 try { 207 String newDocumentId = super.createDocument(parentDocId, mimeType, displayName); 208 if (!Document.MIME_TYPE_DIR.equals(mimeType) 209 && !RawDocumentsHelper.isRawDocId(parentDocId) 210 && !isMediaStoreDownload(parentDocId)) { 211 File newFile = getFileForDocId(newDocumentId); 212 newDocumentId = Long.toString(mDm.addCompletedDownload( 213 newFile.getName(), newFile.getName(), true, mimeType, 214 newFile.getAbsolutePath(), 0L, 215 false, true)); 216 } 217 return newDocumentId; 218 } finally { 219 Binder.restoreCallingIdentity(token); 220 } 221 } 222 223 @Override deleteDocument(String docId)224 public void deleteDocument(String docId) throws FileNotFoundException { 225 // Delegate to real provider 226 final long token = Binder.clearCallingIdentity(); 227 try { 228 if (RawDocumentsHelper.isRawDocId(docId) || isMediaStoreDownload(docId)) { 229 super.deleteDocument(docId); 230 return; 231 } 232 233 if (mDm.remove(Long.parseLong(docId)) != 1) { 234 throw new IllegalStateException("Failed to delete " + docId); 235 } 236 } finally { 237 Binder.restoreCallingIdentity(token); 238 } 239 } 240 241 @Override renameDocument(String docId, String displayName)242 public String renameDocument(String docId, String displayName) 243 throws FileNotFoundException { 244 final long token = Binder.clearCallingIdentity(); 245 246 try { 247 if (RawDocumentsHelper.isRawDocId(docId) 248 || isMediaStoreDownloadDir(docId)) { 249 return super.renameDocument(docId, displayName); 250 } 251 252 displayName = FileUtils.buildValidFatFilename(displayName); 253 if (isMediaStoreDownload(docId)) { 254 return renameMediaStoreDownload(docId, displayName); 255 } else { 256 final long id = Long.parseLong(docId); 257 if (!mDm.rename(getContext(), id, displayName)) { 258 throw new IllegalStateException( 259 "Failed to rename to " + displayName + " in downloadsManager"); 260 } 261 } 262 return null; 263 } finally { 264 Binder.restoreCallingIdentity(token); 265 } 266 } 267 268 @Override queryDocument(String docId, String[] projection)269 public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException { 270 // Delegate to real provider 271 final long token = Binder.clearCallingIdentity(); 272 Cursor cursor = null; 273 try { 274 if (RawDocumentsHelper.isRawDocId(docId)) { 275 return super.queryDocument(docId, projection); 276 } 277 278 final DownloadsCursor result = new DownloadsCursor(projection, 279 getContext().getContentResolver()); 280 281 if (DOC_ID_ROOT.equals(docId)) { 282 includeDefaultDocument(result); 283 } else if (isMediaStoreDownload(docId)) { 284 cursor = getContext().getContentResolver().query(getMediaStoreUriForQuery(docId), 285 null, null, null); 286 copyNotificationUri(result, cursor); 287 if (cursor.moveToFirst()) { 288 includeDownloadFromMediaStore(result, cursor, null /* filePaths */, 289 false /* shouldExcludeMedia */); 290 } 291 } else { 292 cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); 293 copyNotificationUri(result, cursor); 294 if (cursor.moveToFirst()) { 295 // We don't know if this queryDocument() call is from Downloads (manage) 296 // or Files. Safely assume it's Files. 297 includeDownloadFromCursor(result, cursor, null /* filePaths */, 298 null /* queryArgs */); 299 } 300 } 301 result.start(); 302 return result; 303 } finally { 304 IoUtils.closeQuietly(cursor); 305 Binder.restoreCallingIdentity(token); 306 } 307 } 308 309 @Override queryChildDocuments(String documentId, String[] projection, String sortOrder, boolean includeHidden)310 protected Cursor queryChildDocuments(String documentId, String[] projection, String sortOrder, 311 boolean includeHidden) throws FileNotFoundException { 312 // Delegate to real provider 313 final long token = Binder.clearCallingIdentity(); 314 Cursor cursor = null; 315 try { 316 if (RawDocumentsHelper.isRawDocId(documentId)) { 317 return super.queryChildDocuments(documentId, projection, sortOrder, includeHidden); 318 } 319 320 final DownloadsCursor result = new DownloadsCursor(projection, 321 getContext().getContentResolver()); 322 final ArrayList<Uri> notificationUris = new ArrayList<>(); 323 if (isMediaStoreDownloadDir(documentId)) { 324 includeDownloadsFromMediaStore(result, null /* queryArgs */, 325 null /* filePaths */, notificationUris, 326 getMediaStoreIdString(documentId), NO_LIMIT, includeHidden); 327 } else { 328 assert (DOC_ID_ROOT.equals(documentId)); 329 if (includeHidden) { 330 cursor = mDm.query( 331 new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)); 332 } else { 333 cursor = mDm.query( 334 new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 335 .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); 336 } 337 final Set<String> filePaths = new HashSet<>(); 338 while (cursor.moveToNext()) { 339 includeDownloadFromCursor(result, cursor, filePaths, null /* queryArgs */); 340 } 341 notificationUris.add(cursor.getNotificationUri()); 342 includeDownloadsFromMediaStore(result, null /* queryArgs */, 343 filePaths, notificationUris, 344 null /* parentId */, NO_LIMIT, includeHidden); 345 includeFilesFromSharedStorage(result, filePaths, null); 346 } 347 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 348 result.start(); 349 return result; 350 } finally { 351 IoUtils.closeQuietly(cursor); 352 Binder.restoreCallingIdentity(token); 353 } 354 } 355 356 @Override queryRecentDocuments(String rootId, String[] projection, @Nullable Bundle queryArgs, @Nullable CancellationSignal signal)357 public Cursor queryRecentDocuments(String rootId, String[] projection, 358 @Nullable Bundle queryArgs, @Nullable CancellationSignal signal) 359 throws FileNotFoundException { 360 final DownloadsCursor result = 361 new DownloadsCursor(projection, getContext().getContentResolver()); 362 363 // Delegate to real provider 364 final long token = Binder.clearCallingIdentity(); 365 366 int limit = 12; 367 if (queryArgs != null) { 368 limit = queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, -1); 369 370 if (limit < 0) { 371 // Use default value, and no QUERY_ARG* is honored. 372 limit = 12; 373 } else { 374 // We are honoring the QUERY_ARG_LIMIT. 375 Bundle extras = new Bundle(); 376 result.setExtras(extras); 377 extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[]{ 378 ContentResolver.QUERY_ARG_LIMIT 379 }); 380 } 381 } 382 383 Cursor cursor = null; 384 final ArrayList<Uri> notificationUris = new ArrayList<>(); 385 try { 386 cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 387 .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); 388 final Set<String> filePaths = new HashSet<>(); 389 while (cursor.moveToNext() && result.getCount() < limit) { 390 final String mimeType = cursor.getString( 391 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); 392 final String uri = cursor.getString( 393 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); 394 395 // Skip images, videos and documents that have been inserted into the MediaStore so 396 // we don't duplicate them in the recent list. The audio root of 397 // MediaDocumentsProvider doesn't support recent, we add it into recent list. 398 if (mimeType == null || (MediaFile.isImageMimeType(mimeType) 399 || MediaFile.isVideoMimeType(mimeType) || MediaFile.isDocumentMimeType( 400 mimeType)) && !TextUtils.isEmpty(uri)) { 401 continue; 402 } 403 includeDownloadFromCursor(result, cursor, filePaths, 404 null /* queryArgs */); 405 } 406 notificationUris.add(cursor.getNotificationUri()); 407 408 // Skip media files that have been inserted into the MediaStore so we 409 // don't duplicate them in the recent list. 410 final Bundle args = new Bundle(); 411 args.putBoolean(DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, true); 412 413 includeDownloadsFromMediaStore(result, args, filePaths, 414 notificationUris, null /* parentId */, (limit - result.getCount()), 415 false /* includePending */); 416 } finally { 417 IoUtils.closeQuietly(cursor); 418 Binder.restoreCallingIdentity(token); 419 } 420 421 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 422 result.start(); 423 return result; 424 } 425 426 @Override querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)427 public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs) 428 throws FileNotFoundException { 429 430 final DownloadsCursor result = 431 new DownloadsCursor(projection, getContext().getContentResolver()); 432 final ArrayList<Uri> notificationUris = new ArrayList<>(); 433 434 // Delegate to real provider 435 final long token = Binder.clearCallingIdentity(); 436 Cursor cursor = null; 437 try { 438 cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 439 .setFilterByString(DocumentsContract.getSearchDocumentsQuery(queryArgs))); 440 final Set<String> filePaths = new HashSet<>(); 441 while (cursor.moveToNext()) { 442 includeDownloadFromCursor(result, cursor, filePaths, queryArgs); 443 } 444 notificationUris.add(cursor.getNotificationUri()); 445 includeDownloadsFromMediaStore(result, queryArgs, filePaths, 446 notificationUris, null /* parentId */, NO_LIMIT, true /* includePending */); 447 448 includeSearchFilesFromSharedStorage(result, projection, filePaths, queryArgs); 449 } finally { 450 IoUtils.closeQuietly(cursor); 451 Binder.restoreCallingIdentity(token); 452 } 453 454 final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs); 455 if (handledQueryArgs.length > 0) { 456 final Bundle extras = new Bundle(); 457 extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs); 458 result.setExtras(extras); 459 } 460 461 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 462 result.start(); 463 return result; 464 } 465 includeSearchFilesFromSharedStorage(DownloadsCursor result, String[] projection, Set<String> filePaths, Bundle queryArgs)466 private void includeSearchFilesFromSharedStorage(DownloadsCursor result, String[] projection, 467 Set<String> filePaths, Bundle queryArgs) throws FileNotFoundException { 468 final File downloadDir = getPublicDownloadsDirectory(); 469 try (Cursor rawFilesCursor = super.querySearchDocuments(downloadDir, 470 projection, /* exclusion */ filePaths, queryArgs)) { 471 472 final boolean shouldExcludeMedia = queryArgs.getBoolean( 473 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 474 while (rawFilesCursor.moveToNext()) { 475 final String mimeType = rawFilesCursor.getString( 476 rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)); 477 // When the value of shouldExcludeMedia is true, don't add media files into 478 // the result to avoid duplicated files. MediaScanner will scan the files 479 // into MediaStore. If the behavior is changed, we need to add the files back. 480 if (!shouldExcludeMedia || !isMediaMimeType(mimeType)) { 481 String docId = rawFilesCursor.getString( 482 rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)); 483 File rawFile = getFileForDocId(docId); 484 includeFileFromSharedStorage(result, rawFile); 485 } 486 } 487 } 488 } 489 490 @Override getDocumentType(String docId)491 public String getDocumentType(String docId) throws FileNotFoundException { 492 // Delegate to real provider 493 final long token = Binder.clearCallingIdentity(); 494 try { 495 if (RawDocumentsHelper.isRawDocId(docId)) { 496 return super.getDocumentType(docId); 497 } 498 499 final ContentResolver resolver = getContext().getContentResolver(); 500 final Uri contentUri; 501 if (isMediaStoreDownload(docId)) { 502 contentUri = getMediaStoreUriForQuery(docId); 503 } else { 504 final long id = Long.parseLong(docId); 505 contentUri = mDm.getDownloadUri(id); 506 } 507 return resolver.getType(contentUri); 508 } finally { 509 Binder.restoreCallingIdentity(token); 510 } 511 } 512 513 @Override openDocument(String docId, String mode, CancellationSignal signal)514 public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal) 515 throws FileNotFoundException { 516 // Delegate to real provider 517 final long token = Binder.clearCallingIdentity(); 518 try { 519 if (RawDocumentsHelper.isRawDocId(docId)) { 520 return super.openDocument(docId, mode, signal); 521 } 522 523 final ContentResolver resolver = getContext().getContentResolver(); 524 final Uri contentUri; 525 if (isMediaStoreDownload(docId)) { 526 contentUri = getMediaStoreUriForQuery(docId); 527 } else { 528 final long id = Long.parseLong(docId); 529 contentUri = mDm.getDownloadUri(id); 530 } 531 return resolver.openFileDescriptor(contentUri, mode, signal); 532 } finally { 533 Binder.restoreCallingIdentity(token); 534 } 535 } 536 537 @Override getFileForDocId(String docId, boolean visible)538 protected File getFileForDocId(String docId, boolean visible) throws FileNotFoundException { 539 if (RawDocumentsHelper.isRawDocId(docId)) { 540 return new File(RawDocumentsHelper.getAbsoluteFilePath(docId)); 541 } 542 543 if (isMediaStoreDownload(docId)) { 544 return getFileForMediaStoreDownload(docId); 545 } 546 547 if (DOC_ID_ROOT.equals(docId)) { 548 return getPublicDownloadsDirectory(); 549 } 550 551 final long token = Binder.clearCallingIdentity(); 552 Cursor cursor = null; 553 String localFilePath = null; 554 try { 555 cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); 556 if (cursor.moveToFirst()) { 557 localFilePath = cursor.getString( 558 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); 559 } 560 } finally { 561 IoUtils.closeQuietly(cursor); 562 Binder.restoreCallingIdentity(token); 563 } 564 565 if (localFilePath == null) { 566 throw new IllegalStateException("File has no filepath. Could not be found."); 567 } 568 return new File(localFilePath); 569 } 570 571 @Override getDocIdForFile(File file)572 protected String getDocIdForFile(File file) throws FileNotFoundException { 573 return RawDocumentsHelper.getDocIdForFile(file); 574 } 575 576 @Override buildNotificationUri(String docId)577 protected Uri buildNotificationUri(String docId) { 578 return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId); 579 } 580 isMediaMimeType(String mimeType)581 private static boolean isMediaMimeType(String mimeType) { 582 return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType) 583 || MediaFile.isAudioMimeType(mimeType) || MediaFile.isDocumentMimeType(mimeType); 584 } 585 includeDefaultDocument(MatrixCursor result)586 private void includeDefaultDocument(MatrixCursor result) { 587 final RowBuilder row = result.newRow(); 588 row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); 589 // We have the same display name as our root :) 590 row.add(Document.COLUMN_DISPLAY_NAME, 591 getContext().getString(R.string.root_downloads)); 592 row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR); 593 row.add(Document.COLUMN_FLAGS, 594 Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE); 595 } 596 597 /** 598 * Adds the entry from the cursor to the result only if the entry is valid. That is, 599 * if the file exists in the file system. 600 */ includeDownloadFromCursor(MatrixCursor result, Cursor cursor, Set<String> filePaths, Bundle queryArgs)601 private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor, 602 Set<String> filePaths, Bundle queryArgs) { 603 final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); 604 final String docId = String.valueOf(id); 605 606 final String displayName = cursor.getString( 607 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE)); 608 String summary = cursor.getString( 609 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_DESCRIPTION)); 610 String mimeType = cursor.getString( 611 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); 612 if (mimeType == null) { 613 // Provide fake MIME type so it's openable 614 mimeType = "vnd.android.document/file"; 615 } 616 617 if (queryArgs != null) { 618 final boolean shouldExcludeMedia = queryArgs.getBoolean( 619 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 620 if (shouldExcludeMedia) { 621 final String uri = cursor.getString( 622 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); 623 624 // Skip media files that have been inserted into the MediaStore so we 625 // don't duplicate them in the search list. 626 if (isMediaMimeType(mimeType) && !TextUtils.isEmpty(uri)) { 627 return; 628 } 629 } 630 } 631 632 // size could be -1 which indicates that download hasn't started. 633 final long size = cursor.getLong( 634 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); 635 636 String localFilePath = cursor.getString( 637 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); 638 639 int extraFlags = Document.FLAG_PARTIAL; 640 final int status = cursor.getInt( 641 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); 642 switch (status) { 643 case DownloadManager.STATUS_SUCCESSFUL: 644 // Verify that the document still exists in external storage. This is necessary 645 // because files can be deleted from the file system without their entry being 646 // removed from DownloadsManager. 647 if (localFilePath == null || !new File(localFilePath).exists()) { 648 return; 649 } 650 extraFlags = Document.FLAG_SUPPORTS_RENAME; // only successful is non-partial 651 break; 652 case DownloadManager.STATUS_PAUSED: 653 summary = getContext().getString(R.string.download_queued); 654 break; 655 case DownloadManager.STATUS_PENDING: 656 summary = getContext().getString(R.string.download_queued); 657 break; 658 case DownloadManager.STATUS_RUNNING: 659 final long progress = cursor.getLong(cursor.getColumnIndexOrThrow( 660 DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); 661 if (size > 0) { 662 String percent = 663 NumberFormat.getPercentInstance().format((double) progress / size); 664 summary = getContext().getString(R.string.download_running_percent, percent); 665 } else { 666 summary = getContext().getString(R.string.download_running); 667 } 668 break; 669 case DownloadManager.STATUS_FAILED: 670 default: 671 summary = getContext().getString(R.string.download_error); 672 break; 673 } 674 675 final long lastModified = cursor.getLong( 676 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)); 677 678 if (!DocumentsContract.matchSearchQueryArguments(queryArgs, displayName, mimeType, 679 lastModified, size)) { 680 return; 681 } 682 683 includeDownload(result, docId, displayName, summary, size, mimeType, 684 lastModified, extraFlags, status == DownloadManager.STATUS_RUNNING); 685 if (filePaths != null && localFilePath != null) { 686 filePaths.add(localFilePath); 687 } 688 } 689 includeDownload(MatrixCursor result, String docId, String displayName, String summary, long size, String mimeType, long lastModifiedMs, int extraFlags, boolean isPending)690 private void includeDownload(MatrixCursor result, 691 String docId, String displayName, String summary, long size, 692 String mimeType, long lastModifiedMs, int extraFlags, boolean isPending) { 693 694 int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | extraFlags; 695 if (mimeType.startsWith("image/")) { 696 flags |= Document.FLAG_SUPPORTS_THUMBNAIL; 697 } 698 699 if (typeSupportsMetadata(mimeType)) { 700 flags |= Document.FLAG_SUPPORTS_METADATA; 701 } 702 703 final RowBuilder row = result.newRow(); 704 row.add(Document.COLUMN_DOCUMENT_ID, docId); 705 row.add(Document.COLUMN_DISPLAY_NAME, displayName); 706 row.add(Document.COLUMN_SUMMARY, summary); 707 row.add(Document.COLUMN_SIZE, size == -1 ? null : size); 708 row.add(Document.COLUMN_MIME_TYPE, mimeType); 709 row.add(Document.COLUMN_FLAGS, flags); 710 // Incomplete downloads get a null timestamp. This prevents thrashy UI when a bunch of 711 // active downloads get sorted by mod time. 712 if (!isPending) { 713 row.add(Document.COLUMN_LAST_MODIFIED, lastModifiedMs); 714 } 715 } 716 717 /** 718 * Takes all the top-level files from the Downloads directory and adds them to the result. 719 * 720 * @param result cursor containing all documents to be returned by queryChildDocuments or 721 * queryChildDocumentsForManage. 722 * @param downloadedFilePaths The absolute file paths of all the files in the result Cursor. 723 * @param searchString query used to filter out unwanted results. 724 */ includeFilesFromSharedStorage(DownloadsCursor result, Set<String> downloadedFilePaths, @Nullable String searchString)725 private void includeFilesFromSharedStorage(DownloadsCursor result, 726 Set<String> downloadedFilePaths, @Nullable String searchString) 727 throws FileNotFoundException { 728 final File downloadsDir = getPublicDownloadsDirectory(); 729 // Add every file from the Downloads directory to the result cursor. Ignore files that 730 // were in the supplied downloaded file paths. 731 for (File file : FileUtils.listFilesOrEmpty(downloadsDir)) { 732 boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath()); 733 boolean containsQuery = searchString == null || file.getName().contains( 734 searchString); 735 if (!inResultsAlready && containsQuery) { 736 includeFileFromSharedStorage(result, file); 737 } 738 } 739 } 740 741 /** 742 * Adds a file to the result cursor. It uses a combination of {@code #RAW_PREFIX} and its 743 * absolute file path for its id. Directories are not to be included. 744 * 745 * @param result cursor containing all documents to be returned by queryChildDocuments or 746 * queryChildDocumentsForManage. 747 * @param file file to be included in the result cursor. 748 */ includeFileFromSharedStorage(MatrixCursor result, File file)749 private void includeFileFromSharedStorage(MatrixCursor result, File file) 750 throws FileNotFoundException { 751 includeFile(result, null, file); 752 } 753 getPublicDownloadsDirectory()754 private static File getPublicDownloadsDirectory() { 755 return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); 756 } 757 renameMediaStoreDownload(String docId, String displayName)758 private String renameMediaStoreDownload(String docId, String displayName) { 759 final File before = getFileForMediaStoreDownload(docId); 760 final File after = new File(before.getParentFile(), displayName); 761 762 if (after.exists()) { 763 throw new IllegalStateException("Already exists " + after); 764 } 765 if (!before.renameTo(after)) { 766 throw new IllegalStateException("Failed to rename from " + before + " to " + after); 767 } 768 769 final String noMedia = ".nomedia"; 770 // Scan the file to update the database 771 // For file, check whether the file is renamed to .nomedia. If yes, to scan the parent 772 // directory to update all files in the directory. We don't consider the case of renaming 773 // .nomedia file. We don't show .nomedia file. 774 if (!after.isDirectory() && displayName.toLowerCase(Locale.ROOT).endsWith(noMedia)) { 775 final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), 776 after.getParentFile()); 777 // the file will not show in the list, return the parent docId to avoid not finding 778 // the detail for the file. 779 return getDocIdForMediaStoreDownloadUri(newUri, true /* isDir */); 780 } 781 // update the database for the old file 782 MediaStore.scanFile(getContext().getContentResolver(), before); 783 // Update tne database for the new file and get the new uri 784 final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), after); 785 return getDocIdForMediaStoreDownloadUri(newUri, after.isDirectory()); 786 } 787 getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir)788 private static String getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir) { 789 if (uri != null) { 790 return getDocIdForMediaStoreDownload(Long.parseLong(uri.getLastPathSegment()), isDir); 791 } 792 return null; 793 } 794 getFileForMediaStoreDownload(String docId)795 private File getFileForMediaStoreDownload(String docId) { 796 final Uri mediaStoreUri = getMediaStoreUriForQuery(docId); 797 final long token = Binder.clearCallingIdentity(); 798 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 799 new String[] { DownloadColumns.DATA }, null, null, null)) { 800 final String filePath = cursor.getString(0); 801 if (filePath == null) { 802 throw new IllegalStateException("Missing _data for " + mediaStoreUri); 803 } 804 return new File(filePath); 805 } catch (FileNotFoundException e) { 806 throw new IllegalStateException(e); 807 } finally { 808 Binder.restoreCallingIdentity(token); 809 } 810 } 811 getRelativePathAndDisplayNameForDownload(long id)812 private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) { 813 final Uri mediaStoreUri = ContentUris.withAppendedId( 814 MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id); 815 final long token = Binder.clearCallingIdentity(); 816 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 817 new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME }, 818 null, null, null)) { 819 final String relativePath = cursor.getString(0); 820 final String displayName = cursor.getString(1); 821 if (relativePath == null || displayName == null) { 822 throw new IllegalStateException( 823 "relative_path and _display_name should not be null for " + mediaStoreUri); 824 } 825 return Pair.create(relativePath, displayName); 826 } catch (FileNotFoundException e) { 827 throw new IllegalStateException(e); 828 } finally { 829 Binder.restoreCallingIdentity(token); 830 } 831 } 832 833 /** 834 * Copied from MediaProvider.java 835 * 836 * Query the given {@link Uri}, expecting only a single item to be found. 837 * 838 * @throws FileNotFoundException if no items were found, or multiple items 839 * were found, or there was trouble reading the data. 840 */ queryForSingleItem(Uri uri, String[] projection, String selection, String[] selectionArgs, CancellationSignal signal)841 private Cursor queryForSingleItem(Uri uri, String[] projection, 842 String selection, String[] selectionArgs, CancellationSignal signal) 843 throws FileNotFoundException { 844 final Cursor c = getContext().getContentResolver().query(uri, projection, 845 ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal); 846 if (c == null) { 847 throw new FileNotFoundException("Missing cursor for " + uri); 848 } else if (c.getCount() < 1) { 849 IoUtils.closeQuietly(c); 850 throw new FileNotFoundException("No item at " + uri); 851 } else if (c.getCount() > 1) { 852 IoUtils.closeQuietly(c); 853 throw new FileNotFoundException("Multiple items at " + uri); 854 } 855 856 if (c.moveToFirst()) { 857 return c; 858 } else { 859 IoUtils.closeQuietly(c); 860 throw new FileNotFoundException("Failed to read row from " + uri); 861 } 862 } 863 includeDownloadsFromMediaStore(@onNull MatrixCursor result, @Nullable Bundle queryArgs, @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, @Nullable String parentId, int limit, boolean includePending)864 private void includeDownloadsFromMediaStore(@NonNull MatrixCursor result, 865 @Nullable Bundle queryArgs, 866 @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, 867 @Nullable String parentId, int limit, boolean includePending) { 868 if (limit == 0) { 869 return; 870 } 871 872 final long token = Binder.clearCallingIdentity(); 873 874 final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL); 875 final Bundle queryArgsInner = new Bundle(); 876 877 final Pair<String, String[]> selectionPair = buildSearchSelection( 878 queryArgs, filePaths, parentId); 879 queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, 880 selectionPair.first); 881 queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, 882 selectionPair.second); 883 if (limit != NO_LIMIT) { 884 queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit); 885 } 886 if (includePending) { 887 queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE); 888 } 889 890 try (Cursor cursor = getContext().getContentResolver().query(uriInner, 891 null, queryArgsInner, null)) { 892 final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean( 893 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 894 while (cursor.moveToNext()) { 895 includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia); 896 } 897 notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)); 898 notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL)); 899 } finally { 900 Binder.restoreCallingIdentity(token); 901 } 902 } 903 includeDownloadFromMediaStore(@onNull MatrixCursor result, @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, boolean shouldExcludeMedia)904 private void includeDownloadFromMediaStore(@NonNull MatrixCursor result, 905 @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, 906 boolean shouldExcludeMedia) { 907 final String mimeType = getMimeType(mediaCursor); 908 909 // Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments 910 // and queryRecentDocuments. Only exclude document type here for both cases. 911 if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) { 912 return; 913 } 914 915 final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType); 916 final String docId = getDocIdForMediaStoreDownload( 917 mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir); 918 final String displayName = mediaCursor.getString( 919 mediaCursor.getColumnIndex(DownloadColumns.DISPLAY_NAME)); 920 final long size = mediaCursor.getLong( 921 mediaCursor.getColumnIndex(DownloadColumns.SIZE)); 922 final long lastModifiedMs = mediaCursor.getLong( 923 mediaCursor.getColumnIndex(DownloadColumns.DATE_MODIFIED)) * 1000; 924 final boolean isPending = mediaCursor.getInt( 925 mediaCursor.getColumnIndex(DownloadColumns.IS_PENDING)) == 1; 926 927 int extraFlags = isPending ? Document.FLAG_PARTIAL : 0; 928 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 929 extraFlags |= Document.FLAG_DIR_SUPPORTS_CREATE; 930 } 931 if (!isPending) { 932 extraFlags |= Document.FLAG_SUPPORTS_RENAME; 933 } 934 935 includeDownload(result, docId, displayName, null /* description */, size, mimeType, 936 lastModifiedMs, extraFlags, isPending); 937 if (filePaths != null) { 938 filePaths.add(mediaCursor.getString( 939 mediaCursor.getColumnIndex(DownloadColumns.DATA))); 940 } 941 } 942 getMimeType(@onNull Cursor mediaCursor)943 private String getMimeType(@NonNull Cursor mediaCursor) { 944 final String mimeType = mediaCursor.getString( 945 mediaCursor.getColumnIndex(DownloadColumns.MIME_TYPE)); 946 if (mimeType == null) { 947 return Document.MIME_TYPE_DIR; 948 } 949 return mimeType; 950 } 951 952 // Copied from MediaDocumentsProvider with some tweaks buildSearchSelection(@ullable Bundle queryArgs, @Nullable Set<String> filePaths, @Nullable String parentId)953 private Pair<String, String[]> buildSearchSelection(@Nullable Bundle queryArgs, 954 @Nullable Set<String> filePaths, @Nullable String parentId) { 955 final StringBuilder selection = new StringBuilder(); 956 final ArrayList<String> selectionArgs = new ArrayList<>(); 957 958 if (parentId == null && filePaths != null && filePaths.size() > 0) { 959 if (selection.length() > 0) { 960 selection.append(" AND "); 961 } 962 selection.append(DownloadColumns.DATA + " NOT IN ("); 963 selection.append(TextUtils.join(",", Collections.nCopies(filePaths.size(), "?"))); 964 selection.append(")"); 965 selectionArgs.addAll(filePaths); 966 } 967 968 if (parentId != null) { 969 if (selection.length() > 0) { 970 selection.append(" AND "); 971 } 972 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 973 final Pair<String, String> data = getRelativePathAndDisplayNameForDownload( 974 Long.parseLong(parentId)); 975 selectionArgs.add(data.first + data.second + "/"); 976 } else { 977 if (selection.length() > 0) { 978 selection.append(" AND "); 979 } 980 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 981 selectionArgs.add(Environment.DIRECTORY_DOWNLOADS + "/"); 982 } 983 984 if (queryArgs != null) { 985 final boolean shouldExcludeMedia = queryArgs.getBoolean( 986 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 987 if (shouldExcludeMedia) { 988 if (selection.length() > 0) { 989 selection.append(" AND "); 990 } 991 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 992 selectionArgs.add("image/%"); 993 selection.append(" AND "); 994 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 995 selectionArgs.add("audio/%"); 996 selection.append(" AND "); 997 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 998 selectionArgs.add("video/%"); 999 } 1000 1001 final String displayName = queryArgs.getString( 1002 DocumentsContract.QUERY_ARG_DISPLAY_NAME); 1003 if (!TextUtils.isEmpty(displayName)) { 1004 if (selection.length() > 0) { 1005 selection.append(" AND "); 1006 } 1007 selection.append(DownloadColumns.DISPLAY_NAME + " LIKE ?"); 1008 selectionArgs.add("%" + displayName + "%"); 1009 } 1010 1011 final long lastModifiedAfter = queryArgs.getLong( 1012 DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */); 1013 if (lastModifiedAfter != -1) { 1014 if (selection.length() > 0) { 1015 selection.append(" AND "); 1016 } 1017 selection.append(DownloadColumns.DATE_MODIFIED 1018 + " > " + lastModifiedAfter / 1000); 1019 } 1020 1021 final long fileSizeOver = queryArgs.getLong( 1022 DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */); 1023 if (fileSizeOver != -1) { 1024 if (selection.length() > 0) { 1025 selection.append(" AND "); 1026 } 1027 selection.append(DownloadColumns.SIZE + " > " + fileSizeOver); 1028 } 1029 1030 final String[] mimeTypes = queryArgs.getStringArray( 1031 DocumentsContract.QUERY_ARG_MIME_TYPES); 1032 if (mimeTypes != null && mimeTypes.length > 0) { 1033 if (selection.length() > 0) { 1034 selection.append(" AND "); 1035 } 1036 1037 selection.append("("); 1038 final List<String> tempSelectionArgs = new ArrayList<>(); 1039 final StringBuilder tempSelection = new StringBuilder(); 1040 List<String> wildcardMimeTypeList = new ArrayList<>(); 1041 for (int i = 0; i < mimeTypes.length; ++i) { 1042 final String mimeType = mimeTypes[i]; 1043 if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) { 1044 wildcardMimeTypeList.add(mimeType); 1045 continue; 1046 } 1047 1048 if (tempSelectionArgs.size() > 0) { 1049 tempSelection.append(","); 1050 } 1051 tempSelection.append("?"); 1052 tempSelectionArgs.add(mimeType); 1053 } 1054 1055 for (int i = 0; i < wildcardMimeTypeList.size(); i++) { 1056 selection.append(DownloadColumns.MIME_TYPE + " LIKE ?") 1057 .append((i != wildcardMimeTypeList.size() - 1) ? " OR " : ""); 1058 final String mimeType = wildcardMimeTypeList.get(i); 1059 selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%"); 1060 } 1061 1062 if (tempSelectionArgs.size() > 0) { 1063 if (wildcardMimeTypeList.size() > 0) { 1064 selection.append(" OR "); 1065 } 1066 selection.append(DownloadColumns.MIME_TYPE + " IN (") 1067 .append(tempSelection.toString()) 1068 .append(")"); 1069 selectionArgs.addAll(tempSelectionArgs); 1070 } 1071 1072 selection.append(")"); 1073 } 1074 } 1075 1076 return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0])); 1077 } 1078 1079 /** 1080 * A MatrixCursor that spins up a file observer when the first instance is 1081 * started ({@link #start()}, and stops the file observer when the last instance 1082 * closed ({@link #close()}. When file changes are observed, a content change 1083 * notification is sent on the Downloads content URI. 1084 * 1085 * <p>This is necessary as other processes, like ExternalStorageProvider, 1086 * can access and modify files directly (without sending operations 1087 * through DownloadStorageProvider). 1088 * 1089 * <p>Without this, contents accessible by one a Downloads cursor instance 1090 * (like the Downloads root in Files app) can become state. 1091 */ 1092 private static final class DownloadsCursor extends MatrixCursor { 1093 1094 private static final Object mLock = new Object(); 1095 @GuardedBy("mLock") 1096 private static int mOpenCursorCount = 0; 1097 @GuardedBy("mLock") 1098 private static @Nullable ContentChangedRelay mFileWatcher; 1099 1100 private final ContentResolver mResolver; 1101 DownloadsCursor(String[] projection, ContentResolver resolver)1102 DownloadsCursor(String[] projection, ContentResolver resolver) { 1103 super(resolveDocumentProjection(projection)); 1104 mResolver = resolver; 1105 } 1106 start()1107 void start() { 1108 synchronized (mLock) { 1109 if (mOpenCursorCount++ == 0) { 1110 mFileWatcher = new ContentChangedRelay(mResolver, 1111 Arrays.asList(getPublicDownloadsDirectory())); 1112 mFileWatcher.startWatching(); 1113 } 1114 } 1115 } 1116 1117 @Override close()1118 public void close() { 1119 super.close(); 1120 synchronized (mLock) { 1121 if (--mOpenCursorCount == 0) { 1122 mFileWatcher.stopWatching(); 1123 mFileWatcher = null; 1124 } 1125 } 1126 } 1127 } 1128 1129 /** 1130 * A file observer that notifies on the Downloads content URI(s) when 1131 * files change on disk. 1132 */ 1133 private static class ContentChangedRelay extends FileObserver { 1134 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO 1135 | CREATE | DELETE | DELETE_SELF | MOVE_SELF; 1136 1137 private File[] mDownloadDirs; 1138 private final ContentResolver mResolver; 1139 ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs)1140 public ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs) { 1141 super(downloadDirs, NOTIFY_EVENTS); 1142 mDownloadDirs = downloadDirs.toArray(new File[0]); 1143 mResolver = resolver; 1144 } 1145 1146 @Override startWatching()1147 public void startWatching() { 1148 super.startWatching(); 1149 if (DEBUG) Log.d(TAG, "Started watching for file changes in: " 1150 + Arrays.toString(mDownloadDirs)); 1151 } 1152 1153 @Override stopWatching()1154 public void stopWatching() { 1155 super.stopWatching(); 1156 if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " 1157 + Arrays.toString(mDownloadDirs)); 1158 } 1159 1160 @Override onEvent(int event, String path)1161 public void onEvent(int event, String path) { 1162 if ((event & NOTIFY_EVENTS) != 0) { 1163 if (DEBUG) Log.v(TAG, "Change detected at path: " + path); 1164 mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false); 1165 mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false); 1166 } 1167 } 1168 } 1169 } 1170