diff --git a/core/java/android/app/StatusBarManager.java b/core/java/android/app/StatusBarManager.java index a6313dbf52df4..776e34bb47920 100644 --- a/core/java/android/app/StatusBarManager.java +++ b/core/java/android/app/StatusBarManager.java @@ -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. diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 70fdc2070b7ab..5091340298623 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -2575,7 +2575,7 @@ Media - Hide this media control for %1$s? + Hide this media control for %1$s? The current media session cannot be hidden. @@ -2588,6 +2588,8 @@ %1$s by %2$s is playing from %3$s %1$s of %2$s + + %1$s is running Play diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt index fa4211487d1d0..5079487a62619 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt @@ -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,9 +608,11 @@ class MediaDataManager( } } - private fun removeEntry(key: String) { + private fun removeEntry(key: String, logEvent: Boolean = true) { mediaEntries.remove(key)?.let { - logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) + 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) { diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt index 9bc66f6c98d02..3751c60b06d5e 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt @@ -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) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt index d428db7b9ddae..fd6e4577d323c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt @@ -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)