Loading packages/SystemUI/src/com/android/systemui/flags/Flags.kt +3 −0 Original line number Diff line number Diff line Loading @@ -355,6 +355,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") Loading packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt +94 −32 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -1289,27 +1290,94 @@ 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") 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 = getResumeMediaAction(removed.resumeAction!!) 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 = removed.copy( data.copy( token = null, actions = listOf(resumeAction), actions = actions, semanticActions = MediaButton(playOrPause = resumeAction), actionsToShowInCompact = listOf(0), active = false, resumption = true, isPlaying = false, isClearable = true isClearable = true, clickIntent = launcherIntent, ) val pkg = removed.packageName 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(pkg, key, updated) 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 Loading @@ -1317,15 +1385,9 @@ class MediaDataManager( // packageName, just remove it and then send a update to the existing resumption // controls. notifyMediaDataRemoved(key) notifyMediaDataLoaded(pkg, pkg, updated) notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated) } logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId) return } if (removed != null) { notifyMediaDataRemoved(key) logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } } fun setMediaResumptionEnabled(isEnabled: Boolean) { Loading packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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() } } Loading packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt +6 −0 Original line number Diff line number Diff line Loading @@ -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) } packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt +206 −16 Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 { Loading Loading @@ -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()) } Loading Loading @@ -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), Loading @@ -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) Loading Loading @@ -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) Loading @@ -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()) } Loading @@ -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()) } Loading @@ -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( Loading Loading @@ -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( Loading @@ -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( Loading Loading @@ -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) Loading @@ -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()) } } Loading
packages/SystemUI/src/com/android/systemui/flags/Flags.kt +3 −0 Original line number Diff line number Diff line Loading @@ -355,6 +355,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") Loading
packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt +94 −32 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -1289,27 +1290,94 @@ 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") 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 = getResumeMediaAction(removed.resumeAction!!) 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 = removed.copy( data.copy( token = null, actions = listOf(resumeAction), actions = actions, semanticActions = MediaButton(playOrPause = resumeAction), actionsToShowInCompact = listOf(0), active = false, resumption = true, isPlaying = false, isClearable = true isClearable = true, clickIntent = launcherIntent, ) val pkg = removed.packageName 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(pkg, key, updated) 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 Loading @@ -1317,15 +1385,9 @@ class MediaDataManager( // packageName, just remove it and then send a update to the existing resumption // controls. notifyMediaDataRemoved(key) notifyMediaDataLoaded(pkg, pkg, updated) notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated) } logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId) return } if (removed != null) { notifyMediaDataRemoved(key) logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } } fun setMediaResumptionEnabled(isEnabled: Boolean) { Loading
packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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() } } Loading
packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt +6 −0 Original line number Diff line number Diff line Loading @@ -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) }
packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt +206 −16 Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 { Loading Loading @@ -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()) } Loading Loading @@ -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), Loading @@ -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) Loading Loading @@ -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) Loading @@ -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()) } Loading @@ -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()) } Loading @@ -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( Loading Loading @@ -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( Loading @@ -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( Loading Loading @@ -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) Loading @@ -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()) } }