Merge "Add isFuseThread wrapper method" into rvc-dev
diff --git a/Android.bp b/Android.bp
index f59369f..b7092bc 100644
--- a/Android.bp
+++ b/Android.bp
@@ -35,7 +35,6 @@
},
plugins: [
- "compat-changeid-annotation-processor",
"java_api_finder",
],
diff --git a/apex/framework/Android.bp b/apex/framework/Android.bp
index 62104a2..8648c72 100644
--- a/apex/framework/Android.bp
+++ b/apex/framework/Android.bp
@@ -94,19 +94,19 @@
java_library {
name: "framework-mediaprovider-stubs-publicapi",
srcs: [":framework-mediaprovider-stubs-srcs-publicapi"],
- sdk_version: "current",
+ defaults: ["framework-module-stubs-lib-defaults-publicapi"],
}
java_library {
name: "framework-mediaprovider-stubs-systemapi",
srcs: [":framework-mediaprovider-stubs-srcs-systemapi"],
- sdk_version: "system_current",
+ defaults: ["framework-module-stubs-lib-defaults-systemapi"],
}
java_library {
name: "framework-mediaprovider-stubs-module_libs_api",
srcs: [":framework-mediaprovider-stubs-srcs-module_libs_api"],
- sdk_version: "system_current",
+ defaults: ["framework-module-stubs-lib-defaults-module_libs_api"],
}
java_library {
diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java
index 8ca04eb..27e61ec 100644
--- a/apex/framework/java/android/provider/MediaStore.java
+++ b/apex/framework/java/android/provider/MediaStore.java
@@ -161,6 +161,8 @@
public static final String VOLUME_EXTERNAL_PRIMARY = "external_primary";
/** {@hide} */
+ public static final String RESOLVE_PLAYLIST_MEMBERS_CALL = "resolve_playlist_members";
+ /** {@hide} */
public static final String RUN_IDLE_MAINTENANCE_CALL = "run_idle_maintenance";
/** {@hide} */
public static final String WAIT_FOR_IDLE_CALL = "wait_for_idle";
@@ -770,7 +772,11 @@
*/
public static void startLegacyMigration(@NonNull ContentResolver resolver,
@NonNull String volumeName) {
- resolver.call(AUTHORITY_LEGACY, START_LEGACY_MIGRATION_CALL, volumeName, null);
+ try {
+ resolver.call(AUTHORITY_LEGACY, START_LEGACY_MIGRATION_CALL, volumeName, null);
+ } catch (Exception e) {
+ Log.wtf(TAG, "Failed to deliver legacy migration event", e);
+ }
}
/**
@@ -782,7 +788,11 @@
*/
public static void finishLegacyMigration(@NonNull ContentResolver resolver,
@NonNull String volumeName) {
- resolver.call(AUTHORITY_LEGACY, FINISH_LEGACY_MIGRATION_CALL, volumeName, null);
+ try {
+ resolver.call(AUTHORITY_LEGACY, FINISH_LEGACY_MIGRATION_CALL, volumeName, null);
+ } catch (Exception e) {
+ Log.wtf(TAG, "Failed to deliver legacy migration event", e);
+ }
}
private static @NonNull PendingIntent createRequest(@NonNull ContentResolver resolver,
@@ -3892,6 +3902,14 @@
}
/** {@hide} */
+ public static void resolvePlaylistMembers(@NonNull ContentResolver resolver,
+ @NonNull Uri playlistUri) {
+ final Bundle in = new Bundle();
+ in.putParcelable(EXTRA_URI, playlistUri);
+ resolver.call(AUTHORITY, RESOLVE_PLAYLIST_MEMBERS_CALL, null, in);
+ }
+
+ /** {@hide} */
public static void runIdleMaintenance(@NonNull ContentResolver resolver) {
resolver.call(AUTHORITY, RUN_IDLE_MAINTENANCE_CALL, null, null);
}
diff --git a/legacy/src/com/android/providers/media/LegacyMediaProvider.java b/legacy/src/com/android/providers/media/LegacyMediaProvider.java
index b79a955..488e72a 100644
--- a/legacy/src/com/android/providers/media/LegacyMediaProvider.java
+++ b/legacy/src/com/android/providers/media/LegacyMediaProvider.java
@@ -143,13 +143,11 @@
switch (volumeName) {
case MediaStore.VOLUME_INTERNAL: {
mInternalDatabase.close();
- mInternalDatabase = null;
getContext().deleteDatabase(INTERNAL_DATABASE_NAME);
break;
}
default: {
mExternalDatabase.close();
- mExternalDatabase = null;
getContext().deleteDatabase(EXTERNAL_DATABASE_NAME);
break;
}
diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java
index 6b81990..20b6423 100644
--- a/src/com/android/providers/media/DatabaseHelper.java
+++ b/src/com/android/providers/media/DatabaseHelper.java
@@ -188,15 +188,11 @@
mFilterVolumeNames.addAll(filterVolumeNames);
}
- // Recreate all views to apply this filter
- final SQLiteDatabase db = getWritableDatabase();
- try {
- db.beginTransaction();
- createLatestViews(db, mInternal);
- db.setTransactionSuccessful();
- } finally {
- db.endTransaction();
- }
+ // We might be tempted to open a transaction and recreate views here,
+ // but that would result in an obscure deadlock; instead we simply close
+ // the entire database, letting the views be recreated the next time
+ // it's opened.
+ close();
}
@Override
@@ -217,6 +213,7 @@
@Override
public void onConfigure(SQLiteDatabase db) {
+ Log.v(TAG, "onConfigure() for " + mName);
db.setCustomScalarFunction("_INSERT", (arg) -> {
if (arg != null && mFilesListener != null && !mSchemaChanging) {
final String[] split = arg.split(":");
@@ -276,6 +273,14 @@
}
@Override
+ public void onOpen(SQLiteDatabase db) {
+ // Always recreate latest views and triggers during open; they're
+ // cheap and it's an easy way to ensure they're defined consistently
+ createLatestViews(db, mInternal);
+ createLatestTriggers(db, mInternal);
+ }
+
+ @Override
public void onCreate(final SQLiteDatabase db) {
Log.v(TAG, "onCreate() for " + mName);
mSchemaChanging = true;
@@ -1273,11 +1278,6 @@
}
}
- // Always recreate latest views and triggers during upgrade; they're
- // cheap and it's an easy way to ensure they're defined consistently
- createLatestViews(db, internal);
- createLatestTriggers(db, internal);
-
getOrCreateUuid(db);
final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime);
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index d8ad449..9259e94 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -21,12 +21,8 @@
import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
import static android.app.PendingIntent.FLAG_IMMUTABLE;
import static android.app.PendingIntent.FLAG_ONE_SHOT;
-import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY;
-import static android.content.ContentResolver.QUERY_ARG_SQL_HAVING;
-import static android.content.ContentResolver.QUERY_ARG_SQL_LIMIT;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
-import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER;
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
import static android.provider.MediaStore.MATCH_DEFAULT;
import static android.provider.MediaStore.MATCH_EXCLUDE;
@@ -187,6 +183,7 @@
import com.android.providers.media.util.RedactingFileDescriptor;
import com.android.providers.media.util.SQLiteQueryBuilder;
import com.android.providers.media.util.XmpInterface;
+import com.android.providers.playlist.Playlist;
import com.google.common.hash.Hashing;
@@ -196,6 +193,7 @@
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
@@ -208,7 +206,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Matcher;
@@ -291,7 +288,7 @@
@GuardedBy("sCacheLock")
private static final Map<String, Collection<File>> sCachedVolumeScanPaths = new ArrayMap<>();
- private void updateVolumes() {
+ public void updateVolumes() {
synchronized (sCacheLock) {
sCachedExternalVolumeNames.clear();
sCachedExternalVolumeNames.addAll(MediaStore.getExternalVolumeNames(getContext()));
@@ -440,50 +437,11 @@
FileColumns.DATA
};
- private static final String[] sPlaylistIdPlayOrder = new String[] {
- Playlists.Members.PLAYLIST_ID,
- Playlists.Members.PLAY_ORDER
- };
-
private static final String ID_NOT_PARENT_CLAUSE =
"_id NOT IN (SELECT parent FROM files WHERE parent IS NOT NULL)";
private static final String CANONICAL = "canonical";
- private BroadcastReceiver mMediaReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- final StorageVolume sv = intent.getParcelableExtra(StorageVolume.EXTRA_STORAGE_VOLUME);
- try {
- final String volumeName;
- if (sv.isPrimary()) {
- volumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY;
- } else {
- try {
- volumeName = MediaStore
- .checkArgumentVolumeName(sv.getMediaStoreVolumeName());
- } catch (IllegalArgumentException ignored) {
- return;
- }
- }
-
- switch (intent.getAction()) {
- case Intent.ACTION_MEDIA_MOUNTED:
- attachVolume(volumeName);
- break;
- case Intent.ACTION_MEDIA_UNMOUNTED:
- case Intent.ACTION_MEDIA_EJECT:
- case Intent.ACTION_MEDIA_REMOVED:
- case Intent.ACTION_MEDIA_BAD_REMOVAL:
- detachVolume(volumeName);
- break;
- }
- } catch (Exception e) {
- Log.w(TAG, "Failed to handle broadcast " + intent, e);
- }
- }
- };
-
private BroadcastReceiver mPackageReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
@@ -834,32 +792,13 @@
false, false, mLegacyProvider, Column.class,
Metrics::logSchemaChange, mFilesListener, mMigrationListener);
- final IntentFilter filter = new IntentFilter();
- filter.setPriority(10);
- filter.addDataScheme("file");
- filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
- filter.addAction(Intent.ACTION_MEDIA_MOUNTED);
- filter.addAction(Intent.ACTION_MEDIA_EJECT);
- filter.addAction(Intent.ACTION_MEDIA_REMOVED);
- filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL);
- context.registerReceiver(mMediaReceiver, filter);
-
final IntentFilter packageFilter = new IntentFilter();
packageFilter.setPriority(10);
- filter.addDataScheme("package");
+ packageFilter.addDataScheme("package");
packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
context.registerReceiver(mPackageReceiver, packageFilter);
- // Watch for invalidation of cached volumes
- mStorageManager.registerStorageVolumeCallback(context.getMainExecutor(),
- new StorageVolumeCallback() {
- @Override
- public void onStateChanged(@NonNull StorageVolume volume) {
- updateVolumes();
- }
- });
-
updateVolumes();
attachVolume(MediaStore.VOLUME_INTERNAL);
for (String volumeName : getExternalVolumeNames()) {
@@ -1289,14 +1228,8 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- final String appSpecificDir = extractPathOwnerPackageName(path);
- // Apps are allowed to list files only in their own external directory.
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return new String[] {"/"};
- } else {
- return new String[] {""};
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ return new String[] {""};
}
if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) {
@@ -1723,16 +1656,9 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- final String oldPathPackageName = extractPathOwnerPackageName(oldPath);
- final String newPathPackageName = extractPathOwnerPackageName(newPath);
-
- if (oldPathPackageName != null && newPathPackageName != null) {
- if (isCallingIdentitySharedPackageName(oldPathPackageName) &&
- isCallingIdentitySharedPackageName(newPathPackageName)) {
- return renameInLowerFs(oldPath, newPath);
- } else {
- return OsConstants.EACCES;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(oldPath)
+ || isPrivatePackagePathNotOwnedByCaller(newPath)) {
+ return OsConstants.EACCES;
}
if (shouldBypassFuseRestrictions(/*forWrite*/ true, oldPath)
@@ -1864,7 +1790,7 @@
private Cursor queryInternal(Uri uri, String[] projection, Bundle queryArgs,
CancellationSignal signal) throws FallbackException {
- queryArgs = (queryArgs != null) ? queryArgs : Bundle.EMPTY;
+ queryArgs = (queryArgs != null) ? queryArgs : new Bundle();
final ArraySet<String> honoredArgs = new ArraySet<>();
DatabaseUtils.resolveQueryArgs(queryArgs, honoredArgs::add, this::ensureCustomCollator);
@@ -1961,16 +1887,7 @@
}
}
- final String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION);
- final String[] selectionArgs = queryArgs.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
- final String groupBy = queryArgs.getString(QUERY_ARG_SQL_GROUP_BY);
- final String having = queryArgs.getString(QUERY_ARG_SQL_HAVING);
- final String sortOrder = queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER);
- final String limit = queryArgs.getString(QUERY_ARG_SQL_LIMIT);
-
- final Cursor c = qb.query(helper, projection, selection, selectionArgs, groupBy, having,
- sortOrder, limit, signal);
-
+ final Cursor c = qb.query(helper, projection, queryArgs, signal);
if (c != null) {
// As a performance optimization, only configure notifications when
// resulting cursor will leave our process
@@ -1983,7 +1900,6 @@
honoredArgs.toArray(new String[honoredArgs.size()]));
c.setExtras(extras);
}
-
return c;
}
@@ -1993,6 +1909,7 @@
switch (match) {
case IMAGES_MEDIA_ID:
case AUDIO_MEDIA_ID:
+ case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
case VIDEO_MEDIA_ID:
case DOWNLOADS_ID:
@@ -2029,11 +1946,7 @@
case AUDIO_MEDIA_ID_GENRES_ID:
return Audio.Genres.ENTRY_CONTENT_TYPE;
case AUDIO_PLAYLISTS:
- case AUDIO_MEDIA_ID_PLAYLISTS:
return Audio.Playlists.CONTENT_TYPE;
- case AUDIO_PLAYLISTS_ID:
- case AUDIO_MEDIA_ID_PLAYLISTS_ID:
- return Audio.Playlists.ENTRY_CONTENT_TYPE;
case VIDEO_MEDIA:
return Video.Media.CONTENT_TYPE;
@@ -2347,7 +2260,6 @@
case AUDIO_ALBUMART:
case VIDEO_THUMBNAILS:
case IMAGES_THUMBNAILS:
- case AUDIO_PLAYLISTS:
values.remove(MediaColumns.DISPLAY_NAME);
values.remove(MediaColumns.MIME_TYPE);
break;
@@ -2813,7 +2725,7 @@
private @Nullable Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
@Nullable Bundle extras) throws FallbackException {
- extras = (extras != null) ? extras : Bundle.EMPTY;
+ extras = (extras != null) ? extras : new Bundle();
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
@@ -2844,6 +2756,31 @@
return attachedVolume;
}
+ switch (match) {
+ case AUDIO_PLAYLISTS_ID:
+ case AUDIO_PLAYLISTS_ID_MEMBERS: {
+ final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
+ final Uri playlistUri = ContentUris.withAppendedId(
+ MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId);
+
+ final long audioId = initialValues
+ .getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID);
+ final Uri audioUri = ContentUris.withAppendedId(
+ MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId);
+
+ // Require that caller has write access to underlying media
+ enforceCallingPermission(playlistUri, Bundle.EMPTY, true);
+ enforceCallingPermission(audioUri, Bundle.EMPTY, false);
+
+ // Playlist contents are always persisted directly into playlist
+ // files on disk to ensure that we can reliably migrate between
+ // devices and recover from database corruption
+ final long id = addPlaylistMembers(playlistUri, initialValues);
+ return ContentUris.withAppendedId(MediaStore.Audio.Playlists.Members
+ .getContentUri(originalVolumeName, playlistId), id);
+ }
+ }
+
String path = null;
String ownerPackageName = null;
if (initialValues != null) {
@@ -2972,28 +2909,6 @@
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
}
- case AUDIO_MEDIA_ID_PLAYLISTS: {
- // Require that caller has write access to underlying media
- final long audioId = Long.parseLong(uri.getPathSegments().get(2));
- enforceCallingPermission(ContentUris.withAppendedId(
- MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId),
- Bundle.EMPTY, false);
- final long playlistId = initialValues
- .getAsLong(MediaStore.Audio.Playlists.Members.PLAYLIST_ID);
- enforceCallingPermission(ContentUris.withAppendedId(
- MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId),
- Bundle.EMPTY, true);
-
- ContentValues values = new ContentValues(initialValues);
- values.put(Audio.Playlists.Members.AUDIO_ID, audioId);
- rowId = qb.insert(helper, values);
- if (rowId > 0) {
- newUri = ContentUris.withAppendedId(uri, rowId);
- updatePlaylistDateModifiedToNow(helper, playlistId);
- }
- break;
- }
-
case AUDIO_GENRES: {
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
}
@@ -3007,34 +2922,28 @@
final boolean isDownload = maybeMarkAsDownload(initialValues);
ContentValues values = new ContentValues(initialValues);
values.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis() / 1000);
+ // Playlist names are stored as display names, but leave
+ // values untouched if the caller is ModernMediaScanner
+ if (Binder.getCallingUid() != android.os.Process.myUid()) {
+ if (values.containsKey(Playlists.NAME)) {
+ values.put(MediaColumns.DISPLAY_NAME, values.getAsString(Playlists.NAME));
+ }
+ if (!values.containsKey(MediaColumns.MIME_TYPE)) {
+ values.put(MediaColumns.MIME_TYPE, "audio/mpegurl");
+ }
+ }
rowId = insertFile(qb, helper, match, uri, extras, values,
FileColumns.MEDIA_TYPE_PLAYLIST, true);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(
Audio.Playlists.getContentUri(originalVolumeName), rowId);
- }
- break;
- }
-
- case AUDIO_PLAYLISTS_ID:
- case AUDIO_PLAYLISTS_ID_MEMBERS: {
- // Require that caller has write access to underlying media
- final long audioId = initialValues
- .getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID);
- enforceCallingPermission(ContentUris.withAppendedId(
- MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId),
- Bundle.EMPTY, false);
- final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
- enforceCallingPermission(ContentUris.withAppendedId(
- MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId),
- Bundle.EMPTY, true);
-
- ContentValues values = new ContentValues(initialValues);
- values.put(Audio.Playlists.Members.PLAYLIST_ID, playlistId);
- rowId = qb.insert(helper, values);
- if (rowId > 0) {
- newUri = ContentUris.withAppendedId(uri, rowId);
- updatePlaylistDateModifiedToNow(helper, playlistId);
+ // Touch empty playlist file on disk so its ready for renames
+ if (Binder.getCallingUid() != android.os.Process.myUid()) {
+ try (OutputStream out = ContentResolver.wrap(this)
+ .openOutputStream(newUri)) {
+ } catch (IOException ignored) {
+ }
+ }
}
break;
}
@@ -3386,21 +3295,6 @@
}
break;
}
- case AUDIO_MEDIA_ID_PLAYLISTS_ID:
- appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(5));
- // fall-through
- case AUDIO_MEDIA_ID_PLAYLISTS: {
- qb.setTables("audio_playlists");
- qb.setProjectionMap(getProjectionMap(Audio.Playlists.class));
- appendWhereStandalone(qb, "_id IN (SELECT playlist_id FROM " +
- "audio_playlists_map WHERE audio_id=?)", uri.getPathSegments().get(3));
- if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
- // We don't have a great way to filter parsed metadata by
- // owner, so callers need to hold READ_MEDIA_AUDIO
- appendWhereStandalone(qb, "0");
- }
- break;
- }
case AUDIO_GENRES_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
@@ -3808,14 +3702,14 @@
private int deleteInternal(@NonNull Uri uri, @Nullable Bundle extras)
throws FallbackException {
- extras = (extras != null) ? extras : Bundle.EMPTY;
+ extras = (extras != null) ? extras : new Bundle();
final String userWhere = extras.getString(QUERY_ARG_SQL_SELECTION);
final String[] userWhereArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
uri = safeUncanonicalize(uri);
- int count;
+ int count = 0;
final String volumeName = getVolumeName(uri);
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
@@ -3842,6 +3736,23 @@
count = 1;
}
+ switch (match) {
+ case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
+ extras.putString(QUERY_ARG_SQL_SELECTION,
+ BaseColumns._ID + "=" + uri.getPathSegments().get(5));
+ // fall-through
+ case AUDIO_PLAYLISTS_ID_MEMBERS: {
+ final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
+ final Uri playlistUri = ContentUris.withAppendedId(
+ MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId);
+
+ // Playlist contents are always persisted directly into playlist
+ // files on disk to ensure that we can reliably migrate between
+ // devices and recover from database corruption
+ return removePlaylistMembers(playlistUri, extras);
+ }
+ }
+
final DatabaseHelper helper = getDatabaseForUri(uri);
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, match, uri, extras, null);
@@ -3869,8 +3780,6 @@
if (deleteparam == null || ! deleteparam.equals("false")) {
Cursor c = qb.query(helper, projection, userWhere, userWhereArgs,
null, null, null, null, null);
- String [] idvalue = new String[] { "" };
- String [] playlistvalues = new String[] { "", "" };
try {
while (c.moveToNext()) {
final int mediaType = c.getInt(0);
@@ -3883,40 +3792,27 @@
mCallingIdentity.get().setOwned(id, false);
deleteIfAllowed(uri, extras, data);
+ count += qb.delete(helper, BaseColumns._ID + "=" + id, null);
// Only need to inform DownloadProvider about the downloads deleted on
// external volume.
if (isDownload == 1) {
deletedDownloadIds.put(id, mimeType);
}
- if (mediaType == FileColumns.MEDIA_TYPE_AUDIO) {
- if (!helper.mInternal) {
- idvalue[0] = String.valueOf(id);
- // for each playlist that the item appears in, move
- // all the items behind it forward by one
- final SQLiteDatabase db = helper.getWritableDatabase();
- Cursor cc = db.query("audio_playlists_map",
- sPlaylistIdPlayOrder,
- "audio_id=?", idvalue, null, null, null);
- try {
- while (cc.moveToNext()) {
- long playlistId = cc.getLong(0);
- playlistvalues[0] = String.valueOf(playlistId);
- playlistvalues[1] = String.valueOf(cc.getInt(1));
- db.execSQL("UPDATE audio_playlists_map" +
- " SET play_order=play_order-1" +
- " WHERE playlist_id=? AND play_order>?",
- playlistvalues);
- updatePlaylistDateModifiedToNow(helper, playlistId);
- }
- db.delete("audio_playlists_map", "audio_id=?", idvalue);
- } finally {
- FileUtils.closeQuietly(cc);
+
+ // Update any playlists that reference this item
+ if ((mediaType == FileColumns.MEDIA_TYPE_AUDIO)
+ && helper.isExternal()) {
+ final SQLiteDatabase db = helper.getReadableDatabase();
+ try (Cursor cc = db.query("audio_playlists_map",
+ new String[] { "playlist_id" }, "audio_id=" + id,
+ null, "playlist_id", null, null)) {
+ while (cc.moveToNext()) {
+ final Uri playlistUri = ContentUris.withAppendedId(
+ Playlists.getContentUri(volumeName), cc.getLong(0));
+ resolvePlaylistMembers(playlistUri);
}
}
- } else if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
- // TODO, maybe: remove the audio_playlists_cleanup trigger and
- // implement functionality here (clean up the playlist map)
}
}
} finally {
@@ -3948,18 +3844,11 @@
FileUtils.closeQuietly(c);
}
}
- count = deleteRecursive(qb, helper, userWhere, userWhereArgs);
+ count += deleteRecursive(qb, helper, userWhere, userWhereArgs);
break;
- case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
- long playlistId = Long.parseLong(uri.getPathSegments().get(3));
- count = deleteRecursive(qb, helper, userWhere, userWhereArgs);
- if (count > 0) {
- updatePlaylistDateModifiedToNow(helper, playlistId);
- }
- break;
default:
- count = deleteRecursive(qb, helper, userWhere, userWhereArgs);
+ count += deleteRecursive(qb, helper, userWhere, userWhereArgs);
break;
}
@@ -4017,6 +3906,18 @@
private Bundle callInternal(String method, String arg, Bundle extras) {
switch (method) {
+ case MediaStore.RESOLVE_PLAYLIST_MEMBERS_CALL: {
+ final LocalCallingIdentity token = clearLocalCallingIdentity();
+ final CallingIdentity providerToken = clearCallingIdentity();
+ try {
+ final Uri playlistUri = extras.getParcelable(MediaStore.EXTRA_URI);
+ resolvePlaylistMembers(playlistUri);
+ } finally {
+ restoreCallingIdentity(providerToken);
+ restoreLocalCallingIdentity(token);
+ }
+ return null;
+ }
case MediaStore.RUN_IDLE_MAINTENANCE_CALL: {
// Protect ourselves from random apps by requiring a generic
// permission held by common debugging components, such as shell
@@ -4501,7 +4402,7 @@
private int updateInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
@Nullable Bundle extras) throws FallbackException {
- extras = (extras != null) ? extras : Bundle.EMPTY;
+ extras = (extras != null) ? extras : new Bundle();
// Related items are only considered for new media creation, and they
// can't be leveraged to move existing content into blocked locations
@@ -4532,6 +4433,51 @@
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
+ switch (match) {
+ case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
+ extras.putString(QUERY_ARG_SQL_SELECTION,
+ BaseColumns._ID + "=" + uri.getPathSegments().get(5));
+ // fall-through
+ case AUDIO_PLAYLISTS_ID_MEMBERS: {
+ final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
+ final Uri playlistUri = ContentUris.withAppendedId(
+ MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId);
+
+ if (parseBoolean(uri.getQueryParameter("move"))) {
+ // Convert explicit request into query; sigh, moveItem()
+ // uses zero-based indexing instead of one-based indexing
+ final int from = Integer.parseInt(uri.getPathSegments().get(5)) + 1;
+ final int to = initialValues.getAsInteger(Playlists.Members.PLAY_ORDER) + 1;
+ extras.putString(QUERY_ARG_SQL_SELECTION,
+ Playlists.Members.PLAY_ORDER + "=" + from);
+ initialValues.put(Playlists.Members.PLAY_ORDER, to);
+ }
+
+ // Playlist contents are always persisted directly into playlist
+ // files on disk to ensure that we can reliably migrate between
+ // devices and recover from database corruption
+ final int index;
+ if (initialValues.containsKey(Playlists.Members.PLAY_ORDER)) {
+ index = movePlaylistMembers(playlistUri, initialValues, extras);
+ } else {
+ index = resolvePlaylistIndex(playlistUri, extras);
+ }
+ if (initialValues.containsKey(Playlists.Members.AUDIO_ID)) {
+ final Bundle queryArgs = new Bundle();
+ queryArgs.putString(QUERY_ARG_SQL_SELECTION,
+ Playlists.Members.PLAY_ORDER + "=" + (index + 1));
+ removePlaylistMembers(playlistUri, queryArgs);
+
+ final ContentValues values = new ContentValues();
+ values.put(Playlists.Members.AUDIO_ID,
+ initialValues.getAsString(Playlists.Members.AUDIO_ID));
+ values.put(Playlists.Members.PLAY_ORDER, (index + 1));
+ addPlaylistMembers(playlistUri, values);
+ }
+ return 1;
+ }
+ }
+
final DatabaseHelper helper = getDatabaseForUri(uri);
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, match, uri, extras, null);
@@ -4590,17 +4536,7 @@
Log.w(TAG, "Ignoring mutation of " + column + " from "
+ getCallingPackageOrSelf());
initialValues.remove(column);
-
- switch (match) {
- default:
- triggerScan = true;
- break;
- // If entry is a playlist, do not re-scan to match previous behavior
- // and allow persistence of database-only edits until real re-scan
- case AUDIO_MEDIA_ID_PLAYLISTS_ID:
- case AUDIO_PLAYLISTS_ID:
- break;
- }
+ triggerScan = true;
}
// If we're publishing this item, perform a blocking scan to
@@ -4644,6 +4580,23 @@
break;
}
+ switch (match) {
+ case AUDIO_PLAYLISTS:
+ case AUDIO_PLAYLISTS_ID:
+ // Playlist names are stored as display names, but leave
+ // values untouched if the caller is ModernMediaScanner
+ if (Binder.getCallingUid() != android.os.Process.myUid()) {
+ if (initialValues.containsKey(Playlists.NAME)) {
+ initialValues.put(MediaColumns.DISPLAY_NAME,
+ initialValues.getAsString(Playlists.NAME));
+ }
+ if (!initialValues.containsKey(MediaColumns.MIME_TYPE)) {
+ initialValues.put(MediaColumns.MIME_TYPE, "audio/mpegurl");
+ }
+ }
+ break;
+ }
+
// If we're touching columns that would change placement of a file,
// blend in current values and recalculate path
if (containsAny(initialValues.keySet(), sPlacementColumns)
@@ -4655,6 +4608,7 @@
// We only support movement under well-defined collections
switch (match) {
case AUDIO_MEDIA_ID:
+ case AUDIO_PLAYLISTS_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case DOWNLOADS_ID:
@@ -4776,47 +4730,7 @@
}
}
- switch (match) {
- case AUDIO_MEDIA_ID_PLAYLISTS_ID:
- case AUDIO_PLAYLISTS_ID:
- long playlistId = ContentUris.parseId(uri);
- count = qb.update(helper, values, userWhere, userWhereArgs);
- if (count > 0) {
- updatePlaylistDateModifiedToNow(helper, playlistId);
- }
- break;
- case AUDIO_PLAYLISTS_ID_MEMBERS:
- long playlistIdMembers = Long.parseLong(uri.getPathSegments().get(3));
- count = qb.update(helper, values, userWhere, userWhereArgs);
- if (count > 0) {
- updatePlaylistDateModifiedToNow(helper, playlistIdMembers);
- }
- break;
- case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
- String moveit = uri.getQueryParameter("move");
- if (moveit != null) {
- String key = MediaStore.Audio.Playlists.Members.PLAY_ORDER;
- if (values.containsKey(key)) {
- int newpos = values.getAsInteger(key);
- List <String> segments = uri.getPathSegments();
- long playlist = Long.parseLong(segments.get(3));
- int oldpos = Integer.parseInt(segments.get(5));
- int rowsChanged = movePlaylistEntry(volumeName, helper,
- playlist, oldpos, newpos);
- if (rowsChanged > 0) {
- updatePlaylistDateModifiedToNow(helper, playlist);
- }
-
- return rowsChanged;
- }
- throw new IllegalArgumentException("Need to specify " + key +
- " when using 'move' parameter");
- }
- // fall through
- default:
- count = qb.update(helper, values, userWhere, userWhereArgs);
- break;
- }
+ count = qb.update(helper, values, userWhere, userWhereArgs);
// If the caller tried (and failed) to update metadata, the file on disk
// might have changed, to scan it to collect the latest metadata.
@@ -4849,77 +4763,187 @@
return count;
}
- private int movePlaylistEntry(String volumeName, DatabaseHelper helper,
- long playlist, int from, int to) {
- if (from == to) {
- return 0;
- }
- final SQLiteDatabase db = helper.getWritableDatabase();
- db.beginTransaction();
- int numlines = 0;
- Cursor c = null;
+ /**
+ * Update the internal table of {@link MediaStore.Audio.Playlists.Members}
+ * by parsing the playlist file on disk and resolving it against scanned
+ * audio items.
+ * <p>
+ * When a playlist references a missing audio item, the associated
+ * {@link Playlists.Members#PLAY_ORDER} is skipped, leaving a gap to ensure
+ * that the playlist entry is retained to avoid user data loss.
+ */
+ private void resolvePlaylistMembers(@NonNull Uri playlistUri) {
+ Trace.beginSection("resolvePlaylistMembers");
try {
- c = db.query("audio_playlists_map",
- new String [] {"play_order" },
- "playlist_id=?", new String[] {"" + playlist}, null, null, "play_order",
- from + ",1");
- c.moveToFirst();
- int from_play_order = c.getInt(0);
- FileUtils.closeQuietly(c);
- c = db.query("audio_playlists_map",
- new String [] {"play_order" },
- "playlist_id=?", new String[] {"" + playlist}, null, null, "play_order",
- to + ",1");
- c.moveToFirst();
- int to_play_order = c.getInt(0);
- db.execSQL("UPDATE audio_playlists_map SET play_order=-1" +
- " WHERE play_order=" + from_play_order +
- " AND playlist_id=" + playlist);
- // We could just run both of the next two statements, but only one of
- // of them will actually do anything, so might as well skip the compile
- // and execute steps.
- if (from < to) {
- db.execSQL("UPDATE audio_playlists_map SET play_order=play_order-1" +
- " WHERE play_order<=" + to_play_order +
- " AND play_order>" + from_play_order +
- " AND playlist_id=" + playlist);
- numlines = to - from + 1;
- } else {
- db.execSQL("UPDATE audio_playlists_map SET play_order=play_order+1" +
- " WHERE play_order>=" + to_play_order +
- " AND play_order<" + from_play_order +
- " AND playlist_id=" + playlist);
- numlines = from - to + 1;
- }
- db.execSQL("UPDATE audio_playlists_map SET play_order=" + to_play_order +
- " WHERE play_order=-1 AND playlist_id=" + playlist);
- db.setTransactionSuccessful();
+ resolvePlaylistMembersInternal(playlistUri);
} finally {
- db.endTransaction();
- FileUtils.closeQuietly(c);
+ Trace.endSection();
}
-
- Uri uri = ContentUris.withAppendedId(
- MediaStore.Audio.Playlists.getContentUri(volumeName), playlist);
- // notifyChange() must be called after the database transaction is ended
- // or the listeners will read the old data in the callback
- getContext().getContentResolver().notifyChange(uri, null);
-
- return numlines;
}
- private void updatePlaylistDateModifiedToNow(DatabaseHelper helper, long playlistId) {
- ContentValues values = new ContentValues();
- values.put(
- FileColumns.DATE_MODIFIED,
- TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
- );
+ private void resolvePlaylistMembersInternal(@NonNull Uri playlistUri) {
+ final SQLiteDatabase db;
+ try {
+ db = getDatabaseForUri(playlistUri).getWritableDatabase();
+ } catch (VolumeNotFoundException e) {
+ throw e.rethrowAsIllegalArgumentException();
+ }
- final Uri uri = ContentUris.withAppendedId(
- MediaStore.Audio.Playlists.getContentUri(helper.mVolumeName), playlistId);
- final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, AUDIO_PLAYLISTS_ID,
- uri, Bundle.EMPTY, null);
- qb.update(helper, values, null, null);
+ db.beginTransaction();
+ try {
+ // Refresh playlist members based on what we parse from disk
+ final long playlistId = ContentUris.parseId(playlistUri);
+ db.delete("audio_playlists_map", "playlist_id=" + playlistId, null);
+
+ final Path playlistPath = queryForDataFile(playlistUri, null).toPath();
+ final Playlist playlist = new Playlist();
+ playlist.read(playlistPath.toFile());
+
+ final List<Path> members = playlist.asList();
+ for (int i = 0; i < members.size(); i++) {
+ final Path audioPath = playlistPath.getParent().resolve(members.get(i));
+ final Uri audioUri = Audio.Media.getContentUri(getVolumeName(playlistUri));
+ try (Cursor c = query(audioUri, null, MediaColumns.DATA + "=?",
+ new String[] { audioPath.toFile().getCanonicalPath() }, null)) {
+ if (c.moveToFirst()) {
+ final ContentValues values = new ContentValues();
+ values.put(Playlists.Members.PLAY_ORDER, i + 1);
+ values.put(Playlists.Members.PLAYLIST_ID, playlistId);
+ values.put(Playlists.Members.AUDIO_ID,
+ c.getLong(c.getColumnIndex(MediaColumns._ID)));
+ db.insert("audio_playlists_map", null, values);
+ }
+ }
+ }
+ db.setTransactionSuccessful();
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to refresh playlist", e);
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Add the given audio item to the given playlist. Defaults to adding at the
+ * end of the playlist when no {@link Playlists.Members#PLAY_ORDER} is
+ * defined.
+ */
+ private long addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values)
+ throws FallbackException {
+ final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID);
+ final Uri audioUri = Audio.Media.getContentUri(getVolumeName(playlistUri), audioId);
+
+ Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER);
+ playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE;
+
+ try {
+ final File playlistFile = queryForDataFile(playlistUri, null);
+ final File audioFile = queryForDataFile(audioUri, null);
+
+ final Playlist playlist = new Playlist();
+ playlist.read(playlistFile);
+ playOrder = playlist.add(playOrder,
+ playlistFile.toPath().getParent().relativize(audioFile.toPath()));
+ playlist.write(playlistFile);
+
+ resolvePlaylistMembers(playlistUri);
+
+ // Callers are interested in the actual ID we generated
+ final Uri membersUri = Playlists.Members.getContentUri(
+ getVolumeName(playlistUri), ContentUris.parseId(playlistUri));
+ try (Cursor c = query(membersUri, new String[] { BaseColumns._ID },
+ Playlists.Members.PLAY_ORDER + "=" + (playOrder + 1), null, null)) {
+ c.moveToFirst();
+ return c.getLong(0);
+ }
+ } catch (IOException e) {
+ throw new FallbackException("Failed to update playlist", e,
+ android.os.Build.VERSION_CODES.R);
+ }
+ }
+
+ /**
+ * Move an audio item within the given playlist.
+ */
+ private int movePlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values,
+ @NonNull Bundle queryArgs) throws FallbackException {
+ final int fromIndex = resolvePlaylistIndex(playlistUri, queryArgs);
+ final int toIndex = values.getAsInteger(Playlists.Members.PLAY_ORDER) - 1;
+ if (fromIndex == -1) {
+ throw new FallbackException("Failed to resolve playlist member " + queryArgs,
+ android.os.Build.VERSION_CODES.R);
+ }
+ try {
+ final File playlistFile = queryForDataFile(playlistUri, null);
+
+ final Playlist playlist = new Playlist();
+ playlist.read(playlistFile);
+ final int finalIndex = playlist.move(fromIndex, toIndex);
+ playlist.write(playlistFile);
+
+ resolvePlaylistMembers(playlistUri);
+ return finalIndex;
+ } catch (IOException e) {
+ throw new FallbackException("Failed to update playlist", e,
+ android.os.Build.VERSION_CODES.R);
+ }
+ }
+
+ /**
+ * Remove an audio item from the given playlist.
+ */
+ private int removePlaylistMembers(@NonNull Uri playlistUri, @NonNull Bundle queryArgs)
+ throws FallbackException {
+ final int index = resolvePlaylistIndex(playlistUri, queryArgs);
+ try {
+ final File playlistFile = queryForDataFile(playlistUri, null);
+
+ final Playlist playlist = new Playlist();
+ playlist.read(playlistFile);
+ final int count;
+ if (index == -1) {
+ count = playlist.asList().size();
+ playlist.clear();
+ } else {
+ count = 1;
+ playlist.remove(index);
+ }
+ playlist.write(playlistFile);
+
+ resolvePlaylistMembers(playlistUri);
+ return count;
+ } catch (IOException e) {
+ throw new FallbackException("Failed to update playlist", e,
+ android.os.Build.VERSION_CODES.R);
+ }
+ }
+
+ /**
+ * Resolve query arguments that are designed to select a specific playlist
+ * item using its {@link Playlists.Members#PLAY_ORDER}.
+ */
+ private int resolvePlaylistIndex(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) {
+ final Uri membersUri = Playlists.Members.getContentUri(
+ getVolumeName(playlistUri), ContentUris.parseId(playlistUri));
+
+ final DatabaseHelper helper;
+ final SQLiteQueryBuilder qb;
+ try {
+ helper = getDatabaseForUri(membersUri);
+ qb = getQueryBuilder(TYPE_DELETE, AUDIO_PLAYLISTS_ID_MEMBERS,
+ membersUri, queryArgs, null);
+ } catch (VolumeNotFoundException ignored) {
+ return -1;
+ }
+
+ try (Cursor c = qb.query(helper,
+ new String[] { Playlists.Members.PLAY_ORDER }, queryArgs, null)) {
+ if ((c.getCount() == 1) && c.moveToFirst()) {
+ return c.getInt(0) - 1;
+ } else {
+ return -1;
+ }
+ }
}
@Override
@@ -5403,6 +5427,7 @@
* <li>the calling identity is an app targeting Q or older versions AND is requesting legacy
* storage
* <li>the calling identity holds {@code MANAGE_EXTERNAL_STORAGE}
+ * <li>the calling identity owns filePath (eg /Android/data/com.foo)
* <li>the calling identity has permission to write images and the given file is an image file
* <li>the calling identity has permission to write video and the given file is an video file
* </ul>
@@ -5418,6 +5443,12 @@
return true;
}
+ // Files under the apps own private directory
+ final String appSpecificDir = extractPathOwnerPackageName(filePath);
+ if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) {
+ return true;
+ }
+
// Apps with write access to images and/or videos can bypass our restrictions if all of the
// the files they're accessing are of the compatible media type.
if (canAccessMediaFile(filePath)) {
@@ -5428,6 +5459,29 @@
}
/**
+ * Returns true if the passed in path is an application-private data directory
+ * (such as Android/data/com.foo or Android/obb/com.foo) that does not belong to the caller.
+ */
+ private boolean isPrivatePackagePathNotOwnedByCaller(String path) {
+ // Files under the apps own private directory
+ final String appSpecificDir = extractPathOwnerPackageName(path);
+
+ if (appSpecificDir == null) {
+ return false;
+ }
+
+ final String relativePath = extractRelativePath(path);
+ // Android/media is not considered private, because it contains media that is explicitly
+ // scanned and shared by other apps
+ if (relativePath.startsWith("Android/media")) {
+ return false;
+ }
+
+ // This is a private-package path; return true if not owned by the caller
+ return !isCallingIdentitySharedPackageName(appSpecificDir);
+ }
+
+ /**
* Set of Exif tags that should be considered for redaction.
*/
private static final String[] REDACTED_EXIF_TAGS = new String[] {
@@ -5515,15 +5569,6 @@
long[] res = new long[0];
try {
- // Returns null if the path doesn't correspond to an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return res;
- }
- }
-
if (!isRedactionNeeded()
|| shouldBypassFuseRestrictions(/*forWrite*/ false, path)) {
return res;
@@ -5644,16 +5689,9 @@
try {
- // Returns null if the path doesn't correspond to an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return 0;
- } else {
- Log.e(TAG, "Can't open a file in another app's external directory!");
- return OsConstants.ENOENT;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ Log.e(TAG, "Can't open a file in another app's external directory!");
+ return OsConstants.ENOENT;
}
if (shouldBypassFuseRestrictions(forWrite, path)) {
@@ -5830,17 +5868,9 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- // Returns null if the path doesn't correspond to an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
- // App dirs are not indexed, so we don't create an entry for the file.
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return 0;
- } else {
- Log.e(TAG, "Can't create a file in another app's external directory");
- return OsConstants.ENOENT;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ Log.e(TAG, "Can't create a file in another app's external directory");
+ return OsConstants.ENOENT;
}
if (shouldBypassFuseRestrictions(/*forWrite*/ true, path)) {
@@ -5913,17 +5943,9 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- // Check if app is deleting a file under an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
- // Trying to delete file under some app's external storage dir
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return deleteFileUnchecked(path);
- } else {
- Log.e(TAG, "Can't delete a file in another app's external directory!");
- return OsConstants.ENOENT;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ Log.e(TAG, "Can't delete a file in another app's external directory!");
+ return OsConstants.ENOENT;
}
if (shouldBypassFuseRestrictions(/*forWrite*/ true, path)) {
@@ -5989,17 +6011,10 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- // Returns null if the path doesn't correspond to an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
// App dirs are not indexed, so we don't create an entry for the file.
- if (appSpecificDir != null) {
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return 0;
- } else {
- Log.e(TAG, "Can't modify another app's external directory!");
- return OsConstants.EACCES;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ Log.e(TAG, "Can't modify another app's external directory!");
+ return OsConstants.EACCES;
}
if (shouldBypassFuseRestrictions(/*forWrite*/ true, path)) {
@@ -6049,20 +6064,9 @@
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
- // Returns null if the path doesn't correspond to an app specific directory
- final String appSpecificDir = extractPathOwnerPackageName(path);
-
- if (appSpecificDir != null) {
- if (DIRECTORY_MEDIA.equals(sanitizePath(extractRelativePath(path))[1])) {
- // Allow opening external media directories of other packages.
- return 0;
- }
- if (isCallingIdentitySharedPackageName(appSpecificDir)) {
- return 0;
- } else {
- Log.e(TAG, "Can't access another app's external directory!");
- return OsConstants.ENOENT;
- }
+ if (isPrivatePackagePathNotOwnedByCaller(path)) {
+ Log.e(TAG, "Can't access another app's external directory!");
+ return OsConstants.ENOENT;
}
if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) {
@@ -6332,6 +6336,20 @@
mThrowSdkVersion = throwSdkVersion;
}
+ public FallbackException(String message, Throwable cause, int throwSdkVersion) {
+ super(message, cause);
+ mThrowSdkVersion = throwSdkVersion;
+ }
+
+ @Override
+ public String getMessage() {
+ if (getCause() != null) {
+ return super.getMessage() + ": " + getCause().getMessage();
+ } else {
+ return super.getMessage();
+ }
+ }
+
public IllegalArgumentException rethrowAsIllegalArgumentException() {
throw new IllegalArgumentException(getMessage());
}
@@ -6381,10 +6399,6 @@
final String volumeName = resolveVolumeName(uri);
synchronized (mAttachedVolumeNames) {
if (!mAttachedVolumeNames.contains(volumeName)) {
- // Maybe we are racing onVolumeStateChanged, update our cache and try again
- updateVolumes();
- }
- if (!mAttachedVolumeNames.contains(volumeName)) {
throw new VolumeNotFoundException(volumeName);
}
}
@@ -6531,8 +6545,6 @@
static final int AUDIO_MEDIA_ID = 101;
static final int AUDIO_MEDIA_ID_GENRES = 102;
static final int AUDIO_MEDIA_ID_GENRES_ID = 103;
- static final int AUDIO_MEDIA_ID_PLAYLISTS = 104;
- static final int AUDIO_MEDIA_ID_PLAYLISTS_ID = 105;
static final int AUDIO_GENRES = 106;
static final int AUDIO_GENRES_ID = 107;
static final int AUDIO_GENRES_ID_MEMBERS = 108;
@@ -6618,8 +6630,6 @@
mPublic.addURI(auth, "*/audio/media/#", AUDIO_MEDIA_ID);
mPublic.addURI(auth, "*/audio/media/#/genres", AUDIO_MEDIA_ID_GENRES);
mPublic.addURI(auth, "*/audio/media/#/genres/#", AUDIO_MEDIA_ID_GENRES_ID);
- mHidden.addURI(auth, "*/audio/media/#/playlists", AUDIO_MEDIA_ID_PLAYLISTS);
- mHidden.addURI(auth, "*/audio/media/#/playlists/#", AUDIO_MEDIA_ID_PLAYLISTS_ID);
mPublic.addURI(auth, "*/audio/genres", AUDIO_GENRES);
mPublic.addURI(auth, "*/audio/genres/#", AUDIO_GENRES_ID);
mPublic.addURI(auth, "*/audio/genres/#/members", AUDIO_GENRES_ID_MEMBERS);
diff --git a/src/com/android/providers/media/MediaService.java b/src/com/android/providers/media/MediaService.java
index 4c31c9f..385c9ab 100644
--- a/src/com/android/providers/media/MediaService.java
+++ b/src/com/android/providers/media/MediaService.java
@@ -63,10 +63,6 @@
onPackageOrphaned(packageName);
break;
}
- case Intent.ACTION_MEDIA_MOUNTED: {
- onScanVolume(this, intent.getData(), REASON_MOUNTED);
- break;
- }
case Intent.ACTION_MEDIA_SCANNER_SCAN_FILE: {
onScanFile(this, intent.getData());
break;
diff --git a/src/com/android/providers/media/PermissionActivity.java b/src/com/android/providers/media/PermissionActivity.java
index 371522a..85d679c 100644
--- a/src/com/android/providers/media/PermissionActivity.java
+++ b/src/com/android/providers/media/PermissionActivity.java
@@ -36,6 +36,8 @@
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
+import android.graphics.ImageDecoder.ImageInfo;
+import android.graphics.ImageDecoder.Source;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -43,6 +45,7 @@
import android.provider.MediaStore.MediaColumns;
import android.text.TextUtils;
import android.text.format.DateUtils;
+import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Size;
import android.view.KeyEvent;
@@ -534,7 +537,15 @@
thumbnail = resolver.loadThumbnail(uri, size, null);
}
if ((loadFlags & LOAD_FULL) != 0) {
- full = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri));
+ // Only offer full decodes when a supported file type;
+ // otherwise fall back to using thumbnail
+ final String mimeType = resolver.getType(uri);
+ if (ImageDecoder.isMimeTypeSupported(mimeType)) {
+ full = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri),
+ new Resizer(context.getResources().getDisplayMetrics()));
+ } else {
+ full = thumbnail;
+ }
}
} catch (IOException e) {
Log.w(TAG, e);
@@ -560,4 +571,28 @@
imageView.setVisibility(View.VISIBLE);
}
}
+
+ /**
+ * Utility that will speed up decoding of large images, since we never need
+ * them to be larger than the screen dimensions.
+ */
+ private static class Resizer implements ImageDecoder.OnHeaderDecodedListener {
+ private final int maxSize;
+
+ public Resizer(DisplayMetrics metrics) {
+ this.maxSize = Math.max(metrics.widthPixels, metrics.heightPixels);
+ }
+
+ @Override
+ public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) {
+ // We requested a rough thumbnail size, but the remote size may have
+ // returned something giant, so defensively scale down as needed.
+ final int widthSample = info.getSize().getWidth() / maxSize;
+ final int heightSample = info.getSize().getHeight() / maxSize;
+ final int sample = Math.max(widthSample, heightSample);
+ if (sample > 1) {
+ decoder.setTargetSampleSize(sample);
+ }
+ }
+ }
}
diff --git a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java
index 2ef8c91..8cdcba7 100644
--- a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java
+++ b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java
@@ -32,6 +32,7 @@
import com.android.providers.media.MediaProvider;
import com.android.providers.media.MediaService;
+import com.android.providers.media.util.BackgroundThread;
import java.io.File;
import java.io.IOException;
@@ -77,7 +78,13 @@
switch(vol.getState()) {
case Environment.MEDIA_MOUNTED:
mediaProvider.attachVolume(volumeName);
- MediaService.onScanVolume(this, volumeName, REASON_MOUNTED);
+ BackgroundThread.getExecutor().execute(() -> {
+ try {
+ MediaService.onScanVolume(this, volumeName, REASON_MOUNTED);
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to scan volume " + volumeName, e);
+ }
+ });
break;
case Environment.MEDIA_UNMOUNTED:
case Environment.MEDIA_EJECTING:
@@ -89,6 +96,8 @@
Log.i(TAG, "Ignoring volume state for vol:" + volumeName
+ ". State: " + vol.getState());
}
+ // Check for invalidation of cached volumes
+ mediaProvider.updateVolumes();
}
@Override
diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java
index 955c027..4540d56 100644
--- a/src/com/android/providers/media/scan/ModernMediaScanner.java
+++ b/src/com/android/providers/media/scan/ModernMediaScanner.java
@@ -224,6 +224,7 @@
} catch (OperationCanceledException ignored) {
}
}
+
@Override
public Uri scanFile(File file, int reason) {
return scanFile(file, reason, /*ownerPackage*/ null);
@@ -291,9 +292,7 @@
private int mUpdateCount;
private int mDeleteCount;
-
public Scan(File root, int reason, @Nullable String ownerPackage) {
-
Trace.beginSection("ctor");
mClient = mContext.getContentResolver()
@@ -428,14 +427,9 @@
private void resolvePlaylists() {
mSignal.throwIfCanceled();
for (int i = 0; i < mPlaylistIds.size(); i++) {
- final Uri uri = MediaStore.Files.getContentUri(mVolumeName, mPlaylistIds.get(i));
- try {
- PlaylistResolver.resolvePlaylist(mResolver, uri).forEach(this::addPending);
- maybeApplyPending();
- } catch (IOException e) {
- if (LOGW) Log.w(TAG, "Ignoring troubled playlist: " + uri, e);
- }
- applyPending();
+ final Uri playlistUri = ContentUris.withAppendedId(
+ MediaStore.Audio.Playlists.getContentUri(mVolumeName), mPlaylistIds.get(i));
+ MediaStore.resolvePlaylistMembers(mResolver, playlistUri);
}
}
@@ -652,7 +646,7 @@
ContentProviderOperation operation = mPending.get(index);
if (result.exception != null) {
- Log.w(TAG, "Failed to apply " + operation + ": " + result.exception);
+ Log.w(TAG, "Failed to apply " + operation, result.exception);
}
Uri uri = result.uri;
@@ -675,7 +669,7 @@
}
}
} catch (RemoteException | OperationApplicationException e) {
- Log.w(TAG, "Failed to apply: " + e);
+ Log.w(TAG, "Failed to apply", e);
} finally {
mPending.clear();
Trace.endSection();
@@ -793,7 +787,7 @@
withOptionalValue(op, MediaColumns.TITLE,
parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
withOptionalValue(op, MediaColumns.YEAR,
- parseOptionalOrZero(mmr.extractMetadata(METADATA_KEY_YEAR)));
+ parseOptionalYear(mmr.extractMetadata(METADATA_KEY_YEAR)));
withOptionalValue(op, MediaColumns.DURATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
withOptionalValue(op, MediaColumns.NUM_TRACKS,
diff --git a/src/com/android/providers/media/scan/PlaylistResolver.java b/src/com/android/providers/media/scan/PlaylistResolver.java
deleted file mode 100644
index d546a80..0000000
--- a/src/com/android/providers/media/scan/PlaylistResolver.java
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * 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.media.scan;
-
-import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
-import static org.xmlpull.v1.XmlPullParser.START_TAG;
-
-import android.content.ContentProviderOperation;
-import android.content.ContentResolver;
-import android.content.ContentUris;
-import android.database.Cursor;
-import android.net.Uri;
-import android.provider.MediaStore;
-import android.provider.MediaStore.MediaColumns;
-import android.text.TextUtils;
-import android.util.Xml;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class PlaylistResolver {
- private static final Pattern PATTERN_PLS = Pattern.compile("File(\\d+)=(.+)");
-
- private static final String TAG_MEDIA = "media";
- private static final String ATTR_SRC = "src";
-
- /**
- * Resolve the contents of the given
- * {@link android.provider.MediaStore.Audio.Playlists} item, returning a
- * list of {@link ContentProviderOperation} that will update all members.
- */
- public static @NonNull List<ContentProviderOperation> resolvePlaylist(
- @NonNull ContentResolver resolver, @NonNull Uri uri) throws IOException {
- final String mimeType;
- final File file;
- try (Cursor cursor = resolver.query(uri, new String[] {
- MediaColumns.MIME_TYPE, MediaColumns.DATA
- }, null, null, null)) {
- if (!cursor.moveToFirst()) {
- throw new FileNotFoundException(uri.toString());
- }
- mimeType = cursor.getString(0);
- file = new File(cursor.getString(1));
- }
-
- if (mimeType == null) {
- throw new IOException("Unsupported playlist of type " + mimeType);
- }
-
- switch (mimeType) {
- case "audio/x-mpegurl":
- case "audio/mpegurl":
- case "application/x-mpegurl":
- case "application/vnd.apple.mpegurl":
- return resolvePlaylistM3u(resolver, uri, file);
- case "audio/x-scpls":
- return resolvePlaylistPls(resolver, uri, file);
- case "application/vnd.ms-wpl":
- case "video/x-ms-asf":
- return resolvePlaylistWpl(resolver, uri, file);
- default:
- throw new IOException("Unsupported playlist of type " + mimeType);
- }
- }
-
- private static @NonNull List<ContentProviderOperation> resolvePlaylistM3u(
- @NonNull ContentResolver resolver, @NonNull Uri uri, @NonNull File file)
- throws IOException {
- final Path parentPath = file.getParentFile().toPath();
- final List<ContentProviderOperation> res = new ArrayList<>();
- res.add(ContentProviderOperation.newDelete(getPlaylistMembersUri(uri)).build());
- try (BufferedReader reader = new BufferedReader(
- new InputStreamReader(new FileInputStream(file)))) {
- String line;
- while ((line = reader.readLine()) != null) {
- if (!TextUtils.isEmpty(line) && !line.startsWith("#")) {
- final int itemIndex = res.size() + 1;
- final File itemFile = parentPath.resolve(
- line.replace('\\', '/')).toFile();
- try {
- res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
- } catch (FileNotFoundException ignored) {
- }
- }
- }
- }
- return res;
- }
-
- private static @NonNull List<ContentProviderOperation> resolvePlaylistPls(
- @NonNull ContentResolver resolver, @NonNull Uri uri, @NonNull File file)
- throws IOException {
- final Path parentPath = file.getParentFile().toPath();
- final List<ContentProviderOperation> res = new ArrayList<>();
- res.add(ContentProviderOperation.newDelete(getPlaylistMembersUri(uri)).build());
- try (BufferedReader reader = new BufferedReader(
- new InputStreamReader(new FileInputStream(file)))) {
- String line;
- while ((line = reader.readLine()) != null) {
- final Matcher matcher = PATTERN_PLS.matcher(line);
- if (matcher.matches()) {
- final int itemIndex = Integer.parseInt(matcher.group(1));
- final File itemFile = parentPath.resolve(
- matcher.group(2).replace('\\', '/')).toFile();
- try {
- res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
- } catch (FileNotFoundException ignored) {
- }
- }
- }
- }
- return res;
- }
-
- private static @NonNull List<ContentProviderOperation> resolvePlaylistWpl(
- @NonNull ContentResolver resolver, @NonNull Uri uri, @NonNull File file)
- throws IOException {
- final Path parentPath = file.getParentFile().toPath();
- final List<ContentProviderOperation> res = new ArrayList<>();
- res.add(ContentProviderOperation.newDelete(getPlaylistMembersUri(uri)).build());
- try (InputStream in = new FileInputStream(file)) {
- try {
- final XmlPullParser parser = Xml.newPullParser();
- parser.setInput(in, StandardCharsets.UTF_8.name());
-
- int type;
- while ((type = parser.next()) != END_DOCUMENT) {
- if (type != START_TAG) continue;
-
- if (TAG_MEDIA.equals(parser.getName())) {
- final String src = parser.getAttributeValue(null, ATTR_SRC);
- if (src != null) {
- final int itemIndex = res.size() + 1;
- final File itemFile = parentPath.resolve(
- src.replace('\\', '/')).toFile();
- try {
- res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
- } catch (FileNotFoundException ignored) {
- }
- }
- }
- }
- } catch (XmlPullParserException e) {
- throw new IOException(e);
- }
- }
- return res;
- }
-
- private static @Nullable ContentProviderOperation resolvePlaylistItem(
- @NonNull ContentResolver resolver, @NonNull Uri uri, int itemIndex, File itemFile)
- throws IOException {
- final Uri audioUri = MediaStore.Audio.Media.getContentUri(MediaStore.getVolumeName(uri));
- try (Cursor cursor = resolver.query(audioUri,
- new String[] { MediaColumns._ID }, MediaColumns.DATA + "=?",
- new String[] { itemFile.getCanonicalPath() }, null)) {
- if (!cursor.moveToFirst()) {
- throw new FileNotFoundException(uri.toString());
- }
-
- final ContentProviderOperation.Builder op = ContentProviderOperation
- .newInsert(getPlaylistMembersUri(uri));
- op.withValue(MediaStore.Audio.Playlists.Members.PLAY_ORDER, itemIndex);
- op.withValue(MediaStore.Audio.Playlists.Members.AUDIO_ID, cursor.getInt(0));
- return op.build();
- }
- }
-
- private static @NonNull Uri getPlaylistMembersUri(@NonNull Uri uri) {
- return MediaStore.Audio.Playlists.Members.getContentUri(MediaStore.getVolumeName(uri),
- ContentUris.parseId(uri));
- }
-}
diff --git a/src/com/android/providers/media/util/MimeUtils.java b/src/com/android/providers/media/util/MimeUtils.java
index 2bc18db..d6ecece 100644
--- a/src/com/android/providers/media/util/MimeUtils.java
+++ b/src/com/android/providers/media/util/MimeUtils.java
@@ -114,6 +114,7 @@
case "application/vnd.ms-wpl":
case "application/x-extension-smpl":
case "application/x-mpegurl":
+ case "application/xspf+xml":
case "audio/mpegurl":
case "audio/x-mpegurl":
case "audio/x-scpls":
diff --git a/src/com/android/providers/media/util/SQLiteQueryBuilder.java b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
index a8a3565..990457c 100644
--- a/src/com/android/providers/media/util/SQLiteQueryBuilder.java
+++ b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
@@ -16,12 +16,20 @@
package com.android.providers.media.util;
+import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY;
+import static android.content.ContentResolver.QUERY_ARG_SQL_HAVING;
+import static android.content.ContentResolver.QUERY_ARG_SQL_LIMIT;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER;
+
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
+import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
import android.provider.BaseColumns;
@@ -390,6 +398,18 @@
s.append(' ');
}
+ public Cursor query(DatabaseHelper helper, String[] projectionIn, Bundle queryArgs,
+ CancellationSignal cancellationSignal) {
+ final String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION);
+ final String[] selectionArgs = queryArgs.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
+ final String groupBy = queryArgs.getString(QUERY_ARG_SQL_GROUP_BY);
+ final String having = queryArgs.getString(QUERY_ARG_SQL_HAVING);
+ final String sortOrder = queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER);
+ final String limit = queryArgs.getString(QUERY_ARG_SQL_LIMIT);
+ return query(helper, projectionIn, selection, selectionArgs, groupBy, having, sortOrder,
+ limit, cancellationSignal);
+ }
+
public Cursor query(DatabaseHelper helper, String[] projectionIn,
String selection, String[] selectionArgs, String groupBy,
String having, String sortOrder, String limit, CancellationSignal cancellationSignal) {
diff --git a/src/com/android/providers/playlist/M3uPlaylistPersister.java b/src/com/android/providers/playlist/M3uPlaylistPersister.java
new file mode 100644
index 0000000..06cefc7
--- /dev/null
+++ b/src/com/android/providers/playlist/M3uPlaylistPersister.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.List;
+
+public class M3uPlaylistPersister implements PlaylistPersister {
+ @Override
+ public void read(@NonNull InputStream in, @NonNull List<Path> items) throws IOException {
+ final FileSystem fs = FileSystems.getDefault();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (!TextUtils.isEmpty(line) && !line.startsWith("#")) {
+ items.add(fs.getPath(line.replace('\\', '/')));
+ }
+ }
+ }
+ }
+
+ @Override
+ public void write(@NonNull OutputStream out, @NonNull List<Path> items) throws IOException {
+ try (PrintWriter writer = new PrintWriter(out)) {
+ writer.println("#EXTM3U");
+ for (Path item : items) {
+ writer.println(item);
+ }
+ }
+ }
+}
diff --git a/src/com/android/providers/playlist/Playlist.java b/src/com/android/providers/playlist/Playlist.java
new file mode 100644
index 0000000..0a3c9b4
--- /dev/null
+++ b/src/com/android/providers/playlist/Playlist.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import static com.android.providers.media.util.Logging.TAG;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Representation of a playlist of multiple items, each represented by their
+ * {@link Path}. Note that identical items may be repeated within a playlist,
+ * and that strict ordering is maintained.
+ * <p>
+ * This representation is agnostic to file format, but you can {@link #read}
+ * playlist files into memory, modify them, and then {@link #write} them back
+ * into playlist files. This design allows you to easily convert between
+ * playlist file formats by reading one format and writing to another.
+ */
+public class Playlist {
+ private final ArrayList<Path> mItems = new ArrayList<>();
+
+ public List<Path> asList() {
+ return Collections.unmodifiableList(mItems);
+ }
+
+ public void clear() {
+ mItems.clear();
+ }
+
+ public void read(@NonNull File file) throws IOException {
+ clear();
+ try (InputStream in = new FileInputStream(file)) {
+ PlaylistPersister.resolvePersister(file).read(in, mItems);
+ } catch (FileNotFoundException e) {
+ Log.w(TAG, "Treating missing file as empty playlist");
+ }
+ }
+
+ public void write(@NonNull File file) throws IOException {
+ try (OutputStream out = new FileOutputStream(file)) {
+ PlaylistPersister.resolvePersister(file).write(out, mItems);
+ }
+ }
+
+ /**
+ * Add the given playlist item at the nearest valid index.
+ */
+ public int add(int index, Path item) {
+ // Gracefully handle items beyond end
+ final int size = mItems.size();
+ index = Math.min(index, size);
+
+ mItems.add(index, item);
+ return index;
+ }
+
+ /**
+ * Move an existing playlist item from the nearest valid index to the
+ * nearest valid index.
+ */
+ public int move(int from, int to) {
+ // Gracefully handle items beyond end
+ final int size = mItems.size();
+ from = Math.min(from, size - 1);
+ to = Math.min(to, size - 1);
+
+ final Path item = mItems.remove(from);
+ mItems.add(to, item);
+ return to;
+ }
+
+ /**
+ * Remove an existing playlist item from the nearest valid index.
+ */
+ public int remove(int index) {
+ // Gracefully handle items beyond end
+ final int size = mItems.size();
+ index = Math.min(index, size - 1);
+
+ mItems.remove(index);
+ return index;
+ }
+}
diff --git a/src/com/android/providers/playlist/PlaylistPersister.java b/src/com/android/providers/playlist/PlaylistPersister.java
new file mode 100644
index 0000000..a673968
--- /dev/null
+++ b/src/com/android/providers/playlist/PlaylistPersister.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import android.content.ContentResolver;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import com.android.providers.media.util.MimeUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Interface that knows how to {@link #read} and {@link #write} a set of
+ * playlist items using a specific file format. This design allows you to easily
+ * convert between playlist file formats by reading one format and writing to
+ * another.
+ */
+public interface PlaylistPersister {
+ public void read(@NonNull InputStream in, @NonNull List<Path> items) throws IOException;
+ public void write(@NonNull OutputStream out, @NonNull List<Path> items) throws IOException;
+
+ /**
+ * Resolve a concrete version of this interface which matches the given
+ * playlist file format.
+ */
+ public static @NonNull PlaylistPersister resolvePersister(@NonNull File file)
+ throws IOException {
+ return resolvePersister(MimeUtils.resolveMimeType(file));
+ }
+
+ /**
+ * Resolve a concrete version of this interface which matches the given
+ * playlist file format.
+ */
+ public static @NonNull PlaylistPersister resolvePersister(@NonNull ContentResolver resolver,
+ @NonNull Uri uri) throws IOException {
+ return resolvePersister(resolver.getType(uri));
+ }
+
+ /**
+ * Resolve a concrete version of this interface which matches the given
+ * playlist file format.
+ */
+ public static @NonNull PlaylistPersister resolvePersister(@NonNull String mimeType)
+ throws IOException {
+ switch (mimeType) {
+ case "audio/mpegurl":
+ case "audio/x-mpegurl":
+ case "application/vnd.apple.mpegurl":
+ case "application/x-mpegurl":
+ return new M3uPlaylistPersister();
+ case "audio/x-scpls":
+ return new PlsPlaylistPersister();
+ case "application/vnd.ms-wpl":
+ case "video/x-ms-asf":
+ return new WplPlaylistPersister();
+ case "application/xspf+xml":
+ return new XspfPlaylistPersister();
+ default:
+ throw new IOException("Unsupported playlist format " + mimeType);
+ }
+ }
+}
diff --git a/src/com/android/providers/playlist/PlsPlaylistPersister.java b/src/com/android/providers/playlist/PlsPlaylistPersister.java
new file mode 100644
index 0000000..5fcc40e
--- /dev/null
+++ b/src/com/android/providers/playlist/PlsPlaylistPersister.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import androidx.annotation.NonNull;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class PlsPlaylistPersister implements PlaylistPersister {
+ private static final Pattern PATTERN_PLS = Pattern.compile("File(\\d+)=(.+)");
+
+ @Override
+ public void read(@NonNull InputStream in, @NonNull List<Path> items) throws IOException {
+ final FileSystem fs = FileSystems.getDefault();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
+ Path[] res = new Path[0];
+
+ String line;
+ while ((line = reader.readLine()) != null) {
+ final Matcher matcher = PATTERN_PLS.matcher(line);
+ if (matcher.matches()) {
+ final int index = Integer.parseInt(matcher.group(1));
+ final Path item = fs.getPath(matcher.group(2).replace('\\', '/'));
+ if (index + 1 > res.length) {
+ res = Arrays.copyOf(res, index + 1);
+ }
+ res[index] = item;
+ }
+ }
+
+ for (int i = 0; i < res.length; i++) {
+ if (res[i] != null) {
+ items.add(res[i]);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void write(@NonNull OutputStream out, @NonNull List<Path> items) throws IOException {
+ try (PrintWriter writer = new PrintWriter(out)) {
+ writer.printf("[playlist]\n");
+ for (int i = 0; i < items.size(); i++) {
+ writer.printf("File%d=%s\n", i + 1, items.get(i));
+ }
+ writer.printf("NumberOfEntries=%d\n", items.size());
+ writer.printf("Version=2\n");
+ }
+ }
+}
diff --git a/src/com/android/providers/playlist/WplPlaylistPersister.java b/src/com/android/providers/playlist/WplPlaylistPersister.java
new file mode 100644
index 0000000..6b47766
--- /dev/null
+++ b/src/com/android/providers/playlist/WplPlaylistPersister.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
+import static org.xmlpull.v1.XmlPullParser.START_TAG;
+
+import android.util.Xml;
+
+import androidx.annotation.NonNull;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.List;
+
+public class WplPlaylistPersister implements PlaylistPersister {
+ private static final String TAG_SMIL = "smil";
+ private static final String TAG_BODY = "body";
+ private static final String TAG_SEQ = "seq";
+ private static final String TAG_MEDIA = "media";
+
+ private static final String ATTR_SRC = "src";
+
+ @Override
+ public void read(@NonNull InputStream in, @NonNull List<Path> items) throws IOException {
+ final FileSystem fs = FileSystems.getDefault();
+ try {
+ final XmlPullParser parser = Xml.newPullParser();
+ parser.setInput(in, StandardCharsets.UTF_8.name());
+
+ int type;
+ while ((type = parser.next()) != END_DOCUMENT) {
+ if (type != START_TAG) continue;
+
+ if (TAG_MEDIA.equals(parser.getName())) {
+ final String src = parser.getAttributeValue(null, ATTR_SRC);
+ if (src != null) {
+ items.add(fs.getPath(src.replace('\\', '/')));
+ }
+ }
+ }
+ } catch (XmlPullParserException e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void write(@NonNull OutputStream out, @NonNull List<Path> items) throws IOException {
+ final XmlSerializer doc = Xml.newSerializer();
+ doc.setOutput(out, StandardCharsets.UTF_8.name());
+ doc.startDocument(null, true);
+ doc.startTag(null, TAG_SMIL);
+ doc.startTag(null, TAG_BODY);
+ doc.startTag(null, TAG_SEQ);
+ for (Path item : items) {
+ doc.startTag(null, TAG_MEDIA);
+ doc.attribute(null, ATTR_SRC, item.toString());
+ doc.endTag(null, TAG_MEDIA);
+ }
+ doc.endTag(null, TAG_SEQ);
+ doc.endTag(null, TAG_BODY);
+ doc.endTag(null, TAG_SMIL);
+ doc.endDocument();
+ }
+}
diff --git a/src/com/android/providers/playlist/XspfPlaylistPersister.java b/src/com/android/providers/playlist/XspfPlaylistPersister.java
new file mode 100644
index 0000000..264ee6c
--- /dev/null
+++ b/src/com/android/providers/playlist/XspfPlaylistPersister.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2020 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.playlist;
+
+import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
+import static org.xmlpull.v1.XmlPullParser.START_TAG;
+
+import android.util.Xml;
+
+import androidx.annotation.NonNull;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.List;
+
+public class XspfPlaylistPersister implements PlaylistPersister {
+ private static final String TAG_PLAYLIST = "playlist";
+ private static final String TAG_TRACK_LIST = "trackList";
+ private static final String TAG_TRACK = "track";
+ private static final String TAG_LOCATION = "location";
+
+ @Override
+ public void read(@NonNull InputStream in, @NonNull List<Path> items) throws IOException {
+ final FileSystem fs = FileSystems.getDefault();
+ try {
+ final XmlPullParser parser = Xml.newPullParser();
+ parser.setInput(in, StandardCharsets.UTF_8.name());
+
+ int type;
+ while ((type = parser.next()) != END_DOCUMENT) {
+ if (type != START_TAG) continue;
+
+ if (TAG_LOCATION.equals(parser.getName())) {
+ final String src = parser.nextText();
+ if (src != null) {
+ items.add(fs.getPath(src.replace('\\', '/')));
+ }
+ }
+ }
+ } catch (XmlPullParserException e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void write(@NonNull OutputStream out, @NonNull List<Path> items) throws IOException {
+ final XmlSerializer doc = Xml.newSerializer();
+ doc.setOutput(out, StandardCharsets.UTF_8.name());
+ doc.startDocument(null, true);
+ doc.startTag(null, TAG_PLAYLIST);
+ doc.startTag(null, TAG_TRACK_LIST);
+ for (Path item : items) {
+ doc.startTag(null, TAG_TRACK);
+ doc.startTag(null, TAG_LOCATION);
+ doc.text(item.toString());
+ doc.endTag(null, TAG_LOCATION);
+ doc.endTag(null, TAG_TRACK);
+ }
+ doc.endTag(null, TAG_TRACK_LIST);
+ doc.endTag(null, TAG_PLAYLIST);
+ doc.endDocument();
+ }
+}
diff --git a/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java
new file mode 100644
index 0000000..a7d7e98
--- /dev/null
+++ b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java
@@ -0,0 +1,320 @@
+/*
+ * Copyright (C) 2020 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.media.client;
+
+import static android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY;
+import static android.provider.MediaStore.VOLUME_INTERNAL;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.BaseColumns;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Audio.Playlists;
+import android.provider.MediaStore.MediaColumns;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.test.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Verify typical behaviors of {@link MediaStore.Audio.Playlists} from an
+ * external client app. Exercises all supported playlist formats.
+ */
+@RunWith(Parameterized.class)
+public class ClientPlaylistTest {
+ private static final String TAG = "ClientPlaylistTest";
+
+ // TODO: verify playlists relative paths are rewritten when contained music
+ // files are moved/deleted, or when the playlist itself is moved
+
+ // TODO: verify that missing playlist items are preserved
+
+ private final Uri mExternalAudio = MediaStore.Audio.Media
+ .getContentUri(VOLUME_EXTERNAL_PRIMARY);
+ private final Uri mExternalPlaylists = MediaStore.Audio.Playlists
+ .getContentUri(VOLUME_EXTERNAL_PRIMARY);
+
+ private final ContentValues mValues = new ContentValues();
+
+ private Context mContext;
+ private ContentResolver mContentResolver;
+
+ private long mRed;
+ private long mGreen;
+ private long mBlue;
+
+ @Parameter(0)
+ public String mMimeType;
+
+ @Parameters
+ public static Iterable<? extends Object> data() {
+ return Arrays.asList(
+ "audio/x-mpegurl",
+ "audio/x-scpls",
+ "application/vnd.ms-wpl",
+ "application/xspf+xml");
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ mContext = InstrumentationRegistry.getTargetContext();
+ mContentResolver = mContext.getContentResolver();
+
+ mRed = createAudio();
+ mGreen = createAudio();
+ mBlue = createAudio();
+
+ Log.d(TAG, "Using MIME type " + mMimeType);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mContentResolver.delete(ContentUris.withAppendedId(mExternalAudio, mRed), null);
+ mContentResolver.delete(ContentUris.withAppendedId(mExternalAudio, mGreen), null);
+ mContentResolver.delete(ContentUris.withAppendedId(mExternalAudio, mBlue), null);
+ }
+
+ /**
+ * Verify that creating playlists using only {@link Playlists#NAME} defined
+ * will flow into the {@link MediaColumns#DISPLAY_NAME}, both during initial
+ * insert and subsequent updates.
+ */
+ @Test
+ public void testName() throws Exception {
+ final String name1 = "Playlist " + System.nanoTime();
+ final String name2 = "Playlist " + System.nanoTime();
+ assertNotEquals(name1, name2);
+
+ mValues.clear();
+ mValues.put(Playlists.NAME, name1);
+ final Uri playlist = mContentResolver.insert(mExternalPlaylists, mValues);
+ try (Cursor c = mContentResolver.query(playlist,
+ new String[] { Playlists.NAME, MediaColumns.DISPLAY_NAME }, null, null)) {
+ assertTrue(c.moveToFirst());
+ assertTrue(c.getString(0).startsWith(name1));
+ assertTrue(c.getString(1).startsWith(name1));
+ }
+
+ mValues.clear();
+ mValues.put(Playlists.NAME, name2);
+ mContentResolver.update(playlist, mValues, null);
+ try (Cursor c = mContentResolver.query(playlist,
+ new String[] { Playlists.NAME, MediaColumns.DISPLAY_NAME }, null, null)) {
+ assertTrue(c.moveToFirst());
+ assertTrue(c.getString(0).startsWith(name2));
+ assertTrue(c.getString(1).startsWith(name2));
+ }
+ }
+
+ @Test
+ public void testAdd() throws Exception {
+ mValues.clear();
+ mValues.put(MediaColumns.DISPLAY_NAME, "Playlist " + System.nanoTime());
+ mValues.put(MediaColumns.MIME_TYPE, mMimeType);
+
+ final Uri playlist = mContentResolver.insert(mExternalPlaylists, mValues);
+ final Uri members = MediaStore.Audio.Playlists.Members
+ .getContentUri(VOLUME_EXTERNAL_PRIMARY, ContentUris.parseId(playlist));
+
+ // Inserting without ordering will always append
+ mValues.clear();
+ mValues.put(Playlists.Members.AUDIO_ID, mRed);
+ mContentResolver.insert(members, mValues);
+ mValues.put(Playlists.Members.AUDIO_ID, mGreen);
+ mContentResolver.insert(members, mValues);
+ assertMembers(Arrays.asList(
+ Pair.create(mRed, 1),
+ Pair.create(mGreen, 2)), queryMembers(members));
+
+ // Inserting with ordering should be injected
+ mValues.clear();
+ mValues.put(Playlists.Members.AUDIO_ID, mBlue);
+ mValues.put(Playlists.Members.PLAY_ORDER, 1);
+ mContentResolver.insert(members, mValues);
+ assertMembers(Arrays.asList(
+ Pair.create(mBlue, 1),
+ Pair.create(mRed, 2),
+ Pair.create(mGreen, 3)), queryMembers(members));
+ }
+
+ @Test
+ public void testMove() throws Exception {
+ final long playlistId = createPlaylist(mRed, mGreen, mBlue);
+ final Uri members = Playlists.Members.getContentUri(VOLUME_EXTERNAL_PRIMARY, playlistId);
+
+ // Simple move forwards
+ Playlists.Members.moveItem(mContentResolver, playlistId, 0, 2);
+ assertMembers(Arrays.asList(
+ Pair.create(mGreen, 1),
+ Pair.create(mBlue, 2),
+ Pair.create(mRed, 3)), queryMembers(members));
+
+ // Simple move backwards
+ Playlists.Members.moveItem(mContentResolver, playlistId, 2, 0);
+ assertMembers(Arrays.asList(
+ Pair.create(mRed, 1),
+ Pair.create(mGreen, 2),
+ Pair.create(mBlue, 3)), queryMembers(members));
+
+ // Advanced moves using query args
+ mValues.clear();
+ mValues.put(Playlists.Members.PLAY_ORDER, 1);
+ mContentResolver.update(members, mValues, Playlists.Members.PLAY_ORDER + "=?",
+ new String[] { "2" });
+ assertMembers(Arrays.asList(
+ Pair.create(mGreen, 1),
+ Pair.create(mRed, 2),
+ Pair.create(mBlue, 3)), queryMembers(members));
+
+ mContentResolver.update(members, mValues, Playlists.Members.PLAY_ORDER + "=2", null);
+ assertMembers(Arrays.asList(
+ Pair.create(mRed, 1),
+ Pair.create(mGreen, 2),
+ Pair.create(mBlue, 3)), queryMembers(members));
+ }
+
+ @Test
+ public void testRemove() throws Exception {
+ final long playlistId = createPlaylist(mRed, mGreen, mBlue);
+ final Uri members = Playlists.Members.getContentUri(VOLUME_EXTERNAL_PRIMARY, playlistId);
+
+ // Simple delete in middle, duplicates are okay
+ mContentResolver.delete(members, Playlists.Members.PLAY_ORDER + "=?",
+ new String[] { "2" });
+ assertMembers(Arrays.asList(
+ Pair.create(mRed, 1),
+ Pair.create(mBlue, 2)), queryMembers(members));
+
+ mContentResolver.delete(members, Playlists.Members.PLAY_ORDER + "=2", null);
+ assertMembers(Arrays.asList(
+ Pair.create(mRed, 1)), queryMembers(members));
+ }
+
+ /**
+ * Since playlist files are written on a specific storage device, they can
+ * only contain media from that same storage device. This test verifies that
+ * trying to cross the streams will fail.
+ */
+ @Test
+ public void testVolumeName() throws Exception {
+ mValues.clear();
+ mValues.put(MediaColumns.DISPLAY_NAME, "Playlist " + System.nanoTime());
+ mValues.put(MediaColumns.MIME_TYPE, mMimeType);
+
+ final Uri playlist = mContentResolver.insert(mExternalPlaylists, mValues);
+ final Uri members = MediaStore.Audio.Playlists.Members
+ .getContentUri(VOLUME_EXTERNAL_PRIMARY, ContentUris.parseId(playlist));
+
+ // Ensure that we've scanned internal storage to ensure that we have a
+ // valid audio file
+ MediaStore.scanVolume(mContentResolver, VOLUME_INTERNAL);
+
+ final long internalId;
+ try (Cursor c = mContentResolver.query(MediaStore.Audio.Media.INTERNAL_CONTENT_URI,
+ new String[] { BaseColumns._ID }, null, null)) {
+ Assume.assumeTrue(c.moveToFirst());
+ internalId = c.getLong(0);
+ }
+
+ try {
+ mValues.clear();
+ mValues.put(Playlists.Members.AUDIO_ID, internalId);
+ mContentResolver.insert(members, mValues);
+ fail();
+ } catch (Exception expected) {
+ }
+ }
+
+ public long createAudio() throws IOException {
+ mValues.clear();
+ mValues.put(MediaColumns.DISPLAY_NAME, "Song " + System.nanoTime());
+ mValues.put(MediaColumns.MIME_TYPE, "audio/mpeg");
+
+ final Uri uri = mContentResolver.insert(mExternalAudio, mValues);
+ try (OutputStream out = mContentResolver.openOutputStream(uri)) {
+ }
+ return ContentUris.parseId(uri);
+ }
+
+ public long createPlaylist(long... memberIds) throws IOException {
+ mValues.clear();
+ mValues.put(MediaColumns.DISPLAY_NAME, "Playlist " + System.nanoTime());
+ mValues.put(MediaColumns.MIME_TYPE, mMimeType);
+
+ final Uri playlist = mContentResolver.insert(mExternalPlaylists, mValues);
+ final Uri members = MediaStore.Audio.Playlists.Members
+ .getContentUri(VOLUME_EXTERNAL_PRIMARY, ContentUris.parseId(playlist));
+
+ List<Pair<Long, Integer>> expected = new ArrayList<>();
+ for (int i = 0; i < memberIds.length; i++) {
+ final long memberId = memberIds[i];
+ mValues.clear();
+ mValues.put(Playlists.Members.AUDIO_ID, memberId);
+ mContentResolver.insert(members, mValues);
+ expected.add(Pair.create(memberId, i + 1));
+ }
+
+ assertMembers(expected, queryMembers(members));
+ return ContentUris.parseId(playlist);
+ }
+
+ private void assertMembers(List<Pair<Long, Integer>> expected,
+ List<Pair<Long, Integer>> actual) {
+ assertEquals(expected.toString(), actual.toString());
+ }
+
+ private List<Pair<Long, Integer>> queryMembers(Uri uri) {
+ final Bundle queryArgs = new Bundle();
+ queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
+ Playlists.Members.PLAY_ORDER + " ASC");
+
+ final List<Pair<Long, Integer>> res = new ArrayList<>();
+ try (Cursor c = mContentResolver.query(uri, new String[] {
+ Playlists.Members.AUDIO_ID, Playlists.Members.PLAY_ORDER
+ }, queryArgs, null)) {
+ while (c.moveToNext()) {
+ res.add(Pair.create(c.getLong(0), c.getInt(1)));
+ }
+ }
+ return res;
+ }
+}
diff --git a/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java b/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
index 886acd8..89390d3 100644
--- a/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
+++ b/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
@@ -194,6 +194,9 @@
Assume.assumeNotNull(legacyProvider);
Assume.assumeNotNull(modernProvider);
+ // Wait until everything calms down
+ MediaStore.waitForIdle(context.getContentResolver());
+
// Clear data on the legacy provider so that we create a database
executeShellCommand("pm clear " + legacyProvider.applicationInfo.packageName, ui);
diff --git a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
index f676780..b2a8941 100644
--- a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
+++ b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
@@ -652,6 +652,7 @@
/**
* Test that app can see files and directories in Android/media.
*/
+ @Ignore("Re-enable as part of b/145737191")
@Test
public void testListFilesFromExternalMediaDirectory() throws Exception {
final File videoFile = new File(EXTERNAL_MEDIA_DIR, VIDEO_FILE_NAME);
@@ -671,9 +672,10 @@
// Install TEST_APP_A with READ_EXTERNAL_STORAGE permission.
// TEST_APP_A with storage permission should see other app's external media directory.
installApp(TEST_APP_A, true);
- // Apps can't list files in other app's external media directory.
- assertThat(listAs(TEST_APP_A, ANDROID_MEDIA_DIR.getPath())).isEmpty();
- assertThat(listAs(TEST_APP_A, EXTERNAL_MEDIA_DIR.getPath())).isEmpty();
+ // Apps with READ_EXTERNAL_STORAGE can list files in other app's external media directory.
+ assertThat(listAs(TEST_APP_A, ANDROID_MEDIA_DIR.getPath())).contains(THIS_PACKAGE_NAME);
+ // TODO(b/145737191) fails because we don't index these files yet.
+ assertThat(listAs(TEST_APP_A, EXTERNAL_MEDIA_DIR.getPath())).containsExactly(videoFileName);
} finally {
videoFile.delete();
}
diff --git a/tests/res/raw/test_xspf.xspf b/tests/res/raw/test_xspf.xspf
new file mode 100644
index 0000000..79b154c
--- /dev/null
+++ b/tests/res/raw/test_xspf.xspf
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<playlist version="1" xmlns="http://xspf.org/ns/0/">
+ <trackList>
+ <track>
+ <location>001.mp3</location>
+ </track>
+ <track>
+ <title>Just some local audio that is 2mins long</title>
+ <location>../Music/002.mp3</location>
+ </track>
+ <track>
+ <title>absolute path on Windows</title>
+ <location>003.mp3</location>
+ </track>
+ <track>
+ <title>Window style file path</title>
+ <location>..\Music\004.mp3</location>
+ </track>
+ <track>
+ <title>case insensitive</title>
+ <location>../music/005.mp3</location>
+ </track>
+ </trackList>
+</playlist>
diff --git a/tests/src/com/android/providers/media/DatabaseHelperTest.java b/tests/src/com/android/providers/media/DatabaseHelperTest.java
index a655fe6..badff08 100644
--- a/tests/src/com/android/providers/media/DatabaseHelperTest.java
+++ b/tests/src/com/android/providers/media/DatabaseHelperTest.java
@@ -114,42 +114,42 @@
// Confirm that raw view knows everything
assertEquals(asSet("Clocks", "Speed of Sound", "Beautiful Day"),
- queryValues(db, "audio", "title"));
+ queryValues(helper, "audio", "title"));
// By default, database only knows about primary storage
assertEquals(asSet("Coldplay"),
- queryValues(db, "audio_artists", "artist"));
+ queryValues(helper, "audio_artists", "artist"));
assertEquals(asSet("A Rush of Blood"),
- queryValues(db, "audio_albums", "album"));
+ queryValues(helper, "audio_albums", "album"));
assertEquals(asSet("Rock"),
- queryValues(db, "audio_genres", "name"));
+ queryValues(helper, "audio_genres", "name"));
// Once we broaden mounted volumes, we know a lot more
helper.setFilterVolumeNames(asSet(VOLUME_EXTERNAL_PRIMARY, "0000-0000"));
assertEquals(asSet("Coldplay", "U2"),
- queryValues(db, "audio_artists", "artist"));
+ queryValues(helper, "audio_artists", "artist"));
assertEquals(asSet("A Rush of Blood", "X&Y", "All That You Can't Leave Behind"),
- queryValues(db, "audio_albums", "album"));
+ queryValues(helper, "audio_albums", "album"));
assertEquals(asSet("Rock", "Alternative rock"),
- queryValues(db, "audio_genres", "name"));
+ queryValues(helper, "audio_genres", "name"));
// And unmounting primary narrows us the other way
helper.setFilterVolumeNames(asSet("0000-0000"));
assertEquals(asSet("Coldplay", "U2"),
- queryValues(db, "audio_artists", "artist"));
+ queryValues(helper, "audio_artists", "artist"));
assertEquals(asSet("X&Y", "All That You Can't Leave Behind"),
- queryValues(db, "audio_albums", "album"));
+ queryValues(helper, "audio_albums", "album"));
assertEquals(asSet("Rock", "Alternative rock"),
- queryValues(db, "audio_genres", "name"));
+ queryValues(helper, "audio_genres", "name"));
// Finally fully unmounted means nothing
helper.setFilterVolumeNames(asSet());
assertEquals(asSet(),
- queryValues(db, "audio_artists", "artist"));
+ queryValues(helper, "audio_artists", "artist"));
assertEquals(asSet(),
- queryValues(db, "audio_albums", "album"));
+ queryValues(helper, "audio_albums", "album"));
assertEquals(asSet(),
- queryValues(db, "audio_genres", "name"));
+ queryValues(helper, "audio_genres", "name"));
}
}
@@ -411,9 +411,9 @@
return new ArraySet<>(Arrays.asList(vars));
}
- private static Set<String> queryValues(@NonNull SQLiteDatabase db, @NonNull String table,
+ private static Set<String> queryValues(@NonNull DatabaseHelper helper, @NonNull String table,
@NonNull String columnName) {
- try (Cursor c = db.query(table, new String[] { columnName },
+ try (Cursor c = helper.getReadableDatabase().query(table, new String[] { columnName },
null, null, null, null, null)) {
final ArraySet<String> res = new ArraySet<>();
while (c.moveToNext()) {
@@ -433,6 +433,11 @@
public void onCreate(SQLiteDatabase db) {
createOSchema(db, false);
}
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ // Purposefully empty to leave views intact
+ }
}
private static class DatabaseHelperP extends DatabaseHelper {
@@ -445,6 +450,11 @@
public void onCreate(SQLiteDatabase db) {
createPSchema(db, false);
}
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ // Purposefully empty to leave views intact
+ }
}
private static class DatabaseHelperQ extends DatabaseHelper {
@@ -457,6 +467,11 @@
public void onCreate(SQLiteDatabase db) {
createQSchema(db, false);
}
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ // Purposefully empty to leave views intact
+ }
}
private static class DatabaseHelperR extends DatabaseHelper {
diff --git a/tests/src/com/android/providers/media/MediaProviderTest.java b/tests/src/com/android/providers/media/MediaProviderTest.java
index c2677cb..304124f 100644
--- a/tests/src/com/android/providers/media/MediaProviderTest.java
+++ b/tests/src/com/android/providers/media/MediaProviderTest.java
@@ -95,8 +95,6 @@
"audio/media/1",
"audio/media/1/genres",
"audio/media/1/genres/1",
- "audio/media/1/playlists",
- "audio/media/1/playlists/1",
"audio/genres",
"audio/genres/1",
"audio/genres/1/members",
diff --git a/tests/src/com/android/providers/media/playlist/PlaylistPersisterTest.java b/tests/src/com/android/providers/media/playlist/PlaylistPersisterTest.java
new file mode 100644
index 0000000..648195a
--- /dev/null
+++ b/tests/src/com/android/providers/media/playlist/PlaylistPersisterTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2020 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.media.playlist;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.providers.playlist.M3uPlaylistPersister;
+import com.android.providers.playlist.PlaylistPersister;
+import com.android.providers.playlist.PlsPlaylistPersister;
+import com.android.providers.playlist.WplPlaylistPersister;
+import com.android.providers.playlist.XspfPlaylistPersister;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Straightforward test that exercises all rewriters by verifying that written
+ * playlists can be successfully read.
+ */
+@RunWith(Parameterized.class)
+public class PlaylistPersisterTest {
+ /**
+ * List of paths to be used for verifying playlist rewriters. Note that
+ * first and last items are intentionally identical to verify they're
+ * rewritten without being dropped.
+ */
+ private final List<Path> expected = Arrays.asList(
+ new File("test.mp3").toPath(),
+ new File("../parent/../test.mp3").toPath(),
+ new File("subdir/test.mp3").toPath(),
+ new File("/root/test.mp3").toPath(),
+ new File("從不喜歡孤單一個 - 蘇永康/吳雨霏.mp3").toPath(),
+ new File("test.mp3").toPath());
+
+ @Parameter(0)
+ public PlaylistPersister mPersister;
+
+ @Parameters
+ public static Iterable<? extends Object> data() {
+ return Arrays.asList(
+ new M3uPlaylistPersister(),
+ new PlsPlaylistPersister(),
+ new WplPlaylistPersister(),
+ new XspfPlaylistPersister());
+ }
+
+ @Test
+ public void testRewrite() throws Exception {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mPersister.write(out, expected);
+
+ final ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
+ final List<Path> actual = new ArrayList<>();
+ mPersister.read(in, actual);
+
+ assertEquals(expected.toString(), actual.toString());
+ }
+}
diff --git a/tests/src/com/android/providers/media/playlist/PlaylistTest.java b/tests/src/com/android/providers/media/playlist/PlaylistTest.java
new file mode 100644
index 0000000..b13da8e
--- /dev/null
+++ b/tests/src/com/android/providers/media/playlist/PlaylistTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2020 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.media.playlist;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.providers.playlist.Playlist;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.nio.file.Path;
+
+@RunWith(AndroidJUnit4.class)
+public class PlaylistTest {
+ private final Playlist playlist = new Playlist();
+
+ private final Path RED = new File("red").toPath();
+ private final Path GREEN = new File("../green").toPath();
+ private final Path BLUE = new File("path/to/blue").toPath();
+ private final Path YELLOW = new File("/root/yellow").toPath();
+
+ @Before
+ public void setUp() {
+ playlist.clear();
+ }
+
+ @Test
+ public void testAdd() throws Exception {
+ assertPlaylistEquals();
+
+ playlist.add(0, RED);
+ assertPlaylistEquals(RED);
+
+ playlist.add(0, GREEN);
+ assertPlaylistEquals(GREEN, RED);
+
+ playlist.add(100, BLUE);
+ assertPlaylistEquals(GREEN, RED, BLUE);
+ }
+
+ @Test
+ public void testMove_Two() throws Exception {
+ playlist.add(0, RED);
+ playlist.add(1, GREEN);
+ assertPlaylistEquals(RED, GREEN);
+
+ playlist.move(0, 1);
+ assertPlaylistEquals(GREEN, RED);
+ playlist.move(0, 1);
+ assertPlaylistEquals(RED, GREEN);
+
+ playlist.move(1, 0);
+ assertPlaylistEquals(GREEN, RED);
+ playlist.move(1, 0);
+ assertPlaylistEquals(RED, GREEN);
+
+ playlist.move(0, 0);
+ assertPlaylistEquals(RED, GREEN);
+ }
+
+ @Test
+ public void testMove_Three() throws Exception {
+ playlist.add(0, RED);
+ playlist.add(1, GREEN);
+ playlist.add(2, BLUE);
+ assertPlaylistEquals(RED, GREEN, BLUE);
+
+ playlist.move(0, 1);
+ assertPlaylistEquals(GREEN, RED, BLUE);
+ playlist.move(1, 0);
+ assertPlaylistEquals(RED, GREEN, BLUE);
+
+ playlist.move(1, 100);
+ assertPlaylistEquals(RED, BLUE, GREEN);
+ playlist.move(100, 0);
+ assertPlaylistEquals(GREEN, RED, BLUE);
+
+ playlist.move(1, 1);
+ assertPlaylistEquals(GREEN, RED, BLUE);
+ }
+
+ @Test
+ public void testRemove() throws Exception {
+ playlist.add(0, RED);
+ playlist.add(1, GREEN);
+ playlist.add(2, BLUE);
+ assertPlaylistEquals(RED, GREEN, BLUE);
+
+ playlist.remove(100);
+ assertPlaylistEquals(RED, GREEN);
+
+ playlist.remove(0);
+ assertPlaylistEquals(GREEN);
+ }
+
+ private void assertPlaylistEquals(Path... items) {
+ assertEquals(items, playlist.asList().toArray());
+ }
+}
diff --git a/tests/src/com/android/providers/media/scan/DrmTest.java b/tests/src/com/android/providers/media/scan/DrmTest.java
index 47cf150..3776f3d 100644
--- a/tests/src/com/android/providers/media/scan/DrmTest.java
+++ b/tests/src/com/android/providers/media/scan/DrmTest.java
@@ -20,6 +20,7 @@
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
+import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
@@ -29,7 +30,7 @@
import android.drm.DrmSupportInfo;
import android.net.Uri;
import android.provider.MediaStore;
-import android.provider.MediaStore.MediaColumns;
+import android.provider.MediaStore.Files.FileColumns;
import android.util.Log;
import androidx.annotation.Nullable;
@@ -88,14 +89,18 @@
@Test
public void testForwardLock_Audio() throws Exception {
doForwardLock("audio/mpeg", R.raw.test_audio, (values) -> {
- assertEquals(1_045L, (long) values.getAsLong(MediaColumns.DURATION));
+ assertEquals(1_045L, (long) values.getAsLong(FileColumns.DURATION));
+ assertEquals(FileColumns.MEDIA_TYPE_AUDIO,
+ (int) values.getAsInteger(FileColumns.MEDIA_TYPE));
});
}
@Test
public void testForwardLock_Video() throws Exception {
doForwardLock("video/mp4", R.raw.test_video, (values) -> {
- assertEquals(40_000L, (long) values.getAsLong(MediaColumns.DURATION));
+ assertEquals(40_000L, (long) values.getAsLong(FileColumns.DURATION));
+ assertEquals(FileColumns.MEDIA_TYPE_VIDEO,
+ (int) values.getAsInteger(FileColumns.MEDIA_TYPE));
});
}
@@ -104,6 +109,8 @@
doForwardLock("image/jpeg", R.raw.test_image, (values) -> {
// ExifInterface currently doesn't know how to scan DRM images, so
// the best we can do is verify the base test metadata
+ assertEquals(FileColumns.MEDIA_TYPE_IMAGE,
+ (int) values.getAsInteger(FileColumns.MEDIA_TYPE));
});
}
@@ -142,22 +149,25 @@
// Scan the DRM file and confirm that it looks sane
final Uri flUri = MediaStore.scanFile(mContext.getContentResolver(), flPath);
- try (Cursor c = mContext.getContentResolver().query(flUri, null, null, null)) {
+ final Uri fileUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(flUri),
+ ContentUris.parseId(flUri));
+ try (Cursor c = mContext.getContentResolver().query(fileUri, null, null, null)) {
assertTrue(c.moveToFirst());
final ContentValues values = new ContentValues();
- DatabaseUtils.copyFromCursorToContentValues(MediaColumns.DISPLAY_NAME, c, values);
- DatabaseUtils.copyFromCursorToContentValues(MediaColumns.MIME_TYPE, c, values);
- DatabaseUtils.copyFromCursorToContentValues(MediaColumns.IS_DRM, c, values);
- DatabaseUtils.copyFromCursorToContentValues(MediaColumns.DURATION, c, values);
+ DatabaseUtils.copyFromCursorToContentValues(FileColumns.DISPLAY_NAME, c, values);
+ DatabaseUtils.copyFromCursorToContentValues(FileColumns.MIME_TYPE, c, values);
+ DatabaseUtils.copyFromCursorToContentValues(FileColumns.MEDIA_TYPE, c, values);
+ DatabaseUtils.copyFromCursorToContentValues(FileColumns.IS_DRM, c, values);
+ DatabaseUtils.copyFromCursorToContentValues(FileColumns.DURATION, c, values);
Log.v(TAG, values.toString());
// Filename should match what we found on disk
- assertEquals(flPath.getName(), values.get(MediaColumns.DISPLAY_NAME));
+ assertEquals(flPath.getName(), values.get(FileColumns.DISPLAY_NAME));
// Should always be marked as a DRM file
- assertEquals("1", values.get(MediaColumns.IS_DRM));
+ assertEquals("1", values.get(FileColumns.IS_DRM));
- final String actualMimeType = values.getAsString(MediaColumns.MIME_TYPE);
+ final String actualMimeType = values.getAsString(FileColumns.MIME_TYPE);
if (Objects.equals(mimeType, actualMimeType)) {
// We scanned the item successfully, so we can also check our
// custom verifier, if any
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index b874011..ff78be2 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -316,6 +316,11 @@
doPlaylist(R.raw.test_wpl, "test.wpl");
}
+ @Test
+ public void testPlaylistXspf() throws Exception {
+ doPlaylist(R.raw.test_xspf, "test.xspf");
+ }
+
private void doPlaylist(int res, String name) throws Exception {
final File music = new File(mDir, "Music");
music.mkdirs();