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