| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.media.controls.pipeline |
| |
| import android.media.session.MediaController |
| import android.media.session.PlaybackState |
| import android.os.SystemProperties |
| import com.android.internal.annotations.VisibleForTesting |
| import com.android.systemui.dagger.SysUISingleton |
| import com.android.systemui.dagger.qualifiers.Main |
| import com.android.systemui.media.controls.models.player.MediaData |
| import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData |
| import com.android.systemui.media.controls.util.MediaControllerFactory |
| import com.android.systemui.media.controls.util.MediaFlags |
| import com.android.systemui.plugins.statusbar.StatusBarStateController |
| import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState |
| import com.android.systemui.statusbar.SysuiStatusBarStateController |
| import com.android.systemui.util.concurrency.DelayableExecutor |
| import com.android.systemui.util.time.SystemClock |
| import java.util.concurrent.TimeUnit |
| import javax.inject.Inject |
| |
| @VisibleForTesting |
| val PAUSED_MEDIA_TIMEOUT = |
| SystemProperties.getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10)) |
| |
| @VisibleForTesting |
| val RESUME_MEDIA_TIMEOUT = |
| SystemProperties.getLong("debug.sysui.media_timeout_resume", TimeUnit.DAYS.toMillis(2)) |
| |
| /** Controller responsible for keeping track of playback states and expiring inactive streams. */ |
| @SysUISingleton |
| class MediaTimeoutListener |
| @Inject |
| constructor( |
| private val mediaControllerFactory: MediaControllerFactory, |
| @Main private val mainExecutor: DelayableExecutor, |
| private val logger: MediaTimeoutLogger, |
| statusBarStateController: SysuiStatusBarStateController, |
| private val systemClock: SystemClock, |
| private val mediaFlags: MediaFlags, |
| ) : MediaDataManager.Listener { |
| |
| private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf() |
| private val recommendationListeners: MutableMap<String, RecommendationListener> = mutableMapOf() |
| |
| /** |
| * Callback representing that a media object is now expired: |
| * |
| * @param key Media control unique identifier |
| * @param timedOut True when expired for {@code PAUSED_MEDIA_TIMEOUT} for active media, |
| * ``` |
| * or {@code RESUME_MEDIA_TIMEOUT} for resume media |
| * ``` |
| */ |
| 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 |
| |
| /** |
| * Callback representing that the [MediaSession] for an active control has been destroyed |
| * |
| * @param key Media control unique identifier |
| */ |
| lateinit var sessionCallback: (String) -> Unit |
| |
| init { |
| statusBarStateController.addCallback( |
| object : StatusBarStateController.StateListener { |
| override fun onDozingChanged(isDozing: Boolean) { |
| if (!isDozing) { |
| // Check whether any timeouts should have expired |
| mediaListeners.forEach { (key, listener) -> |
| if ( |
| listener.cancellation != null && |
| listener.expiration <= systemClock.elapsedRealtime() |
| ) { |
| // We dozed too long - timeout now, and cancel the pending one |
| listener.expireMediaTimeout(key, "timeout happened while dozing") |
| listener.doTimeout() |
| } |
| } |
| |
| recommendationListeners.forEach { (key, listener) -> |
| if ( |
| listener.cancellation != null && |
| listener.expiration <= systemClock.currentTimeMillis() |
| ) { |
| logger.logTimeoutCancelled(key, "Timed out while dozing") |
| listener.doTimeout() |
| } |
| } |
| } |
| } |
| } |
| ) |
| } |
| |
| override fun onMediaDataLoaded( |
| key: String, |
| oldKey: String?, |
| data: MediaData, |
| immediately: Boolean, |
| receivedSmartspaceCardLatency: Int, |
| isSsReactivated: Boolean |
| ) { |
| var reusedListener: PlaybackStateListener? = null |
| |
| // First check if we already have a listener |
| mediaListeners.get(key)?.let { |
| if (!it.destroyed) { |
| return |
| } |
| |
| // If listener was destroyed previously, we'll need to re-register it |
| logger.logReuseListener(key) |
| reusedListener = it |
| } |
| |
| // Having an old key means that we're migrating from/to resumption. We should update |
| // the old listener to make sure that events will be dispatched to the new location. |
| val migrating = oldKey != null && key != oldKey |
| if (migrating) { |
| reusedListener = mediaListeners.remove(oldKey) |
| logger.logMigrateListener(oldKey, key, reusedListener != null) |
| } |
| |
| reusedListener?.let { |
| val wasPlaying = it.isPlaying() |
| logger.logUpdateListener(key, wasPlaying) |
| it.mediaData = data |
| it.key = key |
| mediaListeners[key] = it |
| 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]?.isPlaying() == true) { |
| logger.logDelayedUpdate(key) |
| timeoutCallback.invoke(key, false /* timedOut */) |
| } |
| } |
| } |
| return |
| } |
| |
| mediaListeners[key] = PlaybackStateListener(key, data) |
| } |
| |
| override fun onMediaDataRemoved(key: String) { |
| mediaListeners.remove(key)?.destroy() |
| } |
| |
| override fun onSmartspaceMediaDataLoaded( |
| key: String, |
| data: SmartspaceMediaData, |
| shouldPrioritize: Boolean |
| ) { |
| if (!mediaFlags.isPersistentSsCardEnabled()) return |
| |
| // First check if we already have a listener |
| recommendationListeners.get(key)?.let { |
| if (!it.destroyed) { |
| it.recommendationData = data |
| return |
| } |
| } |
| |
| // Otherwise, create a new one |
| recommendationListeners[key] = RecommendationListener(key, data) |
| } |
| |
| override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) { |
| if (!mediaFlags.isPersistentSsCardEnabled()) return |
| recommendationListeners.remove(key)?.destroy() |
| } |
| |
| fun isTimedOut(key: String): Boolean { |
| return mediaListeners[key]?.timedOut ?: false |
| } |
| |
| private inner class PlaybackStateListener(var key: String, data: MediaData) : |
| MediaController.Callback() { |
| |
| var timedOut = false |
| var lastState: PlaybackState? = null |
| var resumption: Boolean? = null |
| var destroyed = false |
| var expiration = Long.MAX_VALUE |
| |
| var mediaData: MediaData = data |
| set(value) { |
| destroyed = false |
| mediaController?.unregisterCallback(this) |
| field = value |
| val token = field.token |
| mediaController = |
| if (token != null) { |
| mediaControllerFactory.create(token) |
| } else { |
| null |
| } |
| mediaController?.registerCallback(this) |
| // Let's register the cancellations, but not dispatch events now. |
| // Timeouts didn't happen yet and reentrant events are troublesome. |
| processState(mediaController?.playbackState, dispatchEvents = false) |
| } |
| |
| // Resume controls may have null token |
| private var mediaController: MediaController? = null |
| var cancellation: Runnable? = null |
| private set |
| |
| fun Int.isPlaying() = isPlayingState(this) |
| fun isPlaying() = lastState?.state?.isPlaying() ?: false |
| |
| init { |
| mediaData = data |
| } |
| |
| fun destroy() { |
| mediaController?.unregisterCallback(this) |
| cancellation?.run() |
| destroyed = true |
| } |
| |
| override fun onPlaybackStateChanged(state: PlaybackState?) { |
| processState(state, dispatchEvents = true) |
| } |
| |
| override fun onSessionDestroyed() { |
| logger.logSessionDestroyed(key) |
| if (resumption == true) { |
| // Some apps create a session when MBS is queried. We should unregister the |
| // controller since it will no longer be valid, but don't cancel the timeout |
| mediaController?.unregisterCallback(this) |
| } else { |
| // For active controls, if the session is destroyed, clean up everything since we |
| // will need to recreate it if this key is updated later |
| sessionCallback.invoke(key) |
| destroy() |
| } |
| } |
| |
| private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { |
| logger.logPlaybackState(key, state) |
| |
| val playingStateSame = (state?.state?.isPlaying() == isPlaying()) |
| val actionsSame = |
| (lastState?.actions == state?.actions) && |
| areCustomActionListsEqual(lastState?.customActions, state?.customActions) |
| val resumptionChanged = resumption != mediaData.resumption |
| |
| lastState = state |
| |
| if ((!actionsSame || !playingStateSame) && state != null && dispatchEvents) { |
| logger.logStateCallback(key) |
| stateCallback.invoke(key, state) |
| } |
| |
| if (playingStateSame && !resumptionChanged) { |
| return |
| } |
| resumption = mediaData.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) |
| return |
| } |
| expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state, $resumption") |
| val timeout = |
| if (mediaData.resumption) { |
| RESUME_MEDIA_TIMEOUT |
| } else { |
| PAUSED_MEDIA_TIMEOUT |
| } |
| expiration = systemClock.elapsedRealtime() + timeout |
| cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout) |
| } else { |
| expireMediaTimeout(key, "playback started - $state, $key") |
| timedOut = false |
| if (dispatchEvents) { |
| timeoutCallback(key, timedOut) |
| } |
| } |
| } |
| |
| fun doTimeout() { |
| cancellation = null |
| logger.logTimeout(key) |
| timedOut = true |
| expiration = Long.MAX_VALUE |
| // this event is async, so it's safe even when `dispatchEvents` is false |
| timeoutCallback(key, timedOut) |
| } |
| |
| fun expireMediaTimeout(mediaKey: String, reason: String) { |
| cancellation?.apply { |
| logger.logTimeoutCancelled(mediaKey, reason) |
| run() |
| } |
| expiration = Long.MAX_VALUE |
| 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 |
| } |
| |
| /** Listens to changes in recommendation card data and schedules a timeout for its expiration */ |
| private inner class RecommendationListener(var key: String, data: SmartspaceMediaData) { |
| private var timedOut = false |
| var destroyed = false |
| var expiration = Long.MAX_VALUE |
| private set |
| var cancellation: Runnable? = null |
| private set |
| |
| var recommendationData: SmartspaceMediaData = data |
| set(value) { |
| destroyed = false |
| field = value |
| processUpdate() |
| } |
| |
| init { |
| recommendationData = data |
| } |
| |
| fun destroy() { |
| cancellation?.run() |
| cancellation = null |
| destroyed = true |
| } |
| |
| private fun processUpdate() { |
| if (recommendationData.expiryTimeMs != expiration) { |
| // The expiry time changed - cancel and reschedule |
| val timeout = |
| recommendationData.expiryTimeMs - |
| recommendationData.headphoneConnectionTimeMillis |
| logger.logRecommendationTimeoutScheduled(key, timeout) |
| cancellation?.run() |
| cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout) |
| expiration = recommendationData.expiryTimeMs |
| } |
| } |
| |
| fun doTimeout() { |
| cancellation?.run() |
| cancellation = null |
| logger.logTimeout(key) |
| timedOut = true |
| expiration = Long.MAX_VALUE |
| timeoutCallback(key, timedOut) |
| } |
| } |
| } |