1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.providers.downloads; 18 19 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload; 20 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreIdString; 21 import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreUriForQuery; 22 import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownload; 23 import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownloadDir; 24 25 import android.annotation.NonNull; 26 import android.annotation.Nullable; 27 import android.app.DownloadManager; 28 import android.app.DownloadManager.Query; 29 import android.content.ContentResolver; 30 import android.content.ContentUris; 31 import android.content.Context; 32 import android.content.UriPermission; 33 import android.database.Cursor; 34 import android.database.MatrixCursor; 35 import android.database.MatrixCursor.RowBuilder; 36 import android.media.MediaFile; 37 import android.net.Uri; 38 import android.os.Binder; 39 import android.os.Bundle; 40 import android.os.CancellationSignal; 41 import android.os.Environment; 42 import android.os.FileObserver; 43 import android.os.FileUtils; 44 import android.os.ParcelFileDescriptor; 45 import android.provider.DocumentsContract; 46 import android.provider.DocumentsContract.Document; 47 import android.provider.DocumentsContract.Path; 48 import android.provider.DocumentsContract.Root; 49 import android.provider.Downloads; 50 import android.provider.MediaStore; 51 import android.provider.MediaStore.DownloadColumns; 52 import android.text.TextUtils; 53 import android.util.Log; 54 import android.util.Pair; 55 56 import com.android.internal.annotations.GuardedBy; 57 import com.android.internal.content.FileSystemProvider; 58 59 import libcore.io.IoUtils; 60 61 import java.io.File; 62 import java.io.FileNotFoundException; 63 import java.text.NumberFormat; 64 import java.util.ArrayList; 65 import java.util.Arrays; 66 import java.util.Collections; 67 import java.util.HashSet; 68 import java.util.List; 69 import java.util.Locale; 70 import java.util.Set; 71 72 /** 73 * Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from 74 * {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed 75 * downloads added by other applications using 76 * {@link DownloadManager#addCompletedDownload(String, String, boolean, String, String, long, boolean, boolean, Uri, Uri)} 77 * . 78 */ 79 public class DownloadStorageProvider extends FileSystemProvider { 80 private static final String TAG = "DownloadStorageProvider"; 81 private static final boolean DEBUG = false; 82 83 private static final String AUTHORITY = Constants.STORAGE_AUTHORITY; 84 private static final String DOC_ID_ROOT = Constants.STORAGE_ROOT_ID; 85 86 private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 87 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, 88 Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_QUERY_ARGS 89 }; 90 91 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 92 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, 93 Document.COLUMN_SUMMARY, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, 94 Document.COLUMN_SIZE, 95 }; 96 97 private DownloadManager mDm; 98 99 private static final int NO_LIMIT = -1; 100 101 @Override onCreate()102 public boolean onCreate() { 103 super.onCreate(DEFAULT_DOCUMENT_PROJECTION); 104 mDm = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE); 105 mDm.setAccessAllDownloads(true); 106 mDm.setAccessFilename(true); 107 108 return true; 109 } 110 resolveRootProjection(String[] projection)111 private static String[] resolveRootProjection(String[] projection) { 112 return projection != null ? projection : DEFAULT_ROOT_PROJECTION; 113 } 114 resolveDocumentProjection(String[] projection)115 private static String[] resolveDocumentProjection(String[] projection) { 116 return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; 117 } 118 copyNotificationUri(@onNull MatrixCursor result, @NonNull Cursor cursor)119 private void copyNotificationUri(@NonNull MatrixCursor result, @NonNull Cursor cursor) { 120 final List<Uri> notifyUris = cursor.getNotificationUris(); 121 if (notifyUris != null) { 122 result.setNotificationUris(getContext().getContentResolver(), notifyUris); 123 } 124 } 125 126 /** 127 * Called by {@link DownloadProvider} when deleting a row in the {@link DownloadManager} 128 * database. 129 */ onDownloadProviderDelete(Context context, long id)130 static void onDownloadProviderDelete(Context context, long id) { 131 final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, Long.toString(id)); 132 context.revokeUriPermission(uri, ~0); 133 } 134 onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes)135 static void onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes) { 136 for (int i = 0; i < ids.length; ++i) { 137 final boolean isDir = mimeTypes[i] == null; 138 final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, 139 MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload(ids[i], isDir)); 140 context.revokeUriPermission(uri, ~0); 141 } 142 } 143 revokeAllMediaStoreUriPermissions(Context context)144 static void revokeAllMediaStoreUriPermissions(Context context) { 145 final List<UriPermission> uriPermissions = 146 context.getContentResolver().getOutgoingUriPermissions(); 147 final int size = uriPermissions.size(); 148 final StringBuilder sb = new StringBuilder("Revoking permissions for uris: "); 149 for (int i = 0; i < size; ++i) { 150 final Uri uri = uriPermissions.get(i).getUri(); 151 if (AUTHORITY.equals(uri.getAuthority()) 152 && isMediaStoreDownload(DocumentsContract.getDocumentId(uri))) { 153 context.revokeUriPermission(uri, ~0); 154 sb.append(uri + ","); 155 } 156 } 157 Log.d(TAG, sb.toString()); 158 } 159 160 @Override queryRoots(String[] projection)161 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 162 // It's possible that the folder does not exist on disk, so we will create the folder if 163 // that is the case. If user decides to delete the folder later, then it's OK to fail on 164 // subsequent queries. 165 getPublicDownloadsDirectory().mkdirs(); 166 167 final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); 168 final RowBuilder row = result.newRow(); 169 row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT); 170 row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS 171 | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH); 172 row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download); 173 row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads)); 174 row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); 175 row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS); 176 return result; 177 } 178 179 @Override findDocumentPath(@ullable String parentDocId, String docId)180 public Path findDocumentPath(@Nullable String parentDocId, String docId) throws FileNotFoundException { 181 182 // parentDocId is null if the client is asking for the path to the root of a doc tree. 183 // Don't share root information with those who shouldn't know it. 184 final String rootId = (parentDocId == null) ? DOC_ID_ROOT : null; 185 186 if (parentDocId == null) { 187 parentDocId = DOC_ID_ROOT; 188 } 189 190 final File parent = getFileForDocId(parentDocId); 191 192 final File doc = getFileForDocId(docId); 193 194 return new Path(rootId, findDocumentPath(parent, doc)); 195 } 196 197 /** 198 * Calls on {@link FileSystemProvider#createDocument(String, String, String)}, and then creates 199 * a new database entry in {@link DownloadManager} if it is not a raw file and not a folder. 200 */ 201 @Override createDocument(String parentDocId, String mimeType, String displayName)202 public String createDocument(String parentDocId, String mimeType, String displayName) 203 throws FileNotFoundException { 204 // Delegate to real provider 205 final long token = Binder.clearCallingIdentity(); 206 try { 207 String newDocumentId = super.createDocument(parentDocId, mimeType, displayName); 208 if (!Document.MIME_TYPE_DIR.equals(mimeType) 209 && !RawDocumentsHelper.isRawDocId(parentDocId) 210 && !isMediaStoreDownload(parentDocId)) { 211 File newFile = getFileForDocId(newDocumentId); 212 newDocumentId = Long.toString(mDm.addCompletedDownload( 213 newFile.getName(), newFile.getName(), true, mimeType, 214 newFile.getAbsolutePath(), 0L, 215 false, true)); 216 } 217 return newDocumentId; 218 } finally { 219 Binder.restoreCallingIdentity(token); 220 } 221 } 222 223 @Override deleteDocument(String docId)224 public void deleteDocument(String docId) throws FileNotFoundException { 225 // Delegate to real provider 226 final long token = Binder.clearCallingIdentity(); 227 try { 228 if (RawDocumentsHelper.isRawDocId(docId) || isMediaStoreDownload(docId)) { 229 super.deleteDocument(docId); 230 return; 231 } 232 233 if (mDm.remove(Long.parseLong(docId)) != 1) { 234 throw new IllegalStateException("Failed to delete " + docId); 235 } 236 } finally { 237 Binder.restoreCallingIdentity(token); 238 } 239 } 240 241 @Override renameDocument(String docId, String displayName)242 public String renameDocument(String docId, String displayName) 243 throws FileNotFoundException { 244 final long token = Binder.clearCallingIdentity(); 245 246 try { 247 if (RawDocumentsHelper.isRawDocId(docId) 248 || isMediaStoreDownloadDir(docId)) { 249 return super.renameDocument(docId, displayName); 250 } 251 252 displayName = FileUtils.buildValidFatFilename(displayName); 253 if (isMediaStoreDownload(docId)) { 254 return renameMediaStoreDownload(docId, displayName); 255 } else { 256 final long id = Long.parseLong(docId); 257 if (!mDm.rename(getContext(), id, displayName)) { 258 throw new IllegalStateException( 259 "Failed to rename to " + displayName + " in downloadsManager"); 260 } 261 } 262 return null; 263 } finally { 264 Binder.restoreCallingIdentity(token); 265 } 266 } 267 268 @Override queryDocument(String docId, String[] projection)269 public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException { 270 // Delegate to real provider 271 final long token = Binder.clearCallingIdentity(); 272 Cursor cursor = null; 273 try { 274 if (RawDocumentsHelper.isRawDocId(docId)) { 275 return super.queryDocument(docId, projection); 276 } 277 278 final DownloadsCursor result = new DownloadsCursor(projection, 279 getContext().getContentResolver()); 280 281 if (DOC_ID_ROOT.equals(docId)) { 282 includeDefaultDocument(result); 283 } else if (isMediaStoreDownload(docId)) { 284 cursor = getContext().getContentResolver().query(getMediaStoreUriForQuery(docId), 285 null, null, null); 286 copyNotificationUri(result, cursor); 287 if (cursor.moveToFirst()) { 288 includeDownloadFromMediaStore(result, cursor, null /* filePaths */, 289 false /* shouldExcludeMedia */); 290 } 291 } else { 292 cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); 293 copyNotificationUri(result, cursor); 294 if (cursor.moveToFirst()) { 295 // We don't know if this queryDocument() call is from Downloads (manage) 296 // or Files. Safely assume it's Files. 297 includeDownloadFromCursor(result, cursor, null /* filePaths */, 298 null /* queryArgs */); 299 } 300 } 301 result.start(); 302 return result; 303 } finally { 304 IoUtils.closeQuietly(cursor); 305 Binder.restoreCallingIdentity(token); 306 } 307 } 308 309 @Override queryChildDocuments(String 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 = getMediaStoreUriForQuery(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 = getMediaStoreUriForQuery(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 String 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 String noMedia = ".nomedia"; 784 // Scan the file to update the database 785 // For file, check whether the file is renamed to .nomedia. If yes, to scan the parent 786 // directory to update all files in the directory. We don't consider the case of renaming 787 // .nomedia file. We don't show .nomedia file. 788 if (!after.isDirectory() && displayName.toLowerCase(Locale.ROOT).endsWith(noMedia)) { 789 final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), 790 after.getParentFile()); 791 // the file will not show in the list, return the parent docId to avoid not finding 792 // the detail for the file. 793 return getDocIdForMediaStoreDownloadUri(newUri, true /* isDir */); 794 } 795 // update the database for the old file 796 MediaStore.scanFile(getContext().getContentResolver(), before); 797 // Update tne database for the new file and get the new uri 798 final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), after); 799 return getDocIdForMediaStoreDownloadUri(newUri, after.isDirectory()); 800 } 801 getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir)802 private static String getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir) { 803 if (uri != null) { 804 return getDocIdForMediaStoreDownload(Long.parseLong(uri.getLastPathSegment()), isDir); 805 } 806 return null; 807 } 808 getFileForMediaStoreDownload(String docId)809 private File getFileForMediaStoreDownload(String docId) { 810 final Uri mediaStoreUri = getMediaStoreUriForQuery(docId); 811 final long token = Binder.clearCallingIdentity(); 812 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 813 new String[] { DownloadColumns.DATA }, null, null, null)) { 814 final String filePath = cursor.getString(0); 815 if (filePath == null) { 816 throw new IllegalStateException("Missing _data for " + mediaStoreUri); 817 } 818 return new File(filePath); 819 } catch (FileNotFoundException e) { 820 throw new IllegalStateException(e); 821 } finally { 822 Binder.restoreCallingIdentity(token); 823 } 824 } 825 getRelativePathAndDisplayNameForDownload(long id)826 private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) { 827 final Uri mediaStoreUri = ContentUris.withAppendedId( 828 MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id); 829 final long token = Binder.clearCallingIdentity(); 830 try (Cursor cursor = queryForSingleItem(mediaStoreUri, 831 new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME }, 832 null, null, null)) { 833 final String relativePath = cursor.getString(0); 834 final String displayName = cursor.getString(1); 835 if (relativePath == null || displayName == null) { 836 throw new IllegalStateException( 837 "relative_path and _display_name should not be null for " + mediaStoreUri); 838 } 839 return Pair.create(relativePath, displayName); 840 } catch (FileNotFoundException e) { 841 throw new IllegalStateException(e); 842 } finally { 843 Binder.restoreCallingIdentity(token); 844 } 845 } 846 847 /** 848 * Copied from MediaProvider.java 849 * 850 * Query the given {@link Uri}, expecting only a single item to be found. 851 * 852 * @throws FileNotFoundException if no items were found, or multiple items 853 * were found, or there was trouble reading the data. 854 */ queryForSingleItem(Uri uri, String[] projection, String selection, String[] selectionArgs, CancellationSignal signal)855 private Cursor queryForSingleItem(Uri uri, String[] projection, 856 String selection, String[] selectionArgs, CancellationSignal signal) 857 throws FileNotFoundException { 858 final Cursor c = getContext().getContentResolver().query(uri, projection, 859 ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal); 860 if (c == null) { 861 throw new FileNotFoundException("Missing cursor for " + uri); 862 } else if (c.getCount() < 1) { 863 IoUtils.closeQuietly(c); 864 throw new FileNotFoundException("No item at " + uri); 865 } else if (c.getCount() > 1) { 866 IoUtils.closeQuietly(c); 867 throw new FileNotFoundException("Multiple items at " + uri); 868 } 869 870 if (c.moveToFirst()) { 871 return c; 872 } else { 873 IoUtils.closeQuietly(c); 874 throw new FileNotFoundException("Failed to read row from " + uri); 875 } 876 } 877 includeDownloadsFromMediaStore(@onNull MatrixCursor result, @Nullable Bundle queryArgs, @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, @Nullable String parentId, int limit, boolean includePending)878 private void includeDownloadsFromMediaStore(@NonNull MatrixCursor result, 879 @Nullable Bundle queryArgs, 880 @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, 881 @Nullable String parentId, int limit, boolean includePending) { 882 if (limit == 0) { 883 return; 884 } 885 886 final long token = Binder.clearCallingIdentity(); 887 888 final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL); 889 final Bundle queryArgsInner = new Bundle(); 890 891 final Pair<String, String[]> selectionPair = buildSearchSelection( 892 queryArgs, filePaths, parentId); 893 queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, 894 selectionPair.first); 895 queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, 896 selectionPair.second); 897 if (limit != NO_LIMIT) { 898 queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit); 899 } 900 if (includePending) { 901 queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE); 902 } 903 904 try (Cursor cursor = getContext().getContentResolver().query(uriInner, 905 null, queryArgsInner, null)) { 906 final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean( 907 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 908 while (cursor.moveToNext()) { 909 includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia); 910 } 911 notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)); 912 notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL)); 913 } finally { 914 Binder.restoreCallingIdentity(token); 915 } 916 } 917 includeDownloadFromMediaStore(@onNull MatrixCursor result, @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, boolean shouldExcludeMedia)918 private void includeDownloadFromMediaStore(@NonNull MatrixCursor result, 919 @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, 920 boolean shouldExcludeMedia) { 921 final String mimeType = getMimeType(mediaCursor); 922 923 // Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments 924 // and queryRecentDocuments. Only exclude document type here for both cases. 925 if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) { 926 return; 927 } 928 929 final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType); 930 final String docId = getDocIdForMediaStoreDownload( 931 mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir); 932 final String displayName = mediaCursor.getString( 933 mediaCursor.getColumnIndex(DownloadColumns.DISPLAY_NAME)); 934 final long size = mediaCursor.getLong( 935 mediaCursor.getColumnIndex(DownloadColumns.SIZE)); 936 final long lastModifiedMs = mediaCursor.getLong( 937 mediaCursor.getColumnIndex(DownloadColumns.DATE_MODIFIED)) * 1000; 938 final boolean isPending = mediaCursor.getInt( 939 mediaCursor.getColumnIndex(DownloadColumns.IS_PENDING)) == 1; 940 941 int extraFlags = isPending ? Document.FLAG_PARTIAL : 0; 942 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 943 extraFlags |= Document.FLAG_DIR_SUPPORTS_CREATE; 944 } 945 if (!isPending) { 946 extraFlags |= Document.FLAG_SUPPORTS_RENAME; 947 } 948 949 includeDownload(result, docId, displayName, null /* description */, size, mimeType, 950 lastModifiedMs, extraFlags, isPending); 951 if (filePaths != null) { 952 filePaths.add(mediaCursor.getString( 953 mediaCursor.getColumnIndex(DownloadColumns.DATA))); 954 } 955 } 956 getMimeType(@onNull Cursor mediaCursor)957 private String getMimeType(@NonNull Cursor mediaCursor) { 958 final String mimeType = mediaCursor.getString( 959 mediaCursor.getColumnIndex(DownloadColumns.MIME_TYPE)); 960 if (mimeType == null) { 961 return Document.MIME_TYPE_DIR; 962 } 963 return mimeType; 964 } 965 966 // Copied from MediaDocumentsProvider with some tweaks buildSearchSelection(@ullable Bundle queryArgs, @Nullable Set<String> filePaths, @Nullable String parentId)967 private Pair<String, String[]> buildSearchSelection(@Nullable Bundle queryArgs, 968 @Nullable Set<String> filePaths, @Nullable String parentId) { 969 final StringBuilder selection = new StringBuilder(); 970 final ArrayList<String> selectionArgs = new ArrayList<>(); 971 972 if (parentId == null && filePaths != null && filePaths.size() > 0) { 973 if (selection.length() > 0) { 974 selection.append(" AND "); 975 } 976 selection.append(DownloadColumns.DATA + " NOT IN ("); 977 selection.append(TextUtils.join(",", Collections.nCopies(filePaths.size(), "?"))); 978 selection.append(")"); 979 selectionArgs.addAll(filePaths); 980 } 981 982 if (parentId != null) { 983 if (selection.length() > 0) { 984 selection.append(" AND "); 985 } 986 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 987 final Pair<String, String> data = getRelativePathAndDisplayNameForDownload( 988 Long.parseLong(parentId)); 989 selectionArgs.add(data.first + data.second + "/"); 990 } else { 991 if (selection.length() > 0) { 992 selection.append(" AND "); 993 } 994 selection.append(DownloadColumns.RELATIVE_PATH + "=?"); 995 selectionArgs.add(Environment.DIRECTORY_DOWNLOADS + "/"); 996 } 997 998 if (queryArgs != null) { 999 final boolean shouldExcludeMedia = queryArgs.getBoolean( 1000 DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); 1001 if (shouldExcludeMedia) { 1002 if (selection.length() > 0) { 1003 selection.append(" AND "); 1004 } 1005 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 1006 selectionArgs.add("image/%"); 1007 selection.append(" AND "); 1008 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 1009 selectionArgs.add("audio/%"); 1010 selection.append(" AND "); 1011 selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); 1012 selectionArgs.add("video/%"); 1013 } 1014 1015 final String displayName = queryArgs.getString( 1016 DocumentsContract.QUERY_ARG_DISPLAY_NAME); 1017 if (!TextUtils.isEmpty(displayName)) { 1018 if (selection.length() > 0) { 1019 selection.append(" AND "); 1020 } 1021 selection.append(DownloadColumns.DISPLAY_NAME + " LIKE ?"); 1022 selectionArgs.add("%" + displayName + "%"); 1023 } 1024 1025 final long lastModifiedAfter = queryArgs.getLong( 1026 DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */); 1027 if (lastModifiedAfter != -1) { 1028 if (selection.length() > 0) { 1029 selection.append(" AND "); 1030 } 1031 selection.append(DownloadColumns.DATE_MODIFIED 1032 + " > " + lastModifiedAfter / 1000); 1033 } 1034 1035 final long fileSizeOver = queryArgs.getLong( 1036 DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */); 1037 if (fileSizeOver != -1) { 1038 if (selection.length() > 0) { 1039 selection.append(" AND "); 1040 } 1041 selection.append(DownloadColumns.SIZE + " > " + fileSizeOver); 1042 } 1043 1044 final String[] mimeTypes = queryArgs.getStringArray( 1045 DocumentsContract.QUERY_ARG_MIME_TYPES); 1046 if (mimeTypes != null && mimeTypes.length > 0) { 1047 if (selection.length() > 0) { 1048 selection.append(" AND "); 1049 } 1050 1051 selection.append("("); 1052 final List<String> tempSelectionArgs = new ArrayList<>(); 1053 final StringBuilder tempSelection = new StringBuilder(); 1054 List<String> wildcardMimeTypeList = new ArrayList<>(); 1055 for (int i = 0; i < mimeTypes.length; ++i) { 1056 final String mimeType = mimeTypes[i]; 1057 if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) { 1058 wildcardMimeTypeList.add(mimeType); 1059 continue; 1060 } 1061 1062 if (tempSelectionArgs.size() > 0) { 1063 tempSelection.append(","); 1064 } 1065 tempSelection.append("?"); 1066 tempSelectionArgs.add(mimeType); 1067 } 1068 1069 for (int i = 0; i < wildcardMimeTypeList.size(); i++) { 1070 selection.append(DownloadColumns.MIME_TYPE + " LIKE ?") 1071 .append((i != wildcardMimeTypeList.size() - 1) ? " OR " : ""); 1072 final String mimeType = wildcardMimeTypeList.get(i); 1073 selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%"); 1074 } 1075 1076 if (tempSelectionArgs.size() > 0) { 1077 if (wildcardMimeTypeList.size() > 0) { 1078 selection.append(" OR "); 1079 } 1080 selection.append(DownloadColumns.MIME_TYPE + " IN (") 1081 .append(tempSelection.toString()) 1082 .append(")"); 1083 selectionArgs.addAll(tempSelectionArgs); 1084 } 1085 1086 selection.append(")"); 1087 } 1088 } 1089 1090 return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0])); 1091 } 1092 1093 /** 1094 * A MatrixCursor that spins up a file observer when the first instance is 1095 * started ({@link #start()}, and stops the file observer when the last instance 1096 * closed ({@link #close()}. When file changes are observed, a content change 1097 * notification is sent on the Downloads content URI. 1098 * 1099 * <p>This is necessary as other processes, like ExternalStorageProvider, 1100 * can access and modify files directly (without sending operations 1101 * through DownloadStorageProvider). 1102 * 1103 * <p>Without this, contents accessible by one a Downloads cursor instance 1104 * (like the Downloads root in Files app) can become state. 1105 */ 1106 private static final class DownloadsCursor extends MatrixCursor { 1107 1108 private static final Object mLock = new Object(); 1109 @GuardedBy("mLock") 1110 private static int mOpenCursorCount = 0; 1111 @GuardedBy("mLock") 1112 private static @Nullable ContentChangedRelay mFileWatcher; 1113 1114 private final ContentResolver mResolver; 1115 DownloadsCursor(String[] projection, ContentResolver resolver)1116 DownloadsCursor(String[] projection, ContentResolver resolver) { 1117 super(resolveDocumentProjection(projection)); 1118 mResolver = resolver; 1119 } 1120 start()1121 void start() { 1122 synchronized (mLock) { 1123 if (mOpenCursorCount++ == 0) { 1124 mFileWatcher = new ContentChangedRelay(mResolver, 1125 Arrays.asList(getPublicDownloadsDirectory())); 1126 mFileWatcher.startWatching(); 1127 } 1128 } 1129 } 1130 1131 @Override close()1132 public void close() { 1133 super.close(); 1134 synchronized (mLock) { 1135 if (--mOpenCursorCount == 0) { 1136 mFileWatcher.stopWatching(); 1137 mFileWatcher = null; 1138 } 1139 } 1140 } 1141 } 1142 1143 /** 1144 * A file observer that notifies on the Downloads content URI(s) when 1145 * files change on disk. 1146 */ 1147 private static class ContentChangedRelay extends FileObserver { 1148 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO 1149 | CREATE | DELETE | DELETE_SELF | MOVE_SELF; 1150 1151 private File[] mDownloadDirs; 1152 private final ContentResolver mResolver; 1153 ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs)1154 public ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs) { 1155 super(downloadDirs, NOTIFY_EVENTS); 1156 mDownloadDirs = downloadDirs.toArray(new File[0]); 1157 mResolver = resolver; 1158 } 1159 1160 @Override startWatching()1161 public void startWatching() { 1162 super.startWatching(); 1163 if (DEBUG) Log.d(TAG, "Started watching for file changes in: " 1164 + Arrays.toString(mDownloadDirs)); 1165 } 1166 1167 @Override stopWatching()1168 public void stopWatching() { 1169 super.stopWatching(); 1170 if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " 1171 + Arrays.toString(mDownloadDirs)); 1172 } 1173 1174 @Override onEvent(int event, String path)1175 public void onEvent(int event, String path) { 1176 if ((event & NOTIFY_EVENTS) != 0) { 1177 if (DEBUG) Log.v(TAG, "Change detected at path: " + path); 1178 mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false); 1179 mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false); 1180 } 1181 } 1182 } 1183 } 1184