From b02bbf988e4e7e0c2cdba7bad6ad518e82df1f33 Mon Sep 17 00:00:00 2001 From: Sudheer Shanka Date: Tue, 6 Aug 2019 18:51:48 -0700 Subject: Ensure files get mediascanned after the download is completed. Update file size in MediaStore to 0 before triggering mediascan which should force MediaScanner to scan this file. Also, add a clean-up job to trigger mediscan on already existing downloads which should have been mediascanned but haven't and got stuck in this state. Bug: 138419471 Test: manual Test: atest DownloadProviderTests Test: atest cts/tests/app/src/android/app/cts/DownloadManagerTest.java Test: atest DownloadManagerLegacy Test: atest DownloadManagerApi28 Change-Id: I813086ceba6c70ca42309fcce5f9db209eac1575 (cherry picked from commit c2b0739b08e423e3c9fcde42b095cd72315ecf1f) --- AndroidManifest.xml | 5 + src/com/android/providers/downloads/Constants.java | 11 ++ .../providers/downloads/DownloadIdleService.java | 2 +- .../providers/downloads/DownloadProvider.java | 99 +++++++++-------- src/com/android/providers/downloads/Helpers.java | 24 ++++ .../providers/downloads/MediaScanTriggerJob.java | 122 +++++++++++++++++++++ 6 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 src/com/android/providers/downloads/MediaScanTriggerJob.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 45e2888a..302a58e5 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -107,6 +107,11 @@ android:exported="true" android:permission="android.permission.BIND_JOB_SERVICE" /> + + diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java index 79daeaed..c822c7c6 100644 --- a/src/com/android/providers/downloads/Constants.java +++ b/src/com/android/providers/downloads/Constants.java @@ -83,6 +83,17 @@ public class Constants { /** The default user agent used for downloads */ public static final String DEFAULT_USER_AGENT; + /** + * Job id for the periodic service to clean-up stale and orphan downloads. + */ + public static final int IDLE_JOB_ID = -100; + + /** + * Job id for a one-time clean-up job to trigger mediascan on files which should have been + * mediascanned earlier when they were downloaded but didn't get scanned. + */ + public static final int MEDIA_SCAN_TRIGGER_JOB_ID = -101; + static { final StringBuilder builder = new StringBuilder(); diff --git a/src/com/android/providers/downloads/DownloadIdleService.java b/src/com/android/providers/downloads/DownloadIdleService.java index ecebb0f8..2a689548 100644 --- a/src/com/android/providers/downloads/DownloadIdleService.java +++ b/src/com/android/providers/downloads/DownloadIdleService.java @@ -16,6 +16,7 @@ package com.android.providers.downloads; +import static com.android.providers.downloads.Constants.IDLE_JOB_ID; import static com.android.providers.downloads.Constants.TAG; import static com.android.providers.downloads.StorageUtils.listFilesRecursive; @@ -53,7 +54,6 @@ import java.util.HashSet; * deleted directly on disk. */ public class DownloadIdleService extends JobService { - private static final int IDLE_JOB_ID = -100; private class IdleRunnable implements Runnable { private JobParameters mParams; diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java index eb0313c7..db98c50f 100644 --- a/src/com/android/providers/downloads/DownloadProvider.java +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -31,6 +31,9 @@ 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 static com.android.providers.downloads.Helpers.convertToMediaStoreDownloadsUri; +import static com.android.providers.downloads.Helpers.triggerMediaScan; + import android.annotation.NonNull; import android.annotation.Nullable; import android.app.AppOpsManager; @@ -106,7 +109,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 = 113; + private static final int DB_VERSION = 114; /** Name of table in the database */ private static final String DB_TABLE = "downloads"; /** Memory optimization - close idle connections after 30s of inactivity */ @@ -340,6 +343,11 @@ public final class DownloadProvider extends ContentProvider { canonicalizeDataPaths(db); break; + case 114: + nullifyMediaStoreUris(db); + MediaScanTriggerJob.schedule(getContext()); + break; + default: throw new IllegalStateException("Don't know how to upgrade to " + version); } @@ -479,6 +487,24 @@ public final class DownloadProvider extends ContentProvider { } } + /** + * Set mediastore uri column to null before the clean-up job and fill it again while + * running the job so that if the clean-up job gets preempted, we could use it + * as a way to know the entries which are already handled when the job gets restarted. + */ + private void nullifyMediaStoreUris(SQLiteDatabase db) { + final String whereClause = Downloads.Impl._DATA + " IS NOT NULL" + + " AND (" + COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI + "=1" + + " OR " + COLUMN_MEDIA_SCANNED + "=" + MEDIA_SCANNED + ")" + + " AND (" + COLUMN_DESTINATION + "=" + Downloads.Impl.DESTINATION_EXTERNAL + + " OR " + COLUMN_DESTINATION + "=" + DESTINATION_FILE_URI + + " OR " + COLUMN_DESTINATION + "=" + DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD + + ")"; + final ContentValues values = new ContentValues(); + values.putNull(COLUMN_MEDIASTORE_URI); + db.update(DB_TABLE, values, whereClause, null); + } + /** * Add a column to a table using ALTER TABLE. * @param dbTable name of the table @@ -840,19 +866,28 @@ public final class DownloadProvider extends ContentProvider { 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)); + try { + final Uri mediaStoreUri = MediaStore.scanFile(getContext(), + new File(filteredValues.getAsString(Downloads.Impl._DATA))); if (mediaStoreUri != null) { + final ContentValues mediaValues = new ContentValues(); + mediaValues.put(MediaStore.Downloads.DOWNLOAD_URI, + filteredValues.getAsString(Downloads.Impl.COLUMN_URI)); + mediaValues.put(MediaStore.Downloads.REFERER_URI, + filteredValues.getAsString(Downloads.Impl.COLUMN_REFERER)); + mediaValues.put(MediaStore.Downloads.OWNER_PACKAGE_NAME, + Helpers.getPackageForUid(getContext(), + filteredValues.getAsInteger(Constants.UID))); + getContext().getContentResolver().update( + convertToMediaStoreDownloadsUri(mediaStoreUri), + mediaValues, null, 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); } @@ -948,48 +983,21 @@ public final class DownloadProvider extends ContentProvider { } catch (IOException e) { throw new IllegalArgumentException(e); } + final boolean downloadCompleted = Downloads.Impl.isStatusCompleted(info.mStatus); final ContentValues mediaValues = new ContentValues(); mediaValues.put(MediaStore.Downloads.DATA, filePath); - mediaValues.put(MediaStore.Downloads.SIZE, info.mTotalBytes); + mediaValues.put(MediaStore.Downloads.SIZE, + downloadCompleted ? info.mTotalBytes : info.mCurrentBytes); 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.IS_PENDING, downloadCompleted ? 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 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; @@ -1534,8 +1542,16 @@ public final class DownloadProvider extends ContentProvider { || info.mDestination == Downloads.Impl .DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) && visibleToUser) { - final Uri mediaStoreUri = updateMediaProvider(client, - convertToMediaProviderValues(info)); + final ContentValues mediaValues = convertToMediaProviderValues(info); + final Uri mediaStoreUri; + if (Downloads.Impl.isStatusCompleted(info.mStatus)) { + // Set size to 0 to ensure MediaScanner will scan this file. + mediaValues.put(MediaStore.Downloads.SIZE, 0); + updateMediaProvider(client, mediaValues); + mediaStoreUri = triggerMediaScan(client, new File(info.mFileName)); + } else { + mediaStoreUri = updateMediaProvider(client, mediaValues); + } if (!TextUtils.equals(info.mMediaStoreUri, mediaStoreUri == null ? null : mediaStoreUri.toString())) { updateValues.clear(); @@ -1553,9 +1569,6 @@ public final class DownloadProvider extends ContentProvider { 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)); - } } if (updateSchedule) { Helpers.scheduleJob(context, info); diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java index 565aa52e..f75b627f 100644 --- a/src/com/android/providers/downloads/Helpers.java +++ b/src/com/android/providers/downloads/Helpers.java @@ -34,18 +34,22 @@ import android.app.job.JobInfo; import android.app.job.JobScheduler; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.database.Cursor; import android.net.Uri; +import android.os.Bundle; import android.os.Environment; import android.os.FileUtils; import android.os.Handler; import android.os.HandlerThread; import android.os.Process; +import android.os.RemoteException; import android.os.SystemClock; import android.os.UserHandle; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import android.provider.Downloads; +import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; import android.util.LongSparseArray; @@ -489,6 +493,26 @@ public class Helpers { throw new IOException("Failed to generate an available filename"); } + public static Uri convertToMediaStoreDownloadsUri(Uri mediaStoreUri) { + final String volumeName = MediaStore.getVolumeName(mediaStoreUri); + final long id = android.content.ContentUris.parseId(mediaStoreUri); + return MediaStore.Downloads.getContentUri(volumeName, id); + } + + // TODO: Move it to MediaStore. + public static Uri triggerMediaScan(android.content.ContentProviderClient mediaProviderClient, + File file) { + try { + final Bundle in = new Bundle(); + in.putParcelable(Intent.EXTRA_STREAM, Uri.fromFile(file)); + final Bundle out = mediaProviderClient.call(MediaStore.SCAN_FILE_CALL, null, in); + return out.getParcelable(Intent.EXTRA_STREAM); + } catch (RemoteException e) { + // Should not happen + } + return null; + } + public static boolean isFileInExternalAndroidDirs(String filePath) { return PATTERN_ANDROID_DIRS.matcher(filePath).matches(); } diff --git a/src/com/android/providers/downloads/MediaScanTriggerJob.java b/src/com/android/providers/downloads/MediaScanTriggerJob.java new file mode 100644 index 00000000..8da25258 --- /dev/null +++ b/src/com/android/providers/downloads/MediaScanTriggerJob.java @@ -0,0 +1,122 @@ +/* + * 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 static android.provider.BaseColumns._ID; +import static android.provider.Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI; +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.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_SCANNED; +import static android.provider.Downloads.Impl._DATA; + +import static com.android.providers.downloads.Constants.MEDIA_SCAN_TRIGGER_JOB_ID; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.Downloads; +import android.provider.MediaStore; + +import java.io.File; + +/** + * Clean-up job to force mediascan on downloads which should have been but didn't get mediascanned. + */ +public class MediaScanTriggerJob extends JobService { + private volatile boolean mJobStopped; + + @Override + public boolean onStartJob(JobParameters parameters) { + Helpers.getAsyncHandler().post(() -> { + final String selection = _DATA + " IS NOT NULL" + + " AND (" + COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI + "=1" + + " OR " + COLUMN_MEDIA_SCANNED + "=" + MEDIA_SCANNED + ")" + + " AND (" + COLUMN_DESTINATION + "=" + DESTINATION_EXTERNAL + + " OR " + COLUMN_DESTINATION + "=" + DESTINATION_FILE_URI + + " OR " + COLUMN_DESTINATION + "=" + DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD + + ")"; + try (ContentProviderClient downloadProviderClient + = getContentResolver().acquireContentProviderClient(Downloads.Impl.AUTHORITY); + ContentProviderClient mediaProviderClient + = getContentResolver().acquireContentProviderClient(MediaStore.AUTHORITY)) { + try (Cursor cursor = downloadProviderClient.query(ALL_DOWNLOADS_CONTENT_URI, + new String[] {_ID, _DATA, COLUMN_MEDIASTORE_URI}, + selection, null, null)) { + while (cursor.moveToNext()) { + if (mJobStopped) { + return; + } + // This indicates that this entry has been handled already (perhaps when + // this job ran earlier and got preempted), so skip. + if (cursor.getString(2) != null) { + continue; + } + final long id = cursor.getLong(0); + final String filePath = cursor.getString(1); + final ContentValues mediaValues = new ContentValues(); + mediaValues.put(MediaStore.Files.FileColumns.SIZE, 0); + mediaProviderClient.update(MediaStore.Files.getContentUriForPath(filePath), + mediaValues, + MediaStore.Files.FileColumns.DATA + "=?", + new String[] { filePath }); + + final Uri mediaStoreUri = Helpers.triggerMediaScan(mediaProviderClient, + new File(filePath)); + if (mediaStoreUri != null) { + final ContentValues downloadValues = new ContentValues(); + downloadValues.put(COLUMN_MEDIASTORE_URI, mediaStoreUri.toString()); + downloadProviderClient.update(ALL_DOWNLOADS_CONTENT_URI, + downloadValues, _ID + "=" + id, null); + } + } + } catch (RemoteException e) { + // Should not happen + } + } + jobFinished(parameters, false); + }); + return true; + } + + @Override + public boolean onStopJob(JobParameters parameters) { + mJobStopped = true; + return true; + } + + public static void schedule(Context context) { + final JobScheduler scheduler = context.getSystemService(JobScheduler.class); + final JobInfo job = new JobInfo.Builder(MEDIA_SCAN_TRIGGER_JOB_ID, + new ComponentName(context, MediaScanTriggerJob.class)) + .setRequiresCharging(true) + .setRequiresDeviceIdle(true) + .build(); + scheduler.schedule(job); + } +} -- cgit v1.2.3