Retain media controls longer

When the flag is enabled, apps which have not added support for
resumption via MediaBrowserService will still be set to a resume state when
the underlying notification (for apps using notification-based actions) or
session (for apps using PlaybackState actions) have gone away, if this
occurs after they've already timed out. These resume controls will not
have a play button, but just open the app's launcher intent when tapped.

If the control hadn't timed out yet, it will be removed from the carousel as
before. For apps which do implement long-term resumption, they will
continue to convert into resume state regardless of whether the active
player had already timed out.

Test: atest com.android.systemui.media.controls
Bug: 264691138
Change-Id: I13a43bfc2eca342000726e696afe77706947d774
This commit is contained in:
Beth Thibodeau
2023-01-19 21:24:03 -06:00
parent bdb5065a41
commit ef3c5dc9ee
6 changed files with 322 additions and 50 deletions

View File

@@ -349,6 +349,9 @@ object Flags {
val MEDIA_TAP_TO_TRANSFER_DISMISS_GESTURE =
unreleasedFlag(912, "media_ttt_dismiss_gesture", teamfood = true)
// TODO(b/266157412): Tracking Bug
val MEDIA_RETAIN_SESSIONS = unreleasedFlag(913, "media_retain_sessions")
// 1000 - dock
val SIMULATE_DOCK_THROUGH_CHARGING = releasedFlag(1000, "simulate_dock_through_charging")

View File

@@ -303,6 +303,7 @@ class MediaDataManager(
mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
updateState(key, state)
}
mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
mediaResumeListener.setManager(this)
mediaDataFilter.mediaDataManager = this
@@ -1289,45 +1290,106 @@ class MediaDataManager(
fun onNotificationRemoved(key: String) {
Assert.isMainThread()
val removed = mediaEntries.remove(key)
if (useMediaResumption && removed?.resumeAction != null && removed.isLocalSession()) {
Log.d(TAG, "Not removing $key because resumable")
// Move to resume key (aka package name) if that key doesn't already exist.
val resumeAction = getResumeMediaAction(removed.resumeAction!!)
val updated =
removed.copy(
token = null,
actions = listOf(resumeAction),
semanticActions = MediaButton(playOrPause = resumeAction),
actionsToShowInCompact = listOf(0),
active = false,
resumption = true,
isPlaying = false,
isClearable = true
)
val pkg = removed.packageName
val migrate = mediaEntries.put(pkg, updated) == null
// Notify listeners of "new" controls when migrating or removed and update when not
if (migrate) {
notifyMediaDataLoaded(pkg, key, updated)
} else {
// Since packageName is used for the key of the resumption controls, it is
// possible that another notification has already been reused for the resumption
// controls of this package. In this case, rather than renaming this player as
// packageName, just remove it and then send a update to the existing resumption
// controls.
notifyMediaDataRemoved(key)
notifyMediaDataLoaded(pkg, pkg, updated)
}
logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
return
}
if (removed != null) {
val removed = mediaEntries.remove(key) ?: return
if (useMediaResumption && removed.resumeAction != null && removed.isLocalSession()) {
convertToResumePlayer(removed)
} else if (mediaFlags.isRetainingPlayersEnabled()) {
handlePossibleRemoval(removed, notificationRemoved = true)
} else {
notifyMediaDataRemoved(key)
logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
}
}
private fun onSessionDestroyed(key: String) {
if (!mediaFlags.isRetainingPlayersEnabled()) return
if (DEBUG) Log.d(TAG, "session destroyed for $key")
val entry = mediaEntries.remove(key) ?: return
// Clear token since the session is no longer valid
val updated = entry.copy(token = null)
handlePossibleRemoval(updated)
}
/**
* Convert to resume state if the player is no longer valid and active, then notify listeners
* that the data was updated. Does not convert to resume state if the player is still valid, or
* if it was removed before becoming inactive. (Assumes that [removed] was removed from
* [mediaEntries] before this function was called)
*/
private fun handlePossibleRemoval(removed: MediaData, notificationRemoved: Boolean = false) {
val key = removed.notificationKey!!
val hasSession = removed.token != null
if (hasSession && removed.semanticActions != null) {
// The app was using session actions, and the session is still valid: keep player
if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
mediaEntries.put(key, removed)
notifyMediaDataLoaded(key, key, removed)
} else if (!notificationRemoved && removed.semanticActions == null) {
// The app was using notification actions, and notif wasn't removed yet: keep player
if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
mediaEntries.put(key, removed)
notifyMediaDataLoaded(key, key, removed)
} else if (removed.active) {
// This player was still active - it didn't last long enough to time out: remove
if (DEBUG) Log.d(TAG, "Removing still-active player $key")
notifyMediaDataRemoved(key)
logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
} else {
// Convert to resume
if (DEBUG) {
Log.d(
TAG,
"Notification ($notificationRemoved) and/or session " +
"($hasSession) gone for inactive player $key"
)
}
convertToResumePlayer(removed)
}
}
/** Set the given [MediaData] as a resume state player and notify listeners */
private fun convertToResumePlayer(data: MediaData) {
val key = data.notificationKey!!
if (DEBUG) Log.d(TAG, "Converting $key to resume")
// Move to resume key (aka package name) if that key doesn't already exist.
val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
val launcherIntent =
context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
}
val updated =
data.copy(
token = null,
actions = actions,
semanticActions = MediaButton(playOrPause = resumeAction),
actionsToShowInCompact = listOf(0),
active = false,
resumption = true,
isPlaying = false,
isClearable = true,
clickIntent = launcherIntent,
)
val pkg = data.packageName
val migrate = mediaEntries.put(pkg, updated) == null
// Notify listeners of "new" controls when migrating or removed and update when not
Log.d(TAG, "migrating? $migrate from $key -> $pkg")
if (migrate) {
notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
} else {
// Since packageName is used for the key of the resumption controls, it is
// possible that another notification has already been reused for the resumption
// controls of this package. In this case, rather than renaming this player as
// packageName, just remove it and then send a update to the existing resumption
// controls.
notifyMediaDataRemoved(key)
notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
}
logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
}
fun setMediaResumptionEnabled(isEnabled: Boolean) {
if (useMediaResumption == isEnabled) {
return

View File

@@ -71,6 +71,12 @@ constructor(
*/
lateinit var stateCallback: (String, PlaybackState) -> Unit
/**
* Callback representing that the [MediaSession] for an active control has been destroyed
* @param key Media control unique identifier
*/
lateinit var sessionCallback: (String) -> Unit
init {
statusBarStateController.addCallback(
object : StatusBarStateController.StateListener {
@@ -211,6 +217,7 @@ constructor(
} else {
// For active controls, if the session is destroyed, clean up everything since we
// will need to recreate it if this key is updated later
sessionCallback.invoke(key)
destroy()
}
}

View File

@@ -45,4 +45,10 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) {
/** Check whether we show explicit indicator on UMO */
fun isExplicitIndicatorEnabled() = featureFlags.isEnabled(Flags.MEDIA_EXPLICIT_INDICATOR)
/**
* If true, keep active media controls for the lifetime of the MediaSession, regardless of
* whether the underlying notification was dismissed
*/
fun isRetainingPlayersEnabled() = featureFlags.isEnabled(Flags.MEDIA_RETAIN_SESSIONS)
}

View File

@@ -134,7 +134,8 @@ class MediaDataManagerTest : SysuiTestCase() {
private val clock = FakeSystemClock()
@Mock private lateinit var tunerService: TunerService
@Captor lateinit var tunableCaptor: ArgumentCaptor<TunerService.Tunable>
@Captor lateinit var callbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
@Captor lateinit var stateCallbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
@Captor lateinit var sessionCallbackCaptor: ArgumentCaptor<(String) -> Unit>
@Captor lateinit var smartSpaceConfigBuilderCaptor: ArgumentCaptor<SmartspaceConfig>
private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
@@ -184,6 +185,8 @@ class MediaDataManagerTest : SysuiTestCase() {
)
verify(tunerService)
.addTunable(capture(tunableCaptor), eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
verify(mediaTimeoutListener).stateCallback = capture(stateCallbackCaptor)
verify(mediaTimeoutListener).sessionCallback = capture(sessionCallbackCaptor)
session = MediaSession(context, "MediaDataManagerTestSession")
mediaNotification =
SbnBuilder().run {
@@ -230,6 +233,7 @@ class MediaDataManagerTest : SysuiTestCase() {
whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(1234L)
whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(false)
whenever(mediaFlags.isExplicitIndicatorEnabled()).thenReturn(true)
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(false)
whenever(logger.getNewInstanceId()).thenReturn(instanceIdSequence.newInstanceId())
}
@@ -547,6 +551,7 @@ class MediaDataManagerTest : SysuiTestCase() {
mediaDataManager.onNotificationAdded(KEY_2, mediaNotification)
assertThat(backgroundExecutor.runAllReady()).isEqualTo(2)
assertThat(foregroundExecutor.runAllReady()).isEqualTo(2)
verify(listener)
.onMediaDataLoaded(
eq(KEY),
@@ -558,9 +563,21 @@ class MediaDataManagerTest : SysuiTestCase() {
)
val data = mediaDataCaptor.value
assertThat(data.resumption).isFalse()
val resumableData = data.copy(resumeAction = Runnable {})
mediaDataManager.onMediaDataLoaded(KEY, null, resumableData)
mediaDataManager.onMediaDataLoaded(KEY_2, null, resumableData)
verify(listener)
.onMediaDataLoaded(
eq(KEY_2),
eq(null),
capture(mediaDataCaptor),
eq(true),
eq(0),
eq(false)
)
val data2 = mediaDataCaptor.value
assertThat(data2.resumption).isFalse()
mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
mediaDataManager.onMediaDataLoaded(KEY_2, null, data2.copy(resumeAction = Runnable {}))
reset(listener)
// WHEN the first is removed
mediaDataManager.onNotificationRemoved(KEY)
@@ -1310,11 +1327,10 @@ class MediaDataManagerTest : SysuiTestCase() {
fun testPlaybackStateChange_keyExists_callsListener() {
// Notification has been added
addNotificationAndLoad()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
// Callback gets an updated state
val state = PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0L, 1f).build()
callbackCaptor.value.invoke(KEY, state)
stateCallbackCaptor.value.invoke(KEY, state)
// Listener is notified of updated state
verify(listener)
@@ -1332,11 +1348,10 @@ class MediaDataManagerTest : SysuiTestCase() {
@Test
fun testPlaybackStateChange_keyDoesNotExist_doesNothing() {
val state = PlaybackState.Builder().build()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
// No media added with this key
callbackCaptor.value.invoke(KEY, state)
stateCallbackCaptor.value.invoke(KEY, state)
verify(listener, never())
.onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
@@ -1352,10 +1367,9 @@ class MediaDataManagerTest : SysuiTestCase() {
// And then get a state update
val state = PlaybackState.Builder().build()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
// Then no changes are made
callbackCaptor.value.invoke(KEY, state)
stateCallbackCaptor.value.invoke(KEY, state)
verify(listener, never())
.onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
@@ -1367,8 +1381,7 @@ class MediaDataManagerTest : SysuiTestCase() {
whenever(controller.playbackState).thenReturn(state)
addNotificationAndLoad()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
callbackCaptor.value.invoke(KEY, state)
stateCallbackCaptor.value.invoke(KEY, state)
verify(listener)
.onMediaDataLoaded(
@@ -1410,8 +1423,7 @@ class MediaDataManagerTest : SysuiTestCase() {
backgroundExecutor.runAllReady()
foregroundExecutor.runAllReady()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
callbackCaptor.value.invoke(PACKAGE_NAME, state)
stateCallbackCaptor.value.invoke(PACKAGE_NAME, state)
verify(listener)
.onMediaDataLoaded(
@@ -1436,8 +1448,7 @@ class MediaDataManagerTest : SysuiTestCase() {
.build()
addNotificationAndLoad()
verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
callbackCaptor.value.invoke(KEY, state)
stateCallbackCaptor.value.invoke(KEY, state)
verify(listener)
.onMediaDataLoaded(
@@ -1485,6 +1496,177 @@ class MediaDataManagerTest : SysuiTestCase() {
assertThat(mediaDataCaptor.value.isClearable).isFalse()
}
@Test
fun testRetain_notifPlayer_notifRemoved_setToResume() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
// When a media control based on notification is added, times out, and then removed
addNotificationAndLoad()
mediaDataManager.setTimedOut(KEY, timedOut = true)
assertThat(mediaDataCaptor.value.active).isFalse()
mediaDataManager.onNotificationRemoved(KEY)
// It is converted to a resume player
verify(listener)
.onMediaDataLoaded(
eq(PACKAGE_NAME),
eq(KEY),
capture(mediaDataCaptor),
eq(true),
eq(0),
eq(false)
)
assertThat(mediaDataCaptor.value.resumption).isTrue()
assertThat(mediaDataCaptor.value.active).isFalse()
verify(logger)
.logActiveConvertedToResume(
anyInt(),
eq(PACKAGE_NAME),
eq(mediaDataCaptor.value.instanceId)
)
}
@Test
fun testRetain_notifPlayer_sessionDestroyed_doesNotChange() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
// When a media control based on notification is added and times out
addNotificationAndLoad()
mediaDataManager.setTimedOut(KEY, timedOut = true)
assertThat(mediaDataCaptor.value.active).isFalse()
// and then the session is destroyed
sessionCallbackCaptor.value.invoke(KEY)
// It remains as a regular player
verify(listener, never()).onMediaDataRemoved(eq(KEY))
verify(listener, never())
.onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
@Test
fun testRetain_notifPlayer_removeWhileActive_fullyRemoved() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
// When a media control based on notification is added and then removed, without timing out
addNotificationAndLoad()
val data = mediaDataCaptor.value
assertThat(data.active).isTrue()
mediaDataManager.onNotificationRemoved(KEY)
// It is fully removed
verify(listener).onMediaDataRemoved(eq(KEY))
verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
verify(listener, never())
.onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
@Test
fun testRetain_canResume_removeWhileActive_setToResume() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
// When a media control that supports resumption is added
addNotificationAndLoad()
val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
mediaDataManager.onMediaDataLoaded(KEY, null, dataResumable)
// And then removed while still active
mediaDataManager.onNotificationRemoved(KEY)
// It is converted to a resume player
verify(listener)
.onMediaDataLoaded(
eq(PACKAGE_NAME),
eq(KEY),
capture(mediaDataCaptor),
eq(true),
eq(0),
eq(false)
)
assertThat(mediaDataCaptor.value.resumption).isTrue()
assertThat(mediaDataCaptor.value.active).isFalse()
verify(logger)
.logActiveConvertedToResume(
anyInt(),
eq(PACKAGE_NAME),
eq(mediaDataCaptor.value.instanceId)
)
}
@Test
fun testRetain_sessionPlayer_notifRemoved_doesNotChange() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
addPlaybackStateAction()
// When a media control with PlaybackState actions is added, times out,
// and then the notification is removed
addNotificationAndLoad()
val data = mediaDataCaptor.value
assertThat(data.active).isTrue()
mediaDataManager.setTimedOut(KEY, timedOut = true)
mediaDataManager.onNotificationRemoved(KEY)
// It remains as a regular player
verify(listener, never()).onMediaDataRemoved(eq(KEY))
verify(listener, never())
.onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
@Test
fun testRetain_sessionPlayer_sessionDestroyed_setToResume() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
addPlaybackStateAction()
// When a media control with PlaybackState actions is added, times out,
// and then the session is destroyed
addNotificationAndLoad()
val data = mediaDataCaptor.value
assertThat(data.active).isTrue()
mediaDataManager.setTimedOut(KEY, timedOut = true)
sessionCallbackCaptor.value.invoke(KEY)
// It is converted to a resume player
verify(listener)
.onMediaDataLoaded(
eq(PACKAGE_NAME),
eq(KEY),
capture(mediaDataCaptor),
eq(true),
eq(0),
eq(false)
)
assertThat(mediaDataCaptor.value.resumption).isTrue()
assertThat(mediaDataCaptor.value.active).isFalse()
verify(logger)
.logActiveConvertedToResume(
anyInt(),
eq(PACKAGE_NAME),
eq(mediaDataCaptor.value.instanceId)
)
}
@Test
fun testRetain_sessionPlayer_destroyedWhileActive_fullyRemoved() {
whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
addPlaybackStateAction()
// When a media control using session actions is added, and then the session is destroyed
// without timing out first
addNotificationAndLoad()
val data = mediaDataCaptor.value
assertThat(data.active).isTrue()
sessionCallbackCaptor.value.invoke(KEY)
// It is fully removed
verify(listener).onMediaDataRemoved(eq(KEY))
verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
verify(listener, never())
.onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
}
/** Helper function to add a media notification and capture the resulting MediaData */
private fun addNotificationAndLoad() {
mediaDataManager.onNotificationAdded(KEY, mediaNotification)
@@ -1500,4 +1682,12 @@ class MediaDataManagerTest : SysuiTestCase() {
eq(false)
)
}
/** Helper function to set up a PlaybackState with action */
private fun addPlaybackStateAction() {
val stateActions = PlaybackState.ACTION_PLAY_PAUSE
val stateBuilder = PlaybackState.Builder().setActions(stateActions)
stateBuilder.setState(PlaybackState.STATE_PAUSED, 0, 1.0f)
whenever(controller.playbackState).thenReturn(stateBuilder.build())
}
}

View File

@@ -72,6 +72,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
private lateinit var executor: FakeExecutor
@Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit
@Mock private lateinit var stateCallback: (String, PlaybackState) -> Unit
@Mock private lateinit var sessionCallback: (String) -> Unit
@Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback>
@Captor
private lateinit var dozingCallbackCaptor:
@@ -99,6 +100,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
)
mediaTimeoutListener.timeoutCallback = timeoutCallback
mediaTimeoutListener.stateCallback = stateCallback
mediaTimeoutListener.sessionCallback = sessionCallback
// Create a media session and notification for testing.
metadataBuilder =
@@ -284,6 +286,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
verify(mediaController).unregisterCallback(anyObject())
assertThat(executor.numPending()).isEqualTo(0)
verify(logger).logSessionDestroyed(eq(KEY))
verify(sessionCallback).invoke(eq(KEY))
}
@Test
@@ -322,6 +325,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
// THEN the controller is unregistered, but the timeout is still scheduled
verify(mediaController).unregisterCallback(anyObject())
assertThat(executor.numPending()).isEqualTo(1)
verify(sessionCallback, never()).invoke(eq(KEY))
}
@Test