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.
mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
setTimedOut(key, timedOut) }
mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
updateState(key, state) }
mediaResumeListener.setManager(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) {
mediaEntries.remove(key)?.let {
logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
@@ -673,11 +690,8 @@ class MediaDataManager(
// Otherwise, use the notification actions
var actionIcons: List<MediaAction> = emptyList()
var actionsToShowCollapsed: List<Int> = emptyList()
var semanticActions: MediaButton? = null
if (mediaFlags.areMediaSessionActionsEnabled(sbn.packageName, sbn.user) &&
mediaController.playbackState != null) {
semanticActions = createActionsFromState(sbn.packageName, mediaController)
} else {
val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
if (semanticActions == null) {
val actions = createActionsFromNotification(sbn)
actionIcons = actions.first
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
* of those actions should be shown in the compact player
*/
private fun createActionsFromState(packageName: String, controller: MediaController):
MediaButton? {
private fun createActionsFromState(
packageName: String,
controller: MediaController,
user: UserHandle
): MediaButton? {
val state = controller.playbackState
if (state == null) {
return MediaButton()
if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
return null
}
// First, check for} standard actions
// First, check for standard actions
val playOrPause = if (isConnectingState(state.state)) {
// Spinner needs to be animating to render anything. Start it here.
val drawable = context.getDrawable(

View File

@@ -55,6 +55,13 @@ class MediaTimeoutListener @Inject constructor(
*/
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(
key: String,
oldKey: String?,
@@ -85,17 +92,17 @@ class MediaTimeoutListener @Inject constructor(
}
reusedListener?.let {
val wasPlaying = it.playing ?: false
val wasPlaying = it.isPlaying()
logger.logUpdateListener(key, wasPlaying)
it.mediaData = data
it.key = key
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
// its state. Doing it now would lead to reentrant callbacks, so let's wait
// until we're done.
mainExecutor.execute {
if (mediaListeners[key]?.playing == true) {
if (mediaListeners[key]?.isPlaying() == true) {
logger.logDelayedUpdate(key)
timeoutCallback.invoke(key, false /* timedOut */)
}
@@ -121,7 +128,7 @@ class MediaTimeoutListener @Inject constructor(
) : MediaController.Callback() {
var timedOut = false
var playing: Boolean? = null
var lastState: PlaybackState? = null
var resumption: Boolean? = null
var destroyed = false
@@ -145,6 +152,9 @@ class MediaTimeoutListener @Inject constructor(
private var mediaController: MediaController? = null
private var cancellation: Runnable? = null
fun Int.isPlaying() = isPlayingState(this)
fun isPlaying() = lastState?.state?.isPlaying() ?: false
init {
mediaData = data
}
@@ -175,16 +185,26 @@ class MediaTimeoutListener @Inject constructor(
private fun processState(state: PlaybackState?, dispatchEvents: Boolean) {
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
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
}
playing = isPlaying
resumption = mediaData.resumption
if (!isPlaying) {
logger.logScheduleTimeout(key, isPlaying, resumption!!)
val playing = isPlaying()
if (!playing) {
logger.logScheduleTimeout(key, playing, resumption!!)
if (cancellation != null && !resumptionChanged) {
// if the media changed resume state, we'll need to adjust the timeout length
logger.logCancelIgnored(key)
@@ -220,4 +240,50 @@ class MediaTimeoutListener @Inject constructor(
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(
TAG,
LogLevel.DEBUG,

View File

@@ -30,6 +30,7 @@ import com.android.systemui.statusbar.SbnBuilder
import com.android.systemui.tuner.TunerService
import com.android.systemui.util.concurrency.FakeExecutor
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.eq
import com.android.systemui.util.time.FakeSystemClock
@@ -47,6 +48,7 @@ import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.junit.MockitoJUnit
@@ -938,6 +940,38 @@ class MediaDataManagerTest : SysuiTestCase() {
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
*/

View File

@@ -65,6 +65,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
@Mock private lateinit var logger: MediaTimeoutLogger
private lateinit var executor: FakeExecutor
@Mock private lateinit var timeoutCallback: (String, Boolean) -> Unit
@Mock private lateinit var stateCallback: (String, PlaybackState) -> Unit
@Captor private lateinit var mediaCallbackCaptor: ArgumentCaptor<MediaController.Callback>
@JvmField @Rule val mockito = MockitoJUnit.rule()
private lateinit var metadataBuilder: MediaMetadata.Builder
@@ -80,6 +81,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
executor = FakeExecutor(FakeSystemClock())
mediaTimeoutListener = MediaTimeoutListener(mediaControllerFactory, executor, logger)
mediaTimeoutListener.timeoutCallback = timeoutCallback
mediaTimeoutListener.stateCallback = stateCallback
// Create a media session and notification for testing.
metadataBuilder = MediaMetadata.Builder().apply {
@@ -368,4 +370,169 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
// THEN the timeout runnable is cancelled
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))
}
}