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