Don't include audio items from ejected volume in the playlist

When app queries for audio files from playlist, we should only include
audio files of volumes that are attached. Previously, we included audio
files from all volumes, including the audio files from recently ejected
volumes. When app tries to read these files, this will resullt in an
error as file doesn't exist.

Changed query method to include only attached volume while querying for
audio files from playlists.

Added tests to verify the same.

Bug: 176199779
Test: atest
com.android.providers.media.client.ClientPlaylistTest#testEjectedVolume

Change-Id: Ibb2d5cb4e7bcecbc316ead53fdca9666e2a84000
Merged-In: Ibb2d5cb4e7bcecbc316ead53fdca9666e2a84000
(cherry picked from commit 750471dc98374bcf9a6de49eefa881e641648a4a)
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 60bf87b..9484324 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -4269,6 +4269,13 @@
                     qb.setProjectionMap(projectionMap);
 
                     appendWhereStandalone(qb, "audio._id = audio_id");
+                    // Since we use audio table along with audio_playlists_map
+                    // for querying, we should only include database rows of
+                    // the attached volumes.
+                    if (!includeAllVolumes) {
+                        appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN "
+                             + includeVolumes);
+                    }
                 } else {
                     qb.setTables("audio_playlists_map");
                     qb.setProjectionMap(getProjectionMap(Audio.Playlists.Members.class));
@@ -5940,9 +5947,9 @@
     private long addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values)
             throws FallbackException {
         final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID);
-        final String audioVolumeName = MediaStore.VOLUME_INTERNAL.equals(getVolumeName(playlistUri))
+        final String volumeName = MediaStore.VOLUME_INTERNAL.equals(getVolumeName(playlistUri))
                 ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL;
-        final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId);
+        final Uri audioUri = Audio.Media.getContentUri(volumeName, audioId);
 
         Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER);
         playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE;
@@ -5961,8 +5968,8 @@
             resolvePlaylistMembers(playlistUri);
 
             // Callers are interested in the actual ID we generated
-            final Uri membersUri = Playlists.Members.getContentUri(
-                    getVolumeName(playlistUri), ContentUris.parseId(playlistUri));
+            final Uri membersUri = Playlists.Members.getContentUri(volumeName,
+                    ContentUris.parseId(playlistUri));
             try (Cursor c = query(membersUri, new String[] { BaseColumns._ID },
                     Playlists.Members.PLAY_ORDER + "=" + (playOrder + 1), null, null)) {
                 c.moveToFirst();
diff --git a/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java b/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java
index fc58590..e1af6f6 100644
--- a/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java
+++ b/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java
@@ -16,43 +16,33 @@
 
 package com.android.providers.media.client;
 
+import static com.android.providers.media.client.PublicVolumeSetupHelper.createNewPublicVolume;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.deletePublicVolumes;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.executeShellCommand;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.pollForCondition;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertTrue;
 
-import android.app.UiAutomation;
-import android.content.ContentResolver;
 import android.content.Context;
-import android.os.ParcelFileDescriptor;
 import android.os.storage.StorageManager;
 import android.provider.MediaStore;
-import android.util.Log;
 
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.google.common.io.ByteStreams;
-
-import org.junit.After;
 import org.junit.AfterClass;
-import org.junit.Assume;
-import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.function.Supplier;
 
 /**
  * Verify DownloadProvider's access to app's private files on primary and public volumes.
@@ -64,8 +54,6 @@
 
     private static final String TAG = "DownloadProviderTest";
     private static final  String OTHER_PKG_NAME = "com.example.foo";
-    private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(1);
-    private static final long POLLING_SLEEP_MILLIS = 100;
 
     @BeforeClass
     public static void setUp() throws Exception {
@@ -77,9 +65,6 @@
         deletePublicVolumes();
     }
 
-    private static void deletePublicVolumes() throws Exception {
-        executeShellCommand("sm set-virtual-disk false");
-    }
 
     @Test
     public void testCanReadWriteOtherAppPrivateFiles() throws Exception {
@@ -164,25 +149,6 @@
             .getStorageVolume(MediaStore.Files.getContentUri(volumeName)).getDirectory();
     }
 
-    private static String executeShellCommand(String cmd) throws IOException {
-        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
-        ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(cmd);
-        try (FileInputStream output = new FileInputStream(pfd.getFileDescriptor())) {
-            return new String(ByteStreams.toByteArray(output));
-        }
-    }
-
-    private static void pollForCondition(Supplier<Boolean> condition, String errorMessage)
-            throws Exception {
-        for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
-            if (condition.get()) {
-                return;
-            }
-            Thread.sleep(POLLING_SLEEP_MILLIS);
-        }
-        throw new TimeoutException(errorMessage);
-    }
-
     /**
      * Polls for directory to be created
      */
@@ -200,38 +166,4 @@
             () -> !dir.exists(),
             "Timed out while waiting for dir " + dir + " to be deleted");
     }
-
-    /**
-     * Creates a new virtual public volume and returns the volume's name.
-     */
-    private static void createNewPublicVolume() throws Exception {
-        executeShellCommand("sm set-force-adoptable on");
-        executeShellCommand("sm set-virtual-disk true");
-        pollForCondition(() -> partitionDisk(), "Timed out while waiting for"
-                + " disk partitioning");
-        pollForCondition(() -> isPublicVolumeMounted(), "Timed out while waiting for"
-                + " the public volume to mount");
-    }
-
-    private static boolean isPublicVolumeMounted() {
-        try {
-            final String publicVolume = executeShellCommand("sm list-volumes public").trim();
-            return publicVolume != null && publicVolume.contains("mounted");
-        } catch (Exception e) {
-            return false;
-        }
-    }
-
-    private static boolean partitionDisk() {
-        try {
-            final String listDisks = executeShellCommand("sm list-disks").trim();
-            if (listDisks.length() > 0) {
-                executeShellCommand("sm partition " + listDisks + " public");
-                return true;
-            }
-            return false;
-        } catch (Exception e) {
-            return false;
-        }
-    }
 }
diff --git a/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java b/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java
new file mode 100644
index 0000000..cdb0abf
--- /dev/null
+++ b/tests/client/src/com/android/providers/media/client/PublicVolumePlaylistTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2021 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;
+import static android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY;
+
+import static com.android.providers.media.client.PublicVolumeSetupHelper.createNewPublicVolume;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.deletePublicVolumes;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.mountPublicVolume;
+import static com.android.providers.media.client.PublicVolumeSetupHelper.unmountPublicVolume;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertNotNull;
+
+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.MediaStore;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.OutputStream;
+
+@RunWith(AndroidJUnit4.class)
+public class PublicVolumePlaylistTest {
+    @BeforeClass
+    public static void setUp() throws Exception {
+        createNewPublicVolume();
+    }
+
+    @AfterClass
+    public static void tearDown() throws Exception {
+        deletePublicVolumes();
+    }
+
+    /**
+     * Test that playlist query doesn't return audio files of ejected volume.
+     */
+    @Test
+    public void testEjectedVolume() throws Exception {
+        ContentValues values = new ContentValues();
+        values.clear();
+        values.put(MediaStore.MediaColumns.DISPLAY_NAME, "Playlist " + System.nanoTime());
+        values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/x-mpegurl");
+
+        Context context = InstrumentationRegistry.getTargetContext();
+        ContentResolver contentResolver = context.getContentResolver();
+        MediaStore.waitForIdle(contentResolver);
+
+        final Uri externalPlaylists = MediaStore.Audio.Playlists
+                .getContentUri(VOLUME_EXTERNAL_PRIMARY);
+        final Uri playlist = contentResolver.insert(externalPlaylists, values);
+        assertNotNull(playlist);
+        // Use external uri for playlists to be able to add audio files from
+        // different volumes
+        final Uri members = MediaStore.Audio.Playlists.Members
+                .getContentUri(VOLUME_EXTERNAL, ContentUris.parseId(playlist));
+
+        mountPublicVolume();
+
+        // Create audio files in both volumes and add them to playlist.
+        for (String volumeName : MediaStore.getExternalVolumeNames(context)) {
+            values.clear();
+            values.put(MediaStore.MediaColumns.DISPLAY_NAME, "Song " + System.nanoTime());
+            values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/mpeg");
+
+            final Uri audioUri = contentResolver.insert(
+                    MediaStore.Audio.Media.getContentUri(volumeName), values);
+            assertNotNull(audioUri);
+            try (OutputStream out = contentResolver.openOutputStream(audioUri)) {
+            }
+
+            values.clear();
+            values.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, ContentUris.parseId(audioUri));
+            assertThat(contentResolver.insert(members, values)).isNotNull();
+        }
+
+        final int volumeCount = MediaStore.getExternalVolumeNames(context).size();
+        // Verify that we can see audio files from both volumes in the playlist.
+        try (Cursor c = contentResolver.query(members, new String[] {
+                MediaStore.Audio.Playlists.Members.AUDIO_ID}, Bundle.EMPTY, null)) {
+            assertThat(c.getCount()).isEqualTo(volumeCount);
+        }
+
+        unmountPublicVolume();
+        // Verify that we don't see audio file from the ejected volume.
+        try (Cursor c = contentResolver.query(members, new String[] {
+                MediaStore.Audio.Playlists.Members.AUDIO_ID}, Bundle.EMPTY, null)) {
+            assertThat(c.getCount()).isEqualTo(volumeCount-1);
+        }
+    }
+}
+
diff --git a/tests/client/src/com/android/providers/media/client/PublicVolumeSetupHelper.java b/tests/client/src/com/android/providers/media/client/PublicVolumeSetupHelper.java
new file mode 100644
index 0000000..73bcf41
--- /dev/null
+++ b/tests/client/src/com/android/providers/media/client/PublicVolumeSetupHelper.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2021 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 android.app.UiAutomation;
+import android.os.Environment;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.google.common.io.ByteStreams;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+
+/**
+ * Helper methods for public volume setup.
+ */
+class PublicVolumeSetupHelper {
+    private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(2);
+    private static final long POLLING_SLEEP_MILLIS = 100;
+    private static final String TAG = "TestUtils";
+    private static boolean usingExistingPublicVolume = false;
+
+    /**
+     * Creates a new virtual public volume and returns the volume's name.
+     */
+    static void createNewPublicVolume() throws Exception {
+        // Skip public volume setup if we can use already available public volume on the device.
+        if (getCurrentPublicVolumeString() != null && isPublicVolumeMounted()) {
+            usingExistingPublicVolume = true;
+            return;
+        }
+        executeShellCommand("sm set-force-adoptable on");
+        executeShellCommand("sm set-virtual-disk true");
+        pollForCondition(() -> partitionDisk(), "Timed out while waiting for"
+                + " disk partitioning");
+        // Poll twice to avoid using previous mount status
+        pollForCondition(() -> isPublicVolumeMounted(), "Timed out while waiting for"
+                + " the public volume to mount");
+        pollForCondition(() -> isExternalStorageStateMounted(), "Timed out while"
+               + " waiting for ExternalStorageState to be MEDIA_MOUNTED");
+    }
+
+    private static boolean isExternalStorageStateMounted() {
+        final File target = Environment.getExternalStorageDirectory();
+        try {
+            return (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState(target))
+                    && Os.statvfs(target.getAbsolutePath()).f_blocks > 0);
+        } catch (ErrnoException ignored) {
+        }
+        return false;
+    }
+
+    private static boolean isPublicVolumeMounted() {
+        try {
+            final String publicVolume = executeShellCommand("sm list-volumes public").trim();
+            return publicVolume != null && publicVolume.contains("mounted");
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private static boolean partitionDisk() {
+        try {
+            final String listDisks = executeShellCommand("sm list-disks").trim();
+            if (listDisks.length() > 0) {
+                executeShellCommand("sm partition " + listDisks + " public");
+                return true;
+            }
+            return false;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * Gets the name of the public volume string from list-volumes,
+     * waiting for a bit for it to be available.
+     */
+    private static String getPublicVolumeString() throws Exception {
+        final String[] volName = new String[1];
+        pollForCondition(() -> {
+            volName[0] = getCurrentPublicVolumeString();
+            return volName[0] != null;
+        }, "Timed out while waiting for public volume to be ready");
+
+        return volName[0];
+    }
+
+    /**
+     * @return the currently mounted public volume string, if any.
+     */
+    private static String getCurrentPublicVolumeString() {
+        final String[] allPublicVolumeDetails;
+        try {
+            allPublicVolumeDetails = executeShellCommand("sm list-volumes public")
+                    .trim().split("\n");
+        } catch (Exception e) {
+            Log.e(TAG, "Failed to execute shell command", e);
+            return null;
+        }
+        for (String volDetails : allPublicVolumeDetails) {
+            if (volDetails.startsWith("public")) {
+                final String[] publicVolumeDetails = volDetails.trim().split(" ");
+                String res = publicVolumeDetails[0];
+                if ("null".equals(res)) {
+                    continue;
+                }
+                return res;
+            }
+        }
+        return null;
+    }
+
+    static void mountPublicVolume() throws Exception {
+        executeShellCommand("sm mount " + getPublicVolumeString());
+    }
+
+    static void unmountPublicVolume() throws Exception {
+        executeShellCommand("sm unmount " + getPublicVolumeString());
+    }
+
+    static void deletePublicVolumes() throws Exception {
+        if (!usingExistingPublicVolume) {
+            executeShellCommand("sm set-virtual-disk false");
+            // Wait for the public volume to disappear.
+            for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
+                if (!isPublicVolumeMounted()) {
+                    return;
+                }
+                Thread.sleep(POLLING_SLEEP_MILLIS);
+            }
+        }
+    }
+
+    /**
+     * Executes a shell command.
+     */
+    public static String executeShellCommand(String pattern, Object...args) throws IOException {
+        String command = String.format(pattern, args);
+        int attempt = 0;
+        while (attempt++ < 5) {
+            try {
+                return executeShellCommandInternal(command);
+            } catch (InterruptedIOException e) {
+                // Hmm, we had trouble executing the shell command; the best we
+                // can do is try again a few more times
+                Log.v(TAG, "Trouble executing " + command + "; trying again", e);
+            }
+        }
+        throw new IOException("Failed to execute " + command);
+    }
+
+    private static String executeShellCommandInternal(String cmd) throws IOException {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+        try (FileInputStream output = new FileInputStream(
+                uiAutomation.executeShellCommand(cmd).getFileDescriptor())) {
+            return new String(ByteStreams.toByteArray(output));
+        }
+    }
+
+    static void pollForCondition(Supplier<Boolean> condition, String errorMessage)
+            throws Exception {
+        for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
+            if (condition.get()) {
+                return;
+            }
+            Thread.sleep(POLLING_SLEEP_MILLIS);
+        }
+        throw new TimeoutException(errorMessage);
+    }
+}