Merge "Rewrite of playlists." into rvc-dev
diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java
index 8ca04eb..b791f9e 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";
@@ -3892,6 +3894,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/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index eba9e43..2102a91 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;
@@ -440,11 +437,6 @@
         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)";
 
@@ -1851,7 +1843,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);
@@ -1948,16 +1940,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
@@ -1970,7 +1953,6 @@
                     honoredArgs.toArray(new String[honoredArgs.size()]));
             c.setExtras(extras);
         }
-
         return c;
     }
 
@@ -1980,6 +1962,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:
@@ -2016,11 +1999,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;
@@ -2334,7 +2313,6 @@
             case AUDIO_ALBUMART:
             case VIDEO_THUMBNAILS:
             case IMAGES_THUMBNAILS:
-            case AUDIO_PLAYLISTS:
                 values.remove(MediaColumns.DISPLAY_NAME);
                 values.remove(MediaColumns.MIME_TYPE);
                 break;
@@ -2800,7 +2778,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);
@@ -2831,6 +2809,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) {
@@ -2959,28 +2962,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);
             }
@@ -2994,34 +2975,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;
             }
@@ -3373,21 +3348,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
@@ -3795,14 +3755,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();
@@ -3829,6 +3789,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);
 
@@ -3856,8 +3833,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);
@@ -3870,40 +3845,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 {
@@ -3935,18 +3897,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;
             }
 
@@ -4004,6 +3959,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
@@ -4488,7 +4455,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
@@ -4519,6 +4486,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);
 
@@ -4577,17 +4589,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
@@ -4631,6 +4633,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)
@@ -4642,6 +4661,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:
@@ -4763,47 +4783,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.
@@ -4836,77 +4816,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
@@ -6295,6 +6385,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());
         }
@@ -6494,8 +6598,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;
@@ -6581,8 +6683,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/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java
index 955c027..18895bb 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);
             }
         }
 
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/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/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/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();