| /* |
| * Copyright (C) 2013 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.providers.downloads; |
| |
| import static com.android.providers.downloads.MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload; |
| import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreIdString; |
| import static com.android.providers.downloads.MediaStoreDownloadsHelper.getMediaStoreUriForQuery; |
| import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownload; |
| import static com.android.providers.downloads.MediaStoreDownloadsHelper.isMediaStoreDownloadDir; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.DownloadManager; |
| import android.app.DownloadManager.Query; |
| import android.content.ContentResolver; |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.content.UriPermission; |
| import android.database.Cursor; |
| import android.database.MatrixCursor; |
| import android.database.MatrixCursor.RowBuilder; |
| import android.media.MediaFile; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.Bundle; |
| import android.os.CancellationSignal; |
| import android.os.Environment; |
| import android.os.FileObserver; |
| import android.os.FileUtils; |
| import android.os.ParcelFileDescriptor; |
| import android.provider.DocumentsContract; |
| import android.provider.DocumentsContract.Document; |
| import android.provider.DocumentsContract.Path; |
| import android.provider.DocumentsContract.Root; |
| import android.provider.Downloads; |
| import android.provider.MediaStore; |
| import android.provider.MediaStore.DownloadColumns; |
| import android.text.TextUtils; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.content.FileSystemProvider; |
| |
| import libcore.io.IoUtils; |
| |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.text.NumberFormat; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Set; |
| |
| /** |
| * Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from |
| * {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed |
| * downloads added by other applications using |
| * {@link DownloadManager#addCompletedDownload(String, String, boolean, String, String, long, boolean, boolean, Uri, Uri)} |
| * . |
| */ |
| public class DownloadStorageProvider extends FileSystemProvider { |
| private static final String TAG = "DownloadStorageProvider"; |
| private static final boolean DEBUG = false; |
| |
| private static final String AUTHORITY = Constants.STORAGE_AUTHORITY; |
| private static final String DOC_ID_ROOT = Constants.STORAGE_ROOT_ID; |
| |
| private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { |
| Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, |
| Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_QUERY_ARGS |
| }; |
| |
| private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { |
| Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, |
| Document.COLUMN_SUMMARY, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, |
| Document.COLUMN_SIZE, |
| }; |
| |
| private DownloadManager mDm; |
| |
| private static final int NO_LIMIT = -1; |
| |
| @Override |
| public boolean onCreate() { |
| super.onCreate(DEFAULT_DOCUMENT_PROJECTION); |
| mDm = (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE); |
| mDm.setAccessAllDownloads(true); |
| mDm.setAccessFilename(true); |
| |
| return true; |
| } |
| |
| private static String[] resolveRootProjection(String[] projection) { |
| return projection != null ? projection : DEFAULT_ROOT_PROJECTION; |
| } |
| |
| private static String[] resolveDocumentProjection(String[] projection) { |
| return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; |
| } |
| |
| private void copyNotificationUri(@NonNull MatrixCursor result, @NonNull Cursor cursor) { |
| final List<Uri> notifyUris = cursor.getNotificationUris(); |
| if (notifyUris != null) { |
| result.setNotificationUris(getContext().getContentResolver(), notifyUris); |
| } |
| } |
| |
| /** |
| * Called by {@link DownloadProvider} when deleting a row in the {@link DownloadManager} |
| * database. |
| */ |
| static void onDownloadProviderDelete(Context context, long id) { |
| final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, Long.toString(id)); |
| context.revokeUriPermission(uri, ~0); |
| } |
| |
| static void onMediaProviderDownloadsDelete(Context context, long[] ids, String[] mimeTypes) { |
| for (int i = 0; i < ids.length; ++i) { |
| final boolean isDir = mimeTypes[i] == null; |
| final Uri uri = DocumentsContract.buildDocumentUri(AUTHORITY, |
| MediaStoreDownloadsHelper.getDocIdForMediaStoreDownload(ids[i], isDir)); |
| context.revokeUriPermission(uri, ~0); |
| } |
| } |
| |
| static void revokeAllMediaStoreUriPermissions(Context context) { |
| final List<UriPermission> uriPermissions = |
| context.getContentResolver().getOutgoingUriPermissions(); |
| final int size = uriPermissions.size(); |
| final StringBuilder sb = new StringBuilder("Revoking permissions for uris: "); |
| for (int i = 0; i < size; ++i) { |
| final Uri uri = uriPermissions.get(i).getUri(); |
| if (AUTHORITY.equals(uri.getAuthority()) |
| && isMediaStoreDownload(DocumentsContract.getDocumentId(uri))) { |
| context.revokeUriPermission(uri, ~0); |
| sb.append(uri + ","); |
| } |
| } |
| Log.d(TAG, sb.toString()); |
| } |
| |
| @Override |
| public Cursor queryRoots(String[] projection) throws FileNotFoundException { |
| // It's possible that the folder does not exist on disk, so we will create the folder if |
| // that is the case. If user decides to delete the folder later, then it's OK to fail on |
| // subsequent queries. |
| getPublicDownloadsDirectory().mkdirs(); |
| |
| final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); |
| final RowBuilder row = result.newRow(); |
| row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT); |
| row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS |
| | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH); |
| row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download); |
| row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads)); |
| row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); |
| row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS); |
| return result; |
| } |
| |
| @Override |
| public Path findDocumentPath(@Nullable String parentDocId, String docId) throws FileNotFoundException { |
| |
| // parentDocId is null if the client is asking for the path to the root of a doc tree. |
| // Don't share root information with those who shouldn't know it. |
| final String rootId = (parentDocId == null) ? DOC_ID_ROOT : null; |
| |
| if (parentDocId == null) { |
| parentDocId = DOC_ID_ROOT; |
| } |
| |
| final File parent = getFileForDocId(parentDocId); |
| |
| final File doc = getFileForDocId(docId); |
| |
| return new Path(rootId, findDocumentPath(parent, doc)); |
| } |
| |
| /** |
| * Calls on {@link FileSystemProvider#createDocument(String, String, String)}, and then creates |
| * a new database entry in {@link DownloadManager} if it is not a raw file and not a folder. |
| */ |
| @Override |
| public String createDocument(String parentDocId, String mimeType, String displayName) |
| throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| String newDocumentId = super.createDocument(parentDocId, mimeType, displayName); |
| if (!Document.MIME_TYPE_DIR.equals(mimeType) |
| && !RawDocumentsHelper.isRawDocId(parentDocId) |
| && !isMediaStoreDownload(parentDocId)) { |
| File newFile = getFileForDocId(newDocumentId); |
| newDocumentId = Long.toString(mDm.addCompletedDownload( |
| newFile.getName(), newFile.getName(), true, mimeType, |
| newFile.getAbsolutePath(), 0L, |
| false, true)); |
| } |
| return newDocumentId; |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| public void deleteDocument(String docId) throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| if (RawDocumentsHelper.isRawDocId(docId) || isMediaStoreDownload(docId)) { |
| super.deleteDocument(docId); |
| return; |
| } |
| |
| if (mDm.remove(Long.parseLong(docId)) != 1) { |
| throw new IllegalStateException("Failed to delete " + docId); |
| } |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| public String renameDocument(String docId, String displayName) |
| throws FileNotFoundException { |
| final long token = Binder.clearCallingIdentity(); |
| |
| try { |
| if (RawDocumentsHelper.isRawDocId(docId) |
| || isMediaStoreDownloadDir(docId)) { |
| return super.renameDocument(docId, displayName); |
| } |
| |
| displayName = FileUtils.buildValidFatFilename(displayName); |
| if (isMediaStoreDownload(docId)) { |
| return renameMediaStoreDownload(docId, displayName); |
| } else { |
| final long id = Long.parseLong(docId); |
| if (!mDm.rename(getContext(), id, displayName)) { |
| throw new IllegalStateException( |
| "Failed to rename to " + displayName + " in downloadsManager"); |
| } |
| } |
| return null; |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| Cursor cursor = null; |
| try { |
| if (RawDocumentsHelper.isRawDocId(docId)) { |
| return super.queryDocument(docId, projection); |
| } |
| |
| final DownloadsCursor result = new DownloadsCursor(projection, |
| getContext().getContentResolver()); |
| |
| if (DOC_ID_ROOT.equals(docId)) { |
| includeDefaultDocument(result); |
| } else if (isMediaStoreDownload(docId)) { |
| cursor = getContext().getContentResolver().query(getMediaStoreUriForQuery(docId), |
| null, null, null); |
| copyNotificationUri(result, cursor); |
| if (cursor.moveToFirst()) { |
| includeDownloadFromMediaStore(result, cursor, null /* filePaths */, |
| false /* shouldExcludeMedia */); |
| } |
| } else { |
| cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); |
| copyNotificationUri(result, cursor); |
| if (cursor.moveToFirst()) { |
| // We don't know if this queryDocument() call is from Downloads (manage) |
| // or Files. Safely assume it's Files. |
| includeDownloadFromCursor(result, cursor, null /* filePaths */, |
| null /* queryArgs */); |
| } |
| } |
| result.start(); |
| return result; |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| protected Cursor queryChildDocuments(String documentId, String[] projection, String sortOrder, |
| boolean includeHidden) throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| Cursor cursor = null; |
| try { |
| if (RawDocumentsHelper.isRawDocId(documentId)) { |
| return super.queryChildDocuments(documentId, projection, sortOrder, includeHidden); |
| } |
| |
| final DownloadsCursor result = new DownloadsCursor(projection, |
| getContext().getContentResolver()); |
| final ArrayList<Uri> notificationUris = new ArrayList<>(); |
| if (isMediaStoreDownloadDir(documentId)) { |
| includeDownloadsFromMediaStore(result, null /* queryArgs */, |
| null /* filePaths */, notificationUris, |
| getMediaStoreIdString(documentId), NO_LIMIT, includeHidden); |
| } else { |
| assert (DOC_ID_ROOT.equals(documentId)); |
| if (includeHidden) { |
| cursor = mDm.query( |
| new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)); |
| } else { |
| cursor = mDm.query( |
| new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) |
| .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); |
| } |
| final Set<String> filePaths = new HashSet<>(); |
| while (cursor.moveToNext()) { |
| includeDownloadFromCursor(result, cursor, filePaths, null /* queryArgs */); |
| } |
| notificationUris.add(cursor.getNotificationUri()); |
| includeDownloadsFromMediaStore(result, null /* queryArgs */, |
| filePaths, notificationUris, |
| null /* parentId */, NO_LIMIT, includeHidden); |
| includeFilesFromSharedStorage(result, filePaths, null); |
| } |
| result.setNotificationUris(getContext().getContentResolver(), notificationUris); |
| result.start(); |
| return result; |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| public Cursor queryRecentDocuments(String rootId, String[] projection, |
| @Nullable Bundle queryArgs, @Nullable CancellationSignal signal) |
| throws FileNotFoundException { |
| final DownloadsCursor result = |
| new DownloadsCursor(projection, getContext().getContentResolver()); |
| |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| |
| int limit = 12; |
| if (queryArgs != null) { |
| limit = queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, -1); |
| |
| if (limit < 0) { |
| // Use default value, and no QUERY_ARG* is honored. |
| limit = 12; |
| } else { |
| // We are honoring the QUERY_ARG_LIMIT. |
| Bundle extras = new Bundle(); |
| result.setExtras(extras); |
| extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[]{ |
| ContentResolver.QUERY_ARG_LIMIT |
| }); |
| } |
| } |
| |
| Cursor cursor = null; |
| final ArrayList<Uri> notificationUris = new ArrayList<>(); |
| try { |
| cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) |
| .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); |
| final Set<String> filePaths = new HashSet<>(); |
| while (cursor.moveToNext() && result.getCount() < limit) { |
| final String mimeType = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); |
| final String uri = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); |
| |
| // Skip images, videos and documents that have been inserted into the MediaStore so |
| // we don't duplicate them in the recent list. The audio root of |
| // MediaDocumentsProvider doesn't support recent, we add it into recent list. |
| if (mimeType == null || (MediaFile.isImageMimeType(mimeType) |
| || MediaFile.isVideoMimeType(mimeType) || MediaFile.isDocumentMimeType( |
| mimeType)) && !TextUtils.isEmpty(uri)) { |
| continue; |
| } |
| includeDownloadFromCursor(result, cursor, filePaths, |
| null /* queryArgs */); |
| } |
| notificationUris.add(cursor.getNotificationUri()); |
| |
| // Skip media files that have been inserted into the MediaStore so we |
| // don't duplicate them in the recent list. |
| final Bundle args = new Bundle(); |
| args.putBoolean(DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, true); |
| |
| includeDownloadsFromMediaStore(result, args, filePaths, |
| notificationUris, null /* parentId */, (limit - result.getCount()), |
| false /* includePending */); |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| Binder.restoreCallingIdentity(token); |
| } |
| |
| result.setNotificationUris(getContext().getContentResolver(), notificationUris); |
| result.start(); |
| return result; |
| } |
| |
| @Override |
| public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs) |
| throws FileNotFoundException { |
| |
| final DownloadsCursor result = |
| new DownloadsCursor(projection, getContext().getContentResolver()); |
| final ArrayList<Uri> notificationUris = new ArrayList<>(); |
| |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| Cursor cursor = null; |
| try { |
| cursor = mDm.query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) |
| .setFilterByString(DocumentsContract.getSearchDocumentsQuery(queryArgs))); |
| final Set<String> filePaths = new HashSet<>(); |
| while (cursor.moveToNext()) { |
| includeDownloadFromCursor(result, cursor, filePaths, queryArgs); |
| } |
| notificationUris.add(cursor.getNotificationUri()); |
| includeDownloadsFromMediaStore(result, queryArgs, filePaths, |
| notificationUris, null /* parentId */, NO_LIMIT, true /* includePending */); |
| |
| includeSearchFilesFromSharedStorage(result, projection, filePaths, queryArgs); |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| Binder.restoreCallingIdentity(token); |
| } |
| |
| final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs); |
| if (handledQueryArgs.length > 0) { |
| final Bundle extras = new Bundle(); |
| extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs); |
| result.setExtras(extras); |
| } |
| |
| result.setNotificationUris(getContext().getContentResolver(), notificationUris); |
| result.start(); |
| return result; |
| } |
| |
| private void includeSearchFilesFromSharedStorage(DownloadsCursor result, String[] projection, |
| Set<String> filePaths, Bundle queryArgs) throws FileNotFoundException { |
| final File downloadDir = getPublicDownloadsDirectory(); |
| try (Cursor rawFilesCursor = super.querySearchDocuments(downloadDir, |
| projection, /* exclusion */ filePaths, queryArgs)) { |
| |
| final boolean shouldExcludeMedia = queryArgs.getBoolean( |
| DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); |
| while (rawFilesCursor.moveToNext()) { |
| final String mimeType = rawFilesCursor.getString( |
| rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)); |
| // When the value of shouldExcludeMedia is true, don't add media files into |
| // the result to avoid duplicated files. MediaScanner will scan the files |
| // into MediaStore. If the behavior is changed, we need to add the files back. |
| if (!shouldExcludeMedia || !isMediaMimeType(mimeType)) { |
| String docId = rawFilesCursor.getString( |
| rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)); |
| File rawFile = getFileForDocId(docId); |
| includeFileFromSharedStorage(result, rawFile); |
| } |
| } |
| } |
| } |
| |
| @Override |
| public String getDocumentType(String docId) throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| if (RawDocumentsHelper.isRawDocId(docId)) { |
| return super.getDocumentType(docId); |
| } |
| |
| final ContentResolver resolver = getContext().getContentResolver(); |
| final Uri contentUri; |
| if (isMediaStoreDownload(docId)) { |
| contentUri = getMediaStoreUriForQuery(docId); |
| } else { |
| final long id = Long.parseLong(docId); |
| contentUri = mDm.getDownloadUri(id); |
| } |
| return resolver.getType(contentUri); |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal) |
| throws FileNotFoundException { |
| // Delegate to real provider |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| if (RawDocumentsHelper.isRawDocId(docId)) { |
| return super.openDocument(docId, mode, signal); |
| } |
| |
| final ContentResolver resolver = getContext().getContentResolver(); |
| final Uri contentUri; |
| if (isMediaStoreDownload(docId)) { |
| contentUri = getMediaStoreUriForQuery(docId); |
| } else { |
| final long id = Long.parseLong(docId); |
| contentUri = mDm.getDownloadUri(id); |
| } |
| return resolver.openFileDescriptor(contentUri, mode, signal); |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| @Override |
| protected File getFileForDocId(String docId, boolean visible) throws FileNotFoundException { |
| if (RawDocumentsHelper.isRawDocId(docId)) { |
| return new File(RawDocumentsHelper.getAbsoluteFilePath(docId)); |
| } |
| |
| if (isMediaStoreDownload(docId)) { |
| return getFileForMediaStoreDownload(docId); |
| } |
| |
| if (DOC_ID_ROOT.equals(docId)) { |
| return getPublicDownloadsDirectory(); |
| } |
| |
| final long token = Binder.clearCallingIdentity(); |
| Cursor cursor = null; |
| String localFilePath = null; |
| try { |
| cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); |
| if (cursor.moveToFirst()) { |
| localFilePath = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); |
| } |
| } finally { |
| IoUtils.closeQuietly(cursor); |
| Binder.restoreCallingIdentity(token); |
| } |
| |
| if (localFilePath == null) { |
| throw new IllegalStateException("File has no filepath. Could not be found."); |
| } |
| return new File(localFilePath); |
| } |
| |
| @Override |
| protected String getDocIdForFile(File file) throws FileNotFoundException { |
| return RawDocumentsHelper.getDocIdForFile(file); |
| } |
| |
| @Override |
| protected Uri buildNotificationUri(String docId) { |
| return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId); |
| } |
| |
| private static boolean isMediaMimeType(String mimeType) { |
| return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType) |
| || MediaFile.isAudioMimeType(mimeType) || MediaFile.isDocumentMimeType(mimeType); |
| } |
| |
| private void includeDefaultDocument(MatrixCursor result) { |
| final RowBuilder row = result.newRow(); |
| row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); |
| // We have the same display name as our root :) |
| row.add(Document.COLUMN_DISPLAY_NAME, |
| getContext().getString(R.string.root_downloads)); |
| row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR); |
| row.add(Document.COLUMN_FLAGS, |
| Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE); |
| } |
| |
| /** |
| * Adds the entry from the cursor to the result only if the entry is valid. That is, |
| * if the file exists in the file system. |
| */ |
| private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor, |
| Set<String> filePaths, Bundle queryArgs) { |
| final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); |
| final String docId = String.valueOf(id); |
| |
| final String displayName = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE)); |
| String summary = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_DESCRIPTION)); |
| String mimeType = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE)); |
| if (mimeType == null) { |
| // Provide fake MIME type so it's openable |
| mimeType = "vnd.android.document/file"; |
| } |
| |
| if (queryArgs != null) { |
| final boolean shouldExcludeMedia = queryArgs.getBoolean( |
| DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); |
| if (shouldExcludeMedia) { |
| final String uri = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_URI)); |
| |
| // Skip media files that have been inserted into the MediaStore so we |
| // don't duplicate them in the search list. |
| if (isMediaMimeType(mimeType) && !TextUtils.isEmpty(uri)) { |
| return; |
| } |
| } |
| } |
| |
| // size could be -1 which indicates that download hasn't started. |
| final long size = cursor.getLong( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); |
| |
| String localFilePath = cursor.getString( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); |
| |
| int extraFlags = Document.FLAG_PARTIAL; |
| final int status = cursor.getInt( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); |
| switch (status) { |
| case DownloadManager.STATUS_SUCCESSFUL: |
| // Verify that the document still exists in external storage. This is necessary |
| // because files can be deleted from the file system without their entry being |
| // removed from DownloadsManager. |
| if (localFilePath == null || !new File(localFilePath).exists()) { |
| return; |
| } |
| extraFlags = Document.FLAG_SUPPORTS_RENAME; // only successful is non-partial |
| break; |
| case DownloadManager.STATUS_PAUSED: |
| summary = getContext().getString(R.string.download_queued); |
| break; |
| case DownloadManager.STATUS_PENDING: |
| summary = getContext().getString(R.string.download_queued); |
| break; |
| case DownloadManager.STATUS_RUNNING: |
| final long progress = cursor.getLong(cursor.getColumnIndexOrThrow( |
| DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); |
| if (size > 0) { |
| String percent = |
| NumberFormat.getPercentInstance().format((double) progress / size); |
| summary = getContext().getString(R.string.download_running_percent, percent); |
| } else { |
| summary = getContext().getString(R.string.download_running); |
| } |
| break; |
| case DownloadManager.STATUS_FAILED: |
| default: |
| summary = getContext().getString(R.string.download_error); |
| break; |
| } |
| |
| final long lastModified = cursor.getLong( |
| cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)); |
| |
| if (!DocumentsContract.matchSearchQueryArguments(queryArgs, displayName, mimeType, |
| lastModified, size)) { |
| return; |
| } |
| |
| includeDownload(result, docId, displayName, summary, size, mimeType, |
| lastModified, extraFlags, status == DownloadManager.STATUS_RUNNING); |
| if (filePaths != null && localFilePath != null) { |
| filePaths.add(localFilePath); |
| } |
| } |
| |
| private void includeDownload(MatrixCursor result, |
| String docId, String displayName, String summary, long size, |
| String mimeType, long lastModifiedMs, int extraFlags, boolean isPending) { |
| |
| int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | extraFlags; |
| if (mimeType.startsWith("image/")) { |
| flags |= Document.FLAG_SUPPORTS_THUMBNAIL; |
| } |
| |
| if (typeSupportsMetadata(mimeType)) { |
| flags |= Document.FLAG_SUPPORTS_METADATA; |
| } |
| |
| final RowBuilder row = result.newRow(); |
| row.add(Document.COLUMN_DOCUMENT_ID, docId); |
| row.add(Document.COLUMN_DISPLAY_NAME, displayName); |
| row.add(Document.COLUMN_SUMMARY, summary); |
| row.add(Document.COLUMN_SIZE, size == -1 ? null : size); |
| row.add(Document.COLUMN_MIME_TYPE, mimeType); |
| row.add(Document.COLUMN_FLAGS, flags); |
| // Incomplete downloads get a null timestamp. This prevents thrashy UI when a bunch of |
| // active downloads get sorted by mod time. |
| if (!isPending) { |
| row.add(Document.COLUMN_LAST_MODIFIED, lastModifiedMs); |
| } |
| } |
| |
| /** |
| * Takes all the top-level files from the Downloads directory and adds them to the result. |
| * |
| * @param result cursor containing all documents to be returned by queryChildDocuments or |
| * queryChildDocumentsForManage. |
| * @param downloadedFilePaths The absolute file paths of all the files in the result Cursor. |
| * @param searchString query used to filter out unwanted results. |
| */ |
| private void includeFilesFromSharedStorage(DownloadsCursor result, |
| Set<String> downloadedFilePaths, @Nullable String searchString) |
| throws FileNotFoundException { |
| final File downloadsDir = getPublicDownloadsDirectory(); |
| // Add every file from the Downloads directory to the result cursor. Ignore files that |
| // were in the supplied downloaded file paths. |
| for (File file : FileUtils.listFilesOrEmpty(downloadsDir)) { |
| boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath()); |
| boolean containsQuery = searchString == null || file.getName().contains( |
| searchString); |
| if (!inResultsAlready && containsQuery) { |
| includeFileFromSharedStorage(result, file); |
| } |
| } |
| } |
| |
| /** |
| * Adds a file to the result cursor. It uses a combination of {@code #RAW_PREFIX} and its |
| * absolute file path for its id. Directories are not to be included. |
| * |
| * @param result cursor containing all documents to be returned by queryChildDocuments or |
| * queryChildDocumentsForManage. |
| * @param file file to be included in the result cursor. |
| */ |
| private void includeFileFromSharedStorage(MatrixCursor result, File file) |
| throws FileNotFoundException { |
| includeFile(result, null, file); |
| } |
| |
| private static File getPublicDownloadsDirectory() { |
| return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); |
| } |
| |
| private String renameMediaStoreDownload(String docId, String displayName) { |
| final File before = getFileForMediaStoreDownload(docId); |
| final File after = new File(before.getParentFile(), displayName); |
| |
| if (after.exists()) { |
| throw new IllegalStateException("Already exists " + after); |
| } |
| if (!before.renameTo(after)) { |
| throw new IllegalStateException("Failed to rename from " + before + " to " + after); |
| } |
| |
| final String noMedia = ".nomedia"; |
| // Scan the file to update the database |
| // For file, check whether the file is renamed to .nomedia. If yes, to scan the parent |
| // directory to update all files in the directory. We don't consider the case of renaming |
| // .nomedia file. We don't show .nomedia file. |
| if (!after.isDirectory() && displayName.toLowerCase(Locale.ROOT).endsWith(noMedia)) { |
| final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), |
| after.getParentFile()); |
| // the file will not show in the list, return the parent docId to avoid not finding |
| // the detail for the file. |
| return getDocIdForMediaStoreDownloadUri(newUri, true /* isDir */); |
| } |
| // update the database for the old file |
| MediaStore.scanFile(getContext().getContentResolver(), before); |
| // Update tne database for the new file and get the new uri |
| final Uri newUri = MediaStore.scanFile(getContext().getContentResolver(), after); |
| return getDocIdForMediaStoreDownloadUri(newUri, after.isDirectory()); |
| } |
| |
| private static String getDocIdForMediaStoreDownloadUri(Uri uri, boolean isDir) { |
| if (uri != null) { |
| return getDocIdForMediaStoreDownload(Long.parseLong(uri.getLastPathSegment()), isDir); |
| } |
| return null; |
| } |
| |
| private File getFileForMediaStoreDownload(String docId) { |
| final Uri mediaStoreUri = getMediaStoreUriForQuery(docId); |
| final long token = Binder.clearCallingIdentity(); |
| try (Cursor cursor = queryForSingleItem(mediaStoreUri, |
| new String[] { DownloadColumns.DATA }, null, null, null)) { |
| final String filePath = cursor.getString(0); |
| if (filePath == null) { |
| throw new IllegalStateException("Missing _data for " + mediaStoreUri); |
| } |
| return new File(filePath); |
| } catch (FileNotFoundException e) { |
| throw new IllegalStateException(e); |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) { |
| final Uri mediaStoreUri = ContentUris.withAppendedId( |
| MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id); |
| final long token = Binder.clearCallingIdentity(); |
| try (Cursor cursor = queryForSingleItem(mediaStoreUri, |
| new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME }, |
| null, null, null)) { |
| final String relativePath = cursor.getString(0); |
| final String displayName = cursor.getString(1); |
| if (relativePath == null || displayName == null) { |
| throw new IllegalStateException( |
| "relative_path and _display_name should not be null for " + mediaStoreUri); |
| } |
| return Pair.create(relativePath, displayName); |
| } catch (FileNotFoundException e) { |
| throw new IllegalStateException(e); |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| /** |
| * Copied from MediaProvider.java |
| * |
| * Query the given {@link Uri}, expecting only a single item to be found. |
| * |
| * @throws FileNotFoundException if no items were found, or multiple items |
| * were found, or there was trouble reading the data. |
| */ |
| private Cursor queryForSingleItem(Uri uri, String[] projection, |
| String selection, String[] selectionArgs, CancellationSignal signal) |
| throws FileNotFoundException { |
| final Cursor c = getContext().getContentResolver().query(uri, projection, |
| ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal); |
| if (c == null) { |
| throw new FileNotFoundException("Missing cursor for " + uri); |
| } else if (c.getCount() < 1) { |
| IoUtils.closeQuietly(c); |
| throw new FileNotFoundException("No item at " + uri); |
| } else if (c.getCount() > 1) { |
| IoUtils.closeQuietly(c); |
| throw new FileNotFoundException("Multiple items at " + uri); |
| } |
| |
| if (c.moveToFirst()) { |
| return c; |
| } else { |
| IoUtils.closeQuietly(c); |
| throw new FileNotFoundException("Failed to read row from " + uri); |
| } |
| } |
| |
| private void includeDownloadsFromMediaStore(@NonNull MatrixCursor result, |
| @Nullable Bundle queryArgs, |
| @Nullable Set<String> filePaths, @NonNull ArrayList<Uri> notificationUris, |
| @Nullable String parentId, int limit, boolean includePending) { |
| if (limit == 0) { |
| return; |
| } |
| |
| final long token = Binder.clearCallingIdentity(); |
| |
| final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL); |
| final Bundle queryArgsInner = new Bundle(); |
| |
| final Pair<String, String[]> selectionPair = buildSearchSelection( |
| queryArgs, filePaths, parentId); |
| queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, |
| selectionPair.first); |
| queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, |
| selectionPair.second); |
| if (limit != NO_LIMIT) { |
| queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit); |
| } |
| if (includePending) { |
| queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE); |
| } |
| |
| try (Cursor cursor = getContext().getContentResolver().query(uriInner, |
| null, queryArgsInner, null)) { |
| final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean( |
| DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); |
| while (cursor.moveToNext()) { |
| includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia); |
| } |
| notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)); |
| notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL)); |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| private void includeDownloadFromMediaStore(@NonNull MatrixCursor result, |
| @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths, |
| boolean shouldExcludeMedia) { |
| final String mimeType = getMimeType(mediaCursor); |
| |
| // Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments |
| // and queryRecentDocuments. Only exclude document type here for both cases. |
| if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) { |
| return; |
| } |
| |
| final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType); |
| final String docId = getDocIdForMediaStoreDownload( |
| mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir); |
| final String displayName = mediaCursor.getString( |
| mediaCursor.getColumnIndex(DownloadColumns.DISPLAY_NAME)); |
| final long size = mediaCursor.getLong( |
| mediaCursor.getColumnIndex(DownloadColumns.SIZE)); |
| final long lastModifiedMs = mediaCursor.getLong( |
| mediaCursor.getColumnIndex(DownloadColumns.DATE_MODIFIED)) * 1000; |
| final boolean isPending = mediaCursor.getInt( |
| mediaCursor.getColumnIndex(DownloadColumns.IS_PENDING)) == 1; |
| |
| int extraFlags = isPending ? Document.FLAG_PARTIAL : 0; |
| if (Document.MIME_TYPE_DIR.equals(mimeType)) { |
| extraFlags |= Document.FLAG_DIR_SUPPORTS_CREATE; |
| } |
| if (!isPending) { |
| extraFlags |= Document.FLAG_SUPPORTS_RENAME; |
| } |
| |
| includeDownload(result, docId, displayName, null /* description */, size, mimeType, |
| lastModifiedMs, extraFlags, isPending); |
| if (filePaths != null) { |
| filePaths.add(mediaCursor.getString( |
| mediaCursor.getColumnIndex(DownloadColumns.DATA))); |
| } |
| } |
| |
| private String getMimeType(@NonNull Cursor mediaCursor) { |
| final String mimeType = mediaCursor.getString( |
| mediaCursor.getColumnIndex(DownloadColumns.MIME_TYPE)); |
| if (mimeType == null) { |
| return Document.MIME_TYPE_DIR; |
| } |
| return mimeType; |
| } |
| |
| // Copied from MediaDocumentsProvider with some tweaks |
| private Pair<String, String[]> buildSearchSelection(@Nullable Bundle queryArgs, |
| @Nullable Set<String> filePaths, @Nullable String parentId) { |
| final StringBuilder selection = new StringBuilder(); |
| final ArrayList<String> selectionArgs = new ArrayList<>(); |
| |
| if (parentId == null && filePaths != null && filePaths.size() > 0) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.DATA + " NOT IN ("); |
| selection.append(TextUtils.join(",", Collections.nCopies(filePaths.size(), "?"))); |
| selection.append(")"); |
| selectionArgs.addAll(filePaths); |
| } |
| |
| if (parentId != null) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.RELATIVE_PATH + "=?"); |
| final Pair<String, String> data = getRelativePathAndDisplayNameForDownload( |
| Long.parseLong(parentId)); |
| selectionArgs.add(data.first + data.second + "/"); |
| } else { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.RELATIVE_PATH + "=?"); |
| selectionArgs.add(Environment.DIRECTORY_DOWNLOADS + "/"); |
| } |
| |
| if (queryArgs != null) { |
| final boolean shouldExcludeMedia = queryArgs.getBoolean( |
| DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */); |
| if (shouldExcludeMedia) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); |
| selectionArgs.add("image/%"); |
| selection.append(" AND "); |
| selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); |
| selectionArgs.add("audio/%"); |
| selection.append(" AND "); |
| selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?"); |
| selectionArgs.add("video/%"); |
| } |
| |
| final String displayName = queryArgs.getString( |
| DocumentsContract.QUERY_ARG_DISPLAY_NAME); |
| if (!TextUtils.isEmpty(displayName)) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.DISPLAY_NAME + " LIKE ?"); |
| selectionArgs.add("%" + displayName + "%"); |
| } |
| |
| final long lastModifiedAfter = queryArgs.getLong( |
| DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */); |
| if (lastModifiedAfter != -1) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.DATE_MODIFIED |
| + " > " + lastModifiedAfter / 1000); |
| } |
| |
| final long fileSizeOver = queryArgs.getLong( |
| DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, -1 /* defaultValue */); |
| if (fileSizeOver != -1) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| selection.append(DownloadColumns.SIZE + " > " + fileSizeOver); |
| } |
| |
| final String[] mimeTypes = queryArgs.getStringArray( |
| DocumentsContract.QUERY_ARG_MIME_TYPES); |
| if (mimeTypes != null && mimeTypes.length > 0) { |
| if (selection.length() > 0) { |
| selection.append(" AND "); |
| } |
| |
| selection.append("("); |
| final List<String> tempSelectionArgs = new ArrayList<>(); |
| final StringBuilder tempSelection = new StringBuilder(); |
| List<String> wildcardMimeTypeList = new ArrayList<>(); |
| for (int i = 0; i < mimeTypes.length; ++i) { |
| final String mimeType = mimeTypes[i]; |
| if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) { |
| wildcardMimeTypeList.add(mimeType); |
| continue; |
| } |
| |
| if (tempSelectionArgs.size() > 0) { |
| tempSelection.append(","); |
| } |
| tempSelection.append("?"); |
| tempSelectionArgs.add(mimeType); |
| } |
| |
| for (int i = 0; i < wildcardMimeTypeList.size(); i++) { |
| selection.append(DownloadColumns.MIME_TYPE + " LIKE ?") |
| .append((i != wildcardMimeTypeList.size() - 1) ? " OR " : ""); |
| final String mimeType = wildcardMimeTypeList.get(i); |
| selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%"); |
| } |
| |
| if (tempSelectionArgs.size() > 0) { |
| if (wildcardMimeTypeList.size() > 0) { |
| selection.append(" OR "); |
| } |
| selection.append(DownloadColumns.MIME_TYPE + " IN (") |
| .append(tempSelection.toString()) |
| .append(")"); |
| selectionArgs.addAll(tempSelectionArgs); |
| } |
| |
| selection.append(")"); |
| } |
| } |
| |
| return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0])); |
| } |
| |
| /** |
| * A MatrixCursor that spins up a file observer when the first instance is |
| * started ({@link #start()}, and stops the file observer when the last instance |
| * closed ({@link #close()}. When file changes are observed, a content change |
| * notification is sent on the Downloads content URI. |
| * |
| * <p>This is necessary as other processes, like ExternalStorageProvider, |
| * can access and modify files directly (without sending operations |
| * through DownloadStorageProvider). |
| * |
| * <p>Without this, contents accessible by one a Downloads cursor instance |
| * (like the Downloads root in Files app) can become state. |
| */ |
| private static final class DownloadsCursor extends MatrixCursor { |
| |
| private static final Object mLock = new Object(); |
| @GuardedBy("mLock") |
| private static int mOpenCursorCount = 0; |
| @GuardedBy("mLock") |
| private static @Nullable ContentChangedRelay mFileWatcher; |
| |
| private final ContentResolver mResolver; |
| |
| DownloadsCursor(String[] projection, ContentResolver resolver) { |
| super(resolveDocumentProjection(projection)); |
| mResolver = resolver; |
| } |
| |
| void start() { |
| synchronized (mLock) { |
| if (mOpenCursorCount++ == 0) { |
| mFileWatcher = new ContentChangedRelay(mResolver, |
| Arrays.asList(getPublicDownloadsDirectory())); |
| mFileWatcher.startWatching(); |
| } |
| } |
| } |
| |
| @Override |
| public void close() { |
| super.close(); |
| synchronized (mLock) { |
| if (--mOpenCursorCount == 0) { |
| mFileWatcher.stopWatching(); |
| mFileWatcher = null; |
| } |
| } |
| } |
| } |
| |
| /** |
| * A file observer that notifies on the Downloads content URI(s) when |
| * files change on disk. |
| */ |
| private static class ContentChangedRelay extends FileObserver { |
| private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO |
| | CREATE | DELETE | DELETE_SELF | MOVE_SELF; |
| |
| private File[] mDownloadDirs; |
| private final ContentResolver mResolver; |
| |
| public ContentChangedRelay(ContentResolver resolver, List<File> downloadDirs) { |
| super(downloadDirs, NOTIFY_EVENTS); |
| mDownloadDirs = downloadDirs.toArray(new File[0]); |
| mResolver = resolver; |
| } |
| |
| @Override |
| public void startWatching() { |
| super.startWatching(); |
| if (DEBUG) Log.d(TAG, "Started watching for file changes in: " |
| + Arrays.toString(mDownloadDirs)); |
| } |
| |
| @Override |
| public void stopWatching() { |
| super.stopWatching(); |
| if (DEBUG) Log.d(TAG, "Stopped watching for file changes in: " |
| + Arrays.toString(mDownloadDirs)); |
| } |
| |
| @Override |
| public void onEvent(int event, String path) { |
| if ((event & NOTIFY_EVENTS) != 0) { |
| if (DEBUG) Log.v(TAG, "Change detected at path: " + path); |
| mResolver.notifyChange(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, false); |
| mResolver.notifyChange(Downloads.Impl.CONTENT_URI, null, false); |
| } |
| } |
| } |
| } |