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.getMediaStoreUri; 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.ContentValues; 32 import android.content.Context; 33 import android.content.UriPermission; 34 import android.database.Cursor; 35 import android.database.MatrixCursor; 36 import android.database.MatrixCursor.RowBuilder; 37 import android.media.MediaFile; 38 import android.net.Uri; 39 import android.os.Binder; 40 import android.os.Bundle; 41 import android.os.CancellationSignal; 42 import android.os.Environment; 43 import android.os.FileObserver; 44 import android.os.FileUtils; 45 import android.os.ParcelFileDescriptor; 46 import android.provider.DocumentsContract; 47 import android.provider.DocumentsContract.Document; 48 import android.provider.DocumentsContract.Path; 49 import android.provider.DocumentsContract.Root; 50 import android.provider.Downloads; 51 import android.provider.MediaStore; 52 import android.provider.MediaStore.DownloadColumns; 53 import android.text.TextUtils; 54 import android.util.Log; 55 import android.util.Pair; 56 57 import com.android.internal.annotations.GuardedBy; 58 import com.android.internal.content.FileSystemProvider; 59 60 import libcore.io.IoUtils; 61 62 import java.io.File; 63 import java.io.FileNotFoundException; 64 import java.text.NumberFormat; 65 import java.util.ArrayList; 66 import java.util.Arrays; 67 import java.util.Collections; 68 import java.util.HashSet; 69 import java.util.List; 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 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(getMediaStoreUri(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 parentDocId, String[] projection, String sortOrder)310 public Cursor queryChildDocuments(String parentDocId, String[] projection, String sortOrder) 311 throws FileNotFoundException { 312 return queryChildDocuments(parentDocId, projection, sortOrder, false); 313 } 314 315 @Override queryChildDocumentsForManage( String parentDocId, String[] projection, String sortOrder)316 public Cursor queryChildDocumentsForManage( 317 String parentDocId, String[] projection, String sortOrder) 318 throws FileNotFoundException { 319 return queryChildDocuments(parentDocId, projection, sortOrder, true); 320 } 321 queryChildDocuments(String parentDocId, String[] projection, String sortOrder, boolean manage)322 private Cursor queryChildDocuments(String parentDocId, String[] projection, 323 String sortOrder, boolean manage) throws FileNotFoundException { 324 325 // Delegate to real provider 326 final long token = Binder.clearCallingIdentity(); 327 Cursor cursor = null; 328 try { 329 if (RawDocumentsHelper.isRawDocId(parentDocId)) { 330 return super.queryChildDocuments(parentDocId, projection, sortOrder); 331 } 332 333 final DownloadsCursor result = new DownloadsCursor(projection, 334 getContext().getContentResolver()); 335 final ArrayList<Uri> notificationUris = new ArrayList<>(); 336 if (isMediaStoreDownloadDir(parentDocId)) { 337 includeDownloadsFromMediaStore(result, null /* queryArgs */, 338 null /* filePaths */, notificationUris, 339 getMediaStoreIdString(parentDocId), NO_LIMIT, manage); 340 } else { 341 assert (DOC_ID_ROOT.equals(parentDocId)); 342 if (manage) { 343 cursor = mDm.query( 344 new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)); 345 } else { 346 cursor = mDm.query( 347 new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 348 .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); 349 } 350 final Set<String> filePaths = new HashSet<>(); 351 while (cursor.moveToNext()) { 352 includeDownloadFromCursor(result, cursor, filePaths, null /* queryArgs */); 353 } 354 notificationUris.add(cursor.getNotificationUri()); 355 includeDownloadsFromMediaStore(result, null /* queryArgs */, 356 filePaths, notificationUris, 357 null /* parentId */, NO_LIMIT, manage); 358 includeFilesFromSharedStorage(result, filePaths, null); 359 } 360 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 361 result.start(); 362 return result; 363 } finally { 364 IoUtils.closeQuietly(cursor); 365 Binder.restoreCallingIdentity(token); 366 } 367 } 368 369 @Override queryRecentDocuments(String rootId, String[] projection, @Nullable Bundle queryArgs, @Nullable CancellationSignal signal)370 public Cursor queryRecentDocuments(String rootId, String[] projection, 371 @Nullable Bundle queryArgs, @Nullable CancellationSignal signal) 372 throws FileNotFoundException { 373 final DownloadsCursor result = 374 new DownloadsCursor(projection, getContext().getContentResolver()); 375 376 // Delegate to real provider 377 final long token = Binder.clearCallingIdentity(); 378 379 int limit = 12; 380 if (queryArgs != null) { 381 limit = queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, -1); 382 383 if (limit < 0) { 384 // Use default value, and no QUERY_ARG* is honored. 385 limit = 12; 386 } else { 387 // We are honoring the QUERY_ARG_LIMIT. 388 Bundle extras = new Bundle(); 389 result.setExtras(extras); 390 extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[]{ 391 ContentResolver.QUERY_ARG_LIMIT 392 }); 393 } 394 } 395 396 Cursor cursor = null; 397 final ArrayList<Uri> notificationUris = new ArrayList<>(); 398 try { 399 cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 400 .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); 401 final Set<String> filePaths = new HashSet<>(); 402 while (cursor.moveToNext() && result.getCount() < limit) { 403 final String mimeType = cursor.getString( 404 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); 405 final String uri = cursor.getString( 406 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); 407 408 // Skip images, videos and documents that have been inserted into the MediaStore so 409 // we don't duplicate them in the recent list. The audio root of 410 // MediaDocumentsProvider doesn't support recent, we add it into recent list. 411 if (mimeType == null || (MediaFile.isImageMimeType(mimeType) 412 || MediaFile.isVideoMimeType(mimeType) || MediaFile.isDocumentMimeType( 413 mimeType)) && !TextUtils.isEmpty(uri)) { 414 continue; 415 } 416 includeDownloadFromCursor(result, cursor, filePaths, 417 null /* queryArgs */); 418 } 419 notificationUris.add(cursor.getNotificationUri()); 420 421 // Skip media files that have been inserted into the MediaStore so we 422 // don't duplicate them in the recent list. 423 final Bundle args = new Bundle(); 424 args.putBoolean(DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, true); 425 426 includeDownloadsFromMediaStore(result, args, filePaths, 427 notificationUris, null /* parentId */, (limit - result.getCount()), 428 false /* includePending */); 429 } finally { 430 IoUtils.closeQuietly(cursor); 431 Binder.restoreCallingIdentity(token); 432 } 433 434 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 435 result.start(); 436 return result; 437 } 438 439 @Override querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)440 public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs) 441 throws FileNotFoundException { 442 443 final DownloadsCursor result = 444 new DownloadsCursor(projection, getContext().getContentResolver()); 445 final ArrayList<Uri> notificationUris = new ArrayList<>(); 446 447 // Delegate to real provider 448 final long token = Binder.clearCallingIdentity(); 449 Cursor cursor = null; 450 try { 451 cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) 452 .setFilterByString(DocumentsContract.getSearchDocumentsQuery(queryArgs))); 453 final Set<String> filePaths = new HashSet<>(); 454 while (cursor.moveToNext()) { 455 includeDownloadFromCursor(result, cursor, filePaths, queryArgs); 456 } 457 notificationUris.add(cursor.getNotificationUri()); 458 includeDownloadsFromMediaStore(result, queryArgs, filePaths, 459 notificationUris, null /* parentId */, NO_LIMIT, true /* includePending */); 460 461 includeSearchFilesFromSharedStorage(result, projection, filePaths, queryArgs); 462 } finally { 463 IoUtils.closeQuietly(cursor); 464 Binder.restoreCallingIdentity(token); 465 } 466 467 final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs); 468 if (handledQueryArgs.length > 0) { 469 final Bundle extras = new Bundle(); 470 extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs); 471 result.setExtras(extras); 472 } 473 474 result.setNotificationUris(getContext().getContentResolver(), notificationUris); 475 result.start(); 476 return result; 477 } 478 includeSearchFilesFromSharedStorage(DownloadsCursor result, String[] projection, Set<String> filePaths, Bundle queryArgs)479 private void includeSearchFilesFromSharedStorage(DownloadsCursor result, 480 String[] projection, Set<String> filePaths, 481 Bundle queryArgs) throws FileNotFoundException { 482 final File downloadDir = getPublicDownloadsDirectory(); 483 try (Cursor rawFilesCursor = super.querySearchDocuments(downloadDir, 484 projection, filePaths, queryArgs)) { 485 486 final boolean shouldExcludeMedia = queryArgs.getBoolean( 487 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 488 while (rawFilesCursor.moveToNext()) { 489 final String mimeType = rawFilesCursor.getString( 490 rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)); 491 // When the value of shouldExcludeMedia is true, don't add media files into 492 // the result to avoid duplicated files. MediaScanner will scan the files 493 // into MediaStore. If the behavior is changed, we need to add the files back. 494 if (!shouldExcludeMedia || !isMediaMimeType(mimeType)) { 495 String docId = rawFilesCursor.getString( 496 rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)); 497 File rawFile = getFileForDocId(docId); 498 includeFileFromSharedStorage(result, rawFile); 499 } 500 } 501 } 502 } 503 504 @Override getDocumentType(String docId)505 public String getDocumentType(String docId) throws FileNotFoundException { 506 // Delegate to real provider 507 final long token = Binder.clearCallingIdentity(); 508 try { 509 if (RawDocumentsHelper.isRawDocId(docId)) { 510 return super.getDocumentType(docId); 511 } 512 513 final ContentResolver resolver = getContext().getContentResolver(); 514 final Uri contentUri; 515 if (isMediaStoreDownload(docId)) { 516 contentUri = getMediaStoreUri(docId); 517 } else { 518 final long id = Long.parseLong(docId); 519 contentUri = mDm.getDownloadUri(id); 520 } 521 return resolver.getType(contentUri); 522 } finally { 523 Binder.restoreCallingIdentity(token); 524 } 525 } 526 527 @Override openDocument(String docId, String mode, CancellationSignal signal)528 public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal) 529 throws FileNotFoundException { 530 // Delegate to real provider 531 final long token = Binder.clearCallingIdentity(); 532 try { 533 if (RawDocumentsHelper.isRawDocId(docId)) { 534 return super.openDocument(docId, mode, signal); 535 } 536 537 final ContentResolver resolver = getContext().getContentResolver(); 538 final Uri contentUri; 539 if (isMediaStoreDownload(docId)) { 540 contentUri = getMediaStoreUri(docId); 541 } else { 542 final long id = Long.parseLong(docId); 543 contentUri = mDm.getDownloadUri(id); 544 } 545 return resolver.openFileDescriptor(contentUri, mode, signal); 546 } finally { 547 Binder.restoreCallingIdentity(token); 548 } 549 } 550 551 @Override getFileForDocId(String docId, boolean visible)552 protected File getFileForDocId(String docId, boolean visible) throws FileNotFoundException { 553 if (RawDocumentsHelper.isRawDocId(docId)) { 554 return new File(RawDocumentsHelper.getAbsoluteFilePath(docId)); 555 } 556 557 if (isMediaStoreDownload(docId)) { 558 return getFileForMediaStoreDownload(docId); 559 } 560 561 if (DOC_ID_ROOT.equals(docId)) { 562 return getPublicDownloadsDirectory(); 563 } 564 565 final long token = Binder.clearCallingIdentity(); 566 Cursor cursor = null; 567 String localFilePath = null; 568 try { 569 cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); 570 if (cursor.moveToFirst()) { 571 localFilePath = cursor.getString( 572 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); 573 } 574 } finally { 575 IoUtils.closeQuietly(cursor); 576 Binder.restoreCallingIdentity(token); 577 } 578 579 if (localFilePath == null) { 580 throw new IllegalStateException("File has no filepath. Could not be found."); 581 } 582 return new File(localFilePath); 583 } 584 585 @Override getDocIdForFile(File file)586 protected String getDocIdForFile(File file) throws FileNotFoundException { 587 return RawDocumentsHelper.getDocIdForFile(file); 588 } 589 590 @Override buildNotificationUri(String docId)591 protected Uri buildNotificationUri(String docId) { 592 return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId); 593 } 594 isMediaMimeType(String mimeType)595 private static boolean isMediaMimeType(String mimeType) { 596 return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType) 597 || MediaFile.isAudioMimeType(mimeType) || MediaFile.isDocumentMimeType(mimeType); 598 } 599 includeDefaultDocument(MatrixCursor result)600 private void includeDefaultDocument(MatrixCursor result) { 601 final RowBuilder row = result.newRow(); 602 row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); 603 // We have the same display name as our root :) 604 row.add(Document.COLUMN_DISPLAY_NAME, 605 getContext().getString(R.string.root_downloads)); 606 row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR); 607 row.add(Document.COLUMN_FLAGS, 608 Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE); 609 } 610 611 /** 612 * Adds the entry from the cursor to the result only if the entry is valid. That is, 613 * if the file exists in the file system. 614 */ includeDownloadFromCursor(MatrixCursor result, Cursor cursor, Set<String> filePaths, Bundle queryArgs)615 private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor, 616 Set<String> filePaths, Bundle queryArgs) { 617 final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); 618 final String docId = String.valueOf(id); 619 620 final String displayName = cursor.getString( 621 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE)); 622 String summary = cursor.getString( 623 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_DESCRIPTION)); 624 String mimeType = cursor.getString( 625 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); 626 if (mimeType == null) { 627 // Provide fake MIME type so it's openable 628 mimeType = "vnd.android.document/file"; 629 } 630 631 if (queryArgs != null) { 632 final boolean shouldExcludeMedia = queryArgs.getBoolean( 633 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 634 if (shouldExcludeMedia) { 635 final String uri = cursor.getString( 636 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); 637 638 // Skip media files that have been inserted into the MediaStore so we 639 // don't duplicate them in the search list. 640 if (isMediaMimeType(mimeType) && !TextUtils.isEmpty(uri)) { 641 return; 642 } 643 } 644 } 645 646 // size could be -1 which indicates that download hasn't started. 647 final long size = cursor.getLong( 648 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); 649 650 String localFilePath = cursor.getString( 651 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); 652 653 int extraFlags = Document.FLAG_PARTIAL; 654 final int status = cursor.getInt( 655 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); 656 switch (status) { 657 case DownloadManager.STATUS_SUCCESSFUL: 658 // Verify that the document still exists in external storage. This is necessary 659 // because files can be deleted from the file system without their entry being 660 // removed from DownloadsManager. 661 if (localFilePath == null || !new File(localFilePath).exists()) { 662 return; 663 } 664 extraFlags = Document.FLAG_SUPPORTS_RENAME; // only successful is non-partial 665 break; 666 case DownloadManager.STATUS_PAUSED: 667 summary = getContext().getString(R.string.download_queued); 668 break; 669 case DownloadManager.STATUS_PENDING: 670 summary = getContext().getString(R.string.download_queued); 671 break; 672 case DownloadManager.STATUS_RUNNING: 673 final long progress = cursor.getLong(cursor.getColumnIndexOrThrow( 674 DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); 675 if (size > 0) { 676 String percent = 677 NumberFormat.getPercentInstance().format((double) progress / size); 678 summary = getContext().getString(R.string.download_running_percent, percent); 679 } else { 680 summary = getContext().getString(R.string.download_running); 681 } 682 break; 683 case DownloadManager.STATUS_FAILED: 684 default: 685 summary = getContext().getString(R.string.download_error); 686 break; 687 } 688 689 final long lastModified = cursor.getLong( 690 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)); 691 692 if (!DocumentsContract.matchSearchQueryArguments(queryArgs, displayName, mimeType, 693 lastModified, size)) { 694 return; 695 } 696 697 includeDownload(result, docId, displayName, summary, size, mimeType, 698 lastModified, extraFlags, status == DownloadManager.STATUS_RUNNING); 699 if (filePaths != null && localFilePath != null) { 700 filePaths.add(localFilePath); 701 } 702 } 703 includeDownload(MatrixCursor result, String docId, String displayName, String summary, long size, String mimeType, long lastModifiedMs, int extraFlags, boolean isPending)704 private void includeDownload(MatrixCursor result, 705 String docId, String displayName, String summary, long size, 706 String mimeType, long lastModifiedMs, int extraFlags, boolean isPending) { 707 708 int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | extraFlags; 709 if (mimeType.startsWith("image/")) { 710 flags |= Document.FLAG_SUPPORTS_THUMBNAIL; 711 } 712 713 if (typeSupportsMetadata(mimeType)) { 714 flags |= Document.FLAG_SUPPORTS_METADATA; 715 } 716 717 final RowBuilder row = result.newRow(); 718 row.add(Document.COLUMN_DOCUMENT_ID, docId); 719 row.add(Document.COLUMN_DISPLAY_NAME, displayName); 720 row.add(Document.COLUMN_SUMMARY, summary); 721 row.add(Document.COLUMN_SIZE, size == -1 ? null : size); 722 row.add(Document.COLUMN_MIME_TYPE, mimeType); 723 row.add(Document.COLUMN_FLAGS, flags); 724 // Incomplete downloads get a null timestamp. This prevents thrashy UI when a bunch of 725 // active downloads get sorted by mod time. 726 if (!isPending) { 727 row.add(Document.COLUMN_LAST_MODIFIED, lastModifiedMs); 728 } 729 } 730 731 /** 732 * Takes all the top-level files from the Downloads directory and adds them to the result. 733 * 734 * @param result cursor containing all documents to be returned by queryChildDocuments or 735 * queryChildDocumentsForManage. 736 * @param downloadedFilePaths The absolute file paths of all the files in the result Cursor. 737 * @param searchString query used to filter out unwanted results. 738 */ includeFilesFromSharedStorage(DownloadsCursor result, Set<String> downloadedFilePaths, @Nullable String searchString)739 private void includeFilesFromSharedStorage(DownloadsCursor result, 740 Set<String> downloadedFilePaths, @Nullable String searchString) 741 throws FileNotFoundException { 742 final File downloadsDir = getPublicDownloadsDirectory(); 743 // Add every file from the Downloads directory to the result cursor. Ignore files that 744 // were in the supplied downloaded file paths. 745 for (File file : FileUtils.listFilesOrEmpty(downloadsDir)) { 746 boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath()); 747 boolean containsQuery = searchString == null || file.getName().contains( 748 searchString); 749 if (!inResultsAlready && containsQuery) { 750 includeFileFromSharedStorage(result, file); 751 } 752 } 753 } 754 755 /** 756 * Adds a file to the result cursor. It uses a combination of {@code #RAW_PREFIX} and its 757 * absolute file path for its id. Directories are not to be included. 758 * 759 * @param result cursor containing all documents to be returned by queryChildDocuments or 760 * queryChildDocumentsForManage. 761 * @param file file to be included in the result cursor. 762 */ includeFileFromSharedStorage(MatrixCursor result, File file)763 private void includeFileFromSharedStorage(MatrixCursor result, File file) 764 throws FileNotFoundException { 765 includeFile(result, null, file); 766 } 767 getPublicDownloadsDirectory()768 private static File getPublicDownloadsDirectory() { 769 return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); 770 } 771 renameMediaStoreDownload(String docId, String displayName)772 private void renameMediaStoreDownload(String docId, String displayName) { 773 final File before = getFileForMediaStoreDownload(docId); 774 final File after = new File(before.getParentFile(), displayName); 775 776 if (after.exists()) { 777 throw new IllegalStateException("Already exists " + after); 778 } 779 if (!before.renameTo(after)) { 780 throw new IllegalStateException("Failed to rename from " + before + " to " + after); 781 } 782 783 final long token = Binder.clearCallingIdentity(); 784 try { 785 final Uri mediaStoreUri = getMediaStoreUri(docId); 786 final ContentValues values = new ContentValues(); 787 values.put(DownloadColumns.DATA, after.getAbsolutePath()); 788 values.put(DownloadColumns.DISPLAY_NAME, displayName); 789 final int count = getContext().getContentResolver().update(mediaStoreUri, values, 790 null, null); 791 if (count != 1) { 792 throw new IllegalStateException("Failed to update " + mediaStoreUri 793 + ", values=" + values); 794 } 795 } finally { 796 Binder.restoreCallingIdentity(token); 797 } 798 } 799 getFileForMediaStoreDownload(String docId)800 private File getFileForMediaStoreDownload(String docId) { 801 final Uri mediaStoreUri = getMediaStoreUri(docId); 802 final long token = Binder.clearCallingIdentity(); 803 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 804 new String[] { DownloadColumns.DATA }, null, null, null)) { 805 final String filePath = cursor.getString(0); 806 if (filePath == null) { 807 throw new IllegalStateException("Missing _data for " + mediaStoreUri); 808 } 809 return new File(filePath); 810 } catch (FileNotFoundException e) { 811 throw new IllegalStateException(e); 812 } finally { 813 Binder.restoreCallingIdentity(token); 814 } 815 } 816 getRelativePathAndDisplayNameForDownload(long id)817 private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) { 818 final Uri mediaStoreUri = ContentUris.withAppendedId( 819 MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id); 820 final long token = Binder.clearCallingIdentity(); 821 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 822 new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME }, 823 null, null, null)) { 824 final String relativePath = cursor.getString(0); 825 final String displayName = cursor.getString(1); 826 if (relativePath == null || displayName == null) { 827 throw new IllegalStateException( 828 "relative_path and _display_name should not be null for " + mediaStoreUri); 829 } 830 return Pair.create(relativePath, displayName); 831 } catch (FileNotFoundException e) { 832 throw new IllegalStateException(e); 833 } finally { 834 Binder.restoreCallingIdentity(token); 835 } 836 } 837 838 /** 839 * Copied from MediaProvider.java 840 * 841 * Query the given {@link Uri}, expecting only a single item to be found. 842 * 843 * @throws FileNotFoundException if no items were found, or multiple items 844 * were found, or there was trouble reading the data. 845 */ queryForSingleItem(Uri uri, String[] projection, String selection, String[] selectionArgs, CancellationSignal signal)846 private Cursor queryForSingleItem(Uri uri, String[] projection, 847 String selection, String[] selectionArgs, CancellationSignal signal) 848 throws FileNotFoundException { 849 final Cursor c = getContext().getContentResolver().query(uri, projection, 850 ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal); 851 if (c == null) { 852 throw new FileNotFoundException("Missing cursor for " + uri); 853 } else if (c.getCount() < 1) { 854 IoUtils.closeQuietly(c); 855 throw new FileNotFoundException("No item at " + uri); 856 } else if (c.getCount() > 1) { 857 IoUtils.closeQuietly(c); 858 throw new FileNotFoundException("Multiple items at " + uri); 859 } 860 861 if (c.moveToFirst()) { 862 return c; 863 } else { 864 IoUtils.closeQuietly(c); 865 throw new FileNotFoundException("Failed to read row from " + uri); 866 } 867 } 868 includeDownloadsFromMediaStore(@onNull MatrixCursor result, @Nullable Bundle queryArgs, @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, @Nullable String parentId, int limit, boolean includePending)869 private void includeDownloadsFromMediaStore(@NonNull MatrixCursor result, 870 @Nullable Bundle queryArgs, 871 @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, 872 @Nullable String parentId, int limit, boolean includePending) { 873 if (limit == 0) { 874 return; 875 } 876 877 final long token = Binder.clearCallingIdentity(); 878 879 final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL); 880 final Bundle queryArgsInner = new Bundle(); 881 882 final Pair<String, String[]> selectionPair = buildSearchSelection( 883 queryArgs, filePaths, parentId); 884 queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, 885 selectionPair.first); 886 queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, 887 selectionPair.second); 888 if (limit != NO_LIMIT) { 889 queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit); 890 } 891 if (includePending) { 892 queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE); 893 } 894 895 try (Cursor cursor = getContext().getContentResolver().query(uriInner, 896 null, queryArgsInner, null)) { 897 final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean( 898 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 899 while (cursor.moveToNext()) { 900 includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia); 901 } 902 notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)); 903 notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL)); 904 } finally { 905 Binder.restoreCallingIdentity(token); 906 } 907 } 908 includeDownloadFromMediaStore(@onNull MatrixCursor result, @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, boolean shouldExcludeMedia)909 private void includeDownloadFromMediaStore(@NonNull MatrixCursor result, 910 @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, 911 boolean shouldExcludeMedia) { 912 final String mimeType = getMimeType(mediaCursor); 913 914 // Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments 915 // and queryRecentDocuments. Only exclude document type here for both cases. 916 if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) { 917 return; 918 } 919 920 final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType); 921 final String docId = getDocIdForMediaStoreDownload( 922 mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir); 923 final String displayName = mediaCursor.getString( 924 mediaCursor.getColumnIndex(DownloadColumns.DISPLAY_NAME)); 925 final long size = mediaCursor.getLong( 926 mediaCursor.getColumnIndex(DownloadColumns.SIZE)); 927 final long lastModifiedMs = mediaCursor.getLong( 928 mediaCursor.getColumnIndex(DownloadColumns.DATE_MODIFIED)) * 1000; 929 final boolean isPending = mediaCursor.getInt( 930 mediaCursor.getColumnIndex(DownloadColumns.IS_PENDING)) == 1; 931 932 int extraFlags = isPending ? Document.FLAG_PARTIAL : 0; 933 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 934 extraFlags |= Document.FLAG_DIR_SUPPORTS_CREATE; 935 } 936 if (!isPending) { 937 extraFlags |= Document.FLAG_SUPPORTS_RENAME; 938 } 939 940 includeDownload(result, docId, displayName, null /* description */, size, mimeType, 941 lastModifiedMs, extraFlags, isPending); 942 if (filePaths != null) { 943 filePaths.add(mediaCursor.getString( 944 mediaCursor.getColumnIndex(DownloadColumns.DATA))); 945 } 946 } 947 getMimeType(@onNull Cursor mediaCursor)948 private String getMimeType(@NonNull Cursor mediaCursor) { 949 final String mimeType = mediaCursor.getString( 950 mediaCursor.getColumnIndex(DownloadColumns.MIME_TYPE)); 951 if (mimeType == null) { 952 return Document.MIME_TYPE_DIR; 953 } 954 return mimeType; 955 } 956 957 // Copied from MediaDocumentsProvider with some tweaks buildSearchSelection(@ullable Bundle queryArgs, @Nullable Set<String> filePaths, @Nullable String parentId)958 private Pair<String, String[]> buildSearchSelection(@Nullable Bundle queryArgs, 959 @Nullable Set<String> filePaths, @Nullable String parentId) { 960 final StringBuilder selection = new StringBuilder(); 961 final ArrayList<String> selectionArgs = new ArrayList<>(); 962 963 if (parentId == null && filePaths != null && filePaths.size() > 0) { 964 if (selection.length() > 0) { 965 selection.append(" AND "); 966 } 967 selection.append(DownloadColumns.DATA + " NOT IN ("); 968 selection.append(TextUtils.join(",", Collections.nCopies(filePaths.size(), "?"))); 969 selection.append(")"); 970 selectionArgs.addAll(filePaths); 971 } 972 973 if (parentId != null) { 974 if (selection.length() > 0) { 975 selection.append(" AND "); 976 } 977 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 978 final Pair<String, String> data = getRelativePathAndDisplayNameForDownload( 979 Long.parseLong(parentId)); 980 selectionArgs.add(data.first + data.second + "/"); 981 } else { 982 if (selection.length() > 0) { 983 selection.append(" AND "); 984 } 985 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 986 selectionArgs.add(Environment.DIRECTORY_DOWNLOADS + "/"); 987 } 988 989 if (queryArgs != null) { 990 final boolean shouldExcludeMedia = queryArgs.getBoolean( 991 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 992 if (shouldExcludeMedia) { 993 if (selection.length() > 0) { 994 selection.append(" AND "); 995 } 996 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 997 selectionArgs.add("image/%"); 998 selection.append(" AND "); 999 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 1000 selectionArgs.add("audio/%"); 1001 selection.append(" AND "); 1002 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 1003 selectionArgs.add("video/%"); 1004 } 1005 1006 final String displayName = queryArgs.getString( 1007 DocumentsContract.QUERY_ARG_DISPLAY_NAME); 1008 if (!TextUtils.isEmpty(displayName)) { 1009 if (selection.length() > 0) { 1010 selection.append(" AND "); 1011 } 1012 selection.append(DownloadColumns.DISPLAY_NAME + " LIKE ?"); 1013 selectionArgs.add("%" + displayName + "%"); 1014 } 1015 1016 final long lastModifiedAfter = queryArgs.getLong( 1017 DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */); 1018 if (lastModifiedAfter != -1) { 1019 if (selection.length() > 0) { 1020 selection.append(" AND "); 1021 } 1022 selection.append(DownloadColumns.DATE_MODIFIED 1023 + " > " + lastModifiedAfter / 1000); 1024 } 1025 1026 final long fileSizeOver = queryArgs.getLong( 1027 DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */); 1028 if (fileSizeOver != -1) { 1029 if (selection.length() > 0) { 1030 selection.append(" AND "); 1031 } 1032 selection.append(DownloadColumns.SIZE + " > " + fileSizeOver); 1033 } 1034 1035 final String[] mimeTypes = queryArgs.getStringArray( 1036 DocumentsContract.QUERY_ARG_MIME_TYPES); 1037 if (mimeTypes != null && mimeTypes.length > 0) { 1038 if (selection.length() > 0) { 1039 selection.append(" AND "); 1040 } 1041 1042 selection.append("("); 1043 final List<String> tempSelectionArgs = new ArrayList<>(); 1044 final StringBuilder tempSelection = new StringBuilder(); 1045 List<String> wildcardMimeTypeList = new ArrayList<>(); 1046 for (int i = 0; i < mimeTypes.length; ++i) { 1047 final String mimeType = mimeTypes[i]; 1048 if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) { 1049 wildcardMimeTypeList.add(mimeType); 1050 continue; 1051 } 1052 1053 if (tempSelectionArgs.size() > 0) { 1054 tempSelection.append(","); 1055 } 1056 tempSelection.append("?"); 1057 tempSelectionArgs.add(mimeType); 1058 } 1059 1060 for (int i = 0; i < wildcardMimeTypeList.size(); i++) { 1061 selection.append(DownloadColumns.MIME_TYPE + " LIKE ?") 1062 .append((i != wildcardMimeTypeList.size() - 1) ? " OR " : ""); 1063 final String mimeType = wildcardMimeTypeList.get(i); 1064 selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%"); 1065 } 1066 1067 if (tempSelectionArgs.size() > 0) { 1068 if (wildcardMimeTypeList.size() > 0) { 1069 selection.append(" OR "); 1070 } 1071 selection.append(DownloadColumns.MIME_TYPE + " IN (") 1072 .append(tempSelection.toString()) 1073 .append(")"); 1074 selectionArgs.addAll(tempSelectionArgs); 1075 } 1076 1077 selection.append(")"); 1078 } 1079 } 1080 1081 return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0])); 1082 } 1083 1084 /** 1085 * A MatrixCursor that spins up a file observer when the first instance is 1086 * started ({@link #start()}, and stops the file observer when the last instance 1087 * closed ({@link #close()}. When file changes are observed, a content change 1088 * notification is sent on the Downloads content URI. 1089 * 1090 * <p>This is necessary as other processes, like ExternalStorageProvider, 1091 * can access and modify files directly (without sending operations 1092 * through DownloadStorageProvider). 1093 * 1094 * <p>Without this, contents accessible by one a Downloads cursor instance 1095 * (like the Downloads root in Files app) can become state. 1096 */ 1097 private static final class DownloadsCursor extends MatrixCursor { 1098 1099 private static final Object mLock = new Object(); 1100 @GuardedBy("mLock") 1101 private static int mOpenCursorCount = 0; 1102 @GuardedBy("mLock") 1103 private static @Nullable ContentChangedRelay mFileWatcher; 1104 1105 private final ContentResolver mResolver; 1106 DownloadsCursor(String[] projection, ContentResolver resolver)1107 DownloadsCursor(String[] projection, ContentResolver resolver) { 1108 super(resolveDocumentProjection(projection)); 1109 mResolver = resolver; 1110 } 1111 start()1112 void start() { 1113 synchronized (mLock) { 1114 if (mOpenCursorCount++ == 0) { 1115 mFileWatcher = new ContentChangedRelay(mResolver, 1116 Arrays.asList(getPublicDownloadsDirectory())); 1117 mFileWatcher.startWatching(); 1118 } 1119 } 1120 } 1121 1122 @Override close()1123 public void close() { 1124 super.close(); 1125 synchronized (mLock) { 1126 if (--mOpenCursorCount == 0) { 1127 mFileWatcher.stopWatching(); 1128 mFileWatcher = null; 1129 } 1130 } 1131 } 1132 } 1133 1134 /** 1135 * A file observer that notifies on the Downloads content URI(s) when 1136 * files change on disk. 1137 */ 1138 private static class ContentChangedRelay extends FileObserver { 1139 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO 1140 | CREATE | DELETE | DELETE_SELF | MOVE_SELF; 1141 1142 private File[] mDownloadDirs; 1143 private final ContentResolver mResolver; 1144 ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs)1145 public ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs) { 1146 super(downloadDirs, NOTIFY_EVENTS); 1147 mDownloadDirs = downloadDirs.toArray(new File[0]); 1148 mResolver = resolver; 1149 } 1150 1151 @Override startWatching()1152 public void startWatching() { 1153 super.startWatching(); 1154 if (DEBUG) Log.d(TAG, "Started watching for file changes in: " 1155 + Arrays.toString(mDownloadDirs)); 1156 } 1157 1158 @Override stopWatching()1159 public void stopWatching() { 1160 super.stopWatching(); 1161 if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " 1162 + Arrays.toString(mDownloadDirs)); 1163 } 1164 1165 @Override onEvent(int event, String path)1166 public void onEvent(int event, String path) { 1167 if ((event & NOTIFY_EVENTS) != 0) { 1168 if (DEBUG) Log.v(TAG, "Change detected at path: " + path); 1169 mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false); 1170 mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false); 1171 } 1172 } 1173 } 1174 } 1175