[automerger skipped] Import translations. DO NOT MERGE am: 06b7403bf3 -s ours
am skip reason: subject contains skip directive
Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/providers/DownloadProvider/+/12091372
Change-Id: I88b41292e7e81f505c9b0bb029736db662a6859d
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 302a58e..6ef1b54 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -42,6 +42,7 @@
android:description="@string/permdesc_accessAllDownloads"
android:protectionLevel="signature"/>
+ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_DOWNLOAD_MANAGER" />
<uses-permission android:name="android.permission.ACCESS_CACHE_FILESYSTEM" />
@@ -57,12 +58,15 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.UPDATE_APP_OPS_STATS" />
<uses-permission android:name="android.permission.START_ACTIVITIES_FROM_BACKGROUND"/>
+ <uses-permission android:name="android.permission.WRITE_MEDIA_STORAGE" />
+ <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application android:process="android.process.media"
android:label="@string/app_label"
android:icon="@mipmap/ic_launcher_download"
android:allowBackup="false"
android:supportsRtl="true"
+ android:forceQueryable="true"
android:usesCleartextTraffic="true">
<provider android:name=".DownloadProvider"
diff --git a/TEST_MAPPING b/TEST_MAPPING
new file mode 100644
index 0000000..5fd3d36
--- /dev/null
+++ b/TEST_MAPPING
@@ -0,0 +1,35 @@
+{
+ "presubmit": [
+ {
+ "name": "CtsProviderTestCases",
+ "options": [
+ {
+ "include-filter": "android.provider.cts.media.MediaStore_DownloadsTest"
+ }
+ ]
+ },
+ {
+ "name": "CtsAppTestCases",
+ "options": [
+ {
+ "include-filter": "android.app.cts.DownloadManagerTest"
+ }
+ ]
+ },
+ {
+ "name": "CtsDownloadManagerApi28"
+ },
+ {
+ "name": "CtsDownloadManagerInstaller"
+ },
+ {
+ "name": "DownloadProviderTests"
+ },
+ {
+ "name": "DownloadProviderPermissionTests"
+ },
+ {
+ "name": "DownloadPublicApiAccessTests"
+ }
+ ]
+}
diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java
index c822c7c..dfef8d1 100644
--- a/src/com/android/providers/downloads/Constants.java
+++ b/src/com/android/providers/downloads/Constants.java
@@ -97,18 +97,18 @@
static {
final StringBuilder builder = new StringBuilder();
- final boolean validRelease = !TextUtils.isEmpty(Build.VERSION.RELEASE);
+ final boolean validRelease = !TextUtils.isEmpty(Build.VERSION.RELEASE_OR_CODENAME);
final boolean validId = !TextUtils.isEmpty(Build.ID);
final boolean includeModel = "REL".equals(Build.VERSION.CODENAME)
&& !TextUtils.isEmpty(Build.MODEL);
builder.append("AndroidDownloadManager");
if (validRelease) {
- builder.append("/").append(Build.VERSION.RELEASE);
+ builder.append("/").append(Build.VERSION.RELEASE_OR_CODENAME);
}
builder.append(" (Linux; U; Android");
if (validRelease) {
- builder.append(" ").append(Build.VERSION.RELEASE);
+ builder.append(" ").append(Build.VERSION.RELEASE_OR_CODENAME);
}
if (includeModel || validId) {
builder.append(";");
diff --git a/src/com/android/providers/downloads/DownloadJobService.java b/src/com/android/providers/downloads/DownloadJobService.java
index e1b2023..731e15f 100644
--- a/src/com/android/providers/downloads/DownloadJobService.java
+++ b/src/com/android/providers/downloads/DownloadJobService.java
@@ -81,7 +81,7 @@
@Override
public boolean onStopJob(JobParameters params) {
final int id = params.getJobId();
- Log.d(TAG, "onStopJob id=" + id + ", reason=" + params.getStopReason());
+ Log.d(TAG, "onStopJob id=" + id + ", reason=" + params.getDebugStopReason());
final DownloadThread thread;
synchronized (mActiveThreads) {
diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java
index d61ed84..b629f14 100644
--- a/src/com/android/providers/downloads/DownloadProvider.java
+++ b/src/com/android/providers/downloads/DownloadProvider.java
@@ -22,20 +22,17 @@
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_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 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;
import android.app.DownloadManager;
import android.app.DownloadManager.Request;
@@ -53,7 +50,6 @@
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;
@@ -62,7 +58,6 @@
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;
@@ -76,13 +71,9 @@
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;
@@ -93,10 +84,6 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
@@ -329,6 +316,7 @@
* Upgrade database from (version - 1) to version.
*/
private void upgradeTo(SQLiteDatabase db, int version) {
+ boolean scheduleMediaScanTriggerJob = false;
switch (version) {
case 100:
createDownloadsTable(db);
@@ -390,7 +378,7 @@
case 111:
addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_MEDIASTORE_URI,
"TEXT DEFAULT NULL");
- addMediaStoreUris(db);
+ scheduleMediaScanTriggerJob = true;
break;
case 112:
@@ -403,12 +391,15 @@
case 114:
nullifyMediaStoreUris(db);
- MediaScanTriggerJob.schedule(getContext());
+ scheduleMediaScanTriggerJob = true;
break;
default:
throw new IllegalStateException("Don't know how to upgrade to " + version);
}
+ if (scheduleMediaScanTriggerJob) {
+ MediaScanTriggerJob.schedule(getContext());
+ }
}
/**
@@ -445,53 +436,6 @@
}
/**
- * 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
@@ -644,41 +588,26 @@
mStorageManager = getContext().getSystemService(StorageManager.class);
- reconcileRemovedUidEntries();
- return true;
- }
-
- 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<>();
+ // Grant access permissions for all known downloads to the owning apps.
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);
+ new String[] { _ID, Constants.UID }, null, null, null, null, null)) {
+ while (cursor.moveToNext()) {
+ final long id = cursor.getLong(0);
+ final int uid = cursor.getInt(1);
+ final String[] packageNames = getContext().getPackageManager()
+ .getPackagesForUid(uid);
+ // Potentially stale download, will be deleted after MEDIA_MOUNTED broadcast
+ // is received.
+ if (ArrayUtils.isEmpty(packageNames)) {
+ continue;
+ }
+ // We only need to grant to the first package, since the
+ // platform internally tracks based on UIDs.
+ grantAllDownloadsPermission(packageNames[0], id);
+ }
}
- 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);
- }
- 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);
- }
+ return true;
}
/**
@@ -721,8 +650,8 @@
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);
+ getContext().enforceCallingOrSelfPermission(
+ android.Manifest.permission.WRITE_MEDIA_STORAGE, Constants.TAG);
final long[] deletedDownloadIds = extras.getLongArray(Downloads.EXTRA_IDS);
final String[] mimeTypes = extras.getStringArray(Downloads.EXTRA_MIME_TYPES);
DownloadStorageProvider.onMediaProviderDownloadsDelete(getContext(),
@@ -747,8 +676,8 @@
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);
+ getContext().enforceCallingOrSelfPermission(
+ android.Manifest.permission.WRITE_MEDIA_STORAGE, Constants.TAG);
DownloadStorageProvider.revokeAllMediaStoreUriPermissions(getContext());
return null;
}
@@ -807,8 +736,9 @@
"No permission to write");
final AppOpsManager appOps = getContext().getSystemService(AppOpsManager.class);
- if (appOps.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE,
- getCallingPackage()) != AppOpsManager.MODE_ALLOWED) {
+ if (appOps.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE, getCallingPackage(),
+ Binder.getCallingUid(), getCallingAttributionTag(), null)
+ != AppOpsManager.MODE_ALLOWED) {
throw new SecurityException("No permission to write");
}
}
@@ -925,7 +855,7 @@
== DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) {
final CallingIdentity token = clearCallingIdentity();
try {
- final Uri mediaStoreUri = MediaStore.scanFile(getContext(),
+ final Uri mediaStoreUri = MediaStore.scanFile(getContext().getContentResolver(),
new File(filteredValues.getAsString(Downloads.Impl._DATA)));
if (mediaStoreUri != null) {
final ContentValues mediaValues = new ContentValues();
@@ -991,7 +921,7 @@
* 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,
+ Uri updateMediaProvider(@NonNull ContentProviderClient mediaProvider,
@NonNull ContentValues mediaValues) {
final String filePath = mediaValues.getAsString(MediaStore.DownloadColumns.DATA);
Uri mediaStoreUri = getMediaStoreUri(mediaProvider, filePath);
@@ -999,7 +929,7 @@
try {
if (mediaStoreUri == null) {
mediaStoreUri = mediaProvider.insert(
- MediaStore.Files.getContentUriForPath(filePath),
+ Helpers.getContentUriForPath(getContext(), filePath),
mediaValues);
if (mediaStoreUri == null) {
Log.e(Constants.TAG, "Error inserting into mediaProvider: " + mediaValues);
@@ -1021,7 +951,7 @@
private Uri getMediaStoreUri(@NonNull ContentProviderClient mediaProvider,
@NonNull String filePath) {
final Uri filesUri = MediaStore.setIncludePending(
- MediaStore.Files.getContentUriForPath(filePath));
+ Helpers.getContentUriForPath(getContext(), filePath));
try (Cursor cursor = mediaProvider.query(filesUri,
new String[] { MediaStore.Files.FileColumns._ID },
MediaStore.Files.FileColumns.DATA + "=?", new String[] { filePath }, null, null)) {
@@ -1034,7 +964,7 @@
return null;
}
- private ContentValues convertToMediaProviderValues(DownloadInfo info) {
+ ContentValues convertToMediaProviderValues(DownloadInfo info) {
final String filePath;
try {
filePath = new File(info.mFileName).getCanonicalPath();
@@ -1043,7 +973,10 @@
}
final boolean downloadCompleted = Downloads.Impl.isStatusCompleted(info.mStatus);
final ContentValues mediaValues = new ContentValues();
- mediaValues.put(MediaStore.Downloads.DATA, filePath);
+ mediaValues.put(MediaStore.Downloads.DATA, filePath);
+ mediaValues.put(MediaStore.Downloads.VOLUME_NAME, Helpers.extractVolumeName(filePath));
+ mediaValues.put(MediaStore.Downloads.RELATIVE_PATH, Helpers.extractRelativePath(filePath));
+ mediaValues.put(MediaStore.Downloads.DISPLAY_NAME, Helpers.extractDisplayName(filePath));
mediaValues.put(MediaStore.Downloads.SIZE,
downloadCompleted ? info.mTotalBytes : info.mCurrentBytes);
mediaValues.put(MediaStore.Downloads.DOWNLOAD_URI, info.mUri);
@@ -1052,7 +985,6 @@
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;
}
@@ -1116,7 +1048,7 @@
throw new IllegalArgumentException("Not a file URI: " + uri);
}
final String path = uri.getPath();
- if (path == null || path.contains("..")) {
+ if (path == null || ("/" + path + "/").contains("/../")) {
throw new IllegalArgumentException("Invalid file URI: " + uri);
}
@@ -1129,24 +1061,37 @@
}
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 (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 (targetSdkVersion < Build.VERSION_CODES.Q
- && Helpers.isFilenameValidInExternal(getContext(), file)) {
+ } else if (runningLegacyMode && 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) {
+ if (appOps.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE, getCallingPackage(),
+ Binder.getCallingUid(), getCallingAttributionTag(), null)
+ != AppOpsManager.MODE_ALLOWED) {
throw new SecurityException("No permission to write to " + file);
}
+ } else if (Helpers.isFilenameValidInExternalObbDir(file) &&
+ ((appOpsManager.noteOp(
+ AppOpsManager.OP_REQUEST_INSTALL_PACKAGES,
+ Binder.getCallingUid(), getCallingPackage(), null, "obb_download")
+ == AppOpsManager.MODE_ALLOWED)
+ || (getContext().checkCallingOrSelfPermission(
+ android.Manifest.permission.REQUEST_INSTALL_PACKAGES)
+ == PackageManager.PERMISSION_GRANTED))) {
+ // Installers are allowed to download in OBB dirs, even outside their own package
+ return;
} else {
throw new SecurityException("Unsupported path " + file);
}
@@ -1154,7 +1099,7 @@
private void checkDownloadedFilePath(ContentValues values) {
final String path = values.getAsString(Downloads.Impl._DATA);
- if (path == null || path.contains("..")) {
+ if (path == null || ("/" + path + "/").contains("/../")) {
throw new IllegalArgumentException("Invalid file path: "
+ (path == null ? "null" : path));
}
@@ -1178,20 +1123,21 @@
if (Binder.getCallingPid() == Process.myPid()) {
return;
- } else if (Helpers.isFilenameValidInExternalPackage(getContext(), file, getCallingPackage())) {
- // No permissions required for paths belonging to calling package.
+ } else if (Helpers.isFilenameValidInExternalPackage(getContext(), file, getCallingPackage())
+ || Helpers.isFilenameValidInPublicDownloadsDir(file)) {
+ // No permissions required for paths belonging to calling package or
+ // public downloads dir.
return;
- } else if ((runningLegacyMode && Helpers.isFilenameValidInPublicDownloadsDir(file))
- || (targetSdkVersion < Build.VERSION_CODES.Q
- && Helpers.isFilenameValidInExternal(getContext(), file))) {
+ } else if (runningLegacyMode && 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) {
+ if (appOps.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE, getCallingPackage(),
+ Binder.getCallingUid(), getCallingAttributionTag(), null)
+ != AppOpsManager.MODE_ALLOWED) {
throw new SecurityException("No permission to write to " + file);
}
} else {
@@ -1571,7 +1517,12 @@
updateMediaProvider(client, mediaValues);
mediaStoreUri = triggerMediaScan(client, new File(info.mFileName));
} else {
- mediaStoreUri = updateMediaProvider(client, mediaValues);
+ // Don't insert/update MediaStore db until the download is complete.
+ // Incomplete files can only be inserted to MediaStore by setting
+ // IS_PENDING=1 and using RELATIVE_PATH and DISPLAY_NAME in
+ // MediaProvider#insert operation. We use DATA column, IS_PENDING
+ // with DATA column will not be respected by MediaProvider.
+ mediaStoreUri = null;
}
if (!TextUtils.equals(info.mMediaStoreUri,
mediaStoreUri == null ? null : mediaStoreUri.toString())) {
@@ -1734,7 +1685,7 @@
Log.v(Constants.TAG,
"Deleting " + file + " via provider delete");
file.delete();
- deleteMediaStoreEntry(file);
+ MediaStore.scanFile(getContext().getContentResolver(), file);
} else {
Log.d(Constants.TAG, "Ignoring invalid file: " + file);
}
@@ -1774,24 +1725,6 @@
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
*/
diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java
index 40b5e09..a5da475 100644
--- a/src/com/android/providers/downloads/DownloadReceiver.java
+++ b/src/com/android/providers/downloads/DownloadReceiver.java
@@ -18,8 +18,7 @@
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 android.provider.Downloads.Impl.AUTHORITY;
import static com.android.providers.downloads.Constants.TAG;
import static com.android.providers.downloads.Helpers.getAsyncHandler;
@@ -32,6 +31,7 @@
import android.app.DownloadManager;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
+import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
@@ -39,17 +39,12 @@
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
+import android.os.Process;
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)
*/
@@ -76,21 +71,18 @@
if (Intent.ACTION_BOOT_COMPLETED.equals(action)
|| Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
final PendingResult result = goAsync();
- getAsyncHandler().post(new Runnable() {
- @Override
- public void run() {
- handleBootCompleted(context);
- result.finish();
+ getAsyncHandler().post(() -> {
+ handleBootCompleted(context);
+ if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
+ handleRemovedUidEntries(context);
}
+ result.finish();
});
} else if (Intent.ACTION_UID_REMOVED.equals(action)) {
final PendingResult result = goAsync();
- getAsyncHandler().post(new Runnable() {
- @Override
- public void run() {
- handleUidRemoved(context, intent);
- result.finish();
- }
+ getAsyncHandler().post(() -> {
+ handleUidRemoved(context, intent);
+ result.finish();
});
} else if (Constants.ACTION_OPEN.equals(action)
@@ -102,12 +94,9 @@
// TODO: remove this once test is refactored
handleNotificationBroadcast(context, intent);
} else {
- getAsyncHandler().post(new Runnable() {
- @Override
- public void run() {
- handleNotificationBroadcast(context, intent);
- result.finish();
- }
+ getAsyncHandler().post(() -> {
+ handleNotificationBroadcast(context, intent);
+ result.finish();
});
}
} else if (Constants.ACTION_CANCEL.equals(action)) {
@@ -145,31 +134,23 @@
DownloadIdleService.scheduleIdlePass(context);
}
+ private void handleRemovedUidEntries(Context context) {
+ try (ContentProviderClient cpc = context.getContentResolver()
+ .acquireContentProviderClient(AUTHORITY)) {
+ Helpers.handleRemovedUidEntries(context, cpc.getLocalContentProvider(),
+ Process.INVALID_UID);
+ }
+ }
+
private void handleUidRemoved(Context context, Intent intent) {
- final ContentResolver resolver = context.getContentResolver();
final int uid = intent.getIntExtra(Intent.EXTRA_UID, -1);
-
- 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 (uid == -1) {
+ return;
}
- 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);
+ try (ContentProviderClient cpc = context.getContentResolver()
+ .acquireContentProviderClient(AUTHORITY)) {
+ Helpers.handleRemovedUidEntries(context, cpc.getLocalContentProvider(), uid);
}
}
diff --git a/src/com/android/providers/downloads/DownloadStorageProvider.java b/src/com/android/providers/downloads/DownloadStorageProvider.java
index fc7dd5e..36304aa 100644
--- a/src/com/android/providers/downloads/DownloadStorageProvider.java
+++ b/src/com/android/providers/downloads/DownloadStorageProvider.java
@@ -116,8 +116,11 @@
return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
}
- private void copyNotificationUri(MatrixCursor result, Cursor cursor) {
- result.setNotificationUri(getContext().getContentResolver(), cursor.getNotificationUri());
+ private void copyNotificationUri(@NonNull MatrixCursor result, @NonNull Cursor cursor) {
+ final List<Uri> notifyUris = cursor.getNotificationUris();
+ if (notifyUris != null) {
+ result.setNotificationUris(getContext().getContentResolver(), notifyUris);
+ }
}
/**
@@ -165,8 +168,7 @@
final RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT);
row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS
- | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH
- | Root.FLAG_SUPPORTS_IS_CHILD);
+ | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH);
row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download);
row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads));
row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
@@ -283,7 +285,8 @@
null, null, null);
copyNotificationUri(result, cursor);
if (cursor.moveToFirst()) {
- includeDownloadFromMediaStore(result, cursor, null /* filePaths */);
+ includeDownloadFromMediaStore(result, cursor, null /* filePaths */,
+ false /* shouldExcludeMedia */);
}
} else {
cursor = mDm.query(new Query().setFilterById(Long.parseLong(docId)));
@@ -402,11 +405,12 @@
final String uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIAPROVIDER_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
+ // Skip images, videos and documents that have been inserted into the MediaStore so
+ // we don't duplicate them in the recent list. The audio root of
// MediaDocumentsProvider doesn't support recent, we add it into recent list.
if (mimeType == null || (MediaFile.isImageMimeType(mimeType)
- || MediaFile.isVideoMimeType(mimeType)) && !TextUtils.isEmpty(uri)) {
+ || MediaFile.isVideoMimeType(mimeType) || MediaFile.isDocumentMimeType(
+ mimeType)) && !TextUtils.isEmpty(uri)) {
continue;
}
includeDownloadFromCursor(result, cursor, filePaths,
@@ -590,7 +594,7 @@
private static boolean isMediaMimeType(String mimeType) {
return MediaFile.isImageMimeType(mimeType) || MediaFile.isVideoMimeType(mimeType)
- || MediaFile.isAudioMimeType(mimeType);
+ || MediaFile.isAudioMimeType(mimeType) || MediaFile.isDocumentMimeType(mimeType);
}
private void includeDefaultDocument(MatrixCursor result) {
@@ -812,7 +816,7 @@
private Pair<String, String> getRelativePathAndDisplayNameForDownload(long id) {
final Uri mediaStoreUri = ContentUris.withAppendedId(
- MediaStore.Downloads.EXTERNAL_CONTENT_URI, id);
+ MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL), id);
final long token = Binder.clearCallingIdentity();
try (Cursor cursor = queryForSingleItem(mediaStoreUri,
new String[] { DownloadColumns.RELATIVE_PATH, DownloadColumns.DISPLAY_NAME },
@@ -871,31 +875,48 @@
}
final long token = Binder.clearCallingIdentity();
- final Pair<String, String[]> selectionPair
- = buildSearchSelection(queryArgs, filePaths, parentId);
- final Uri.Builder queryUriBuilder = MediaStore.Downloads.EXTERNAL_CONTENT_URI.buildUpon();
+
+ final Uri uriInner = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL);
+ final Bundle queryArgsInner = new Bundle();
+
+ final Pair<String, String[]> selectionPair = buildSearchSelection(
+ queryArgs, filePaths, parentId);
+ queryArgsInner.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
+ selectionPair.first);
+ queryArgsInner.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
+ selectionPair.second);
if (limit != NO_LIMIT) {
- queryUriBuilder.appendQueryParameter(MediaStore.PARAM_LIMIT, String.valueOf(limit));
+ queryArgsInner.putInt(ContentResolver.QUERY_ARG_LIMIT, limit);
}
if (includePending) {
- MediaStore.setIncludePending(queryUriBuilder);
+ queryArgsInner.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
}
- try (Cursor cursor = getContext().getContentResolver().query(
- queryUriBuilder.build(), null,
- selectionPair.first, selectionPair.second, null)) {
+
+ try (Cursor cursor = getContext().getContentResolver().query(uriInner,
+ null, queryArgsInner, null)) {
+ final boolean shouldExcludeMedia = queryArgs != null && queryArgs.getBoolean(
+ DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, false /* defaultValue */);
while (cursor.moveToNext()) {
- includeDownloadFromMediaStore(result, cursor, filePaths);
+ includeDownloadFromMediaStore(result, cursor, filePaths, shouldExcludeMedia);
}
- notificationUris.add(MediaStore.Files.EXTERNAL_CONTENT_URI);
- notificationUris.add(MediaStore.Downloads.EXTERNAL_CONTENT_URI);
+ notificationUris.add(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL));
+ notificationUris.add(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL));
} finally {
Binder.restoreCallingIdentity(token);
}
}
private void includeDownloadFromMediaStore(@NonNull MatrixCursor result,
- @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths) {
+ @NonNull Cursor mediaCursor, @Nullable Set<String> filePaths,
+ boolean shouldExcludeMedia) {
final String mimeType = getMimeType(mediaCursor);
+
+ // Image, Audio and Video are excluded from buildSearchSelection in querySearchDocuments
+ // and queryRecentDocuments. Only exclude document type here for both cases.
+ if (shouldExcludeMedia && MediaFile.isDocumentMimeType(mimeType)) {
+ return;
+ }
+
final boolean isDir = Document.MIME_TYPE_DIR.equals(mimeType);
final String docId = getDocIdForMediaStoreDownload(
mediaCursor.getLong(mediaCursor.getColumnIndex(DownloadColumns._ID)), isDir);
@@ -972,11 +993,14 @@
if (selection.length() > 0) {
selection.append(" AND ");
}
- selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE \"image/%\"");
+ selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
+ selectionArgs.add("image/%");
selection.append(" AND ");
- selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE \"audio/%\"");
+ selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
+ selectionArgs.add("audio/%");
selection.append(" AND ");
- selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE \"video/%\"");
+ selection.append(DownloadColumns.MIME_TYPE + " NOT LIKE ?");
+ selectionArgs.add("video/%");
}
final String displayName = queryArgs.getString(
@@ -1014,11 +1038,43 @@
if (selection.length() > 0) {
selection.append(" AND ");
}
- selection.append(DownloadColumns.MIME_TYPE + " IN (");
+
+ selection.append("(");
+ final List<String> tempSelectionArgs = new ArrayList<>();
+ final StringBuilder tempSelection = new StringBuilder();
+ List<String> wildcardMimeTypeList = new ArrayList<>();
for (int i = 0; i < mimeTypes.length; ++i) {
- selection.append("?").append((i == mimeTypes.length - 1) ? ")" : ",");
- selectionArgs.add(mimeTypes[i]);
+ final String mimeType = mimeTypes[i];
+ if (!TextUtils.isEmpty(mimeType) && mimeType.endsWith("/*")) {
+ wildcardMimeTypeList.add(mimeType);
+ continue;
+ }
+
+ if (tempSelectionArgs.size() > 0) {
+ tempSelection.append(",");
+ }
+ tempSelection.append("?");
+ tempSelectionArgs.add(mimeType);
}
+
+ for (int i = 0; i < wildcardMimeTypeList.size(); i++) {
+ selection.append(DownloadColumns.MIME_TYPE + " LIKE ?")
+ .append((i != wildcardMimeTypeList.size() - 1) ? " OR " : "");
+ final String mimeType = wildcardMimeTypeList.get(i);
+ selectionArgs.add(mimeType.substring(0, mimeType.length() - 1) + "%");
+ }
+
+ if (tempSelectionArgs.size() > 0) {
+ if (wildcardMimeTypeList.size() > 0) {
+ selection.append(" OR ");
+ }
+ selection.append(DownloadColumns.MIME_TYPE + " IN (")
+ .append(tempSelection.toString())
+ .append(")");
+ selectionArgs.addAll(tempSelectionArgs);
+ }
+
+ selection.append(")");
}
}
diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java
index bc7997f..752dbfc 100644
--- a/src/com/android/providers/downloads/DownloadThread.java
+++ b/src/com/android/providers/downloads/DownloadThread.java
@@ -688,6 +688,13 @@
} else if (Downloads.Impl.isStatusSuccess(mInfoDelta.mStatus)) {
// When success, open access if local file
if (mInfoDelta.mFileName != null) {
+ if (Helpers.isFileInExternalAndroidDirs(mInfoDelta.mFileName)) {
+ // Files that are downloaded in Android/ may need fixing up
+ // of permissions on devices without sdcardfs; do so here,
+ // before we give the file back to the client
+ File file = new File(mInfoDelta.mFileName);
+ mStorage.fixupAppDir(file.getParentFile());
+ }
if (mInfo.mDestination != Downloads.Impl.DESTINATION_FILE_URI) {
try {
// Move into final resting place, if needed
diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java
index 57cdf04..772c0b9 100644
--- a/src/com/android/providers/downloads/Helpers.java
+++ b/src/com/android/providers/downloads/Helpers.java
@@ -16,15 +16,19 @@
package com.android.providers.downloads;
+import static android.os.Environment.buildExternalStorageAndroidObbDirs;
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.os.Process.INVALID_UID;
+import static android.provider.Downloads.Impl.COLUMN_DESTINATION;
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 android.provider.Downloads.Impl._DATA;
import static com.android.providers.downloads.Constants.TAG;
@@ -33,17 +37,17 @@
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
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;
@@ -52,21 +56,18 @@
import android.provider.MediaStore;
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.annotations.VisibleForTesting;
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.Arrays;
+import java.util.Locale;
import java.util.Random;
-import java.util.Set;
-import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -192,9 +193,10 @@
if (info.mCurrentBytes > 0 && !TextUtils.isEmpty(info.mETag)) {
// If we're resuming an in-progress download, we only need to
// download the remaining bytes.
- builder.setEstimatedNetworkBytes(info.mTotalBytes - info.mCurrentBytes);
+ builder.setEstimatedNetworkBytes(info.mTotalBytes - info.mCurrentBytes,
+ JobInfo.NETWORK_BYTES_UNKNOWN);
} else {
- builder.setEstimatedNetworkBytes(info.mTotalBytes);
+ builder.setEstimatedNetworkBytes(info.mTotalBytes, JobInfo.NETWORK_BYTES_UNKNOWN);
}
}
@@ -500,18 +502,15 @@
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;
+ return MediaStore.scanFile(ContentResolver.wrap(mediaProviderClient), file);
+ }
+
+ public static final Uri getContentUriForPath(Context context, String path) {
+ final StorageManager sm = context.getSystemService(StorageManager.class);
+ final String volumeName = sm.getStorageVolume(new File(path)).getMediaStoreVolumeName();
+ return MediaStore.Downloads.getContentUri(volumeName);
}
public static boolean isFileInExternalAndroidDirs(String filePath) {
@@ -547,6 +546,19 @@
return false;
}
+ static boolean isFilenameValidInExternalObbDir(File file) {
+ try {
+ if (containsCanonical(buildExternalStorageAndroidObbDirs(), file)) {
+ return true;
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to resolve canonical path: " + e);
+ return false;
+ }
+
+ return false;
+ }
+
static boolean isFilenameValidInPublicDownloadsDir(File file) {
try {
if (containsCanonical(buildExternalStoragePublicDirs(
@@ -603,6 +615,83 @@
return false;
}
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
+ "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)(Android/sandbox/([^/]+)/)?");
+
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
+ "(?i)^/storage/([^/]+)");
+
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
+ return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
+ }
+
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ public static @Nullable String extractVolumeName(@Nullable String data) {
+ if (data == null) return null;
+ final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
+ if (matcher.find()) {
+ final String volumeName = matcher.group(1);
+ if (volumeName.equals("emulated")) {
+ return MediaStore.VOLUME_EXTERNAL_PRIMARY;
+ } else {
+ return normalizeUuid(volumeName);
+ }
+ } else {
+ return MediaStore.VOLUME_INTERNAL;
+ }
+ }
+
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ public static @Nullable String extractRelativePath(@Nullable String data) {
+ if (data == null) return null;
+ final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
+ if (matcher.find()) {
+ final int lastSlash = data.lastIndexOf('/');
+ if (lastSlash == -1 || lastSlash < matcher.end()) {
+ // This is a file in the top-level directory, so relative path is "/"
+ // which is different than null, which means unknown path
+ return "/";
+ } else {
+ return data.substring(matcher.end(), lastSlash + 1);
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Shamelessly borrowed from
+ * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
+ */
+ public static @Nullable String extractDisplayName(@Nullable String data) {
+ if (data == null) return null;
+ if (data.indexOf('/') == -1) {
+ return data;
+ }
+ if (data.endsWith("/")) {
+ data = data.substring(0, data.length() - 1);
+ }
+ return data.substring(data.lastIndexOf('/') + 1);
+ }
+
private static boolean containsCanonical(File dir, File file) throws IOException {
return FileUtils.contains(dir.getCanonicalFile(), file);
}
@@ -651,39 +740,60 @@
}
}
- public static void handleRemovedUidEntries(@NonNull Context context, @NonNull Cursor cursor,
- @NonNull ArrayList<Long> idsToDelete, @NonNull ArrayList<Long> idsToOrphan,
- @Nullable LongSparseArray<String> idsToGrantPermission) {
+ @VisibleForTesting
+ public static void handleRemovedUidEntries(@NonNull Context context,
+ ContentProvider downloadProvider, int removedUid) {
final SparseArray<String> knownUids = new SparseArray<>();
- while (cursor.moveToNext()) {
- final long downloadId = cursor.getLong(0);
- final int uid = cursor.getInt(1);
+ final ArrayList<Long> idsToDelete = new ArrayList<>();
+ final ArrayList<Long> idsToOrphan = new ArrayList<>();
+ final String selection = removedUid == INVALID_UID ? Constants.UID + " IS NOT NULL"
+ : Constants.UID + "=" + removedUid;
+ try (Cursor cursor = downloadProvider.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
+ new String[] { Downloads.Impl._ID, Constants.UID, COLUMN_DESTINATION, _DATA },
+ selection, null, null)) {
+ 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);
- }
-
- if (ownerPackageName == null) {
- final int destination = cursor.getInt(2);
- final String filePath = cursor.getString(3);
-
- if ((destination == DESTINATION_EXTERNAL
- || destination == DESTINATION_FILE_URI
- || destination == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD)
- && isFilenameValidInKnownPublicDir(filePath)) {
- idsToOrphan.add(downloadId);
+ final String ownerPackageName;
+ final int index = knownUids.indexOfKey(uid);
+ if (index >= 0) {
+ ownerPackageName = knownUids.valueAt(index);
} else {
- idsToDelete.add(downloadId);
+ ownerPackageName = getPackageForUid(context, uid);
+ knownUids.put(uid, ownerPackageName);
}
- } else if (idsToGrantPermission != null) {
- idsToGrantPermission.put(downloadId, ownerPackageName);
+
+ if (ownerPackageName == null) {
+ final int destination = cursor.getInt(2);
+ final String filePath = cursor.getString(3);
+
+ if ((destination == DESTINATION_EXTERNAL
+ || destination == DESTINATION_FILE_URI
+ || destination == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD)
+ && isFilenameValidInKnownPublicDir(filePath)) {
+ idsToOrphan.add(downloadId);
+ } else {
+ idsToDelete.add(downloadId);
+ }
+ }
}
}
+
+ 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);
+ downloadProvider.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");
+ downloadProvider.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
+ Helpers.buildQueryWithIds(idsToDelete), null);
+ }
}
public static String buildQueryWithIds(ArrayList<Long> downloadIds) {
diff --git a/src/com/android/providers/downloads/MediaScanTriggerJob.java b/src/com/android/providers/downloads/MediaScanTriggerJob.java
index 8da2525..d889fa3 100644
--- a/src/com/android/providers/downloads/MediaScanTriggerJob.java
+++ b/src/com/android/providers/downloads/MediaScanTriggerJob.java
@@ -39,20 +39,26 @@
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
-import android.os.RemoteException;
+import android.os.Environment;
import android.provider.Downloads;
import android.provider.MediaStore;
+import android.util.Log;
import java.io.File;
/**
- * Clean-up job to force mediascan on downloads which should have been but didn't get mediascanned.
+ * Job to update MediaProvider with all the downloads and force mediascan on them.
*/
public class MediaScanTriggerJob extends JobService {
private volatile boolean mJobStopped;
@Override
public boolean onStartJob(JobParameters parameters) {
+ if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
+ // External storage is not available yet, so defer this job to a later time.
+ jobFinished(parameters, true /* reschedule */);
+ return false;
+ }
Helpers.getAsyncHandler().post(() -> {
final String selection = _DATA + " IS NOT NULL"
+ " AND (" + COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI + "=1"
@@ -61,45 +67,53 @@
+ " OR " + COLUMN_DESTINATION + "=" + DESTINATION_FILE_URI
+ " OR " + COLUMN_DESTINATION + "=" + DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD
+ ")";
- try (ContentProviderClient downloadProviderClient
+ try (ContentProviderClient cpc
= 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)) {
+ final DownloadProvider downloadProvider
+ = ((DownloadProvider) cpc.getLocalContentProvider());
+ try (Cursor cursor = downloadProvider.query(ALL_DOWNLOADS_CONTENT_URI,
+ null, selection, null, null)) {
+
+ final DownloadInfo.Reader reader
+ = new DownloadInfo.Reader(getContentResolver(), cursor);
+ final DownloadInfo info = new DownloadInfo(MediaScanTriggerJob.this);
while (cursor.moveToNext()) {
if (mJobStopped) {
return;
}
+ reader.updateFromDatabase(info);
// 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) {
+ if (info.mMediaStoreUri != null) {
continue;
}
- final long id = cursor.getLong(0);
- final String filePath = cursor.getString(1);
- final ContentValues mediaValues = new ContentValues();
+ final ContentValues mediaValues;
+ try {
+ mediaValues = downloadProvider.convertToMediaProviderValues(info);
+ } catch (IllegalArgumentException e) {
+ Log.e(Constants.TAG,
+ "Error getting media content values from " + info, e);
+ continue;
+ }
+ // Overriding size column value to 0 for forcing the mediascan
+ // later (to address http://b/138419471).
mediaValues.put(MediaStore.Files.FileColumns.SIZE, 0);
- mediaProviderClient.update(MediaStore.Files.getContentUriForPath(filePath),
- mediaValues,
- MediaStore.Files.FileColumns.DATA + "=?",
- new String[] { filePath });
+ downloadProvider.updateMediaProvider(mediaProviderClient, mediaValues);
final Uri mediaStoreUri = Helpers.triggerMediaScan(mediaProviderClient,
- new File(filePath));
+ new File(info.mFileName));
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);
+ downloadProvider.update(ALL_DOWNLOADS_CONTENT_URI,
+ downloadValues, _ID + "=" + info.mId, null);
}
}
- } catch (RemoteException e) {
- // Should not happen
}
}
- jobFinished(parameters, false);
+ jobFinished(parameters, false /* reschedule */);
});
return true;
}
diff --git a/tests/permission/Android.bp b/tests/permission/Android.bp
index c3767e1..274106f 100644
--- a/tests/permission/Android.bp
+++ b/tests/permission/Android.bp
@@ -15,6 +15,7 @@
android_test {
name: "DownloadProviderPermissionTests",
+ test_suites: ["device-tests"],
srcs: [
"src/**/*.java",
@@ -26,6 +27,7 @@
],
static_libs: [
+ "androidx.test.rules",
"junit",
],
diff --git a/tests/permission/AndroidManifest.xml b/tests/permission/AndroidManifest.xml
index c575fc4..cdc0b3a 100644
--- a/tests/permission/AndroidManifest.xml
+++ b/tests/permission/AndroidManifest.xml
@@ -28,11 +28,7 @@
include any uses-permissions tags
-->
- <!--
- The test declared in this instrumentation can be run via this command
- "adb shell am instrument -w com.android.providers.downloads.permission.tests/android.test.InstrumentationTestRunner"
- -->
- <instrumentation android:name="android.test.InstrumentationTestRunner"
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.android.providers.downloads.permission.tests"
android:label="Tests for Download provider permissions"/>
diff --git a/tests/permission/AndroidTest.xml b/tests/permission/AndroidTest.xml
index e6375ab..386fcad 100644
--- a/tests/permission/AndroidTest.xml
+++ b/tests/permission/AndroidTest.xml
@@ -20,9 +20,9 @@
<option name="test-suite-tag" value="apct" />
<option name="test-tag" value="DownloadProviderPermissionTests" />
- <test class="com.android.tradefed.testtype.InstrumentationTest" >
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
<option name="package" value="com.android.providers.downloads.permission.tests" />
- <option name="runner" value="android.test.InstrumentationTestRunner" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
<option name="hidden-api-checks" value="false"/>
</test>
</configuration>
diff --git a/tests/permission/src/com/android/providers/downloads/permission/tests/DownloadProviderPermissionsTest.java b/tests/permission/src/com/android/providers/downloads/permission/tests/DownloadProviderPermissionsTest.java
index 4c6717c..2b72a8b 100644
--- a/tests/permission/src/com/android/providers/downloads/permission/tests/DownloadProviderPermissionsTest.java
+++ b/tests/permission/src/com/android/providers/downloads/permission/tests/DownloadProviderPermissionsTest.java
@@ -16,16 +16,25 @@
package com.android.providers.downloads.permission.tests;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
+import static org.junit.Assert.fail;
import android.content.ContentResolver;
import android.content.ContentValues;
+import android.content.Context;
import android.content.Intent;
import android.provider.Downloads;
-import android.test.AndroidTestCase;
-import android.test.suitebuilder.annotation.MediumTest;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
/**
* Verify that protected Download provider actions require specific permissions.
@@ -33,13 +42,17 @@
* TODO: consider adding test where app has ACCESS_DOWNLOAD_MANAGER, but not
* ACCESS_DOWNLOAD_MANAGER_ADVANCED
*/
-public class DownloadProviderPermissionsTest extends AndroidTestCase {
+@RunWith(AndroidJUnit4.class)
+public class DownloadProviderPermissionsTest {
private ContentResolver mContentResolver;
- @Override
- protected void setUp() throws Exception {
- super.setUp();
+ private Context getContext() {
+ return InstrumentationRegistry.getContext();
+ }
+
+ @Before
+ public void setUp() throws Exception {
mContentResolver = getContext().getContentResolver();
}
@@ -48,7 +61,7 @@
* <p>Tests Permission:
* {@link android.Manifest.permission#ACCESS_CACHE_FILESYSTEM}
*/
- @MediumTest
+ @Test
public void testAccessCacheFilesystem() throws IOException {
try {
String filePath = "/cache/this-should-not-exist.txt";
@@ -71,7 +84,7 @@
* and
* {@link android.Manifest.permission#INTERNET}
*/
- @MediumTest
+ @Test
public void testWriteDownloadProvider() {
try {
ContentValues values = new ContentValues();
@@ -88,7 +101,8 @@
* <p>Tests Permission:
* {@link com.android.providers.downloads.Manifest.permission#ACCESS_DOWNLOAD_MANAGER}
*/
- @MediumTest
+ @Test
+ @Ignore
public void testStartDownloadService() {
try {
Intent downloadServiceIntent = new Intent();
diff --git a/tests/public_api_access/Android.bp b/tests/public_api_access/Android.bp
index 0845bfa..1f982b3 100644
--- a/tests/public_api_access/Android.bp
+++ b/tests/public_api_access/Android.bp
@@ -15,6 +15,7 @@
android_test {
name: "DownloadPublicApiAccessTests",
+ test_suites: ["device-tests"],
srcs: [
"src/**/*.java",
@@ -26,6 +27,7 @@
],
static_libs: [
+ "androidx.test.rules",
"junit",
],
diff --git a/tests/public_api_access/AndroidManifest.xml b/tests/public_api_access/AndroidManifest.xml
index 0104846..a5fb1aa 100644
--- a/tests/public_api_access/AndroidManifest.xml
+++ b/tests/public_api_access/AndroidManifest.xml
@@ -25,12 +25,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!--
- The test declared in this instrumentation can be run via this command
- "adb shell am instrument -w com.android.providers.downloads.permission.tests/android.test.InstrumentationTestRunner"
We intentionally target our own package to ensure this runs in a separate process under a
separate UID.
-->
- <instrumentation android:name="android.test.InstrumentationTestRunner"
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.android.providers.downloads.public_api_access_tests"
android:label="Tests for public API access channels to DownloadProvider"/>
diff --git a/tests/public_api_access/AndroidTest.xml b/tests/public_api_access/AndroidTest.xml
index abd1dec..9c27f5b 100644
--- a/tests/public_api_access/AndroidTest.xml
+++ b/tests/public_api_access/AndroidTest.xml
@@ -20,9 +20,9 @@
<option name="test-suite-tag" value="apct" />
<option name="test-tag" value="DownloadPublicApiAccessTests" />
- <test class="com.android.tradefed.testtype.InstrumentationTest" >
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
<option name="package" value="com.android.providers.downloads.public_api_access_tests" />
- <option name="runner" value="android.test.InstrumentationTestRunner" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
<option name="hidden-api-checks" value="false"/>
</test>
</configuration>
diff --git a/tests/public_api_access/src/com/android/providers/downloads/public_api_access_tests/PublicApiAccessTest.java b/tests/public_api_access/src/com/android/providers/downloads/public_api_access_tests/PublicApiAccessTest.java
index 82fa934..ba35461 100644
--- a/tests/public_api_access/src/com/android/providers/downloads/public_api_access_tests/PublicApiAccessTest.java
+++ b/tests/public_api_access/src/com/android/providers/downloads/public_api_access_tests/PublicApiAccessTest.java
@@ -16,21 +16,37 @@
package com.android.providers.downloads.public_api_access_tests;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
import android.app.DownloadManager;
import android.content.ContentResolver;
import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
import android.net.Uri;
+import android.os.Environment;
import android.provider.Downloads;
-import android.test.AndroidTestCase;
-import android.test.suitebuilder.annotation.MediumTest;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
/**
* DownloadProvider allows apps without permission ACCESS_DOWNLOAD_MANAGER to access it -- this is
* how the public API works. But such access is subject to strict constraints on what can be
* inserted. This test suite checks those constraints.
*/
-@MediumTest
-public class PublicApiAccessTest extends AndroidTestCase {
+@RunWith(AndroidJUnit4.class)
+public class PublicApiAccessTest {
private static final String[] DISALLOWED_COLUMNS = new String[] {
Downloads.Impl.COLUMN_COOKIE_DATA,
Downloads.Impl.COLUMN_REFERER,
@@ -47,25 +63,30 @@
private ContentResolver mContentResolver;
private DownloadManager mManager;
- @Override
- protected void setUp() throws Exception {
- super.setUp();
- mContentResolver = getContext().getContentResolver();
- mManager = new DownloadManager(getContext());
+ private Context getContext() {
+ return InstrumentationRegistry.getContext();
}
- @Override
- protected void tearDown() throws Exception {
+ @Before
+ public void setUp() throws Exception {
+ mContentResolver = getContext().getContentResolver();
+ mManager = new DownloadManager(getContext());
+ mManager.setAccessFilename(true);
+ }
+
+ @After
+ public void tearDown() throws Exception {
if (mContentResolver != null) {
mContentResolver.delete(Downloads.Impl.CONTENT_URI, null, null);
}
- super.tearDown();
}
+ @Test
public void testMinimalValidWrite() {
mContentResolver.insert(Downloads.Impl.CONTENT_URI, buildValidValues());
}
+ @Test
public void testMaximalValidWrite() {
ContentValues values = buildValidValues();
values.put(Downloads.Impl.COLUMN_TITLE, "foo");
@@ -88,12 +109,14 @@
return values;
}
+ @Test
public void testNoPublicApi() {
ContentValues values = buildValidValues();
values.remove(Downloads.Impl.COLUMN_IS_PUBLIC_API);
testInvalidValues(values);
}
+ @Test
public void testInvalidDestination() {
ContentValues values = buildValidValues();
values.put(Downloads.Impl.COLUMN_DESTINATION, Downloads.Impl.DESTINATION_EXTERNAL);
@@ -102,6 +125,8 @@
testInvalidValues(values);
}
+ @Ignore
+ @Test
public void testInvalidVisibility() {
ContentValues values = buildValidValues();
values.put(Downloads.Impl.COLUMN_VISIBILITY,
@@ -115,6 +140,7 @@
testInvalidValues(values);
}
+ @Test
public void testDisallowedColumns() {
for (String column : DISALLOWED_COLUMNS) {
ContentValues values = buildValidValues();
@@ -123,6 +149,7 @@
}
}
+ @Test
public void testFileUriWithoutExternalPermission() {
ContentValues values = buildValidValues();
values.put(Downloads.Impl.COLUMN_DESTINATION, Downloads.Impl.DESTINATION_FILE_URI);
@@ -139,6 +166,7 @@
}
}
+ @Test
public void testDownloadManagerRequest() {
// first try a minimal request
DownloadManager.Request request = new DownloadManager.Request(Uri.parse("http://localhost/path"));
@@ -153,4 +181,43 @@
request.addRequestHeader("X-Some-Header", "value");
mManager.enqueue(request);
}
+
+ /**
+ * Internally, {@code DownloadManager} synchronizes its contents with
+ * {@code MediaStore}, which relies heavily on using file extensions to
+ * determine MIME types.
+ * <p>
+ * This test verifies that if an app attempts to add an already-completed
+ * download without an extension, that we'll force the MIME type with what
+ * {@code MediaStore} would have derived.
+ */
+ @Test
+ public void testAddCompletedWithoutExtension() throws Exception {
+ final File dir = Environment
+ .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ final File file = new File(dir, "test" + System.nanoTime());
+ file.createNewFile();
+
+ final long id = mManager.addCompletedDownload("My Title", "My Description", true,
+ "application/pdf", file.getAbsolutePath(), file.length(), true, true,
+ Uri.parse("http://example.com/"), Uri.parse("http://example.net/"));
+ final Uri uri = mManager.getDownloadUri(id);
+
+ // Trigger a generic update so that we push to MediaStore
+ final ContentValues values = new ContentValues();
+ values.put(DownloadManager.COLUMN_DESCRIPTION, "Modified Description");
+ mContentResolver.update(uri, values, null);
+
+ try (Cursor c = mContentResolver.query(uri, null, null, null)) {
+ assertTrue(c.moveToFirst());
+
+ final String actualMime = c
+ .getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE));
+ final String actualPath = c
+ .getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
+
+ assertEquals("application/octet-stream", actualMime);
+ assertEquals(file.getAbsolutePath(), actualPath);
+ }
+ }
}
diff --git a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
index 6acdfed..a505632 100644
--- a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
+++ b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
@@ -95,7 +95,7 @@
@Override
public synchronized void notifyChange(
- Uri uri, ContentObserver observer, boolean syncToNetwork) {
+ Uri uri, ContentObserver observer) {
mNotifyWasCalled = true;
}
}
diff --git a/tests/src/com/android/providers/downloads/HelpersTest.java b/tests/src/com/android/providers/downloads/HelpersTest.java
index 65c5d36..61515ce 100644
--- a/tests/src/com/android/providers/downloads/HelpersTest.java
+++ b/tests/src/com/android/providers/downloads/HelpersTest.java
@@ -16,14 +16,46 @@
package com.android.providers.downloads;
+import static android.provider.Downloads.Impl.COLUMN_DESTINATION;
+import static android.provider.Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE;
+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._DATA;
+import static android.provider.Downloads.Impl._ID;
+
+import static com.android.internal.util.ArrayUtils.contains;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.ContentProvider;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.MatrixCursor;
import android.net.Uri;
+import android.os.Environment;
+import android.os.Process;
import android.provider.Downloads;
+
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.SmallTest;
+import android.util.LongArray;
+import android.util.LongSparseArray;
import libcore.io.IoUtils;
import java.io.File;
+import java.util.Arrays;
+import java.util.function.BiConsumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
* This test exercises methods in the {@Helpers} utility class.
@@ -31,8 +63,22 @@
@SmallTest
public class HelpersTest extends AndroidTestCase {
+ private final static int TEST_UID1 = 11111;
+ private final static int TEST_UID2 = 11112;
+ private final static int TEST_UID3 = 11113;
+
+ private final MockitoHelper mMockitoHelper = new MockitoHelper();
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+
+ mMockitoHelper.setUp(getClass());
+ }
+
@Override
protected void tearDown() throws Exception {
+ mMockitoHelper.tearDown();
IoUtils.deleteContents(getContext().getFilesDir());
IoUtils.deleteContents(getContext().getCacheDir());
@@ -117,4 +163,160 @@
assertFalse(Helpers.isFilenameValidInKnownPublicDir(
"/storage/emulated/0/Android/data/com.example/bar.jpg"));
}
+
+ public void testHandleRemovedUidEntries() throws Exception {
+ // Prepare
+ final int[] testUids = {
+ TEST_UID1, TEST_UID2, TEST_UID3
+ };
+ final int[] unknownUids = {
+ TEST_UID1, TEST_UID2
+ };
+ final Context context = mock(Context.class);
+ final PackageManager packageManager = mock(PackageManager.class);
+ when(context.getPackageManager()).thenReturn(packageManager);
+ for (int uid : testUids) {
+ when(packageManager.getPackagesForUid(uid)).thenReturn(
+ contains(unknownUids, uid) ? null : new String[] {"com.example" + uid}
+ );
+ }
+
+ final LongArray idsToRemove = new LongArray();
+ final LongArray idsToOrphan = new LongArray();
+ final LongSparseArray<String> validEntries = new LongSparseArray<>();
+ final MatrixCursor cursor = prepareData(testUids, unknownUids,
+ idsToOrphan, idsToRemove, validEntries);
+
+ final ContentProvider downloadProvider = mock(ContentProvider.class);
+ when(downloadProvider.query(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ any(String[].class), any(String.class),isNull(), isNull())).thenReturn(cursor);
+
+ // Call
+ Helpers.handleRemovedUidEntries(context, downloadProvider, Process.INVALID_UID);
+
+ // Verify
+ verify(downloadProvider).update(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ argThat(values -> values.get(Constants.UID) == null),
+ argThat(selection -> Arrays.equals(
+ idsToOrphan.toArray(), extractIdsFromSelection(selection))),
+ isNull());
+ verify(downloadProvider).delete(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ argThat(selection -> Arrays.equals(
+ idsToRemove.toArray(), extractIdsFromSelection(selection))),
+ isNull());
+
+
+ // Reset
+ idsToOrphan.clear();
+ idsToRemove.clear();
+ validEntries.clear();
+ reset(downloadProvider);
+
+ // Prepare
+ final MatrixCursor cursor2 = prepareData(new int[] {TEST_UID2}, unknownUids,
+ idsToOrphan, idsToRemove, validEntries);
+ when(downloadProvider.query(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ any(String[].class), any(String.class),isNull(), isNull())).thenReturn(cursor2);
+
+ // Call
+ Helpers.handleRemovedUidEntries(context, downloadProvider, TEST_UID2);
+
+ // Verify
+ verify(downloadProvider).update(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ argThat(values -> values.get(Constants.UID) == null),
+ argThat(selection -> Arrays.equals(
+ idsToOrphan.toArray(), extractIdsFromSelection(selection))),
+ isNull());
+ verify(downloadProvider).delete(eq(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI),
+ argThat(selection -> Arrays.equals(
+ idsToRemove.toArray(), extractIdsFromSelection(selection))),
+ isNull());
+ }
+
+ private MatrixCursor prepareData(int[] uids, int[] unknownUids,
+ final LongArray idsToOrphan, final LongArray idsToRemove,
+ LongSparseArray<String> validEntries) {
+ final MatrixCursor cursor = new MatrixCursor(
+ new String[] {_ID, Constants.UID, COLUMN_DESTINATION, _DATA});
+ final int[] destinations = {
+ DESTINATION_EXTERNAL,
+ DESTINATION_FILE_URI,
+ DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD,
+ DESTINATION_CACHE_PARTITION_PURGEABLE
+ };
+ long counter = 0;
+ for (int uid : uids) {
+ for (int destination : destinations) {
+ final String fileName = uid + "_" + destination + ".txt";
+ switch (destination) {
+ case DESTINATION_EXTERNAL: {
+ final File file = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOWNLOADS), fileName);
+ cursor.addRow(new Object[]{++counter, uid, destination, file.getPath()});
+ if (contains(unknownUids, uid)) {
+ idsToOrphan.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ } break;
+ case DESTINATION_FILE_URI: {
+ final File file1 = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOCUMENTS), fileName);
+ cursor.addRow(new Object[]{++counter, uid, destination, file1.getPath()});
+ if (contains(unknownUids, uid)) {
+ idsToOrphan.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ final File file2 = new File(getContext().getExternalFilesDir(null),
+ fileName);
+ cursor.addRow(new Object[]{++counter, uid, destination, file2.getPath()});
+ if (contains(unknownUids, uid)) {
+ idsToRemove.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ } break;
+ case DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD: {
+ final File file1 = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOCUMENTS), fileName);
+ cursor.addRow(new Object[]{++counter, uid, destination, file1.getPath()});
+ if (contains(unknownUids, uid)) {
+ idsToOrphan.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ final File file2 = new File(getContext().getExternalFilesDir(null),
+ fileName);
+ cursor.addRow(new Object[]{++counter, uid, destination, file2.getPath()});
+ if (contains(unknownUids, uid)) {
+ idsToRemove.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ } break;
+ case DESTINATION_CACHE_PARTITION_PURGEABLE: {
+ final File file = new File(getContext().getCacheDir(), fileName);
+ final String filePath = file.getPath().replace(
+ getContext().getPackageName(), "com.android.providers.downloads");
+ cursor.addRow(new Object[]{++counter, uid, destination, filePath});
+ if (contains(unknownUids, uid)) {
+ idsToRemove.add(counter);
+ } else {
+ validEntries.put(counter, "com.example" + uid);
+ }
+ } break;
+ }
+ }
+ }
+ return cursor;
+ }
+
+ private long[] extractIdsFromSelection(String selection) {
+ final Pattern uidsListPattern = Pattern.compile(".*\\((.+)\\)");
+ final Matcher matcher = uidsListPattern.matcher(selection);
+ assertTrue(matcher.matches());
+ return Arrays.stream(matcher.group(1).split(","))
+ .mapToLong(Long::valueOf).sorted().toArray();
+ }
}