Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c5dc24bc authored by Beth Thibodeau's avatar Beth Thibodeau
Browse files

Prevent media controls with no title

Apps targeting U+ will be required to include a non-empty title when posting
media controls. If the title is blank, the notification will be
cancelled and report an error back to the app.

For apps not yet targeting U, a placeholder string with the app name will be
used for the title instead.

Bug: 274775190
Test: atest MediaDataManagerTest
Test: manual with test app and YTM
Change-Id: I2db9edd269c2b62ee7de6714875fa098567510b6
Merged-In: I2db9edd269c2b62ee7de6714875fa098567510b6
(cherry picked from commit e3a971d8)
(cherry picked from commit 8a7ab0e9)
parent a6cc8ca9
Loading
Loading
Loading
Loading
+24 −0
Original line number Diff line number Diff line
@@ -571,6 +571,15 @@ public class StatusBarManager {
    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.TIRAMISU)
    private static final long MEDIA_CONTROL_SESSION_ACTIONS = 203800354L;

    /**
     * Media controls based on {@link android.app.Notification.MediaStyle} notifications will be
     * required to include a non-empty title, either in the {@link android.media.MediaMetadata} or
     * notification title.
     */
    @ChangeId
    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    private static final long MEDIA_CONTROL_REQUIRES_TITLE = 274775190L;

    @UnsupportedAppUsage
    private Context mContext;
    private IStatusBarService mService;
@@ -1216,6 +1225,21 @@ public class StatusBarManager {
        return CompatChanges.isChangeEnabled(MEDIA_CONTROL_SESSION_ACTIONS, packageName, user);
    }

    /**
     * Checks whether the given package must include a non-empty title for its media controls.
     *
     * @param packageName App posting media controls
     * @param user Current user handle
     * @return true if the app is required to provide a non-empty title
     *
     * @hide
     */
    @RequiresPermission(allOf = {android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
            android.Manifest.permission.LOG_COMPAT_CHANGE})
    public static boolean isMediaTitleRequiredForApp(String packageName, UserHandle user) {
        return CompatChanges.isChangeEnabled(MEDIA_CONTROL_REQUIRES_TITLE, packageName, user);
    }

    /**
     * Checks whether the supplied activity can {@link Activity#startActivityForResult(Intent, int)}
     * a system activity that captures content on the screen to take a screenshot.
+3 −1
Original line number Diff line number Diff line
@@ -2584,7 +2584,7 @@
    <!-- Title for media controls [CHAR_LIMIT=50] -->
    <string name="controls_media_title">Media</string>
    <!-- Explanation for closing controls associated with a specific media session [CHAR_LIMIT=50] -->
    <string name="controls_media_close_session">Hide this media control for <xliff:g id="app_name" example="YouTube Music">%1$s</xliff:g>?</string>
    <string name="controls_media_close_session">Hide this media control for <xliff:g id="app_name" example="Foo Music App">%1$s</xliff:g>?</string>
    <!-- Explanation that controls associated with a specific media session are active [CHAR_LIMIT=50] -->
    <string name="controls_media_active_session">The current media session cannot be hidden.</string>
    <!-- Label for a button that will hide media controls [CHAR_LIMIT=30] -->
@@ -2597,6 +2597,8 @@
    <string name="controls_media_playing_item_description"><xliff:g id="song_name" example="Daily mix">%1$s</xliff:g> by <xliff:g id="artist_name" example="Various artists">%2$s</xliff:g> is playing from <xliff:g id="app_label" example="Spotify">%3$s</xliff:g></string>
    <!-- Content description for media cotnrols progress bar [CHAR_LIMIT=NONE] -->
    <string name="controls_media_seekbar_description"><xliff:g id="elapsed_time" example="1:30">%1$s</xliff:g> of <xliff:g id="total_time" example="3:00">%2$s</xliff:g></string>
    <!-- Placeholder title to inform user that an app has posted media controls [CHAR_LIMIT=NONE] -->
    <string name="controls_media_empty_title"><xliff:g id="app_name" example="Foo Music App">%1$s</xliff:g> is running</string>

    <!-- Description for button in media controls. Pressing button starts playback [CHAR_LIMIT=NONE] -->
    <string name="controls_media_button_play">Play</string>
+55 −22
Original line number Diff line number Diff line
@@ -43,6 +43,7 @@ import android.media.session.PlaybackState
import android.net.Uri
import android.os.Parcelable
import android.os.Process
import android.os.RemoteException
import android.os.UserHandle
import android.provider.Settings
import android.service.notification.StatusBarNotification
@@ -52,6 +53,7 @@ import android.util.Log
import android.util.Pair as APair
import androidx.media.utils.MediaConstants
import com.android.internal.logging.InstanceId
import com.android.internal.statusbar.IStatusBarService
import com.android.keyguard.KeyguardUpdateMonitor
import com.android.systemui.Dumpable
import com.android.systemui.R
@@ -137,6 +139,8 @@ internal val EMPTY_SMARTSPACE_MEDIA_DATA =
        expiryTimeMs = 0,
    )

const val MEDIA_TITLE_ERROR_MESSAGE = "Invalid media data: title is null or blank."

fun isMediaNotification(sbn: StatusBarNotification): Boolean {
    return sbn.notification.isMediaNotification()
}
@@ -181,6 +185,7 @@ class MediaDataManager(
    private val logger: MediaUiEventLogger,
    private val smartspaceManager: SmartspaceManager,
    private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
    private val statusBarService: IStatusBarService,
) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener {

    companion object {
@@ -252,6 +257,7 @@ class MediaDataManager(
        mediaFlags: MediaFlags,
        logger: MediaUiEventLogger,
        smartspaceManager: SmartspaceManager,
        statusBarService: IStatusBarService,
        keyguardUpdateMonitor: KeyguardUpdateMonitor,
    ) : this(
        context,
@@ -277,6 +283,7 @@ class MediaDataManager(
        logger,
        smartspaceManager,
        keyguardUpdateMonitor,
        statusBarService,
    )

    private val appChangeReceiver =
@@ -378,21 +385,21 @@ class MediaDataManager(

    fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
        if (useQsMediaPlayer && isMediaNotification(sbn)) {
            var logEvent = false
            var isNewlyActiveEntry = false
            Assert.isMainThread()
            val oldKey = findExistingEntry(key, sbn.packageName)
            if (oldKey == null) {
                val instanceId = logger.getNewInstanceId()
                val temp = LOADING.copy(packageName = sbn.packageName, instanceId = instanceId)
                mediaEntries.put(key, temp)
                logEvent = true
                isNewlyActiveEntry = true
            } else if (oldKey != key) {
                // Resume -> active conversion; move to new key
                val oldData = mediaEntries.remove(oldKey)!!
                logEvent = true
                isNewlyActiveEntry = true
                mediaEntries.put(key, oldData)
            }
            loadMediaData(key, sbn, oldKey, logEvent)
            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
        } else {
            onNotificationRemoved(key)
        }
@@ -475,9 +482,9 @@ class MediaDataManager(
        key: String,
        sbn: StatusBarNotification,
        oldKey: String?,
        logEvent: Boolean = false
        isNewlyActiveEntry: Boolean = false,
    ) {
        backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, logEvent) }
        backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
    }

    /** Add a listener for changes in this class */
@@ -601,10 +608,12 @@ class MediaDataManager(
        }
    }

    private fun removeEntry(key: String) {
    private fun removeEntry(key: String, logEvent: Boolean = true) {
        mediaEntries.remove(key)?.let {
            if (logEvent) {
                logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
            }
        }
        notifyMediaDataRemoved(key)
    }

@@ -751,7 +760,7 @@ class MediaDataManager(
        key: String,
        sbn: StatusBarNotification,
        oldKey: String?,
        logEvent: Boolean = false
        isNewlyActiveEntry: Boolean = false,
    ) {
        val token =
            sbn.notification.extras.getParcelable(
@@ -772,6 +781,42 @@ class MediaDataManager(
            )
                ?: getAppInfoFromPackage(sbn.packageName)

        // App name
        val appName = getAppName(sbn, appInfo)

        // Song name
        var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
        if (song == null) {
            song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
        }
        if (song == null) {
            song = HybridGroupManager.resolveTitle(notif)
        }
        if (song.isNullOrBlank()) {
            if (mediaFlags.isMediaTitleRequired(sbn.packageName, sbn.user)) {
                // App is required to provide a title: cancel the underlying notification
                try {
                    statusBarService.onNotificationError(
                        sbn.packageName,
                        sbn.tag,
                        sbn.id,
                        sbn.uid,
                        sbn.initialPid,
                        MEDIA_TITLE_ERROR_MESSAGE,
                        sbn.user.identifier
                    )
                } catch (e: RemoteException) {
                    Log.e(TAG, "cancelNotification failed: $e")
                }
                // Only add log for media removed if active media is updated with invalid title.
                foregroundExecutor.execute { removeEntry(key, !isNewlyActiveEntry) }
                return
            } else {
                // For apps that don't have the title requirement yet, add a placeholder
                song = context.getString(R.string.controls_media_empty_title, appName)
            }
        }

        // Album art
        var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
        if (artworkBitmap == null) {
@@ -787,21 +832,9 @@ class MediaDataManager(
                Icon.createWithBitmap(artworkBitmap)
            }

        // App name
        val appName = getAppName(sbn, appInfo)

        // App Icon
        val smallIcon = sbn.notification.smallIcon

        // Song name
        var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
        if (song == null) {
            song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
        }
        if (song == null) {
            song = HybridGroupManager.resolveTitle(notif)
        }

        // Explicit Indicator
        var isExplicit = false
        if (mediaFlags.isExplicitIndicatorEnabled()) {
@@ -873,7 +906,7 @@ class MediaDataManager(
        val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
        val appUid = appInfo?.uid ?: Process.INVALID_UID

        if (logEvent) {
        if (isNewlyActiveEntry) {
            logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
            logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
        } else if (playbackLocation != currentEntry?.playbackLocation) {
+5 −0
Original line number Diff line number Diff line
@@ -64,4 +64,9 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) {

    /** Check whether we allow remote media to generate resume controls */
    fun isRemoteResumeAllowed() = featureFlags.isEnabled(Flags.MEDIA_REMOTE_RESUME)

    /** Check whether app is required to provide a non-empty media title */
    fun isMediaTitleRequired(packageName: String, user: UserHandle): Boolean {
        return StatusBarManager.isMediaTitleRequiredForApp(packageName, user)
    }
}
+206 −11
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import android.app.smartspace.SmartspaceConfig
import android.app.smartspace.SmartspaceManager
import android.app.smartspace.SmartspaceTarget
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.media.MediaDescription
@@ -40,6 +41,7 @@ import android.testing.TestableLooper.RunWithLooper
import androidx.media.utils.MediaConstants
import androidx.test.filters.SmallTest
import com.android.internal.logging.InstanceId
import com.android.internal.statusbar.IStatusBarService
import com.android.keyguard.KeyguardUpdateMonitor
import com.android.systemui.InstanceIdSequenceFake
import com.android.systemui.R
@@ -76,6 +78,7 @@ import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
@@ -130,6 +133,7 @@ class MediaDataManagerTest : SysuiTestCase() {
    @Mock lateinit var activityStarter: ActivityStarter
    @Mock lateinit var smartspaceManager: SmartspaceManager
    @Mock lateinit var keyguardUpdateMonitor: KeyguardUpdateMonitor
    @Mock lateinit var statusBarService: IStatusBarService
    lateinit var smartspaceMediaDataProvider: SmartspaceMediaDataProvider
    @Mock lateinit var mediaSmartspaceTarget: SmartspaceTarget
    @Mock private lateinit var mediaRecommendationItem: SmartspaceAction
@@ -192,7 +196,8 @@ class MediaDataManagerTest : SysuiTestCase() {
                mediaFlags = mediaFlags,
                logger = logger,
                smartspaceManager = smartspaceManager,
                keyguardUpdateMonitor = keyguardUpdateMonitor
                keyguardUpdateMonitor = keyguardUpdateMonitor,
                statusBarService = statusBarService,
            )
        verify(tunerService)
            .addTunable(capture(tunableCaptor), eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
@@ -517,19 +522,211 @@ class MediaDataManagerTest : SysuiTestCase() {
    }

    @Test
    fun testOnNotificationRemoved_emptyTitle_notConverted() {
        // GIVEN that the manager has a notification with a resume action and empty title.
    fun testOnNotificationAdded_emptyTitle_isRequired_notLoaded() {
        // When the manager has a notification with an empty title, and the app is required
        // to include a non-empty title
        whenever(mediaFlags.isMediaTitleRequired(any(), any())).thenReturn(true)
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
                    .build()
            )
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)

        // Then the media control is not added and we report a notification error
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
        verify(statusBarService)
            .onNotificationError(
                eq(PACKAGE_NAME),
                eq(mediaNotification.tag),
                eq(mediaNotification.id),
                eq(mediaNotification.uid),
                eq(mediaNotification.initialPid),
                eq(MEDIA_TITLE_ERROR_MESSAGE),
                eq(mediaNotification.user.identifier)
            )
        verify(listener, never())
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
        verify(logger, never()).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), any())
    }

    @Test
    fun testOnNotificationAdded_blankTitle_isRequired_notLoaded() {
        // When the manager has a notification with a blank title, and the app is required
        // to include a non-empty title
        whenever(mediaFlags.isMediaTitleRequired(any(), any())).thenReturn(true)
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
                    .build()
            )
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)

        // Then the media control is not added and we report a notification error
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
        verify(statusBarService)
            .onNotificationError(
                eq(PACKAGE_NAME),
                eq(mediaNotification.tag),
                eq(mediaNotification.id),
                eq(mediaNotification.uid),
                eq(mediaNotification.initialPid),
                eq(MEDIA_TITLE_ERROR_MESSAGE),
                eq(mediaNotification.user.identifier)
            )
        verify(listener, never())
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
        verify(logger, never()).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), any())
    }

    @Test
    fun testOnNotificationUpdated_invalidTitle_isRequired_logMediaRemoved() {
        // When the app is required to provide a non-blank title, and updates a previously valid
        // title to an empty one
        whenever(mediaFlags.isMediaTitleRequired(any(), any())).thenReturn(true)
        addNotificationAndLoad()
        val data = mediaDataCaptor.value

        verify(listener)
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )

        reset(listener)
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
                    .build()
            )
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)

        // Then the media control is removed
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
        verify(statusBarService)
            .onNotificationError(
                eq(PACKAGE_NAME),
                eq(mediaNotification.tag),
                eq(mediaNotification.id),
                eq(mediaNotification.uid),
                eq(mediaNotification.initialPid),
                eq(MEDIA_TITLE_ERROR_MESSAGE),
                eq(mediaNotification.user.identifier)
            )
        verify(listener, never())
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
    }

    @Test
    fun testOnNotificationAdded_emptyTitle_notRequired_hasPlaceholder() {
        // When the manager has a notification with an empty title, and the app is not
        // required to include a non-empty title
        val mockPackageManager = mock(PackageManager::class.java)
        context.setMockPackageManager(mockPackageManager)
        whenever(mockPackageManager.getApplicationLabel(any())).thenReturn(APP_NAME)
        whenever(mediaFlags.isMediaTitleRequired(any(), any())).thenReturn(false)
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
                    .build()
            )
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)

        // Then a media control is created with a placeholder title string
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
        verify(listener)
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
        assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
    }

    @Test
    fun testOnNotificationAdded_blankTitle_notRequired_hasPlaceholder() {
        // GIVEN that the manager has a notification with a blank title, and the app is not
        // required to include a non-empty title
        val mockPackageManager = mock(PackageManager::class.java)
        context.setMockPackageManager(mockPackageManager)
        whenever(mockPackageManager.getApplicationLabel(any())).thenReturn(APP_NAME)
        whenever(mediaFlags.isMediaTitleRequired(any(), any())).thenReturn(false)
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
                    .build()
            )
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)

        // Then a media control is created with a placeholder title string
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
        verify(listener)
            .onMediaDataLoaded(
                eq(KEY),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
        assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
    }

    @Test
    fun testOnNotificationRemoved_emptyTitle_notConverted() {
        // GIVEN that the manager has a notification with a resume action and empty title.
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        val instanceId = data.instanceId
        assertThat(data.resumption).isFalse()
        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
        mediaDataManager.onMediaDataLoaded(
            KEY,
            null,
            data.copy(song = SESSION_EMPTY_TITLE, resumeAction = Runnable {})
        )

        // WHEN the notification is removed
        reset(listener)
@@ -554,17 +751,15 @@ class MediaDataManagerTest : SysuiTestCase() {
    @Test
    fun testOnNotificationRemoved_blankTitle_notConverted() {
        // GIVEN that the manager has a notification with a resume action and blank title.
        whenever(controller.metadata)
            .thenReturn(
                metadataBuilder
                    .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
                    .build()
            )
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        val instanceId = data.instanceId
        assertThat(data.resumption).isFalse()
        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
        mediaDataManager.onMediaDataLoaded(
            KEY,
            null,
            data.copy(song = SESSION_BLANK_TITLE, resumeAction = Runnable {})
        )

        // WHEN the notification is removed
        reset(listener)