Check URI permissions for resumable media artwork

When resumable media is added that has artwork set via URI, check the
permissions for the URI before attempting to load it

Test: atest MediaDataManagerTest UriGrantsManagerServiceTest
Test: manual with test app
Bug: 284297452
Change-Id: Ie79915d3d1712f08dc2e8dfbd5bc7fd32bb308a3
Merged-In: Ie79915d3d1712f08dc2e8dfbd5bc7fd32bb308a3
diff --git a/core/java/android/app/IUriGrantsManager.aidl b/core/java/android/app/IUriGrantsManager.aidl
index 9e7f2fe..b630d03 100644
--- a/core/java/android/app/IUriGrantsManager.aidl
+++ b/core/java/android/app/IUriGrantsManager.aidl
@@ -39,4 +39,7 @@
     void clearGrantedUriPermissions(in String packageName, int userId);
     ParceledListSlice getUriPermissions(in String packageName, boolean incoming,
             boolean persistedOnly);
+
+    int checkGrantUriPermission_ignoreNonSystem(
+            int sourceUid, String targetPkg, in Uri uri, int modeFlags, int userId);
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
index 4c2336e..db343e5 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -18,11 +18,13 @@
 
 import android.app.Notification
 import android.app.PendingIntent
+import android.app.UriGrantsManager
 import android.app.smartspace.SmartspaceConfig
 import android.app.smartspace.SmartspaceManager
 import android.app.smartspace.SmartspaceSession
 import android.app.smartspace.SmartspaceTarget
 import android.content.BroadcastReceiver
+import android.content.ContentProvider
 import android.content.ContentResolver
 import android.content.Context
 import android.content.Intent
@@ -592,10 +594,13 @@
             Log.d(TAG, "adding track for $userId from browser: $desc")
         }
 
+        val currentEntry = mediaEntries.get(packageName)
+        val appUid = currentEntry?.appUid ?: Process.INVALID_UID
+
         // Album art
         var artworkBitmap = desc.iconBitmap
         if (artworkBitmap == null && desc.iconUri != null) {
-            artworkBitmap = loadBitmapFromUri(desc.iconUri!!)
+            artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
         }
         val artworkIcon = if (artworkBitmap != null) {
             Icon.createWithBitmap(artworkBitmap)
@@ -603,9 +608,7 @@
             null
         }
 
-        val currentEntry = mediaEntries.get(packageName)
         val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
-        val appUid = currentEntry?.appUid ?: Process.INVALID_UID
 
         val mediaAction = getResumeMediaAction(resumeAction)
         val lastActive = systemClock.elapsedRealtime()
@@ -1000,6 +1003,30 @@
             false
         }
     }
+
+    /** Returns a bitmap if the user can access the given URI, else null */
+    private fun loadBitmapFromUriForUser(
+        uri: Uri,
+        userId: Int,
+        appUid: Int,
+        packageName: String,
+    ): Bitmap? {
+        try {
+            val ugm = UriGrantsManager.getService()
+            ugm.checkGrantUriPermission_ignoreNonSystem(
+                appUid,
+                packageName,
+                ContentProvider.getUriWithoutUserId(uri),
+                Intent.FLAG_GRANT_READ_URI_PERMISSION,
+                ContentProvider.getUserIdFromUri(uri, userId)
+            )
+            return loadBitmapFromUri(uri)
+        } catch (e: SecurityException) {
+            Log.e(TAG, "Failed to get URI permission: $e")
+        }
+        return null
+    }
+
     /**
      * Load a bitmap from a URI
      * @param uri the uri to load
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
index 52266f9..347ea3b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
@@ -1,18 +1,22 @@
 package com.android.systemui.media
 
+import android.app.IUriGrantsManager
 import android.app.Notification
 import android.app.Notification.MediaStyle
 import android.app.PendingIntent
+import android.app.UriGrantsManager
 import android.app.smartspace.SmartspaceAction
 import android.app.smartspace.SmartspaceTarget
 import android.content.Intent
 import android.graphics.Bitmap
+import android.graphics.ImageDecoder
 import android.graphics.drawable.Icon
 import android.media.MediaDescription
 import android.media.MediaMetadata
 import android.media.session.MediaController
 import android.media.session.MediaSession
 import android.media.session.PlaybackState
+import android.net.Uri
 import android.os.Bundle
 import android.provider.Settings
 import android.service.notification.StatusBarNotification
@@ -20,6 +24,7 @@
 import android.testing.TestableLooper.RunWithLooper
 import androidx.media.utils.MediaConstants
 import androidx.test.filters.SmallTest
+import com.android.dx.mockito.inline.extended.ExtendedMockito
 import com.android.internal.logging.InstanceId
 import com.android.systemui.InstanceIdSequenceFake
 import com.android.systemui.R
@@ -53,8 +58,10 @@
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.MockitoSession
 import org.mockito.junit.MockitoJUnit
 import org.mockito.Mockito.`when` as whenever
+import org.mockito.quality.Strictness
 
 private const val KEY = "KEY"
 private const val KEY_2 = "KEY_2"
@@ -111,14 +118,25 @@
     private val clock = FakeSystemClock()
     @Mock private lateinit var tunerService: TunerService
     @Captor lateinit var tunableCaptor: ArgumentCaptor<TunerService.Tunable>
+    @Mock private lateinit var ugm: IUriGrantsManager
+    @Mock private lateinit var imageSource: ImageDecoder.Source
 
     private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
 
     private val originalSmartspaceSetting = Settings.Secure.getInt(context.contentResolver,
             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 1)
 
+    private lateinit var staticMockSession: MockitoSession
+
     @Before
     fun setup() {
+        staticMockSession =
+            ExtendedMockito.mockitoSession()
+                .mockStatic<UriGrantsManager>(UriGrantsManager::class.java)
+                .mockStatic<ImageDecoder>(ImageDecoder::class.java)
+                .strictness(Strictness.LENIENT)
+                .startMocking()
+        whenever(UriGrantsManager.getService()).thenReturn(ugm)
         foregroundExecutor = FakeExecutor(clock)
         backgroundExecutor = FakeExecutor(clock)
         smartspaceMediaDataProvider = SmartspaceMediaDataProvider()
@@ -195,6 +213,7 @@
 
     @After
     fun tearDown() {
+        staticMockSession.finishMocking()
         session.release()
         mediaDataManager.destroy()
         Settings.Secure.putInt(context.contentResolver,
@@ -1093,6 +1112,66 @@
             anyBoolean())
     }
 
+    @Test
+    fun testResumeMediaLoaded_hasArtPermission_artLoaded() {
+        // When resume media is loaded and user/app has permission to access the art URI,
+        whenever(
+                ugm.checkGrantUriPermission_ignoreNonSystem(
+                    anyInt(),
+                    any(),
+                    any(),
+                    anyInt(),
+                    anyInt()
+                )
+            )
+            .thenReturn(1)
+        val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
+        val uri = Uri.parse("content://example")
+        whenever(ImageDecoder.createSource(any(), eq(uri))).thenReturn(imageSource)
+        whenever(ImageDecoder.decodeBitmap(any(), any())).thenReturn(artwork)
+
+        val desc =
+            MediaDescription.Builder().run {
+                setTitle(SESSION_TITLE)
+                setIconUri(uri)
+                build()
+            }
+        addResumeControlAndLoad(desc)
+
+        // Then the artwork is loaded
+        assertThat(mediaDataCaptor.value.artwork).isNotNull()
+    }
+
+    @Test
+    fun testResumeMediaLoaded_noArtPermission_noArtLoaded() {
+        // When resume media is loaded and user/app does not have permission to access the art URI
+        whenever(
+                ugm.checkGrantUriPermission_ignoreNonSystem(
+                    anyInt(),
+                    any(),
+                    any(),
+                    anyInt(),
+                    anyInt()
+                )
+            )
+            .thenThrow(SecurityException("Test no permission"))
+        val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
+        val uri = Uri.parse("content://example")
+        whenever(ImageDecoder.createSource(any(), eq(uri))).thenReturn(imageSource)
+        whenever(ImageDecoder.decodeBitmap(any(), any())).thenReturn(artwork)
+
+        val desc =
+            MediaDescription.Builder().run {
+                setTitle(SESSION_TITLE)
+                setIconUri(uri)
+                build()
+            }
+        addResumeControlAndLoad(desc)
+
+        // Then the artwork is not loaded
+        assertThat(mediaDataCaptor.value.artwork).isNull()
+    }
+
     /**
      * Helper function to add a media notification and capture the resulting MediaData
      */
@@ -1103,4 +1182,32 @@
         verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
             eq(0), eq(false))
     }
+
+    /** Helper function to add a resumption control and capture the resulting MediaData */
+    private fun addResumeControlAndLoad(
+        desc: MediaDescription,
+        packageName: String = PACKAGE_NAME
+    ) {
+        mediaDataManager.addResumptionControls(
+            USER_ID,
+            desc,
+            Runnable {},
+            session.sessionToken,
+            APP_NAME,
+            pendingIntent,
+            packageName
+        )
+        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+
+        verify(listener)
+            .onMediaDataLoaded(
+                eq(packageName),
+                eq(null),
+                capture(mediaDataCaptor),
+                eq(true),
+                eq(0),
+                eq(false)
+            )
+    }
 }
diff --git a/services/core/java/com/android/server/uri/UriGrantsManagerService.java b/services/core/java/com/android/server/uri/UriGrantsManagerService.java
index 6aa06e8..92329cf 100644
--- a/services/core/java/com/android/server/uri/UriGrantsManagerService.java
+++ b/services/core/java/com/android/server/uri/UriGrantsManagerService.java
@@ -41,6 +41,7 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
 import android.app.ActivityManager;
 import android.app.ActivityManagerInternal;
 import android.app.AppGlobals;
@@ -62,6 +63,7 @@
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteException;
 import android.os.SystemClock;
 import android.os.UserHandle;
@@ -1304,6 +1306,46 @@
         return false;
     }
 
+    /**
+     * Check if the targetPkg can be granted permission to access uri by
+     * the callingUid using the given modeFlags. See {@link #checkGrantUriPermissionUnlocked}.
+     *
+     * @param callingUid The uid of the grantor app that has permissions to the uri.
+     * @param targetPkg The package name of the granted app that needs permissions to the uri.
+     * @param uri The uri for which permissions should be granted.
+     * @param modeFlags The modes to grant. See {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, etc.
+     * @param userId The userId in which the uri is to be resolved.
+     * @return uid of the target or -1 if permission grant not required. Returns -1 if the caller
+     *  does not hold INTERACT_ACROSS_USERS_FULL
+     * @throws SecurityException if the grant is not allowed.
+     */
+    @Override
+    @RequiresPermission(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL)
+    public int checkGrantUriPermission_ignoreNonSystem(int callingUid, String targetPkg, Uri uri,
+            int modeFlags, int userId) {
+        if (!isCallerIsSystemOrPrivileged()) {
+            return Process.INVALID_UID;
+        }
+        final long origId = Binder.clearCallingIdentity();
+        try {
+            return checkGrantUriPermissionUnlocked(callingUid, targetPkg, uri, modeFlags,
+                userId);
+        } finally {
+            Binder.restoreCallingIdentity(origId);
+        }
+    }
+
+    private boolean isCallerIsSystemOrPrivileged() {
+        final int uid = Binder.getCallingUid();
+        if (uid == Process.SYSTEM_UID || uid == Process.ROOT_UID) {
+            return true;
+        }
+        return ActivityManager.checkComponentPermission(
+            android.Manifest.permission.INTERACT_ACROSS_USERS_FULL,
+            uid, /* owningUid = */-1, /* exported = */ true)
+            == PackageManager.PERMISSION_GRANTED;
+    }
+
     @Override
     public ArrayList<UriPermission> providePersistentUriGrants() {
         final ArrayList<UriPermission> result = new ArrayList<>();