diff options
Diffstat (limited to 'src/com')
12 files changed, 1453 insertions, 555 deletions
diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java index 9ad7e755..a414bd86 100644 --- a/src/com/android/providers/downloads/DownloadInfo.java +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -89,6 +89,7 @@ public class DownloadInfo { info.mMediaScanned = getInt(Downloads.Impl.COLUMN_MEDIA_SCANNED); info.mDeleted = getInt(Downloads.Impl.COLUMN_DELETED) == 1; info.mMediaProviderUri = getString(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI); + info.mMediaStoreUri = getString(Downloads.Impl.COLUMN_MEDIASTORE_URI); info.mIsPublicApi = getInt(Downloads.Impl.COLUMN_IS_PUBLIC_API) != 0; info.mAllowedNetworkTypes = getInt(Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES); info.mAllowRoaming = getInt(Downloads.Impl.COLUMN_ALLOW_ROAMING) != 0; @@ -98,6 +99,8 @@ public class DownloadInfo { info.mDescription = getString(Downloads.Impl.COLUMN_DESCRIPTION); info.mBypassRecommendedSizeLimit = getInt(Downloads.Impl.COLUMN_BYPASS_RECOMMENDED_SIZE_LIMIT); + info.mIsVisibleInDownloadsUi + = getInt(Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI) != 0; synchronized (this) { info.mControl = getInt(Downloads.Impl.COLUMN_CONTROL); @@ -175,6 +178,7 @@ public class DownloadInfo { public int mMediaScanned; public boolean mDeleted; public String mMediaProviderUri; + public String mMediaStoreUri; public boolean mIsPublicApi; public int mAllowedNetworkTypes; public boolean mAllowRoaming; @@ -183,6 +187,7 @@ public class DownloadInfo { public String mTitle; public String mDescription; public int mBypassRecommendedSizeLimit; + public boolean mIsVisibleInDownloadsUi; private List<Pair<String, String>> mRequestHeaders = new ArrayList<Pair<String, String>>(); @@ -442,7 +447,7 @@ public class DownloadInfo { * Returns whether a file should be scanned */ public boolean shouldScanFile(int status) { - return (mMediaScanned == 0) + return (mMediaScanned == Downloads.Impl.MEDIA_NOT_SCANNED) && (mDestination == Downloads.Impl.DESTINATION_EXTERNAL || mDestination == Downloads.Impl.DESTINATION_FILE_URI || mDestination == Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) diff --git a/src/com/android/providers/downloads/DownloadJobService.java b/src/com/android/providers/downloads/DownloadJobService.java index d09738c2..e1b20234 100644 --- a/src/com/android/providers/downloads/DownloadJobService.java +++ b/src/com/android/providers/downloads/DownloadJobService.java @@ -81,6 +81,7 @@ public class DownloadJobService extends JobService { @Override public boolean onStopJob(JobParameters params) { final int id = params.getJobId(); + Log.d(TAG, "onStopJob id=" + id + ", reason=" + params.getStopReason()); final DownloadThread thread; synchronized (mActiveThreads) { diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java index f8d5aae2..c68d702a 100644 --- a/src/com/android/providers/downloads/DownloadProvider.java +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -18,18 +18,27 @@ package com.android.providers.downloads; import static android.provider.BaseColumns._ID; import static android.provider.Downloads.Impl.COLUMN_DESTINATION; +import static android.provider.Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI; +import static android.provider.Downloads.Impl.COLUMN_MEDIASTORE_URI; import static android.provider.Downloads.Impl.COLUMN_MEDIA_SCANNED; -import static android.provider.Downloads.Impl.COLUMN_MIME_TYPE; import static android.provider.Downloads.Impl.COLUMN_OTHER_UID; +import static android.provider.Downloads.Impl.DESTINATION_EXTERNAL; +import static android.provider.Downloads.Impl.DESTINATION_FILE_URI; import static android.provider.Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD; +import static android.provider.Downloads.Impl.MEDIA_NOT_SCANNABLE; +import static android.provider.Downloads.Impl.MEDIA_NOT_SCANNED; +import static android.provider.Downloads.Impl.MEDIA_SCANNED; import static android.provider.Downloads.Impl.PERMISSION_ACCESS_ALL; import static android.provider.Downloads.Impl._DATA; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.app.AppOpsManager; import android.app.DownloadManager; import android.app.DownloadManager.Request; import android.app.job.JobScheduler; import android.content.ContentProvider; +import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; @@ -38,30 +47,42 @@ import android.content.Intent; import android.content.UriMatcher; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.SQLException; +import android.database.TranslatingCursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.FileUtils; import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor.OnCloseListener; import android.os.Process; +import android.os.RemoteException; +import android.os.storage.StorageManager; import android.provider.BaseColumns; import android.provider.Downloads; +import android.provider.MediaStore; import android.provider.OpenableColumns; import android.text.TextUtils; import android.text.format.DateUtils; +import android.util.ArrayMap; import android.util.Log; +import android.util.LongArray; +import android.util.LongSparseArray; +import android.util.SparseArray; +import com.android.internal.util.ArrayUtils; import com.android.internal.util.IndentingPrintWriter; +import com.android.internal.util.Preconditions; import libcore.io.IoUtils; -import com.google.android.collect.Maps; import com.google.common.annotations.VisibleForTesting; import java.io.File; @@ -74,7 +95,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; -import java.util.List; import java.util.Map; /** @@ -84,7 +104,7 @@ public final class DownloadProvider extends ContentProvider { /** Database filename */ private static final String DB_NAME = "downloads.db"; /** Current database version */ - private static final int DB_VERSION = 110; + private static final int DB_VERSION = 113; /** Name of table in the database */ private static final String DB_TABLE = "downloads"; /** Memory optimization - close idle connections after 30s of inactivity */ @@ -134,48 +154,108 @@ public final class DownloadProvider extends ContentProvider { Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, }; - private static final String[] sAppReadableColumnsArray = new String[] { - Downloads.Impl._ID, - Downloads.Impl.COLUMN_APP_DATA, - Downloads.Impl._DATA, - Downloads.Impl.COLUMN_MIME_TYPE, - Downloads.Impl.COLUMN_VISIBILITY, - Downloads.Impl.COLUMN_DESTINATION, - Downloads.Impl.COLUMN_CONTROL, - Downloads.Impl.COLUMN_STATUS, - Downloads.Impl.COLUMN_LAST_MODIFICATION, - Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE, - Downloads.Impl.COLUMN_NOTIFICATION_CLASS, - Downloads.Impl.COLUMN_TOTAL_BYTES, - Downloads.Impl.COLUMN_CURRENT_BYTES, - Downloads.Impl.COLUMN_TITLE, - Downloads.Impl.COLUMN_DESCRIPTION, - Downloads.Impl.COLUMN_URI, - Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, - Downloads.Impl.COLUMN_FILE_NAME_HINT, - Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, - Downloads.Impl.COLUMN_DELETED, - OpenableColumns.DISPLAY_NAME, - OpenableColumns.SIZE, - }; + private static void addMapping(Map<String, String> map, String column) { + if (!map.containsKey(column)) { + map.put(column, column); + } + } - private static final HashSet<String> sAppReadableColumnsSet; - private static final HashMap<String, String> sColumnsMap; + private static void addMapping(Map<String, String> map, String column, String rawColumn) { + if (!map.containsKey(column)) { + map.put(column, rawColumn + " AS " + column); + } + } + private static final Map<String, String> sDownloadsMap = new ArrayMap<>(); static { - sAppReadableColumnsSet = new HashSet<String>(); - for (int i = 0; i < sAppReadableColumnsArray.length; ++i) { - sAppReadableColumnsSet.add(sAppReadableColumnsArray[i]); - } + final Map<String, String> map = sDownloadsMap; + + // Columns defined by public API + addMapping(map, DownloadManager.COLUMN_ID, + Downloads.Impl._ID); + addMapping(map, DownloadManager.COLUMN_LOCAL_FILENAME, + Downloads.Impl._DATA); + addMapping(map, DownloadManager.COLUMN_MEDIAPROVIDER_URI); + addMapping(map, DownloadManager.COLUMN_DESTINATION); + addMapping(map, DownloadManager.COLUMN_TITLE); + addMapping(map, DownloadManager.COLUMN_DESCRIPTION); + addMapping(map, DownloadManager.COLUMN_URI); + addMapping(map, DownloadManager.COLUMN_STATUS); + addMapping(map, DownloadManager.COLUMN_FILE_NAME_HINT); + addMapping(map, DownloadManager.COLUMN_MEDIA_TYPE, + Downloads.Impl.COLUMN_MIME_TYPE); + addMapping(map, DownloadManager.COLUMN_TOTAL_SIZE_BYTES, + Downloads.Impl.COLUMN_TOTAL_BYTES); + addMapping(map, DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP, + Downloads.Impl.COLUMN_LAST_MODIFICATION); + addMapping(map, DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR, + Downloads.Impl.COLUMN_CURRENT_BYTES); + addMapping(map, DownloadManager.COLUMN_ALLOW_WRITE); + addMapping(map, DownloadManager.COLUMN_LOCAL_URI, + "'placeholder'"); + addMapping(map, DownloadManager.COLUMN_REASON, + "'placeholder'"); + + // Columns defined by OpenableColumns + addMapping(map, OpenableColumns.DISPLAY_NAME, + Downloads.Impl.COLUMN_TITLE); + addMapping(map, OpenableColumns.SIZE, + Downloads.Impl.COLUMN_TOTAL_BYTES); + + // Allow references to all other columns to support DownloadInfo.Reader; + // we're already using SQLiteQueryBuilder to block access to other rows + // that don't belong to the calling UID. + addMapping(map, Downloads.Impl._ID); + addMapping(map, Downloads.Impl._DATA); + addMapping(map, Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES); + addMapping(map, Downloads.Impl.COLUMN_ALLOW_METERED); + addMapping(map, Downloads.Impl.COLUMN_ALLOW_ROAMING); + addMapping(map, Downloads.Impl.COLUMN_ALLOW_WRITE); + addMapping(map, Downloads.Impl.COLUMN_APP_DATA); + addMapping(map, Downloads.Impl.COLUMN_BYPASS_RECOMMENDED_SIZE_LIMIT); + addMapping(map, Downloads.Impl.COLUMN_CONTROL); + addMapping(map, Downloads.Impl.COLUMN_COOKIE_DATA); + addMapping(map, Downloads.Impl.COLUMN_CURRENT_BYTES); + addMapping(map, Downloads.Impl.COLUMN_DELETED); + addMapping(map, Downloads.Impl.COLUMN_DESCRIPTION); + addMapping(map, Downloads.Impl.COLUMN_DESTINATION); + addMapping(map, Downloads.Impl.COLUMN_ERROR_MSG); + addMapping(map, Downloads.Impl.COLUMN_FAILED_CONNECTIONS); + addMapping(map, Downloads.Impl.COLUMN_FILE_NAME_HINT); + addMapping(map, Downloads.Impl.COLUMN_FLAGS); + addMapping(map, Downloads.Impl.COLUMN_IS_PUBLIC_API); + addMapping(map, Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI); + addMapping(map, Downloads.Impl.COLUMN_LAST_MODIFICATION); + addMapping(map, Downloads.Impl.COLUMN_MEDIAPROVIDER_URI); + addMapping(map, Downloads.Impl.COLUMN_MEDIA_SCANNED); + addMapping(map, Downloads.Impl.COLUMN_MEDIASTORE_URI); + addMapping(map, Downloads.Impl.COLUMN_MIME_TYPE); + addMapping(map, Downloads.Impl.COLUMN_NO_INTEGRITY); + addMapping(map, Downloads.Impl.COLUMN_NOTIFICATION_CLASS); + addMapping(map, Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS); + addMapping(map, Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE); + addMapping(map, Downloads.Impl.COLUMN_OTHER_UID); + addMapping(map, Downloads.Impl.COLUMN_REFERER); + addMapping(map, Downloads.Impl.COLUMN_STATUS); + addMapping(map, Downloads.Impl.COLUMN_TITLE); + addMapping(map, Downloads.Impl.COLUMN_TOTAL_BYTES); + addMapping(map, Downloads.Impl.COLUMN_URI); + addMapping(map, Downloads.Impl.COLUMN_USER_AGENT); + addMapping(map, Downloads.Impl.COLUMN_VISIBILITY); + + addMapping(map, Constants.ETAG); + addMapping(map, Constants.RETRY_AFTER_X_REDIRECT_COUNT); + addMapping(map, Constants.UID); + } - sColumnsMap = Maps.newHashMap(); - sColumnsMap.put(OpenableColumns.DISPLAY_NAME, - Downloads.Impl.COLUMN_TITLE + " AS " + OpenableColumns.DISPLAY_NAME); - sColumnsMap.put(OpenableColumns.SIZE, - Downloads.Impl.COLUMN_TOTAL_BYTES + " AS " + OpenableColumns.SIZE); + private static final Map<String, String> sHeadersMap = new ArrayMap<>(); + static { + final Map<String, String> map = sHeadersMap; + addMapping(map, "id"); + addMapping(map, Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID); + addMapping(map, Downloads.Impl.RequestHeaders.COLUMN_HEADER); + addMapping(map, Downloads.Impl.RequestHeaders.COLUMN_VALUE); } - private static final List<String> downloadManagerColumnsList = - Arrays.asList(DownloadManager.UNDERLYING_COLUMNS); @VisibleForTesting SystemFacade mSystemFacade; @@ -185,7 +265,8 @@ public final class DownloadProvider extends ContentProvider { /** List of uids that can access the downloads */ private int mSystemUid = -1; - private int mDefContainerUid = -1; + + private StorageManager mStorageManager; /** * Creates and updated database on demand when opening it. @@ -303,6 +384,20 @@ public final class DownloadProvider extends ContentProvider { "INTEGER NOT NULL DEFAULT 0"); break; + case 111: + addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_MEDIASTORE_URI, + "TEXT DEFAULT NULL"); + addMediaStoreUris(db); + break; + + case 112: + updateMediaStoreUrisFromFilesToDownloads(db); + break; + + case 113: + canonicalizeDataPaths(db); + break; + default: throw new IllegalStateException("Don't know how to upgrade to " + version); } @@ -342,6 +437,107 @@ public final class DownloadProvider extends ContentProvider { } /** + * Add {@link Downloads.Impl#COLUMN_MEDIASTORE_URI} for all successful downloads and + * add/update corresponding entries in MediaProvider. + */ + private void addMediaStoreUris(@NonNull SQLiteDatabase db) { + final String[] selectionArgs = new String[] { + Integer.toString(Downloads.Impl.DESTINATION_EXTERNAL), + Integer.toString(Downloads.Impl.DESTINATION_FILE_URI), + Integer.toString(Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD), + }; + final CallingIdentity token = clearCallingIdentity(); + try (Cursor cursor = db.query(DB_TABLE, null, + "_data IS NOT NULL AND is_visible_in_downloads_ui != '0'" + + " AND (destination=? OR destination=? OR destination=?)", + selectionArgs, null, null, null); + ContentProviderClient client = getContext().getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + if (cursor.getCount() == 0) { + return; + } + final DownloadInfo.Reader reader + = new DownloadInfo.Reader(getContext().getContentResolver(), cursor); + final DownloadInfo info = new DownloadInfo(getContext()); + final ContentValues updateValues = new ContentValues(); + while (cursor.moveToNext()) { + reader.updateFromDatabase(info); + final ContentValues mediaValues; + try { + mediaValues = convertToMediaProviderValues(info); + } catch (IllegalArgumentException e) { + Log.e(Constants.TAG, "Error getting media content values from " + info, e); + continue; + } + final Uri mediaStoreUri = updateMediaProvider(client, mediaValues); + if (mediaStoreUri != null) { + updateValues.clear(); + updateValues.put(Downloads.Impl.COLUMN_MEDIASTORE_URI, + mediaStoreUri.toString()); + db.update(DB_TABLE, updateValues, Downloads.Impl._ID + "=?", + new String[] { Long.toString(info.mId) }); + } + } + } finally { + restoreCallingIdentity(token); + } + } + + /** + * DownloadProvider has been updated to use MediaStore.Downloads based uris + * for COLUMN_MEDIASTORE_URI but the existing entries would still have MediaStore.Files + * based uris. It's possible that in the future we might incorrectly assume that all the + * uris are MediaStore.DownloadColumns based and end up querying some + * MediaStore.Downloads specific columns. To avoid this, update the existing entries to + * use MediaStore.Downloads based uris only. + */ + private void updateMediaStoreUrisFromFilesToDownloads(SQLiteDatabase db) { + try (Cursor cursor = db.query(DB_TABLE, + new String[] { Downloads.Impl._ID, COLUMN_MEDIASTORE_URI }, + COLUMN_MEDIASTORE_URI + " IS NOT NULL", null, null, null, null)) { + final ContentValues updateValues = new ContentValues(); + while (cursor.moveToNext()) { + final long id = cursor.getLong(0); + final Uri mediaStoreFilesUri = Uri.parse(cursor.getString(1)); + + final long mediaStoreId = ContentUris.parseId(mediaStoreFilesUri); + final String volumeName = MediaStore.getVolumeName(mediaStoreFilesUri); + final Uri mediaStoreDownloadsUri + = MediaStore.Downloads.getContentUri(volumeName, mediaStoreId); + + updateValues.clear(); + updateValues.put(COLUMN_MEDIASTORE_URI, mediaStoreDownloadsUri.toString()); + db.update(DB_TABLE, updateValues, Downloads.Impl._ID + "=?", + new String[] { Long.toString(id) }); + } + } + } + + private void canonicalizeDataPaths(SQLiteDatabase db) { + try (Cursor cursor = db.query(DB_TABLE, + new String[] { Downloads.Impl._ID, Downloads.Impl._DATA}, + Downloads.Impl._DATA + " IS NOT NULL", null, null, null, null)) { + final ContentValues updateValues = new ContentValues(); + while (cursor.moveToNext()) { + final long id = cursor.getLong(0); + final String filePath = cursor.getString(1); + final String canonicalPath; + try { + canonicalPath = new File(filePath).getCanonicalPath(); + } catch (IOException e) { + Log.e(Constants.TAG, "Found invalid path='" + filePath + "' for id=" + id); + continue; + } + + updateValues.clear(); + updateValues.put(Downloads.Impl._DATA, canonicalPath); + db.update(DB_TABLE, updateValues, Downloads.Impl._ID + "=?", + new String[] { Long.toString(id) }); + } + } + } + + /** * Add a column to a table using ALTER TABLE. * @param dbTable name of the table * @param columnName name of the column to add @@ -419,57 +615,44 @@ public final class DownloadProvider extends ContentProvider { mOpenHelper = new DatabaseHelper(getContext()); // Initialize the system uid mSystemUid = Process.SYSTEM_UID; - // Initialize the default container uid. Package name hardcoded - // for now. - ApplicationInfo appInfo = null; - try { - appInfo = getContext().getPackageManager(). - getApplicationInfo("com.android.defcontainer", 0); - } catch (NameNotFoundException e) { - Log.wtf(Constants.TAG, "Could not get ApplicationInfo for com.android.defconatiner", e); - } - if (appInfo != null) { - mDefContainerUid = appInfo.uid; - } - // Grant access permissions for all known downloads to the owning apps - final SQLiteDatabase db = mOpenHelper.getReadableDatabase(); - final Cursor cursor = db.query(DB_TABLE, new String[] { - Downloads.Impl._ID, Constants.UID }, null, null, null, null, null); - final ArrayList<Long> idsToDelete = new ArrayList<>(); - try { - while (cursor.moveToNext()) { - final long downloadId = cursor.getLong(0); - final int uid = cursor.getInt(1); - final String ownerPackage = getPackageForUid(uid); - if (ownerPackage == null) { - idsToDelete.add(downloadId); - } else { - grantAllDownloadsPermission(ownerPackage, downloadId); - } - } - } finally { - cursor.close(); - } - if (idsToDelete.size() > 0) { - Log.i(Constants.TAG, - "Deleting downloads with ids " + idsToDelete + " as owner package is missing"); - deleteDownloadsWithIds(idsToDelete); - } + mStorageManager = getContext().getSystemService(StorageManager.class); + + reconcileRemovedUidEntries(); return true; } - private void deleteDownloadsWithIds(ArrayList<Long> downloadIds) { - final int N = downloadIds.size(); - if (N == 0) { - return; + private void reconcileRemovedUidEntries() { + // Grant access permissions for all known downloads to the owning apps + final ArrayList<Long> idsToDelete = new ArrayList<>(); + final ArrayList<Long> idsToOrphan = new ArrayList<>(); + final LongSparseArray<String> idsToGrantPermission = new LongSparseArray<>(); + final SQLiteDatabase db = mOpenHelper.getReadableDatabase(); + try (Cursor cursor = db.query(DB_TABLE, + new String[] { Downloads.Impl._ID, Constants.UID, COLUMN_DESTINATION, _DATA }, + Constants.UID + " IS NOT NULL", null, null, null, null)) { + Helpers.handleRemovedUidEntries(getContext(), cursor, + idsToDelete, idsToOrphan, idsToGrantPermission); + } + for (int i = 0; i < idsToGrantPermission.size(); ++i) { + final long downloadId = idsToGrantPermission.keyAt(i); + final String ownerPackageName = idsToGrantPermission.valueAt(i); + grantAllDownloadsPermission(ownerPackageName, downloadId); + } + if (idsToOrphan.size() > 0) { + Log.i(Constants.TAG, "Orphaning downloads with ids " + + Arrays.toString(idsToOrphan.toArray()) + " as owner package is missing"); + final ContentValues values = new ContentValues(); + values.putNull(Constants.UID); + update(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, values, + Helpers.buildQueryWithIds(idsToOrphan), null); } - final StringBuilder queryBuilder = new StringBuilder(Downloads.Impl._ID + " in ("); - for (int i = 0; i < N; i++) { - queryBuilder.append(downloadIds.get(i)); - queryBuilder.append((i == N - 1) ? ")" : ","); + if (idsToDelete.size() > 0) { + Log.i(Constants.TAG, "Deleting downloads with ids " + + Arrays.toString(idsToDelete.toArray()) + " as owner package is missing"); + delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + Helpers.buildQueryWithIds(idsToDelete), null); } - delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, queryBuilder.toString(), null); } /** @@ -508,6 +691,46 @@ public final class DownloadProvider extends ContentProvider { } } + @Override + public Bundle call(String method, String arg, Bundle extras) { + switch (method) { + case Downloads.CALL_MEDIASTORE_DOWNLOADS_DELETED: { + Preconditions.checkArgument(Binder.getCallingUid() == Process.myUid(), + "Not allowed to call " + Downloads.CALL_MEDIASTORE_DOWNLOADS_DELETED); + final long[] deletedDownloadIds = extras.getLongArray(Downloads.EXTRA_IDS); + final String[] mimeTypes = extras.getStringArray(Downloads.EXTRA_MIME_TYPES); + DownloadStorageProvider.onMediaProviderDownloadsDelete(getContext(), + deletedDownloadIds, mimeTypes); + return null; + } + case Downloads.CALL_CREATE_EXTERNAL_PUBLIC_DIR: { + final String dirType = extras.getString(Downloads.DIR_TYPE); + if (!ArrayUtils.contains(Environment.STANDARD_DIRECTORIES, dirType)) { + throw new IllegalStateException("Not one of standard directories: " + dirType); + } + final File file = Environment.getExternalStoragePublicDirectory(dirType); + if (file.exists()) { + if (!file.isDirectory()) { + throw new IllegalStateException(file.getAbsolutePath() + + " already exists and is not a directory"); + } + } else if (!file.mkdirs()) { + throw new IllegalStateException("Unable to create directory: " + + file.getAbsolutePath()); + } + return null; + } + case Downloads.CALL_REVOKE_MEDIASTORE_URI_PERMS : { + Preconditions.checkArgument(Binder.getCallingUid() == Process.myUid(), + "Not allowed to call " + Downloads.CALL_REVOKE_MEDIASTORE_URI_PERMS); + DownloadStorageProvider.revokeAllMediaStoreUriPermissions(getContext()); + return null; + } + default: + throw new UnsupportedOperationException("Unsupported call: " + method); + } + } + /** * Inserts a row in the database */ @@ -523,14 +746,7 @@ public final class DownloadProvider extends ContentProvider { throw new IllegalArgumentException("Unknown/Invalid URI " + uri); } - // copy some of the input values as it ContentValues filteredValues = new ContentValues(); - copyString(Downloads.Impl.COLUMN_URI, values, filteredValues); - copyString(Downloads.Impl.COLUMN_APP_DATA, values, filteredValues); - copyBoolean(Downloads.Impl.COLUMN_NO_INTEGRITY, values, filteredValues); - copyString(Downloads.Impl.COLUMN_FILE_NAME_HINT, values, filteredValues); - copyString(Downloads.Impl.COLUMN_MIME_TYPE, values, filteredValues); - copyBoolean(Downloads.Impl.COLUMN_IS_PUBLIC_API, values, filteredValues); boolean isPublicApi = values.getAsBoolean(Downloads.Impl.COLUMN_IS_PUBLIC_API) == Boolean.TRUE; @@ -557,7 +773,8 @@ public final class DownloadProvider extends ContentProvider { } if (dest == Downloads.Impl.DESTINATION_FILE_URI) { checkFileUriDestination(values); - + } else if (dest == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) { + checkDownloadedFilePath(values); } else if (dest == Downloads.Impl.DESTINATION_EXTERNAL) { getContext().enforceCallingOrSelfPermission( android.Manifest.permission.WRITE_EXTERNAL_STORAGE, @@ -569,9 +786,20 @@ public final class DownloadProvider extends ContentProvider { throw new SecurityException("No permission to write"); } } + filteredValues.put(Downloads.Impl.COLUMN_DESTINATION, dest); } + ensureDefaultColumns(values); + + // copy some of the input values as is + copyString(Downloads.Impl.COLUMN_URI, values, filteredValues); + copyString(Downloads.Impl.COLUMN_APP_DATA, values, filteredValues); + copyBoolean(Downloads.Impl.COLUMN_NO_INTEGRITY, values, filteredValues); + copyString(Downloads.Impl.COLUMN_FILE_NAME_HINT, values, filteredValues); + copyString(Downloads.Impl.COLUMN_MIME_TYPE, values, filteredValues); + copyBoolean(Downloads.Impl.COLUMN_IS_PUBLIC_API, values, filteredValues); + // validate the visibility column Integer vis = values.getAsInteger(Downloads.Impl.COLUMN_VISIBILITY); if (vis == null) { @@ -600,7 +828,6 @@ public final class DownloadProvider extends ContentProvider { filteredValues.put(Downloads.Impl.COLUMN_TOTAL_BYTES, values.getAsLong(Downloads.Impl.COLUMN_TOTAL_BYTES)); filteredValues.put(Downloads.Impl.COLUMN_CURRENT_BYTES, 0); - copyInteger(Downloads.Impl.COLUMN_MEDIA_SCANNED, values, filteredValues); copyString(Downloads.Impl._DATA, values, filteredValues); copyBoolean(Downloads.Impl.COLUMN_ALLOW_WRITE, values, filteredValues); } else { @@ -651,13 +878,7 @@ public final class DownloadProvider extends ContentProvider { copyStringWithDefault(Downloads.Impl.COLUMN_DESCRIPTION, values, filteredValues, ""); // is_visible_in_downloads_ui column - if (values.containsKey(Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI)) { - copyBoolean(Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, values, filteredValues); - } else { - // by default, make external downloads visible in the UI - boolean isExternal = (dest == null || dest == Downloads.Impl.DESTINATION_EXTERNAL); - filteredValues.put(Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, isExternal); - } + copyBoolean(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, values, filteredValues); // public api requests and networktypes/roaming columns if (isPublicApi) { @@ -667,6 +888,34 @@ public final class DownloadProvider extends ContentProvider { copyInteger(Downloads.Impl.COLUMN_FLAGS, values, filteredValues); } + final Integer mediaScanned = values.getAsInteger(Downloads.Impl.COLUMN_MEDIA_SCANNED); + filteredValues.put(COLUMN_MEDIA_SCANNED, + mediaScanned == null ? MEDIA_NOT_SCANNED : mediaScanned); + + final boolean shouldBeVisibleToUser + = filteredValues.getAsBoolean(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI) + || filteredValues.getAsInteger(COLUMN_MEDIA_SCANNED) == MEDIA_NOT_SCANNED; + if (shouldBeVisibleToUser && filteredValues.getAsInteger(COLUMN_DESTINATION) + == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) { + final CallingIdentity token = clearCallingIdentity(); + try (ContentProviderClient client = getContext().getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + final Uri mediaStoreUri = updateMediaProvider(client, + convertToMediaProviderValues(filteredValues)); + if (mediaStoreUri != null) { + filteredValues.put(Downloads.Impl.COLUMN_MEDIASTORE_URI, + mediaStoreUri.toString()); + filteredValues.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, + mediaStoreUri.toString()); + filteredValues.put(COLUMN_MEDIA_SCANNED, MEDIA_SCANNED); + } + MediaStore.scanFile(getContext(), + new File(filteredValues.getAsString(Downloads.Impl._DATA))); + } finally { + restoreCallingIdentity(token); + } + } + if (Constants.LOGVV) { Log.v(Constants.TAG, "initiating download with UID " + filteredValues.getAsInteger(Constants.UID)); @@ -684,7 +933,8 @@ public final class DownloadProvider extends ContentProvider { insertRequestHeaders(db, rowID, values); - final String callingPackage = getPackageForUid(Binder.getCallingUid()); + final String callingPackage = Helpers.getPackageForUid(getContext(), + Binder.getCallingUid()); if (callingPackage == null) { Log.e(Constants.TAG, "Package does not exist for calling uid"); return null; @@ -699,22 +949,149 @@ public final class DownloadProvider extends ContentProvider { Binder.restoreCallingIdentity(token); } - if (values.getAsInteger(COLUMN_DESTINATION) == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD - && values.getAsInteger(COLUMN_MEDIA_SCANNED) == 0) { - DownloadScanner.requestScanBlocking(getContext(), rowID, values.getAsString(_DATA), - values.getAsString(COLUMN_MIME_TYPE)); + return ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, rowID); + } + + /** + * If an entry corresponding to given mediaValues doesn't already exist in MediaProvider, + * add it, otherwise update that entry with the given values. + */ + private Uri updateMediaProvider(@NonNull ContentProviderClient mediaProvider, + @NonNull ContentValues mediaValues) { + final String filePath = mediaValues.getAsString(MediaStore.DownloadColumns.DATA); + Uri mediaStoreUri = getMediaStoreUri(mediaProvider, filePath); + + try { + if (mediaStoreUri == null) { + mediaStoreUri = mediaProvider.insert( + MediaStore.Files.getContentUriForPath(filePath), + mediaValues); + if (mediaStoreUri == null) { + Log.e(Constants.TAG, "Error inserting into mediaProvider: " + mediaValues); + } + return mediaStoreUri; + } else { + if (mediaProvider.update(mediaStoreUri, mediaValues, null, null) != 1) { + Log.e(Constants.TAG, "Error updating MediaProvider, uri: " + mediaStoreUri + + ", values: " + mediaValues); + } + return mediaStoreUri; + } + } catch (RemoteException e) { + // Should not happen + } + return null; + } + + private Uri getMediaStoreUri(@NonNull ContentProviderClient mediaProvider, + @NonNull String filePath) { + final Uri filesUri = MediaStore.setIncludePending( + MediaStore.Files.getContentUriForPath(filePath)); + try (Cursor cursor = mediaProvider.query(filesUri, + new String[] { MediaStore.Files.FileColumns._ID }, + MediaStore.Files.FileColumns.DATA + "=?", new String[] { filePath }, null, null)) { + if (cursor.moveToNext()) { + return ContentUris.withAppendedId(filesUri, cursor.getLong(0)); + } + } catch (RemoteException e) { + // Should not happen } + return null; + } - return ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, rowID); + private ContentValues convertToMediaProviderValues(DownloadInfo info) { + final String filePath; + try { + filePath = new File(info.mFileName).getCanonicalPath(); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + final ContentValues mediaValues = new ContentValues(); + mediaValues.put(MediaStore.Downloads.DATA, filePath); + mediaValues.put(MediaStore.Downloads.SIZE, info.mTotalBytes); + mediaValues.put(MediaStore.Downloads.DOWNLOAD_URI, info.mUri); + mediaValues.put(MediaStore.Downloads.REFERER_URI, info.mReferer); + mediaValues.put(MediaStore.Downloads.MIME_TYPE, info.mMimeType); + mediaValues.put(MediaStore.Downloads.IS_PENDING, + Downloads.Impl.isStatusSuccess(info.mStatus) ? 0 : 1); + mediaValues.put(MediaStore.Downloads.OWNER_PACKAGE_NAME, + Helpers.getPackageForUid(getContext(), info.mUid)); + mediaValues.put(MediaStore.Files.FileColumns.IS_DOWNLOAD, info.mIsVisibleInDownloadsUi); + return mediaValues; } - private String getPackageForUid(int uid) { - String[] packages = getContext().getPackageManager().getPackagesForUid(uid); - if (packages == null || packages.length == 0) { - return null; + private ContentValues convertToMediaProviderValues(ContentValues downloadValues) { + final String filePath; + try { + filePath = new File(downloadValues.getAsString(Downloads.Impl._DATA)) + .getCanonicalPath(); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + final ContentValues mediaValues = new ContentValues(); + mediaValues.put(MediaStore.Downloads.DATA, filePath); + mediaValues.put(MediaStore.Downloads.SIZE, + downloadValues.getAsLong(Downloads.Impl.COLUMN_TOTAL_BYTES)); + mediaValues.put(MediaStore.Downloads.DOWNLOAD_URI, + downloadValues.getAsString(Downloads.Impl.COLUMN_URI)); + mediaValues.put(MediaStore.Downloads.REFERER_URI, + downloadValues.getAsString(Downloads.Impl.COLUMN_REFERER)); + mediaValues.put(MediaStore.Downloads.MIME_TYPE, + downloadValues.getAsString(Downloads.Impl.COLUMN_MIME_TYPE)); + final boolean isPending = downloadValues.getAsInteger(Downloads.Impl.COLUMN_STATUS) + != Downloads.Impl.STATUS_SUCCESS; + mediaValues.put(MediaStore.Downloads.IS_PENDING, isPending ? 1 : 0); + mediaValues.put(MediaStore.Downloads.OWNER_PACKAGE_NAME, + Helpers.getPackageForUid(getContext(), downloadValues.getAsInteger(Constants.UID))); + mediaValues.put(MediaStore.Files.FileColumns.IS_DOWNLOAD, + downloadValues.getAsBoolean(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI)); + return mediaValues; + } + + private static Uri getFileUri(String uriString) { + final Uri uri = Uri.parse(uriString); + return TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_FILE) ? uri : null; + } + + private void ensureDefaultColumns(ContentValues values) { + final Integer dest = values.getAsInteger(COLUMN_DESTINATION); + if (dest != null) { + final int mediaScannable; + final boolean visibleInDownloadsUi; + if (dest == Downloads.Impl.DESTINATION_EXTERNAL) { + mediaScannable = MEDIA_NOT_SCANNED; + visibleInDownloadsUi = true; + } else if (dest != DESTINATION_FILE_URI + && dest != DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) { + mediaScannable = MEDIA_NOT_SCANNABLE; + visibleInDownloadsUi = false; + } else { + final File file; + if (dest == Downloads.Impl.DESTINATION_FILE_URI) { + final String fileUri = values.getAsString(Downloads.Impl.COLUMN_FILE_NAME_HINT); + file = new File(getFileUri(fileUri).getPath()); + } else { + file = new File(values.getAsString(Downloads.Impl._DATA)); + } + + if (Helpers.isFileInExternalAndroidDirs(file.getAbsolutePath())) { + mediaScannable = MEDIA_NOT_SCANNABLE; + visibleInDownloadsUi = false; + } else if (Helpers.isFilenameValidInPublicDownloadsDir(file)) { + mediaScannable = MEDIA_NOT_SCANNED; + visibleInDownloadsUi = true; + } else { + mediaScannable = MEDIA_NOT_SCANNED; + visibleInDownloadsUi = false; + } + } + values.put(COLUMN_MEDIA_SCANNED, mediaScannable); + values.put(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, visibleInDownloadsUi); + } else { + if (!values.containsKey(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI)) { + values.put(COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI, true); + } } - // For permission related purposes, any package belonging to the given uid should work. - return packages[0]; } /** @@ -726,27 +1103,32 @@ public final class DownloadProvider extends ContentProvider { throw new IllegalArgumentException( "DESTINATION_FILE_URI must include a file URI under COLUMN_FILE_NAME_HINT"); } - Uri uri = Uri.parse(fileUri); - String scheme = uri.getScheme(); - if (scheme == null || !scheme.equals("file")) { + final Uri uri = getFileUri(fileUri); + if (uri == null) { throw new IllegalArgumentException("Not a file URI: " + uri); } final String path = uri.getPath(); - if (path == null) { + if (path == null || path.contains("..")) { throw new IllegalArgumentException("Invalid file URI: " + uri); } final File file; try { file = new File(path).getCanonicalFile(); + values.put(Downloads.Impl.COLUMN_FILE_NAME_HINT, Uri.fromFile(file).toString()); } catch (IOException e) { throw new SecurityException(e); } - if (Helpers.isFilenameValidInExternalPackage(getContext(), file, getCallingPackage())) { - // No permissions required for paths belonging to calling package + final int targetSdkVersion = getCallingPackageTargetSdkVersion(); + + if (Helpers.isFilenameValidInExternalPackage(getContext(), file, getCallingPackage()) + || Helpers.isFilenameValidInKnownPublicDir(file.getAbsolutePath())) { + // No permissions required for paths belonging to calling package or + // public downloads dir. return; - } else if (Helpers.isFilenameValidInExternal(getContext(), file)) { + } else if (targetSdkVersion < Build.VERSION_CODES.Q + && Helpers.isFilenameValidInExternal(getContext(), file)) { // Otherwise we require write permission getContext().enforceCallingOrSelfPermission( android.Manifest.permission.WRITE_EXTERNAL_STORAGE, @@ -757,12 +1139,74 @@ public final class DownloadProvider extends ContentProvider { getCallingPackage()) != AppOpsManager.MODE_ALLOWED) { throw new SecurityException("No permission to write to " + file); } + } else { + throw new SecurityException("Unsupported path " + file); + } + } + + private void checkDownloadedFilePath(ContentValues values) { + final String path = values.getAsString(Downloads.Impl._DATA); + if (path == null || path.contains("..")) { + throw new IllegalArgumentException("Invalid file path: " + + (path == null ? "null" : path)); + } + final File file; + try { + file = new File(path).getCanonicalFile(); + values.put(Downloads.Impl._DATA, file.getPath()); + } catch (IOException e) { + throw new SecurityException(e); + } + + if (!file.exists()) { + throw new IllegalArgumentException("File doesn't exist: " + file); + } + + final int targetSdkVersion = getCallingPackageTargetSdkVersion(); + final AppOpsManager appOpsManager = getContext().getSystemService(AppOpsManager.class); + final boolean runningLegacyMode = appOpsManager.checkOp(AppOpsManager.OP_LEGACY_STORAGE, + Binder.getCallingUid(), getCallingPackage()) == AppOpsManager.MODE_ALLOWED; + + if (Binder.getCallingPid() == Process.myPid()) { + return; + } else if (Helpers.isFilenameValidInExternalPackage(getContext(), file, getCallingPackage())) { + // No permissions required for paths belonging to calling package. + return; + } else if ((runningLegacyMode && Helpers.isFilenameValidInPublicDownloadsDir(file)) + || (targetSdkVersion < Build.VERSION_CODES.Q + && Helpers.isFilenameValidInExternal(getContext(), file))) { + // Otherwise we require write permission + getContext().enforceCallingOrSelfPermission( + android.Manifest.permission.WRITE_EXTERNAL_STORAGE, + "No permission to write to " + file); + + final AppOpsManager appOps = getContext().getSystemService(AppOpsManager.class); + if (appOps.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE, + getCallingPackage()) != AppOpsManager.MODE_ALLOWED) { + throw new SecurityException("No permission to write to " + file); + } } else { throw new SecurityException("Unsupported path " + file); } } + private int getCallingPackageTargetSdkVersion() { + final String callingPackage = getCallingPackage(); + if (callingPackage != null) { + ApplicationInfo ai = null; + try { + ai = getContext().getPackageManager() + .getApplicationInfo(callingPackage, 0); + } catch (PackageManager.NameNotFoundException ignored) { + } + if (ai != null) { + return ai.targetSdkVersion; + } + } + return Build.VERSION_CODES.CUR_DEVELOPMENT; + } + /** * Apps with the ACCESS_DOWNLOAD_MANAGER permission can access this provider freely, subject to * constraints in the rest of the code. Apps without that may still access this provider through @@ -918,33 +1362,12 @@ public final class DownloadProvider extends ContentProvider { return qb.query(db, projection, null, null, null, null, null); } - if (shouldRestrictVisibility()) { - if (projection == null) { - projection = sAppReadableColumnsArray.clone(); - } else { - // check the validity of the columns in projection - for (int i = 0; i < projection.length; ++i) { - if (!sAppReadableColumnsSet.contains(projection[i]) && - !downloadManagerColumnsList.contains(projection[i])) { - throw new IllegalArgumentException( - "column " + projection[i] + " is not allowed in queries"); - } - } - } - - for (int i = 0; i < projection.length; i++) { - final String newColumn = sColumnsMap.get(projection[i]); - if (newColumn != null) { - projection[i] = newColumn; - } - } - } - if (Constants.LOGVV) { logVerboseQueryInfo(projection, selection, selectionArgs, sort, db); } final SQLiteQueryBuilder qb = getQueryBuilder(uri, match); + final Cursor ret = qb.query(db, projection, selection, selectionArgs, null, null, sort); if (ret != null) { @@ -1031,25 +1454,11 @@ public final class DownloadProvider extends ContentProvider { } /** - * @return true if we should restrict the columns readable by this caller - */ - private boolean shouldRestrictVisibility() { - int callingUid = Binder.getCallingUid(); - return Binder.getCallingPid() != Process.myPid() && - callingUid != mSystemUid && - callingUid != mDefContainerUid; - } - - /** * Updates a row in the database */ @Override public int update(final Uri uri, final ContentValues values, final String where, final String[] whereArgs) { - if (shouldRestrictVisibility()) { - Helpers.validateSelection(where, sAppReadableColumnsSet); - } - final Context context = getContext(); final ContentResolver resolver = context.getContentResolver(); @@ -1079,6 +1488,12 @@ public final class DownloadProvider extends ContentProvider { filteredValues = values; String filename = values.getAsString(Downloads.Impl._DATA); if (filename != null) { + try { + filteredValues.put(Downloads.Impl._DATA, new File(filename).getCanonicalPath()); + } catch (IOException e) { + throw new IllegalStateException("Invalid path: " + filename); + } + Cursor c = null; try { c = query(uri, new String[] @@ -1114,24 +1529,64 @@ public final class DownloadProvider extends ContentProvider { final SQLiteQueryBuilder qb = getQueryBuilder(uri, match); count = qb.update(db, filteredValues, where, whereArgs); - if (updateSchedule || isCompleting) { - final long token = Binder.clearCallingIdentity(); - try (Cursor cursor = qb.query(db, null, where, whereArgs, null, null, null)) { - final DownloadInfo.Reader reader = new DownloadInfo.Reader(resolver, - cursor); - final DownloadInfo info = new DownloadInfo(context); - while (cursor.moveToNext()) { - reader.updateFromDatabase(info); - if (updateSchedule) { - Helpers.scheduleJob(context, info); + final CallingIdentity token = clearCallingIdentity(); + try (Cursor cursor = qb.query(db, null, where, whereArgs, null, null, null); + ContentProviderClient client = getContext().getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + final DownloadInfo.Reader reader = new DownloadInfo.Reader(resolver, + cursor); + final DownloadInfo info = new DownloadInfo(context); + final ContentValues updateValues = new ContentValues(); + while (cursor.moveToNext()) { + reader.updateFromDatabase(info); + final boolean visibleToUser = info.mIsVisibleInDownloadsUi + || (info.mMediaScanned != MEDIA_NOT_SCANNABLE); + if (info.mFileName == null) { + if (info.mMediaStoreUri != null) { + // If there was a mediastore entry, it would be deleted in it's + // next idle pass. + updateValues.clear(); + updateValues.putNull(Downloads.Impl.COLUMN_MEDIASTORE_URI); + qb.update(db, updateValues, Downloads.Impl._ID + "=?", + new String[] { Long.toString(info.mId) }); } - if (isCompleting) { - info.sendIntentIfRequested(); + } else if ((info.mDestination == Downloads.Impl.DESTINATION_EXTERNAL + || info.mDestination == Downloads.Impl.DESTINATION_FILE_URI + || info.mDestination == Downloads.Impl + .DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) + && visibleToUser) { + final Uri mediaStoreUri = updateMediaProvider(client, + convertToMediaProviderValues(info)); + if (!TextUtils.equals(info.mMediaStoreUri, + mediaStoreUri == null ? null : mediaStoreUri.toString())) { + updateValues.clear(); + if (mediaStoreUri == null) { + updateValues.putNull(Downloads.Impl.COLUMN_MEDIASTORE_URI); + updateValues.putNull(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI); + updateValues.put(COLUMN_MEDIA_SCANNED, MEDIA_NOT_SCANNED); + } else { + updateValues.put(Downloads.Impl.COLUMN_MEDIASTORE_URI, + mediaStoreUri.toString()); + updateValues.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, + mediaStoreUri.toString()); + updateValues.put(COLUMN_MEDIA_SCANNED, MEDIA_SCANNED); + } + qb.update(db, updateValues, Downloads.Impl._ID + "=?", + new String[] { Long.toString(info.mId) }); + } + if (Downloads.Impl.isStatusSuccess(info.mStatus)) { + MediaStore.scanFile(getContext(), new File(info.mFileName)); } } - } finally { - Binder.restoreCallingIdentity(token); + if (updateSchedule) { + Helpers.scheduleJob(context, info); + } + if (isCompleting) { + info.sendIntentIfRequested(); + } } + } finally { + restoreCallingIdentity(token); } break; @@ -1168,6 +1623,8 @@ public final class DownloadProvider extends ContentProvider { */ private SQLiteQueryBuilder getQueryBuilder(final Uri uri, int match) { final String table; + final Map<String, String> projectionMap; + final StringBuilder where = new StringBuilder(); switch (match) { // The "my_downloads" view normally limits the caller to operating @@ -1178,6 +1635,7 @@ public final class DownloadProvider extends ContentProvider { // fall-through case MY_DOWNLOADS: table = DB_TABLE; + projectionMap = sDownloadsMap; if (getContext().checkCallingOrSelfPermission( PERMISSION_ACCESS_ALL) != PackageManager.PERMISSION_GRANTED) { appendWhereExpression(where, Constants.UID + "=" + Binder.getCallingUid() @@ -1193,6 +1651,7 @@ public final class DownloadProvider extends ContentProvider { // fall-through case ALL_DOWNLOADS: table = DB_TABLE; + projectionMap = sDownloadsMap; break; // Headers are limited to callers holding the ACCESS_ALL_DOWNLOADS @@ -1200,6 +1659,7 @@ public final class DownloadProvider extends ContentProvider { case MY_DOWNLOADS_ID_HEADERS: case ALL_DOWNLOADS_ID_HEADERS: table = Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE; + projectionMap = sHeadersMap; appendWhereExpression(where, Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID + "=" + getDownloadIdFromUri(uri)); break; @@ -1209,8 +1669,11 @@ public final class DownloadProvider extends ContentProvider { } final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); - qb.setStrict(true); qb.setTables(table); + qb.setProjectionMap(projectionMap); + qb.setStrict(true); + qb.setStrictColumns(true); + qb.setStrictGrammar(true); qb.appendWhere(where); return qb; } @@ -1227,10 +1690,6 @@ public final class DownloadProvider extends ContentProvider { */ @Override public int delete(final Uri uri, final String where, final String[] whereArgs) { - if (shouldRestrictVisibility()) { - Helpers.validateSelection(where, sAppReadableColumnsSet); - } - final Context context = getContext(); final ContentResolver resolver = context.getContentResolver(); final JobScheduler scheduler = context.getSystemService(JobScheduler.class); @@ -1262,21 +1721,12 @@ public final class DownloadProvider extends ContentProvider { Log.v(Constants.TAG, "Deleting " + file + " via provider delete"); file.delete(); + deleteMediaStoreEntry(file); + } else { + Log.d(Constants.TAG, "Ignoring invalid file: " + file); } - } catch (IOException ignored) { - } - } - - final String mediaUri = info.mMediaProviderUri; - if (!TextUtils.isEmpty(mediaUri)) { - final long token = Binder.clearCallingIdentity(); - try { - getContext().getContentResolver().delete(Uri.parse(mediaUri), null, - null); - } catch (Exception e) { - Log.w(Constants.TAG, "Failed to delete media entry: " + e); - } finally { - Binder.restoreCallingIdentity(token); + } catch (IOException e) { + Log.e(Constants.TAG, "Couldn't delete file: " + path, e); } } @@ -1311,6 +1761,24 @@ public final class DownloadProvider extends ContentProvider { return count; } + private void deleteMediaStoreEntry(File file) { + final long token = Binder.clearCallingIdentity(); + try { + final String path = file.getAbsolutePath(); + final Uri.Builder builder = MediaStore.setIncludePending( + MediaStore.Files.getContentUriForPath(path).buildUpon()); + builder.appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false"); + + final Uri filesUri = builder.build(); + getContext().getContentResolver().delete(filesUri, + MediaStore.Files.FileColumns.DATA + "=?", new String[] { path }); + } catch (Exception e) { + Log.d(Constants.TAG, "Failed to delete mediastore entry for file:" + file, e); + } finally { + Binder.restoreCallingIdentity(token); + } + } + /** * Remotely opens a file */ @@ -1359,7 +1827,7 @@ public final class DownloadProvider extends ContentProvider { destination == Downloads.Impl.DESTINATION_EXTERNAL || destination == Downloads.Impl.DESTINATION_FILE_URI || destination == Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) - && mediaScanned != 2; + && mediaScanned != Downloads.Impl.MEDIA_NOT_SCANNABLE; } else { throw new FileNotFoundException("Failed moveToFirst"); } diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java index 92d0bad4..40b5e093 100644 --- a/src/com/android/providers/downloads/DownloadReceiver.java +++ b/src/com/android/providers/downloads/DownloadReceiver.java @@ -18,6 +18,8 @@ package com.android.providers.downloads; import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED; import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION; +import static android.provider.Downloads.Impl.COLUMN_DESTINATION; +import static android.provider.Downloads.Impl._DATA; import static com.android.providers.downloads.Constants.TAG; import static com.android.providers.downloads.Helpers.getAsyncHandler; @@ -26,6 +28,7 @@ import static com.android.providers.downloads.Helpers.getInt; import static com.android.providers.downloads.Helpers.getString; import static com.android.providers.downloads.Helpers.getSystemFacade; +import android.app.BroadcastOptions; import android.app.DownloadManager; import android.app.NotificationManager; import android.content.BroadcastReceiver; @@ -37,11 +40,16 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.provider.Downloads; +import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; import android.util.Slog; import android.widget.Toast; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.regex.Pattern; + /** * Receives system broadcasts (boot, network connectivity) */ @@ -141,22 +149,27 @@ public class DownloadReceiver extends BroadcastReceiver { final ContentResolver resolver = context.getContentResolver(); final int uid = intent.getIntExtra(Intent.EXTRA_UID, -1); - // First, disown any downloads that live in shared storage - final ContentValues values = new ContentValues(); - values.putNull(Constants.UID); - final int disowned = resolver.update(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, values, - Constants.UID + "=" + uid + " AND " + Downloads.Impl.COLUMN_DESTINATION + " IN (" - + Downloads.Impl.DESTINATION_EXTERNAL + "," - + Downloads.Impl.DESTINATION_FILE_URI + ")", - null); - - // Finally, delete any remaining downloads owned by UID - final int deleted = resolver.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - Constants.UID + "=" + uid, null); - - if ((disowned + deleted) > 0) { - Slog.d(TAG, "Disowned " + disowned + " and deleted " + deleted - + " downloads owned by UID " + uid); + final ArrayList<Long> idsToDelete = new ArrayList<>(); + final ArrayList<Long> idsToOrphan = new ArrayList<>(); + try (Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + new String[] { Downloads.Impl._ID, Constants.UID, COLUMN_DESTINATION, _DATA }, + Constants.UID + "=" + uid, null, null)) { + Helpers.handleRemovedUidEntries(context, cursor, idsToDelete, idsToOrphan, null); + } + + if (idsToOrphan.size() > 0) { + Log.i(Constants.TAG, "Orphaning downloads with ids " + + Arrays.toString(idsToOrphan.toArray()) + " as owner package is removed"); + final ContentValues values = new ContentValues(); + values.putNull(Constants.UID); + resolver.update(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, values, + Helpers.buildQueryWithIds(idsToOrphan), null); + } + if (idsToDelete.size() > 0) { + Log.i(Constants.TAG, "Deleting downloads with ids " + + Arrays.toString(idsToDelete.toArray()) + " as owner package is removed"); + resolver.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + Helpers.buildQueryWithIds(idsToDelete), null); } } @@ -276,6 +289,8 @@ public class DownloadReceiver extends BroadcastReceiver { } } - getSystemFacade(context).sendBroadcast(appIntent); + final BroadcastOptions options = BroadcastOptions.makeBasic(); + options.setBackgroundActivityStartsAllowed(true); + getSystemFacade(context).sendBroadcast(appIntent, null, options.toBundle()); } } diff --git a/src/com/android/providers/downloads/DownloadScanner.java b/src/com/android/providers/downloads/DownloadScanner.java index 4a5ba87e..a4ec0ba8 100644 --- a/src/com/android/providers/downloads/DownloadScanner.java +++ b/src/com/android/providers/downloads/DownloadScanner.java @@ -156,13 +156,19 @@ public class DownloadScanner implements MediaScannerConnectionClient { return; } + // File got deleted while waiting for it to be mediascanned. + if (uri == null) { + if (mLatch != null) { + mLatch.countDown(); + } + return; + } + // Update scanned column, which will kick off a database update pass, // eventually deciding if overall service is ready for teardown. final ContentValues values = new ContentValues(); values.put(Downloads.Impl.COLUMN_MEDIA_SCANNED, 1); - if (uri != null) { - values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, uri.toString()); - } + values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, uri.toString()); final ContentResolver resolver = mContext.getContentResolver(); final Uri downloadUri = ContentUris.withAppendedId( diff --git a/src/com/android/providers/downloads/DownloadStorageProvider.java b/src/com/android/providers/downloads/DownloadStorageProvider.java index afcba961..fc7dd5ed 100644 --- a/src/com/android/providers/downloads/DownloadStorageProvider.java +++ b/src/com/android/providers/downloads/DownloadStorageProvider.java @@ -16,17 +16,28 @@ 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.getMediaStoreUri; +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.ContentValues; import android.content.Context; -import android.content.res.AssetFileDescriptor; +import android.content.UriPermission; import android.database.Cursor; import android.database.MatrixCursor; import android.database.MatrixCursor.RowBuilder; -import android.graphics.Point; +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; @@ -37,9 +48,13 @@ 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; @@ -47,12 +62,13 @@ 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.Set; -import javax.annotation.Nullable; -import javax.annotation.concurrent.GuardedBy; - /** * Presents files located in {@link Environment#DIRECTORY_DOWNLOADS} and contents from * {@link DownloadManager}. {@link DownloadManager} contents include active downloads and completed @@ -69,7 +85,7 @@ public class DownloadStorageProvider extends FileSystemProvider { 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_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_QUERY_ARGS }; private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { @@ -80,6 +96,8 @@ public class DownloadStorageProvider extends FileSystemProvider { private DownloadManager mDm; + private static final int NO_LIMIT = -1; + @Override public boolean onCreate() { super.onCreate(DEFAULT_DOCUMENT_PROJECTION); @@ -111,12 +129,37 @@ public class DownloadStorageProvider extends FileSystemProvider { 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. - getDownloadsDirectory().mkdirs(); + getPublicDownloadsDirectory().mkdirs(); final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); final RowBuilder row = result.newRow(); @@ -127,6 +170,7 @@ public class DownloadStorageProvider extends FileSystemProvider { 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; } @@ -160,7 +204,8 @@ public class DownloadStorageProvider extends FileSystemProvider { try { String newDocumentId = super.createDocument(parentDocId, mimeType, displayName); if (!Document.MIME_TYPE_DIR.equals(mimeType) - && !RawDocumentsHelper.isRawDocId(parentDocId)) { + && !RawDocumentsHelper.isRawDocId(parentDocId) + && !isMediaStoreDownload(parentDocId)) { File newFile = getFileForDocId(newDocumentId); newDocumentId = Long.toString(mDm.addCompletedDownload( newFile.getName(), newFile.getName(), true, mimeType, @@ -178,10 +223,11 @@ public class DownloadStorageProvider extends FileSystemProvider { // Delegate to real provider final long token = Binder.clearCallingIdentity(); try { - if (RawDocumentsHelper.isRawDocId(docId)) { + if (RawDocumentsHelper.isRawDocId(docId) || isMediaStoreDownload(docId)) { super.deleteDocument(docId); return; } + if (mDm.remove(Long.parseLong(docId)) != 1) { throw new IllegalStateException("Failed to delete " + docId); } @@ -196,15 +242,20 @@ public class DownloadStorageProvider extends FileSystemProvider { final long token = Binder.clearCallingIdentity(); try { - if (RawDocumentsHelper.isRawDocId(docId)) { + if (RawDocumentsHelper.isRawDocId(docId) + || isMediaStoreDownloadDir(docId)) { return super.renameDocument(docId, displayName); } displayName = FileUtils.buildValidFatFilename(displayName); - final long id = Long.parseLong(docId); - if (!mDm.rename(getContext(), id, displayName)) { - throw new IllegalStateException( - "Failed to rename to " + displayName + " in downloadsManager"); + if (isMediaStoreDownload(docId)) { + 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 { @@ -227,14 +278,21 @@ public class DownloadStorageProvider extends FileSystemProvider { if (DOC_ID_ROOT.equals(docId)) { includeDefaultDocument(result); + } else if (isMediaStoreDownload(docId)) { + cursor = getContext().getContentResolver().query(getMediaStoreUri(docId), + null, null, null); + copyNotificationUri(result, cursor); + if (cursor.moveToFirst()) { + includeDownloadFromMediaStore(result, cursor, null /* filePaths */); + } } else { cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId))); copyNotificationUri(result, cursor); - Set<String> filePaths = new HashSet<>(); 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, filePaths); + includeDownloadFromCursor(result, cursor, null /* filePaths */, + null /* queryArgs */); } } result.start(); @@ -269,24 +327,34 @@ public class DownloadStorageProvider extends FileSystemProvider { return super.queryChildDocuments(parentDocId, projection, sortOrder); } - assert (DOC_ID_ROOT.equals(parentDocId)); final DownloadsCursor result = new DownloadsCursor(projection, getContext().getContentResolver()); - if (manage) { - cursor = mDm.query( - new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true)); + final ArrayList<Uri> notificationUris = new ArrayList<>(); + if (isMediaStoreDownloadDir(parentDocId)) { + includeDownloadsFromMediaStore(result, null /* queryArgs */, + null /* filePaths */, notificationUris, + getMediaStoreIdString(parentDocId), NO_LIMIT, manage); } else { - cursor = mDm - .query(new DownloadManager.Query().setOnlyIncludeVisibleInDownloadsUi(true) - .setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); - } - copyNotificationUri(result, cursor); - Set<String> filePaths = new HashSet<>(); - while (cursor.moveToNext()) { - includeDownloadFromCursor(result, cursor, filePaths); + assert (DOC_ID_ROOT.equals(parentDocId)); + if (manage) { + 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, manage); + includeFilesFromSharedStorage(result, filePaths, null); } - includeFilesFromSharedStorage(result, filePaths, null); - + result.setNotificationUris(getContext().getContentResolver(), notificationUris); result.start(); return result; } finally { @@ -296,75 +364,139 @@ public class DownloadStorageProvider extends FileSystemProvider { } @Override - public Cursor queryRecentDocuments(String rootId, String[] projection) + 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)); - copyNotificationUri(result, cursor); - while (cursor.moveToNext() && result.getCount() < 12) { + 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 that have been inserted into the MediaStore so we - // don't duplicate them in the recents list. - if (mimeType == null - || (mimeType.startsWith("image/") && !TextUtils.isEmpty(uri))) { + // Skip images and videos 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)) && !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 query, String[] projection) + 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(query)); - copyNotificationUri(result, cursor); - Set<String> filePaths = new HashSet<>(); + .setFilterByString(DocumentsContract.getSearchDocumentsQuery(queryArgs))); + final Set<String> filePaths = new HashSet<>(); while (cursor.moveToNext()) { - includeDownloadFromCursor(result, cursor, filePaths); - } - Cursor rawFilesCursor = super.querySearchDocuments(getDownloadsDirectory(), query, - projection, filePaths); - while (rawFilesCursor.moveToNext()) { - String docId = rawFilesCursor.getString( - rawFilesCursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)); - File rawFile = getFileForDocId(docId); - includeFileFromSharedStorage(result, rawFile); + 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, 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 @@ -374,9 +506,15 @@ public class DownloadStorageProvider extends FileSystemProvider { return super.getDocumentType(docId); } - final long id = Long.parseLong(docId); final ContentResolver resolver = getContext().getContentResolver(); - return resolver.getType(mDm.getDownloadUri(id)); + final Uri contentUri; + if (isMediaStoreDownload(docId)) { + contentUri = getMediaStoreUri(docId); + } else { + final long id = Long.parseLong(docId); + contentUri = mDm.getDownloadUri(id); + } + return resolver.getType(contentUri); } finally { Binder.restoreCallingIdentity(token); } @@ -392,9 +530,15 @@ public class DownloadStorageProvider extends FileSystemProvider { return super.openDocument(docId, mode, signal); } - final long id = Long.parseLong(docId); final ContentResolver resolver = getContext().getContentResolver(); - return resolver.openFileDescriptor(mDm.getDownloadUri(id), mode, signal); + final Uri contentUri; + if (isMediaStoreDownload(docId)) { + contentUri = getMediaStoreUri(docId); + } else { + final long id = Long.parseLong(docId); + contentUri = mDm.getDownloadUri(id); + } + return resolver.openFileDescriptor(contentUri, mode, signal); } finally { Binder.restoreCallingIdentity(token); } @@ -406,8 +550,12 @@ public class DownloadStorageProvider extends FileSystemProvider { return new File(RawDocumentsHelper.getAbsoluteFilePath(docId)); } + if (isMediaStoreDownload(docId)) { + return getFileForMediaStoreDownload(docId); + } + if (DOC_ID_ROOT.equals(docId)) { - return getDownloadsDirectory(); + return getPublicDownloadsDirectory(); } final long token = Binder.clearCallingIdentity(); @@ -440,6 +588,11 @@ public class DownloadStorageProvider extends FileSystemProvider { return DocumentsContract.buildChildDocumentsUri(AUTHORITY, docId); } + private static boolean isMediaMimeType(String mimeType) { + return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType) + || MediaFile.isAudioMimeType(mimeType); + } + private void includeDefaultDocument(MatrixCursor result) { final RowBuilder row = result.newRow(); row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT); @@ -456,7 +609,7 @@ public class DownloadStorageProvider extends FileSystemProvider { * if the file exists in the file system. */ private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor, - Set<String> filePaths) { + Set<String> filePaths, Bundle queryArgs) { final long id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); final String docId = String.valueOf(id); @@ -470,11 +623,26 @@ public class DownloadStorageProvider extends FileSystemProvider { // Provide fake MIME type so it's openable mimeType = "vnd.android.document/file"; } - Long size = cursor.getLong( - cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); - if (size == -1) { - size = null; + + 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)); @@ -500,7 +668,7 @@ public class DownloadStorageProvider extends FileSystemProvider { case DownloadManager.STATUS_RUNNING: final long progress = cursor.getLong(cursor.getColumnIndexOrThrow( DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); - if (size != null) { + if (size > 0) { String percent = NumberFormat.getPercentInstance().format((double) progress / size); summary = getContext().getString(R.string.download_running_percent, percent); @@ -514,6 +682,25 @@ public class DownloadStorageProvider extends FileSystemProvider { 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; @@ -523,22 +710,18 @@ public class DownloadStorageProvider extends FileSystemProvider { flags |= Document.FLAG_SUPPORTS_METADATA; } - final long lastModified = cursor.getLong( - cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)); - 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); + 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 (status != DownloadManager.STATUS_RUNNING) { - row.add(Document.COLUMN_LAST_MODIFIED, lastModified); + if (!isPending) { + row.add(Document.COLUMN_LAST_MODIFIED, lastModifiedMs); } - filePaths.add(localFilePath); } /** @@ -549,15 +732,16 @@ public class DownloadStorageProvider extends FileSystemProvider { * @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(MatrixCursor result, + private void includeFilesFromSharedStorage(DownloadsCursor result, Set<String> downloadedFilePaths, @Nullable String searchString) throws FileNotFoundException { - File downloadsDir = getDownloadsDirectory(); + 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 : downloadsDir.listFiles()) { + for (File file : FileUtils.listFilesOrEmpty(downloadsDir)) { boolean inResultsAlready = downloadedFilePaths.contains(file.getAbsolutePath()); - boolean containsQuery = searchString == null || file.getName().contains(searchString); + boolean containsQuery = searchString == null || file.getName().contains( + searchString); if (!inResultsAlready && containsQuery) { includeFileFromSharedStorage(result, file); } @@ -577,10 +761,270 @@ public class DownloadStorageProvider extends FileSystemProvider { includeFile(result, null, file); } - private static File getDownloadsDirectory() { + private static File getPublicDownloadsDirectory() { return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); } + private void 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 long token = Binder.clearCallingIdentity(); + try { + final Uri mediaStoreUri = getMediaStoreUri(docId); + final ContentValues values = new ContentValues(); + values.put(DownloadColumns.DATA, after.getAbsolutePath()); + values.put(DownloadColumns.DISPLAY_NAME, displayName); + final int count = getContext().getContentResolver().update(mediaStoreUri, values, + null, null); + if (count != 1) { + throw new IllegalStateException("Failed to update " + mediaStoreUri + + ", values=" + values); + } + } finally { + Binder.restoreCallingIdentity(token); + } + } + + private File getFileForMediaStoreDownload(String docId) { + final Uri mediaStoreUri = getMediaStoreUri(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.EXTERNAL_CONTENT_URI, 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 Pair<String, String[]> selectionPair + = buildSearchSelection(queryArgs, filePaths, parentId); + final Uri.Builder queryUriBuilder = MediaStore.Downloads.EXTERNAL_CONTENT_URI.buildUpon(); + if (limit != NO_LIMIT) { + queryUriBuilder.appendQueryParameter(MediaStore.PARAM_LIMIT, String.valueOf(limit)); + } + if (includePending) { + MediaStore.setIncludePending(queryUriBuilder); + } + try (Cursor cursor = getContext().getContentResolver().query( + queryUriBuilder.build(), null, + selectionPair.first, selectionPair.second, null)) { + while (cursor.moveToNext()) { + includeDownloadFromMediaStore(result, cursor, filePaths); + } + notificationUris.add(MediaStore.Files.EXTERNAL_CONTENT_URI); + notificationUris.add(MediaStore.Downloads.EXTERNAL_CONTENT_URI); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + private void includeDownloadFromMediaStore(@NonNull MatrixCursor result, + @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths) { + final String mimeType = getMimeType(mediaCursor); + 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 \"image/%\""); + selection.append(" AND "); + selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE \"audio/%\""); + selection.append(" AND "); + selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE \"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(DownloadColumns.MIME_TYPE + " IN ("); + for (int i = 0; i < mimeTypes.length; ++i) { + selection.append("?").append((i == mimeTypes.length - 1) ? ")" : ","); + selectionArgs.add(mimeTypes[i]); + } + } + } + + 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 @@ -612,7 +1056,8 @@ public class DownloadStorageProvider extends FileSystemProvider { void start() { synchronized (mLock) { if (mOpenCursorCount++ == 0) { - mFileWatcher = new ContentChangedRelay(mResolver); + mFileWatcher = new ContentChangedRelay(mResolver, + Arrays.asList(getPublicDownloadsDirectory())); mFileWatcher.startWatching(); } } @@ -638,30 +1083,33 @@ public class DownloadStorageProvider extends FileSystemProvider { private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO | CREATE | DELETE | DELETE_SELF | MOVE_SELF; - private static final String DOWNLOADS_PATH = getDownloadsDirectory().getAbsolutePath(); + private File[] mDownloadDirs; private final ContentResolver mResolver; - public ContentChangedRelay(ContentResolver resolver) { - super(DOWNLOADS_PATH, NOTIFY_EVENTS); + 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: " + DOWNLOADS_PATH); + 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: " + DOWNLOADS_PATH); + 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: " + DOWNLOADS_PATH); + 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); } diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java index 54cc1a5d..bc7997f6 100644 --- a/src/com/android/providers/downloads/DownloadThread.java +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -388,12 +388,7 @@ public class DownloadThread extends Thread { } boolean needsReschedule = false; - if (Downloads.Impl.isStatusCompleted(mInfoDelta.mStatus)) { - if (mInfo.shouldScanFile(mInfoDelta.mStatus)) { - DownloadScanner.requestScanBlocking(mContext, mInfo.mId, mInfoDelta.mFileName, - mInfoDelta.mMimeType); - } - } else if (mInfoDelta.mStatus == STATUS_WAITING_TO_RETRY + if (mInfoDelta.mStatus == STATUS_WAITING_TO_RETRY || mInfoDelta.mStatus == STATUS_WAITING_FOR_NETWORK || mInfoDelta.mStatus == STATUS_QUEUED_FOR_WIFI) { needsReschedule = true; diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java index 963ca9da..226fb481 100644 --- a/src/com/android/providers/downloads/Helpers.java +++ b/src/com/android/providers/downloads/Helpers.java @@ -16,15 +16,20 @@ package com.android.providers.downloads; -import static android.os.Environment.buildExternalStorageAppCacheDirs; -import static android.os.Environment.buildExternalStorageAppFilesDirs; +import static android.os.Environment.buildExternalStorageAppDataDirs; import static android.os.Environment.buildExternalStorageAppMediaDirs; import static android.os.Environment.buildExternalStorageAppObbDirs; +import static android.os.Environment.buildExternalStoragePublicDirs; +import static android.provider.Downloads.Impl.DESTINATION_EXTERNAL; +import static android.provider.Downloads.Impl.DESTINATION_FILE_URI; +import static android.provider.Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD; import static android.provider.Downloads.Impl.FLAG_REQUIRES_CHARGING; import static android.provider.Downloads.Impl.FLAG_REQUIRES_DEVICE_IDLE; import static com.android.providers.downloads.Constants.TAG; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.app.job.JobInfo; import android.app.job.JobScheduler; import android.content.ComponentName; @@ -43,14 +48,21 @@ import android.os.storage.StorageVolume; import android.provider.Downloads; import android.text.TextUtils; import android.util.Log; +import android.util.LongSparseArray; +import android.util.SparseArray; +import android.util.SparseBooleanArray; import android.webkit.MimeTypeMap; +import com.android.internal.util.ArrayUtils; + import com.google.common.annotations.VisibleForTesting; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Random; import java.util.Set; +import java.util.function.BiConsumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -64,6 +76,12 @@ public class Helpers { private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); + private static final Pattern PATTERN_ANDROID_DIRS = + Pattern.compile("(?i)^/storage/[^/]+(?:/[0-9]+)?/Android/(?:data|obb|media)/.+"); + + private static final Pattern PATTERN_PUBLIC_DIRS = + Pattern.compile("(?i)^/storage/[^/]+(?:/[0-9]+)?/([^/]+)/.+"); + private static final Object sUniqueLock = new Object(); private static HandlerThread sAsyncHandlerThread; @@ -144,7 +162,7 @@ public class Helpers { // When this download will show a notification, run with a higher // priority, since it's effectively a foreground service if (info.isVisible()) { - builder.setPriority(JobInfo.PRIORITY_FOREGROUND_APP); + builder.setPriority(JobInfo.PRIORITY_FOREGROUND_SERVICE); builder.setFlags(JobInfo.FLAG_WILL_BE_FOREGROUND); } @@ -472,6 +490,10 @@ public class Helpers { throw new IOException("Failed to generate an available filename"); } + public static boolean isFileInExternalAndroidDirs(String filePath) { + return PATTERN_ANDROID_DIRS.matcher(filePath).matches(); + } + static boolean isFilenameValid(Context context, File file) { return isFilenameValid(context, file, true); } @@ -488,9 +510,8 @@ public class Helpers { static boolean isFilenameValidInExternalPackage(Context context, File file, String packageName) { try { - if (containsCanonical(buildExternalStorageAppFilesDirs(packageName), file) || + if (containsCanonical(buildExternalStorageAppDataDirs(packageName), file) || containsCanonical(buildExternalStorageAppObbDirs(packageName), file) || - containsCanonical(buildExternalStorageAppCacheDirs(packageName), file) || containsCanonical(buildExternalStorageAppMediaDirs(packageName), file)) { return true; } @@ -499,7 +520,33 @@ public class Helpers { return false; } - Log.w(TAG, "Path appears to be invalid: " + file); + return false; + } + + static boolean isFilenameValidInPublicDownloadsDir(File file) { + try { + if (containsCanonical(buildExternalStoragePublicDirs( + Environment.DIRECTORY_DOWNLOADS), file)) { + return true; + } + } catch (IOException e) { + Log.w(TAG, "Failed to resolve canonical path: " + e); + return false; + } + + return false; + } + + @com.android.internal.annotations.VisibleForTesting + public static boolean isFilenameValidInKnownPublicDir(@Nullable String filePath) { + if (filePath == null) { + return false; + } + final Matcher matcher = PATTERN_PUBLIC_DIRS.matcher(filePath); + if (matcher.matches()) { + final String publicDir = matcher.group(1); + return ArrayUtils.contains(Environment.STANDARD_DIRECTORIES, publicDir); + } return false; } @@ -529,7 +576,6 @@ public class Helpers { return false; } - Log.w(TAG, "Path appears to be invalid: " + file); return false; } @@ -581,264 +627,57 @@ public class Helpers { } } - /** - * Checks whether this looks like a legitimate selection parameter - */ - public static void validateSelection(String selection, Set<String> allowedColumns) { - try { - if (selection == null || selection.isEmpty()) { - return; - } - Lexer lexer = new Lexer(selection, allowedColumns); - parseExpression(lexer); - if (lexer.currentToken() != Lexer.TOKEN_END) { - throw new IllegalArgumentException("syntax error"); - } - } catch (RuntimeException ex) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex); - } else if (false) { - Log.d(Constants.TAG, "invalid selection triggered " + ex); + public static void handleRemovedUidEntries(@NonNull Context context, @NonNull Cursor cursor, + @NonNull ArrayList<Long> idsToDelete, @NonNull ArrayList<Long> idsToOrphan, + @Nullable LongSparseArray<String> idsToGrantPermission) { + final SparseArray<String> knownUids = new SparseArray<>(); + while (cursor.moveToNext()) { + final long downloadId = cursor.getLong(0); + final int uid = cursor.getInt(1); + + final String ownerPackageName; + final int index = knownUids.indexOfKey(uid); + if (index >= 0) { + ownerPackageName = knownUids.valueAt(index); + } else { + ownerPackageName = getPackageForUid(context, uid); + knownUids.put(uid, ownerPackageName); } - throw ex; - } - } + if (ownerPackageName == null) { + final int destination = cursor.getInt(2); + final String filePath = cursor.getString(3); - // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] * - // | statement [AND_OR expression]* - private static void parseExpression(Lexer lexer) { - for (;;) { - // ( expression ) - if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) { - lexer.advance(); - parseExpression(lexer); - if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) { - throw new IllegalArgumentException("syntax error, unmatched parenthese"); + if ((destination == DESTINATION_EXTERNAL + || destination == DESTINATION_FILE_URI + || destination == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) + && isFilenameValidInKnownPublicDir(filePath)) { + idsToOrphan.add(downloadId); + } else { + idsToDelete.add(downloadId); } - lexer.advance(); - } else { - // statement - parseStatement(lexer); - } - if (lexer.currentToken() != Lexer.TOKEN_AND_OR) { - break; + } else if (idsToGrantPermission != null) { + idsToGrantPermission.put(downloadId, ownerPackageName); } - lexer.advance(); } } - // statement <- COLUMN COMPARE VALUE - // | COLUMN IS NULL - private static void parseStatement(Lexer lexer) { - // both possibilities start with COLUMN - if (lexer.currentToken() != Lexer.TOKEN_COLUMN) { - throw new IllegalArgumentException("syntax error, expected column name"); - } - lexer.advance(); - - // statement <- COLUMN COMPARE VALUE - if (lexer.currentToken() == Lexer.TOKEN_COMPARE) { - lexer.advance(); - if (lexer.currentToken() != Lexer.TOKEN_VALUE) { - throw new IllegalArgumentException("syntax error, expected quoted string"); - } - lexer.advance(); - return; - } - - // statement <- COLUMN IS NULL - if (lexer.currentToken() == Lexer.TOKEN_IS) { - lexer.advance(); - if (lexer.currentToken() != Lexer.TOKEN_NULL) { - throw new IllegalArgumentException("syntax error, expected NULL"); - } - lexer.advance(); - return; + public static String buildQueryWithIds(ArrayList<Long> downloadIds) { + final StringBuilder queryBuilder = new StringBuilder(Downloads.Impl._ID + " in ("); + final int size = downloadIds.size(); + for (int i = 0; i < size; i++) { + queryBuilder.append(downloadIds.get(i)); + queryBuilder.append((i == size - 1) ? ")" : ","); } - - // didn't get anything good after COLUMN - throw new IllegalArgumentException("syntax error after column name"); + return queryBuilder.toString(); } - /** - * A simple lexer that recognizes the words of our restricted subset of SQL where clauses - */ - private static class Lexer { - public static final int TOKEN_START = 0; - public static final int TOKEN_OPEN_PAREN = 1; - public static final int TOKEN_CLOSE_PAREN = 2; - public static final int TOKEN_AND_OR = 3; - public static final int TOKEN_COLUMN = 4; - public static final int TOKEN_COMPARE = 5; - public static final int TOKEN_VALUE = 6; - public static final int TOKEN_IS = 7; - public static final int TOKEN_NULL = 8; - public static final int TOKEN_END = 9; - - private final String mSelection; - private final Set<String> mAllowedColumns; - private int mOffset = 0; - private int mCurrentToken = TOKEN_START; - private final char[] mChars; - - public Lexer(String selection, Set<String> allowedColumns) { - mSelection = selection; - mAllowedColumns = allowedColumns; - mChars = new char[mSelection.length()]; - mSelection.getChars(0, mChars.length, mChars, 0); - advance(); - } - - public int currentToken() { - return mCurrentToken; - } - - public void advance() { - char[] chars = mChars; - - // consume whitespace - while (mOffset < chars.length && chars[mOffset] == ' ') { - ++mOffset; - } - - // end of input - if (mOffset == chars.length) { - mCurrentToken = TOKEN_END; - return; - } - - // "(" - if (chars[mOffset] == '(') { - ++mOffset; - mCurrentToken = TOKEN_OPEN_PAREN; - return; - } - - // ")" - if (chars[mOffset] == ')') { - ++mOffset; - mCurrentToken = TOKEN_CLOSE_PAREN; - return; - } - - // "?" - if (chars[mOffset] == '?') { - ++mOffset; - mCurrentToken = TOKEN_VALUE; - return; - } - - // "=" and "==" - if (chars[mOffset] == '=') { - ++mOffset; - mCurrentToken = TOKEN_COMPARE; - if (mOffset < chars.length && chars[mOffset] == '=') { - ++mOffset; - } - return; - } - - // ">" and ">=" - if (chars[mOffset] == '>') { - ++mOffset; - mCurrentToken = TOKEN_COMPARE; - if (mOffset < chars.length && chars[mOffset] == '=') { - ++mOffset; - } - return; - } - - // "<", "<=" and "<>" - if (chars[mOffset] == '<') { - ++mOffset; - mCurrentToken = TOKEN_COMPARE; - if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) { - ++mOffset; - } - return; - } - - // "!=" - if (chars[mOffset] == '!') { - ++mOffset; - mCurrentToken = TOKEN_COMPARE; - if (mOffset < chars.length && chars[mOffset] == '=') { - ++mOffset; - return; - } - throw new IllegalArgumentException("Unexpected character after !"); - } - - // columns and keywords - // first look for anything that looks like an identifier or a keyword - // and then recognize the individual words. - // no attempt is made at discarding sequences of underscores with no alphanumeric - // characters, even though it's not clear that they'd be legal column names. - if (isIdentifierStart(chars[mOffset])) { - int startOffset = mOffset; - ++mOffset; - while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) { - ++mOffset; - } - String word = mSelection.substring(startOffset, mOffset); - if (mOffset - startOffset <= 4) { - if (word.equals("IS")) { - mCurrentToken = TOKEN_IS; - return; - } - if (word.equals("OR") || word.equals("AND")) { - mCurrentToken = TOKEN_AND_OR; - return; - } - if (word.equals("NULL")) { - mCurrentToken = TOKEN_NULL; - return; - } - } - if (mAllowedColumns.contains(word)) { - mCurrentToken = TOKEN_COLUMN; - return; - } - throw new IllegalArgumentException("unrecognized column or keyword: " + word); - } - - // quoted strings - if (chars[mOffset] == '\'') { - ++mOffset; - while (mOffset < chars.length) { - if (chars[mOffset] == '\'') { - if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') { - ++mOffset; - } else { - break; - } - } - ++mOffset; - } - if (mOffset == chars.length) { - throw new IllegalArgumentException("unterminated string"); - } - ++mOffset; - mCurrentToken = TOKEN_VALUE; - return; - } - - // anything we don't recognize - throw new IllegalArgumentException("illegal character: " + chars[mOffset]); - } - - private static final boolean isIdentifierStart(char c) { - return c == '_' || - (c >= 'A' && c <= 'Z') || - (c >= 'a' && c <= 'z'); - } - - private static final boolean isIdentifierChar(char c) { - return c == '_' || - (c >= 'A' && c <= 'Z') || - (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9'); + public static String getPackageForUid(Context context, int uid) { + String[] packages = context.getPackageManager().getPackagesForUid(uid); + if (packages == null || packages.length == 0) { + return null; } + // For permission related purposes, any package belonging to the given uid should work. + return packages[0]; } } diff --git a/src/com/android/providers/downloads/MediaStoreDownloadsHelper.java b/src/com/android/providers/downloads/MediaStoreDownloadsHelper.java new file mode 100644 index 00000000..c4f347cb --- /dev/null +++ b/src/com/android/providers/downloads/MediaStoreDownloadsHelper.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2019 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 android.content.ContentUris; +import android.net.Uri; +import android.provider.MediaStore; + +public class MediaStoreDownloadsHelper { + + private static final String MEDIASTORE_DOWNLOAD_FILE_PREFIX = "msf:"; + private static final String MEDIASTORE_DOWNLOAD_DIR_PREFIX = "msd:"; + + public static String getDocIdForMediaStoreDownload(long id, boolean isDir) { + return (isDir ? MEDIASTORE_DOWNLOAD_DIR_PREFIX : MEDIASTORE_DOWNLOAD_FILE_PREFIX) + id; + } + + public static boolean isMediaStoreDownload(String docId) { + return docId != null && (docId.startsWith(MEDIASTORE_DOWNLOAD_FILE_PREFIX) + || docId.startsWith(MEDIASTORE_DOWNLOAD_DIR_PREFIX)); + } + + public static long getMediaStoreId(String docId) { + return Long.parseLong(getMediaStoreIdString(docId)); + } + + + public static String getMediaStoreIdString(String docId) { + final int index = docId.indexOf(":"); + return docId.substring(index + 1); + } + + public static boolean isMediaStoreDownloadDir(String docId) { + return docId != null && docId.startsWith(MEDIASTORE_DOWNLOAD_DIR_PREFIX); + } + + public static Uri getMediaStoreUri(String docId) { + return ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, + getMediaStoreId(docId)); + } +} diff --git a/src/com/android/providers/downloads/OpenHelper.java b/src/com/android/providers/downloads/OpenHelper.java index c88902b7..19cfee95 100644 --- a/src/com/android/providers/downloads/OpenHelper.java +++ b/src/com/android/providers/downloads/OpenHelper.java @@ -29,11 +29,13 @@ import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInstaller; +import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.Process; import android.provider.DocumentsContract; import android.provider.Downloads.Impl.RequestHeaders; +import android.provider.MediaStore; import android.util.Log; import java.io.File; @@ -51,6 +53,10 @@ public class OpenHelper { } intent.addFlags(intentFlags); + return startViewIntent(context, intent); + } + + public static boolean startViewIntent(Context context, Intent intent) { try { context.startActivity(intent); return true; @@ -60,6 +66,42 @@ public class OpenHelper { } } + public static Intent buildViewIntentForMediaStoreDownload(Context context, + Uri documentUri) { + final long mediaStoreId = MediaStoreDownloadsHelper.getMediaStoreId( + DocumentsContract.getDocumentId(documentUri)); + final Uri queryUri = ContentUris.withAppendedId( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, mediaStoreId); + try (Cursor cursor = context.getContentResolver().query( + queryUri, null, null, null)) { + if (cursor.moveToFirst()) { + final String mimeType = cursor.getString( + cursor.getColumnIndex(MediaStore.Downloads.MIME_TYPE)); + + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(documentUri, mimeType); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + if ("application/vnd.android.package-archive".equals(mimeType)) { + // Also splice in details about where it came from + intent.putExtra(Intent.EXTRA_ORIGINATING_URI, + getCursorUri(cursor, MediaStore.Downloads.DOWNLOAD_URI)); + intent.putExtra(Intent.EXTRA_REFERRER, + getCursorUri(cursor, MediaStore.Downloads.REFERER_URI)); + final String ownerPackageName = getCursorString(cursor, + MediaStore.Downloads.OWNER_PACKAGE_NAME); + final int ownerUid = getPackageUid(context, ownerPackageName); + if (ownerUid > 0) { + intent.putExtra(Intent.EXTRA_ORIGINATING_UID, ownerUid); + } + } + return intent; + } + } + return null; + } + /** * Build an {@link Intent} to view the download with given ID, handling * subtleties around installing packages. @@ -140,12 +182,25 @@ public class OpenHelper { return PackageInstaller.SessionParams.UID_UNKNOWN; } + private static int getPackageUid(Context context, String packageName) { + if (packageName == null) { + return -1; + } + try { + return context.getPackageManager().getPackageUid(packageName, 0); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Couldn't get uid for " + packageName, e); + return -1; + } + } + private static String getCursorString(Cursor cursor, String column) { return cursor.getString(cursor.getColumnIndexOrThrow(column)); } private static Uri getCursorUri(Cursor cursor, String column) { - return Uri.parse(getCursorString(cursor, column)); + final String uriString = cursor.getString(cursor.getColumnIndexOrThrow(column)); + return uriString == null ? null : Uri.parse(uriString); } private static File getCursorFile(Cursor cursor, String column) { diff --git a/src/com/android/providers/downloads/RealSystemFacade.java b/src/com/android/providers/downloads/RealSystemFacade.java index a0ce92c3..94461a68 100644 --- a/src/com/android/providers/downloads/RealSystemFacade.java +++ b/src/com/android/providers/downloads/RealSystemFacade.java @@ -28,6 +28,7 @@ import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkInfo; +import android.os.Bundle; import android.security.NetworkSecurityPolicy; import android.security.net.config.ApplicationConfig; @@ -85,6 +86,11 @@ class RealSystemFacade implements SystemFacade { } @Override + public void sendBroadcast(Intent intent, String receiverPermission, Bundle options) { + mContext.sendBroadcast(intent, receiverPermission, options); + } + + @Override public boolean userOwnsPackage(int uid, String packageName) throws NameNotFoundException { return mContext.getPackageManager().getApplicationInfo(packageName, 0).uid == uid; } diff --git a/src/com/android/providers/downloads/SystemFacade.java b/src/com/android/providers/downloads/SystemFacade.java index 14002a15..d73fe117 100644 --- a/src/com/android/providers/downloads/SystemFacade.java +++ b/src/com/android/providers/downloads/SystemFacade.java @@ -23,6 +23,7 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkInfo; +import android.os.Bundle; import java.security.GeneralSecurityException; @@ -58,6 +59,11 @@ interface SystemFacade { public void sendBroadcast(Intent intent); /** + * Send a broadcast intent with options. + */ + public void sendBroadcast(Intent intent, String receiverPermission, Bundle options); + + /** * Returns true if the specified UID owns the specified package name. */ public boolean userOwnsPackage(int uid, String pckg) throws NameNotFoundException; |