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