Merge "Update when PlaybackState state or actions change" into tm-dev

This commit is contained in:
Beth Thibodeau
2022-04-29 17:16:41 +00:00
committed by Android (Google) Code Review
5 changed files with 315 additions and 19 deletions

View File

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

View File

@@ -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?,
@@ -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 */)
} }
@@ -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
@@ -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
} }
@@ -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)
@@ -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
}
} }

View File

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

View File

@@ -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
@@ -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
@@ -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
*/ */

View File

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