Update when PlaybackState state or actions change
Some apps are updating PlaybackState without also posting a new notification, which is looks buggy when using the new actions. This adds a check so that when the PlaybackState updates, if the actions (standard or custom) have changed, or the state has changed between playing or not, MediaDataManager will get notified and update the PlaybackState-based buttons. Fixes: 214335602 Fixes: 229392609 Test: manual - see bug Test: atest MediaTimeoutListenerTest MediaDataManagerTest Change-Id: If2eb02c9c969124ebf193a89ff37edd5b1e6ede1
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user