Offer fallback playlist member resolution.

Ideally playlist members should be an exact match, but this isn't
always the case, such as when a user manually shuffles files around
without updating playlist files, or when the playlists use absolute
paths on another device.

To handle this case, when there is no exact match, we're willing to
relax our search to look for a media item with that DISPLAY_NAME.

Bug: 149941437
Test: atest --test-mapping packages/providers/MediaProvider
Change-Id: I9dddb324bca9f14c076a86fda08e6b9d2e3a1971
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 77497f4..555e85a 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -125,7 +125,6 @@
 import android.os.Trace;
 import android.os.UserHandle;
 import android.os.storage.StorageManager;
-import android.os.storage.StorageManager.StorageVolumeCallback;
 import android.os.storage.StorageVolume;
 import android.preference.PreferenceManager;
 import android.provider.BaseColumns;
@@ -4802,6 +4801,7 @@
         db.beginTransaction();
         try {
             // Refresh playlist members based on what we parse from disk
+            final String volumeName = getVolumeName(playlistUri);
             final long playlistId = ContentUris.parseId(playlistUri);
             db.delete("audio_playlists_map", "playlist_id=" + playlistId, null);
 
@@ -4811,18 +4811,17 @@
 
             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);
-                    }
+                try {
+                    final Path audioPath = playlistPath.getParent().resolve(members.get(i));
+                    final long audioId = queryForPlaylistMember(volumeName, audioPath);
+
+                    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, audioId);
+                    db.insert("audio_playlists_map", null, values);
+                } catch (IOException e) {
+                    Log.w(TAG, "Failed to resolve playlist member", e);
                 }
             }
             db.setTransactionSuccessful();
@@ -4834,6 +4833,30 @@
     }
 
     /**
+     * Make two attempts to query this playlist member: first based on the exact
+     * path, and if that fails, fall back to picking a single item matching the
+     * display name. When there are multiple items with the same display name,
+     * we can't resolve between them, and leave this member unresolved.
+     */
+    private long queryForPlaylistMember(@NonNull String volumeName, @NonNull Path path)
+            throws IOException {
+        final Uri audioUri = Audio.Media.getContentUri(volumeName);
+        try (Cursor c = queryForSingleItem(audioUri,
+                new String[] { BaseColumns._ID }, MediaColumns.DATA + "=?",
+                new String[] { path.toFile().getCanonicalPath() }, null)) {
+            return c.getLong(0);
+        } catch (FileNotFoundException ignored) {
+        }
+        try (Cursor c = queryForSingleItem(audioUri,
+                new String[] { BaseColumns._ID }, MediaColumns.DISPLAY_NAME + "=?",
+                new String[] { path.toFile().getName() }, null)) {
+            return c.getLong(0);
+        } catch (FileNotFoundException ignored) {
+        }
+        throw new FileNotFoundException();
+    }
+
+    /**
      * 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.
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index d02235e..1e79545 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -388,6 +388,28 @@
             cursor.moveToNext();
             assertEquals("003.mp3", cursor.getString(0));
         }
+
+        // Replace media file in a completely different location, which normally
+        // wouldn't match the exact playlist path, but we're willing to perform
+        // a relaxed search
+        final File soundtracks = new File(mDir, "Soundtracks");
+        soundtracks.mkdirs();
+        stage(R.raw.test_audio, new File(soundtracks, "002.mp3"));
+        stage(res, new File(music, name));
+
+        mModern.scanDirectory(mDir, REASON_UNKNOWN);
+
+        try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
+                MediaColumns.DISPLAY_NAME
+        }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
+            assertEquals(5, cursor.getCount());
+            cursor.moveToNext();
+            assertEquals("001.mp3", cursor.getString(0));
+            cursor.moveToNext();
+            assertEquals("002.mp3", cursor.getString(0));
+            cursor.moveToNext();
+            assertEquals("003.mp3", cursor.getString(0));
+        }
     }
 
     @Test