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