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
This commit is contained in:
Beth Thibodeau
2023-05-10 19:55:53 -05:00
parent a24961f087
commit e3a971d814
5 changed files with 293 additions and 34 deletions

View File

@@ -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.

View File

@@ -2575,7 +2575,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] -->
@@ -2588,6 +2588,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>

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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)