Loading packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt +28 −10 Original line number Original line Diff line number Diff line Loading @@ -261,6 +261,8 @@ class MediaDataManager( // Set up links back into the pipeline for listeners that need to send events upstream. // Set up links back into the pipeline for listeners that need to send events upstream. mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> setTimedOut(key, timedOut) } setTimedOut(key, timedOut) } mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState -> updateState(key, state) } mediaResumeListener.setManager(this) mediaResumeListener.setManager(this) mediaDataFilter.mediaDataManager = this mediaDataFilter.mediaDataManager = this Loading Loading @@ -502,6 +504,21 @@ class MediaDataManager( } } } } /** * Called when the player's [PlaybackState] has been updated with new actions and/or state */ private fun updateState(key: String, state: PlaybackState) { mediaEntries.get(key)?.let { val actions = createActionsFromState(it.packageName, mediaControllerFactory.create(it.token), UserHandle(it.userId)) val data = it.copy( semanticActions = actions, isPlaying = isPlayingState(state.state)) if (DEBUG) Log.d(TAG, "State updated outside of notification") onMediaDataLoaded(key, key, data) } } private fun removeEntry(key: String) { private fun removeEntry(key: String) { mediaEntries.remove(key)?.let { mediaEntries.remove(key)?.let { logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) Loading Loading @@ -673,11 +690,8 @@ class MediaDataManager( // Otherwise, use the notification actions // Otherwise, use the notification actions var actionIcons: List<MediaAction> = emptyList() var actionIcons: List<MediaAction> = emptyList() var actionsToShowCollapsed: List<Int> = emptyList() var actionsToShowCollapsed: List<Int> = emptyList() var semanticActions: MediaButton? = null val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user) if (mediaFlags.areMediaSessionActionsEnabled(sbn.packageName, sbn.user) && if (semanticActions == null) { mediaController.playbackState != null) { semanticActions = createActionsFromState(sbn.packageName, mediaController) } else { val actions = createActionsFromNotification(sbn) val actions = createActionsFromNotification(sbn) actionIcons = actions.first actionIcons = actions.first actionsToShowCollapsed = actions.second actionsToShowCollapsed = actions.second Loading Loading @@ -789,13 +803,17 @@ class MediaDataManager( * @return a Pair consisting of a list of media actions, and a list of ints representing which * @return a Pair consisting of a list of media actions, and a list of ints representing which * of those actions should be shown in the compact player * of those actions should be shown in the compact player */ */ private fun createActionsFromState(packageName: String, controller: MediaController): private fun createActionsFromState( MediaButton? { packageName: String, controller: MediaController, user: UserHandle ): MediaButton? { val state = controller.playbackState val state = controller.playbackState if (state == null) { if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) { return MediaButton() return null } } // First, check for} standard actions // First, check for standard actions val playOrPause = if (isConnectingState(state.state)) { val playOrPause = if (isConnectingState(state.state)) { // Spinner needs to be animating to render anything. Start it here. // Spinner needs to be animating to render anything. Start it here. val drawable = context.getDrawable( val drawable = context.getDrawable( Loading packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt +75 −9 Original line number Original line Diff line number Diff line Loading @@ -55,6 +55,13 @@ class MediaTimeoutListener @Inject constructor( */ */ lateinit var timeoutCallback: (String, Boolean) -> Unit lateinit var timeoutCallback: (String, Boolean) -> Unit /** * Callback representing that a media object [PlaybackState] has changed. * @param key Media control unique identifier * @param state The new [PlaybackState] */ lateinit var stateCallback: (String, PlaybackState) -> Unit override fun onMediaDataLoaded( override fun onMediaDataLoaded( key: String, key: String, oldKey: String?, oldKey: String?, Loading Loading @@ -85,17 +92,17 @@ class MediaTimeoutListener @Inject constructor( } } reusedListener?.let { reusedListener?.let { val wasPlaying = it.playing ?: false val wasPlaying = it.isPlaying() logger.logUpdateListener(key, wasPlaying) logger.logUpdateListener(key, wasPlaying) it.mediaData = data it.mediaData = data it.key = key it.key = key mediaListeners[key] = it mediaListeners[key] = it if (wasPlaying != it.playing) { if (wasPlaying != it.isPlaying()) { // If a player becomes active because of a migration, we'll need to broadcast // If a player becomes active because of a migration, we'll need to broadcast // its state. Doing it now would lead to reentrant callbacks, so let's wait // its state. Doing it now would lead to reentrant callbacks, so let's wait // until we're done. // until we're done. mainExecutor.execute { mainExecutor.execute { if (mediaListeners[key]?.playing == true) { if (mediaListeners[key]?.isPlaying() == true) { logger.logDelayedUpdate(key) logger.logDelayedUpdate(key) timeoutCallback.invoke(key, false /* timedOut */) timeoutCallback.invoke(key, false /* timedOut */) } } Loading @@ -121,7 +128,7 @@ class MediaTimeoutListener @Inject constructor( ) : MediaController.Callback() { ) : MediaController.Callback() { var timedOut = false var timedOut = false var playing: Boolean? = null var lastState: PlaybackState? = null var resumption: Boolean? = null var resumption: Boolean? = null var destroyed = false var destroyed = false Loading @@ -145,6 +152,9 @@ class MediaTimeoutListener @Inject constructor( private var mediaController: MediaController? = null private var mediaController: MediaController? = null private var cancellation: Runnable? = null private var cancellation: Runnable? = null fun Int.isPlaying() = isPlayingState(this) fun isPlaying() = lastState?.state?.isPlaying() ?: false init { init { mediaData = data mediaData = data } } Loading Loading @@ -175,16 +185,26 @@ class MediaTimeoutListener @Inject constructor( private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { logger.logPlaybackState(key, state) logger.logPlaybackState(key, state) val isPlaying = state != null && isPlayingState(state.state) val playingStateSame = (state?.state?.isPlaying() == isPlaying()) val actionsSame = (lastState?.actions == state?.actions) && areCustomActionListsEqual(lastState?.customActions, state?.customActions) val resumptionChanged = resumption != mediaData.resumption val resumptionChanged = resumption != mediaData.resumption if (playing == isPlaying && playing != null && !resumptionChanged) { lastState = state if ((!actionsSame || !playingStateSame) && state != null && dispatchEvents) { logger.logStateCallback(key) stateCallback.invoke(key, state) } if (playingStateSame && !resumptionChanged) { return return } } playing = isPlaying resumption = mediaData.resumption resumption = mediaData.resumption if (!isPlaying) { val playing = isPlaying() logger.logScheduleTimeout(key, isPlaying, resumption!!) if (!playing) { logger.logScheduleTimeout(key, playing, resumption!!) if (cancellation != null && !resumptionChanged) { if (cancellation != null && !resumptionChanged) { // if the media changed resume state, we'll need to adjust the timeout length // if the media changed resume state, we'll need to adjust the timeout length logger.logCancelIgnored(key) logger.logCancelIgnored(key) Loading Loading @@ -220,4 +240,50 @@ class MediaTimeoutListener @Inject constructor( cancellation = null cancellation = null } } } } private fun areCustomActionListsEqual( first: List<PlaybackState.CustomAction>?, second: List<PlaybackState.CustomAction>? ): Boolean { // Same object, or both null if (first === second) { return true } // Only one null, or different number of actions if ((first == null || second == null) || (first.size != second.size)) { return false } // Compare individual actions first.asSequence().zip(second.asSequence()).forEach { (firstAction, secondAction) -> if (!areCustomActionsEqual(firstAction, secondAction)) { return false } } return true } private fun areCustomActionsEqual( firstAction: PlaybackState.CustomAction, secondAction: PlaybackState.CustomAction ): Boolean { if (firstAction.action != secondAction.action || firstAction.name != secondAction.name || firstAction.icon != secondAction.icon) { return false } if ((firstAction.extras == null) != (secondAction.extras == null)) { return false } if (firstAction.extras != null) { firstAction.extras.keySet().forEach { key -> if (firstAction.extras.get(key) != secondAction.extras.get(key)) { return false } } } return true } } } packages/SystemUI/src/com/android/systemui/media/MediaTimeoutLogger.kt +11 −0 Original line number Original line Diff line number Diff line Loading @@ -102,6 +102,17 @@ class MediaTimeoutLogger @Inject constructor( } } ) ) fun logStateCallback(key: String) = buffer.log( TAG, LogLevel.VERBOSE, { str1 = key }, { "dispatching state update for $key" } ) fun logScheduleTimeout(key: String, playing: Boolean, resumption: Boolean) = buffer.log( fun logScheduleTimeout(key: String, playing: Boolean, resumption: Boolean) = buffer.log( TAG, TAG, LogLevel.DEBUG, LogLevel.DEBUG, Loading packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt +34 −0 Original line number Original line Diff line number Diff line Loading @@ -30,6 +30,7 @@ import com.android.systemui.statusbar.SbnBuilder import com.android.systemui.tuner.TunerService import com.android.systemui.tuner.TunerService import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.argumentCaptor import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.eq import com.android.systemui.util.time.FakeSystemClock import com.android.systemui.util.time.FakeSystemClock Loading @@ -47,6 +48,7 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito import org.mockito.Mockito.never import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.reset import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoJUnit Loading Loading @@ -938,6 +940,38 @@ class MediaDataManagerTest : SysuiTestCase() { eq(instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE)) eq(instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE)) } } @Test fun testPlaybackStateChange_keyExists_callsListener() { // Notification has been added addNotificationAndLoad() val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>() 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) // Listener is notified of updated state verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), capture(mediaDataCaptor), eq(true), eq(0), eq(false)) assertThat(mediaDataCaptor.value.isPlaying).isTrue() } @Test fun testPlaybackStateChange_keyDoesNotExist_doesNothing() { val state = PlaybackState.Builder().build() val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>() verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor) // No media added with this key callbackCaptor.value.invoke(KEY, state) verify(listener, never()).onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean()) } /** /** * Helper function to add a media notification and capture the resulting MediaData * Helper function to add a media notification and capture the resulting MediaData */ */ Loading packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt +167 −0 Original line number Original line Diff line number Diff line Loading @@ -65,6 +65,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { @Mock private lateinit var logger: MediaTimeoutLogger @Mock private lateinit var logger: MediaTimeoutLogger private lateinit var executor: FakeExecutor private lateinit var executor: FakeExecutor @Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit @Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit @Mock private lateinit var stateCallback: (String, PlaybackState) -> Unit @Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback> @Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback> @JvmField @Rule val mockito = MockitoJUnit.rule() @JvmField @Rule val mockito = MockitoJUnit.rule() private lateinit var metadataBuilder: MediaMetadata.Builder private lateinit var metadataBuilder: MediaMetadata.Builder Loading @@ -80,6 +81,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { executor = FakeExecutor(FakeSystemClock()) executor = FakeExecutor(FakeSystemClock()) mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor, logger) mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor, logger) mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.stateCallback = stateCallback // Create a media session and notification for testing. // Create a media session and notification for testing. metadataBuilder = MediaMetadata.Builder().apply { metadataBuilder = MediaMetadata.Builder().apply { Loading Loading @@ -368,4 +370,169 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // THEN the timeout runnable is cancelled // THEN the timeout runnable is cancelled assertThat(executor.numPending()).isEqualTo(0) assertThat(executor.numPending()).isEqualTo(0) } } @Test fun testOnMediaDataLoaded_playbackActionsChanged_noCallback() { // Load media data once val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .build() loadMediaDataWithPlaybackState(pausedState) // When media data is loaded again, with different actions val playingState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() loadMediaDataWithPlaybackState(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_playbackActionsChanged_sendsCallback() { // Load media data once val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state changes, and has different actions val playingState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(playingState!!)) } @Test fun testOnPlaybackStateChanged_differentCustomActions_sendsCallback() { val customOne = PlaybackState.CustomAction.Builder( "ACTION_1", "custom action 1", android.R.drawable.ic_media_ff) .build() val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state actions change val customTwo = PlaybackState.CustomAction.Builder( "ACTION_2", "custom action 2", android.R.drawable.ic_media_rew) .build() val pausedStateTwoActions = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .addCustomAction(customTwo) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(pausedStateTwoActions) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(pausedStateTwoActions!!)) } @Test fun testOnPlaybackStateChanged_sameActions_noCallback() { val stateWithActions = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() loadMediaDataWithPlaybackState(stateWithActions) // When the playback state updates with the same actions mediaCallbackCaptor.value.onPlaybackStateChanged(stateWithActions) // Then the callback is not invoked again verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_sameCustomActions_noCallback() { val actionName = "custom action" val actionIcon = android.R.drawable.ic_media_ff val customOne = PlaybackState.CustomAction.Builder(actionName, actionName, actionIcon) .build() val stateOne = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .build() loadMediaDataWithPlaybackState(stateOne) // When the playback state is updated, but has the same actions val customTwo = PlaybackState.CustomAction.Builder(actionName, actionName, actionIcon) .build() val stateTwo = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customTwo) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(stateTwo) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnMediaDataLoaded_isPlayingChanged_noCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When media data is loaded again but playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, 0L, 1f) .build() loadMediaDataWithPlaybackState(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_isPlayingChanged_sendsCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state changes to playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, 0L, 1f) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(playingState!!)) } @Test fun testOnPlaybackStateChanged_isPlayingSame_noCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state is updated, but still not playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_STOPPED, 0L, 0f) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), eq(playingState!!)) } private fun loadMediaDataWithPlaybackState(state: PlaybackState) { `when`(mediaController.playbackState).thenReturn(state) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) } } } Loading
packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt +28 −10 Original line number Original line Diff line number Diff line Loading @@ -261,6 +261,8 @@ class MediaDataManager( // Set up links back into the pipeline for listeners that need to send events upstream. // Set up links back into the pipeline for listeners that need to send events upstream. mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> setTimedOut(key, timedOut) } setTimedOut(key, timedOut) } mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState -> updateState(key, state) } mediaResumeListener.setManager(this) mediaResumeListener.setManager(this) mediaDataFilter.mediaDataManager = this mediaDataFilter.mediaDataManager = this Loading Loading @@ -502,6 +504,21 @@ class MediaDataManager( } } } } /** * Called when the player's [PlaybackState] has been updated with new actions and/or state */ private fun updateState(key: String, state: PlaybackState) { mediaEntries.get(key)?.let { val actions = createActionsFromState(it.packageName, mediaControllerFactory.create(it.token), UserHandle(it.userId)) val data = it.copy( semanticActions = actions, isPlaying = isPlayingState(state.state)) if (DEBUG) Log.d(TAG, "State updated outside of notification") onMediaDataLoaded(key, key, data) } } private fun removeEntry(key: String) { private fun removeEntry(key: String) { mediaEntries.remove(key)?.let { mediaEntries.remove(key)?.let { logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) Loading Loading @@ -673,11 +690,8 @@ class MediaDataManager( // Otherwise, use the notification actions // Otherwise, use the notification actions var actionIcons: List<MediaAction> = emptyList() var actionIcons: List<MediaAction> = emptyList() var actionsToShowCollapsed: List<Int> = emptyList() var actionsToShowCollapsed: List<Int> = emptyList() var semanticActions: MediaButton? = null val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user) if (mediaFlags.areMediaSessionActionsEnabled(sbn.packageName, sbn.user) && if (semanticActions == null) { mediaController.playbackState != null) { semanticActions = createActionsFromState(sbn.packageName, mediaController) } else { val actions = createActionsFromNotification(sbn) val actions = createActionsFromNotification(sbn) actionIcons = actions.first actionIcons = actions.first actionsToShowCollapsed = actions.second actionsToShowCollapsed = actions.second Loading Loading @@ -789,13 +803,17 @@ class MediaDataManager( * @return a Pair consisting of a list of media actions, and a list of ints representing which * @return a Pair consisting of a list of media actions, and a list of ints representing which * of those actions should be shown in the compact player * of those actions should be shown in the compact player */ */ private fun createActionsFromState(packageName: String, controller: MediaController): private fun createActionsFromState( MediaButton? { packageName: String, controller: MediaController, user: UserHandle ): MediaButton? { val state = controller.playbackState val state = controller.playbackState if (state == null) { if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) { return MediaButton() return null } } // First, check for} standard actions // First, check for standard actions val playOrPause = if (isConnectingState(state.state)) { val playOrPause = if (isConnectingState(state.state)) { // Spinner needs to be animating to render anything. Start it here. // Spinner needs to be animating to render anything. Start it here. val drawable = context.getDrawable( val drawable = context.getDrawable( Loading
packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt +75 −9 Original line number Original line Diff line number Diff line Loading @@ -55,6 +55,13 @@ class MediaTimeoutListener @Inject constructor( */ */ lateinit var timeoutCallback: (String, Boolean) -> Unit lateinit var timeoutCallback: (String, Boolean) -> Unit /** * Callback representing that a media object [PlaybackState] has changed. * @param key Media control unique identifier * @param state The new [PlaybackState] */ lateinit var stateCallback: (String, PlaybackState) -> Unit override fun onMediaDataLoaded( override fun onMediaDataLoaded( key: String, key: String, oldKey: String?, oldKey: String?, Loading Loading @@ -85,17 +92,17 @@ class MediaTimeoutListener @Inject constructor( } } reusedListener?.let { reusedListener?.let { val wasPlaying = it.playing ?: false val wasPlaying = it.isPlaying() logger.logUpdateListener(key, wasPlaying) logger.logUpdateListener(key, wasPlaying) it.mediaData = data it.mediaData = data it.key = key it.key = key mediaListeners[key] = it mediaListeners[key] = it if (wasPlaying != it.playing) { if (wasPlaying != it.isPlaying()) { // If a player becomes active because of a migration, we'll need to broadcast // If a player becomes active because of a migration, we'll need to broadcast // its state. Doing it now would lead to reentrant callbacks, so let's wait // its state. Doing it now would lead to reentrant callbacks, so let's wait // until we're done. // until we're done. mainExecutor.execute { mainExecutor.execute { if (mediaListeners[key]?.playing == true) { if (mediaListeners[key]?.isPlaying() == true) { logger.logDelayedUpdate(key) logger.logDelayedUpdate(key) timeoutCallback.invoke(key, false /* timedOut */) timeoutCallback.invoke(key, false /* timedOut */) } } Loading @@ -121,7 +128,7 @@ class MediaTimeoutListener @Inject constructor( ) : MediaController.Callback() { ) : MediaController.Callback() { var timedOut = false var timedOut = false var playing: Boolean? = null var lastState: PlaybackState? = null var resumption: Boolean? = null var resumption: Boolean? = null var destroyed = false var destroyed = false Loading @@ -145,6 +152,9 @@ class MediaTimeoutListener @Inject constructor( private var mediaController: MediaController? = null private var mediaController: MediaController? = null private var cancellation: Runnable? = null private var cancellation: Runnable? = null fun Int.isPlaying() = isPlayingState(this) fun isPlaying() = lastState?.state?.isPlaying() ?: false init { init { mediaData = data mediaData = data } } Loading Loading @@ -175,16 +185,26 @@ class MediaTimeoutListener @Inject constructor( private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { logger.logPlaybackState(key, state) logger.logPlaybackState(key, state) val isPlaying = state != null && isPlayingState(state.state) val playingStateSame = (state?.state?.isPlaying() == isPlaying()) val actionsSame = (lastState?.actions == state?.actions) && areCustomActionListsEqual(lastState?.customActions, state?.customActions) val resumptionChanged = resumption != mediaData.resumption val resumptionChanged = resumption != mediaData.resumption if (playing == isPlaying && playing != null && !resumptionChanged) { lastState = state if ((!actionsSame || !playingStateSame) && state != null && dispatchEvents) { logger.logStateCallback(key) stateCallback.invoke(key, state) } if (playingStateSame && !resumptionChanged) { return return } } playing = isPlaying resumption = mediaData.resumption resumption = mediaData.resumption if (!isPlaying) { val playing = isPlaying() logger.logScheduleTimeout(key, isPlaying, resumption!!) if (!playing) { logger.logScheduleTimeout(key, playing, resumption!!) if (cancellation != null && !resumptionChanged) { if (cancellation != null && !resumptionChanged) { // if the media changed resume state, we'll need to adjust the timeout length // if the media changed resume state, we'll need to adjust the timeout length logger.logCancelIgnored(key) logger.logCancelIgnored(key) Loading Loading @@ -220,4 +240,50 @@ class MediaTimeoutListener @Inject constructor( cancellation = null cancellation = null } } } } private fun areCustomActionListsEqual( first: List<PlaybackState.CustomAction>?, second: List<PlaybackState.CustomAction>? ): Boolean { // Same object, or both null if (first === second) { return true } // Only one null, or different number of actions if ((first == null || second == null) || (first.size != second.size)) { return false } // Compare individual actions first.asSequence().zip(second.asSequence()).forEach { (firstAction, secondAction) -> if (!areCustomActionsEqual(firstAction, secondAction)) { return false } } return true } private fun areCustomActionsEqual( firstAction: PlaybackState.CustomAction, secondAction: PlaybackState.CustomAction ): Boolean { if (firstAction.action != secondAction.action || firstAction.name != secondAction.name || firstAction.icon != secondAction.icon) { return false } if ((firstAction.extras == null) != (secondAction.extras == null)) { return false } if (firstAction.extras != null) { firstAction.extras.keySet().forEach { key -> if (firstAction.extras.get(key) != secondAction.extras.get(key)) { return false } } } return true } } }
packages/SystemUI/src/com/android/systemui/media/MediaTimeoutLogger.kt +11 −0 Original line number Original line Diff line number Diff line Loading @@ -102,6 +102,17 @@ class MediaTimeoutLogger @Inject constructor( } } ) ) fun logStateCallback(key: String) = buffer.log( TAG, LogLevel.VERBOSE, { str1 = key }, { "dispatching state update for $key" } ) fun logScheduleTimeout(key: String, playing: Boolean, resumption: Boolean) = buffer.log( fun logScheduleTimeout(key: String, playing: Boolean, resumption: Boolean) = buffer.log( TAG, TAG, LogLevel.DEBUG, LogLevel.DEBUG, Loading
packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt +34 −0 Original line number Original line Diff line number Diff line Loading @@ -30,6 +30,7 @@ import com.android.systemui.statusbar.SbnBuilder import com.android.systemui.tuner.TunerService import com.android.systemui.tuner.TunerService import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.argumentCaptor import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.eq import com.android.systemui.util.time.FakeSystemClock import com.android.systemui.util.time.FakeSystemClock Loading @@ -47,6 +48,7 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito import org.mockito.Mockito.never import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.reset import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoJUnit Loading Loading @@ -938,6 +940,38 @@ class MediaDataManagerTest : SysuiTestCase() { eq(instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE)) eq(instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE)) } } @Test fun testPlaybackStateChange_keyExists_callsListener() { // Notification has been added addNotificationAndLoad() val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>() 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) // Listener is notified of updated state verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), capture(mediaDataCaptor), eq(true), eq(0), eq(false)) assertThat(mediaDataCaptor.value.isPlaying).isTrue() } @Test fun testPlaybackStateChange_keyDoesNotExist_doesNothing() { val state = PlaybackState.Builder().build() val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>() verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor) // No media added with this key callbackCaptor.value.invoke(KEY, state) verify(listener, never()).onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean()) } /** /** * Helper function to add a media notification and capture the resulting MediaData * Helper function to add a media notification and capture the resulting MediaData */ */ Loading
packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt +167 −0 Original line number Original line Diff line number Diff line Loading @@ -65,6 +65,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { @Mock private lateinit var logger: MediaTimeoutLogger @Mock private lateinit var logger: MediaTimeoutLogger private lateinit var executor: FakeExecutor private lateinit var executor: FakeExecutor @Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit @Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit @Mock private lateinit var stateCallback: (String, PlaybackState) -> Unit @Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback> @Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback> @JvmField @Rule val mockito = MockitoJUnit.rule() @JvmField @Rule val mockito = MockitoJUnit.rule() private lateinit var metadataBuilder: MediaMetadata.Builder private lateinit var metadataBuilder: MediaMetadata.Builder Loading @@ -80,6 +81,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { executor = FakeExecutor(FakeSystemClock()) executor = FakeExecutor(FakeSystemClock()) mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor, logger) mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor, logger) mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.stateCallback = stateCallback // Create a media session and notification for testing. // Create a media session and notification for testing. metadataBuilder = MediaMetadata.Builder().apply { metadataBuilder = MediaMetadata.Builder().apply { Loading Loading @@ -368,4 +370,169 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // THEN the timeout runnable is cancelled // THEN the timeout runnable is cancelled assertThat(executor.numPending()).isEqualTo(0) assertThat(executor.numPending()).isEqualTo(0) } } @Test fun testOnMediaDataLoaded_playbackActionsChanged_noCallback() { // Load media data once val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .build() loadMediaDataWithPlaybackState(pausedState) // When media data is loaded again, with different actions val playingState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() loadMediaDataWithPlaybackState(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_playbackActionsChanged_sendsCallback() { // Load media data once val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state changes, and has different actions val playingState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(playingState!!)) } @Test fun testOnPlaybackStateChanged_differentCustomActions_sendsCallback() { val customOne = PlaybackState.CustomAction.Builder( "ACTION_1", "custom action 1", android.R.drawable.ic_media_ff) .build() val pausedState = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state actions change val customTwo = PlaybackState.CustomAction.Builder( "ACTION_2", "custom action 2", android.R.drawable.ic_media_rew) .build() val pausedStateTwoActions = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .addCustomAction(customTwo) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(pausedStateTwoActions) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(pausedStateTwoActions!!)) } @Test fun testOnPlaybackStateChanged_sameActions_noCallback() { val stateWithActions = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PLAY) .build() loadMediaDataWithPlaybackState(stateWithActions) // When the playback state updates with the same actions mediaCallbackCaptor.value.onPlaybackStateChanged(stateWithActions) // Then the callback is not invoked again verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_sameCustomActions_noCallback() { val actionName = "custom action" val actionIcon = android.R.drawable.ic_media_ff val customOne = PlaybackState.CustomAction.Builder(actionName, actionName, actionIcon) .build() val stateOne = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customOne) .build() loadMediaDataWithPlaybackState(stateOne) // When the playback state is updated, but has the same actions val customTwo = PlaybackState.CustomAction.Builder(actionName, actionName, actionIcon) .build() val stateTwo = PlaybackState.Builder() .setActions(PlaybackState.ACTION_PAUSE) .addCustomAction(customTwo) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(stateTwo) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnMediaDataLoaded_isPlayingChanged_noCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When media data is loaded again but playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, 0L, 1f) .build() loadMediaDataWithPlaybackState(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), any()) } @Test fun testOnPlaybackStateChanged_isPlayingChanged_sendsCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state changes to playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, 0L, 1f) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is invoked verify(stateCallback).invoke(eq(KEY), eq(playingState!!)) } @Test fun testOnPlaybackStateChanged_isPlayingSame_noCallback() { // Load media data in paused state val pausedState = PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f) .build() loadMediaDataWithPlaybackState(pausedState) // When the playback state is updated, but still not playing val playingState = PlaybackState.Builder() .setState(PlaybackState.STATE_STOPPED, 0L, 0f) .build() mediaCallbackCaptor.value.onPlaybackStateChanged(playingState) // Then the callback is not invoked verify(stateCallback, never()).invoke(eq(KEY), eq(playingState!!)) } private fun loadMediaDataWithPlaybackState(state: PlaybackState) { `when`(mediaController.playbackState).thenReturn(state) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) } } }