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();