Add findDocumentPath support to DownloadStorageProvider.
[android/platform/packages/providers/DownloadProvider.git] / src / com / android / providers / downloads / DownloadStorageProvider.java
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 android.app.DownloadManager;
20 import android.app.DownloadManager.Query;
21 import android.content.ContentResolver;
22 import android.content.Context;
23 import android.content.res.AssetFileDescriptor;
24 import android.database.Cursor;
25 import android.database.MatrixCursor;
26 import android.database.MatrixCursor.RowBuilder;
27 import android.graphics.Point;
28 import android.net.Uri;
29 import android.os.Binder;
30 import android.os.CancellationSignal;
31 import android.os.Environment;
32 import android.os.FileObserver;
33 import android.os.FileUtils;
34 import android.os.ParcelFileDescriptor;
35 import android.provider.DocumentsContract;
36 import android.provider.DocumentsContract.Document;
37 import android.provider.DocumentsContract.Path;
38 import android.provider.DocumentsContract.Root;
39 import android.provider.Downloads;
40 import android.text.TextUtils;
41 import android.util.Log;
42
43 import com.android.internal.content.FileSystemProvider;
44
45 import libcore.io.IoUtils;
46
47 import java.io.File;
48 import java.io.FileNotFoundException;
49 import java.text.NumberFormat;
50 import java.util.HashSet;
51 import java.util.Set;
52
53 import javax.annotation.Nullable;
54 import javax.annotation.concurrent.GuardedBy;
55
56 /**
57  * Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from
58  * {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed
59  * downloads added by other applications using
60  * {@link DownloadManager#addCompletedDownload(String, String, boolean, String, String, long, boolean, boolean, Uri, Uri)}
61  * .
62  */
63 public class DownloadStorageProvider extends FileSystemProvider {
64     private static final String TAG = "DownloadStorageProvider";
65     private static final boolean DEBUG = false;
66
67     private static final String AUTHORITY = Constants.STORAGE_AUTHORITY;
68     private static final String DOC_ID_ROOT = Constants.STORAGE_ROOT_ID;
69
70     private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
71             Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
72             Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID,
73     };
74
75     private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
76             Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
77             Document.COLUMN_SUMMARY, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS,
78             Document.COLUMN_SIZE,
79     };
80
81     private DownloadManager mDm;
82
83     @Override
84     public boolean onCreate() {
85         super.onCreate(DEFAULT_DOCUMENT_PROJECTION);
86         mDm = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
87         mDm.setAccessAllDownloads(true);
88         mDm.setAccessFilename(true);
89
90         return true;
91     }
92
93     private static String[] resolveRootProjection(String[] projection) {
94         return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
95     }
96
97     private static String[] resolveDocumentProjection(String[] projection) {
98         return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
99     }
100
101     private void copyNotificationUri(MatrixCursor result, Cursor cursor) {
102         result.setNotificationUri(getContext().getContentResolver(), cursor.getNotificationUri());
103     }
104
105     /**
106      * Called by {@link DownloadProvider} when deleting a row in the {@link DownloadManager}
107      * database.
108      */
109     static void onDownloadProviderDelete(Context context, long id) {
110         final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, Long.toString(id));
111         context.revokeUriPermission(uri, ~0);
112     }
113
114     @Override
115     public Cursor queryRoots(String[] projection) throws FileNotFoundException {
116         final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
117         final RowBuilder row = result.newRow();
118         row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT);
119         row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS
120                 | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH);
121         row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download);
122         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads));
123         row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
124         return result;
125     }
126
127     @Override
128     public Path findDocumentPath(String parentDocId, String docId) throws FileNotFoundException {
129
130         if (parentDocId == null) {
131             parentDocId = DOC_ID_ROOT;
132         }
133
134         final File parent = getFileForDocId(parentDocId);
135
136         final File doc = getFileForDocId(docId);
137
138         final String rootId = (parentDocId == null) ? DOC_ID_ROOT : null;
139
140         return new Path(rootId, findDocumentPath(parent, doc));
141     }
142
143     /**
144      * Calls on {@link FileSystemProvider#createDocument(String, String, String)}, and then creates
145      * a new database entry in {@link DownloadManager} if it is not a raw file and not a folder.
146      */
147     @Override
148     public String createDocument(String parentDocId, String mimeType, String displayName)
149             throws FileNotFoundException {
150         // Delegate to real provider
151         final long token = Binder.clearCallingIdentity();
152         try {
153             String newDocumentId = super.createDocument(parentDocId, mimeType, displayName);
154             if (!Document.MIME_TYPE_DIR.equals(mimeType)
155                     && !RawDocumentsHelper.isRawDocId(parentDocId)) {
156                 File newFile = getFileForDocId(newDocumentId);
157                 newDocumentId = Long.toString(mDm.addCompletedDownload(
158                         newFile.getName(), newFile.getName(), true, mimeType,
159                         newFile.getAbsolutePath(), 0L,
160                         false, true));
161             }
162             return newDocumentId;
163         } finally {
164             Binder.restoreCallingIdentity(token);
165         }
166     }
167
168     @Override
169     public void deleteDocument(String docId) throws FileNotFoundException {
170         // Delegate to real provider
171         final long token = Binder.clearCallingIdentity();
172         try {
173             if (RawDocumentsHelper.isRawDocId(docId)) {
174                 super.deleteDocument(docId);
175                 return;
176             }
177             if (mDm.remove(Long.parseLong(docId)) != 1) {
178                 throw new IllegalStateException("Failed to delete " + docId);
179             }
180         } finally {
181             Binder.restoreCallingIdentity(token);
182         }
183     }
184
185     @Override
186     public String renameDocument(String docId, String displayName)
187             throws FileNotFoundException {
188         final long token = Binder.clearCallingIdentity();
189
190         try {
191             if (RawDocumentsHelper.isRawDocId(docId)) {
192                 return super.renameDocument(docId, displayName);
193             }
194
195             displayName = FileUtils.buildValidFatFilename(displayName);
196             final long id = Long.parseLong(docId);
197             if (!mDm.rename(getContext(), id, displayName)) {
198                 throw new IllegalStateException(
199                         "Failed to rename to " + displayName + " in downloadsManager");
200             }
201             return null;
202         } finally {
203             Binder.restoreCallingIdentity(token);
204         }
205     }
206
207     @Override
208     public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException {
209         // Delegate to real provider
210         final long token = Binder.clearCallingIdentity();
211         Cursor cursor = null;
212         try {
213             if (RawDocumentsHelper.isRawDocId(docId)) {
214                 return super.queryDocument(docId, projection);
215             }
216
217             final DownloadsCursor result = new DownloadsCursor(projection,
218                     getContext().getContentResolver());
219
220             if (DOC_ID_ROOT.equals(docId)) {
221                 includeDefaultDocument(result);
222             } else {
223                 cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
224                 copyNotificationUri(result, cursor);
225                 Set<String> filePaths = new HashSet<>();
226                 if (cursor.moveToFirst()) {
227                     // We don't know if this queryDocument() call is from Downloads (manage)
228                     // or Files. Safely assume it's Files.
229                     includeDownloadFromCursor(result, cursor, filePaths);
230                 }
231             }
232             result.start();
233             return result;
234         } finally {
235             IoUtils.closeQuietly(cursor);
236             Binder.restoreCallingIdentity(token);
237         }
238     }
239
240     @Override
241     public Cursor queryChildDocuments(String parentDocId, String[] projection, String sortOrder)
242             throws FileNotFoundException {
243         return queryChildDocuments(parentDocId, projection, sortOrder, false);
244     }
245
246     @Override
247     public Cursor queryChildDocumentsForManage(
248             String parentDocId, String[] projection, String sortOrder)
249             throws FileNotFoundException {
250         return queryChildDocuments(parentDocId, projection, sortOrder, true);
251     }
252
253     private Cursor queryChildDocuments(String parentDocId, String[] projection,
254             String sortOrder, boolean manage) throws FileNotFoundException {
255
256         // Delegate to real provider
257         final long token = Binder.clearCallingIdentity();
258         Cursor cursor = null;
259         try {
260             if (RawDocumentsHelper.isRawDocId(parentDocId)) {
261                 return super.queryChildDocuments(parentDocId, projection, sortOrder);
262             }
263
264             assert (DOC_ID_ROOT.equals(parentDocId));
265             final DownloadsCursor result = new DownloadsCursor(projection,
266                     getContext().getContentResolver());
267             if (manage) {
268                 cursor = mDm.query(
269                         new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true));
270             } else {
271                 cursor = mDm
272                         .query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
273                                 .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
274             }
275             copyNotificationUri(result, cursor);
276             Set<String> filePaths = new HashSet<>();
277             while (cursor.moveToNext()) {
278                 includeDownloadFromCursor(result, cursor, filePaths);
279             }
280             includeFilesFromSharedStorage(result, filePaths, null);
281
282             result.start();
283             return result;
284         } finally {
285             IoUtils.closeQuietly(cursor);
286             Binder.restoreCallingIdentity(token);
287         }
288     }
289
290     @Override
291     public Cursor queryRecentDocuments(String rootId, String[] projection)
292             throws FileNotFoundException {
293         final DownloadsCursor result =
294                 new DownloadsCursor(projection, getContext().getContentResolver());
295
296         // Delegate to real provider
297         final long token = Binder.clearCallingIdentity();
298         Cursor cursor = null;
299         try {
300             cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
301                     .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
302             copyNotificationUri(result, cursor);
303             while (cursor.moveToNext() && result.getCount() < 12) {
304                 final String mimeType = cursor.getString(
305                         cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
306                 final String uri = cursor.getString(
307                         cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI));
308
309                 // Skip images that have been inserted into the MediaStore so we
310                 // don't duplicate them in the recents list.
311                 if (mimeType == null
312                         || (mimeType.startsWith("image/") && !TextUtils.isEmpty(uri))) {
313                     continue;
314                 }
315             }
316         } finally {
317             IoUtils.closeQuietly(cursor);
318             Binder.restoreCallingIdentity(token);
319         }
320
321         result.start();
322         return result;
323     }
324
325     @Override
326     public Cursor querySearchDocuments(String rootId, String query, String[] projection)
327             throws FileNotFoundException {
328
329         final DownloadsCursor result =
330                 new DownloadsCursor(projection, getContext().getContentResolver());
331
332         // Delegate to real provider
333         final long token = Binder.clearCallingIdentity();
334         Cursor cursor = null;
335         try {
336             cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)
337                     .setFilterByString(query));
338             copyNotificationUri(result, cursor);
339             Set<String> filePaths = new HashSet<>();
340             while (cursor.moveToNext()) {
341                 includeDownloadFromCursor(result, cursor, filePaths);
342             }
343             Cursor rawFilesCursor = super.querySearchDocuments(getDownloadsDirectory(), query,
344                     projection, filePaths);
345             while (rawFilesCursor.moveToNext()) {
346                 String docId = rawFilesCursor.getString(
347                         rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID));
348                 File rawFile = getFileForDocId(docId);
349                 includeFileFromSharedStorage(result, rawFile);
350             }
351         } finally {
352             IoUtils.closeQuietly(cursor);
353             Binder.restoreCallingIdentity(token);
354         }
355
356         result.start();
357         return result;
358     }
359
360     @Override
361     public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal)
362             throws FileNotFoundException {
363         // Delegate to real provider
364         final long token = Binder.clearCallingIdentity();
365         try {
366             if (RawDocumentsHelper.isRawDocId(docId)) {
367                 return super.openDocument(docId, mode, signal);
368             }
369
370             final long id = Long.parseLong(docId);
371             final ContentResolver resolver = getContext().getContentResolver();
372             return resolver.openFileDescriptor(mDm.getDownloadUri(id), mode, signal);
373         } finally {
374             Binder.restoreCallingIdentity(token);
375         }
376     }
377
378     @Override
379     public AssetFileDescriptor openDocumentThumbnail(
380             String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException {
381         // TODO: extend ExifInterface to support fds
382         final ParcelFileDescriptor pfd = openDocument(docId, "r", signal);
383         return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
384     }
385
386     @Override
387     protected File getFileForDocId(String docId, boolean visible) throws FileNotFoundException {
388         if (RawDocumentsHelper.isRawDocId(docId)) {
389             return new File(RawDocumentsHelper.getAbsoluteFilePath(docId));
390         }
391
392         if (DOC_ID_ROOT.equals(docId)) {
393             return getDownloadsDirectory();
394         }
395
396         final long token = Binder.clearCallingIdentity();
397         Cursor cursor = null;
398         String localFilePath = null;
399         try {
400             cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
401             if (cursor.moveToFirst()) {
402                 localFilePath = cursor.getString(
403                         cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
404             }
405         } finally {
406             IoUtils.closeQuietly(cursor);
407             Binder.restoreCallingIdentity(token);
408         }
409
410         if (localFilePath == null) {
411             throw new IllegalStateException("File has no filepath. Could not be found.");
412         }
413         return new File(localFilePath);
414     }
415
416     @Override
417     protected String getDocIdForFile(File file) throws FileNotFoundException {
418         return RawDocumentsHelper.getDocIdForFile(file);
419     }
420
421     @Override
422     protected Uri buildNotificationUri(String docId) {
423         return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId);
424     }
425
426     private void includeDefaultDocument(MatrixCursor result) {
427         final RowBuilder row = result.newRow();
428         row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
429         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
430         row.add(Document.COLUMN_FLAGS,
431                 Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE);
432     }
433
434
435
436     /**
437      * Adds the entry from the cursor to the result only if the entry is valid. That is,
438      * if the file exists in the file system.
439      */
440     private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor,
441             Set<String> filePaths) {
442         final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID));
443         final String docId = String.valueOf(id);
444
445         final String displayName = cursor.getString(
446                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE));
447         String summary = cursor.getString(
448                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_DESCRIPTION));
449         String mimeType = cursor.getString(
450                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
451         if (mimeType == null) {
452             // Provide fake MIME type so it's openable
453             mimeType = "vnd.android.document/file";
454         }
455         Long size = cursor.getLong(
456                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
457         if (size == -1) {
458             size = null;
459         }
460         String localFilePath = cursor.getString(
461                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
462
463         int extraFlags = Document.FLAG_PARTIAL;
464         final int status = cursor.getInt(
465                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
466         switch (status) {
467             case DownloadManager.STATUS_SUCCESSFUL:
468                 // Verify that the document still exists in external storage. This is necessary
469                 // because files can be deleted from the file system without their entry being
470                 // removed from DownloadsManager.
471                 if (localFilePath == null || !new File(localFilePath).exists()) {
472                     return;
473                 }
474                 extraFlags = Document.FLAG_SUPPORTS_RENAME;  // only successful is non-partial
475                 break;
476             case DownloadManager.STATUS_PAUSED:
477                 summary = getContext().getString(R.string.download_queued);
478                 break;
479             case DownloadManager.STATUS_PENDING:
480                 summary = getContext().getString(R.string.download_queued);
481                 break;
482             case DownloadManager.STATUS_RUNNING:
483                 final long progress = cursor.getLong(cursor.getColumnIndexOrThrow(
484                         DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
485                 if (size != null) {
486                     String percent =
487                             NumberFormat.getPercentInstance().format((double) progress / size);
488                     summary = getContext().getString(R.string.download_running_percent, percent);
489                 } else {
490                     summary = getContext().getString(R.string.download_running);
491                 }
492                 break;
493             case DownloadManager.STATUS_FAILED:
494             default:
495                 summary = getContext().getString(R.string.download_error);
496                 break;
497         }
498
499         int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | extraFlags;
500         if (mimeType.startsWith("image/")) {
501             flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
502         }
503
504         final long lastModified = cursor.getLong(
505                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP));
506
507         final RowBuilder row = result.newRow();
508         row.add(Document.COLUMN_DOCUMENT_ID, docId);
509         row.add(Document.COLUMN_DISPLAY_NAME, displayName);
510         row.add(Document.COLUMN_SUMMARY, summary);
511         row.add(Document.COLUMN_SIZE, size);
512         row.add(Document.COLUMN_MIME_TYPE, mimeType);
513         row.add(Document.COLUMN_FLAGS, flags);
514         // Incomplete downloads get a null timestamp.  This prevents thrashy UI when a bunch of
515         // active downloads get sorted by mod time.
516         if (status != DownloadManager.STATUS_RUNNING) {
517             row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
518         }
519         filePaths.add(localFilePath);
520     }
521
522     /**
523      * Takes all the top-level files from the Downloads directory and adds them to the result.
524      *
525      * @param result cursor containing all documents to be returned by queryChildDocuments or
526      *            queryChildDocumentsForManage.
527      * @param downloadedFilePaths The absolute file paths of all the files in the result Cursor.
528      * @param searchString query used to filter out unwanted results.
529      */
530     private void includeFilesFromSharedStorage(MatrixCursor result,
531             Set<String> downloadedFilePaths, @Nullable String searchString)
532             throws FileNotFoundException {
533         File downloadsDir = getDownloadsDirectory();
534         // Add every file from the Downloads directory to the result cursor. Ignore files that
535         // were in the supplied downloaded file paths.
536         for (File file : downloadsDir.listFiles()) {
537             boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath());
538             boolean containsQuery = searchString == null || file.getName().contains(searchString);
539             if (!inResultsAlready && containsQuery) {
540                 includeFileFromSharedStorage(result, file);
541             }
542         }
543     }
544
545     /**
546      * Adds a file to the result cursor. It uses a combination of {@code #RAW_PREFIX} and its
547      * absolute file path for its id. Directories are not to be included.
548      *
549      * @param result cursor containing all documents to be returned by queryChildDocuments or
550      *            queryChildDocumentsForManage.
551      * @param file file to be included in the result cursor.
552      */
553     private void includeFileFromSharedStorage(MatrixCursor result, File file)
554             throws FileNotFoundException {
555         includeFile(result, null, file);
556     }
557
558     private static File getDownloadsDirectory() {
559         return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
560     }
561
562     /**
563      * A MatrixCursor that spins up a file observer when the first instance is
564      * started ({@link #start()}, and stops the file observer when the last instance
565      * closed ({@link #close()}. When file changes are observed, a content change
566      * notification is sent on the Downloads content URI.
567      *
568      * <p>This is necessary as other processes, like ExternalStorageProvider,
569      * can access and modify files directly (without sending operations
570      * through DownloadStorageProvider).
571      *
572      * <p>Without this, contents accessible by one a Downloads cursor instance
573      * (like the Downloads root in Files app) can become state.
574      */
575     private static final class DownloadsCursor extends MatrixCursor {
576
577         private static final Object mLock = new Object();
578         @GuardedBy("mLock")
579         private static int mOpenCursorCount = 0;
580         @GuardedBy("mLock")
581         private static @Nullable ContentChangedRelay mFileWatcher;
582
583         private final ContentResolver mResolver;
584
585         DownloadsCursor(String[] projection, ContentResolver resolver) {
586             super(resolveDocumentProjection(projection));
587             mResolver = resolver;
588         }
589
590         void start() {
591             synchronized (mLock) {
592                 if (mOpenCursorCount++ == 0) {
593                     mFileWatcher = new ContentChangedRelay(mResolver);
594                     mFileWatcher.startWatching();
595                 }
596             }
597         }
598
599         @Override
600         public void close() {
601             super.close();
602             synchronized (mLock) {
603                 if (--mOpenCursorCount == 0) {
604                     mFileWatcher.stopWatching();
605                     mFileWatcher = null;
606                 }
607             }
608         }
609     }
610
611     /**
612      * A file observer that notifies on the Downloads content URI(s) when
613      * files change on disk.
614      */
615     private static class ContentChangedRelay extends FileObserver {
616         private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO
617                 | CREATE | DELETE | DELETE_SELF | MOVE_SELF;
618
619         private static final String DOWNLOADS_PATH = getDownloadsDirectory().getAbsolutePath();
620         private final ContentResolver mResolver;
621
622         public ContentChangedRelay(ContentResolver resolver) {
623             super(DOWNLOADS_PATH, NOTIFY_EVENTS);
624             mResolver = resolver;
625         }
626
627         @Override
628         public void startWatching() {
629             super.startWatching();
630             if (DEBUG) Log.d(TAG, "Started watching for file changes in: " + DOWNLOADS_PATH);
631         }
632
633         @Override
634         public void stopWatching() {
635             super.stopWatching();
636             if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " + DOWNLOADS_PATH);
637         }
638
639         @Override
640         public void onEvent(int event, String path) {
641             if ((event & NOTIFY_EVENTS) != 0) {
642                 if (DEBUG) Log.v(TAG, "Change detected at path: " + DOWNLOADS_PATH);
643                 mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false);
644                 mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false);
645             }
646         }
647     }
648 }