• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.media.controls.domain.pipeline
18 
19 import android.annotation.WorkerThread
20 import android.media.session.MediaController
21 import android.media.session.MediaSession
22 import android.media.session.PlaybackState
23 import android.os.SystemProperties
24 import com.android.internal.annotations.VisibleForTesting
25 import com.android.systemui.dagger.SysUISingleton
26 import com.android.systemui.dagger.qualifiers.Background
27 import com.android.systemui.dagger.qualifiers.Main
28 import com.android.systemui.media.NotificationMediaManager.isPlayingState
29 import com.android.systemui.media.controls.shared.model.MediaData
30 import com.android.systemui.media.controls.util.MediaControllerFactory
31 import com.android.systemui.plugins.statusbar.StatusBarStateController
32 import com.android.systemui.statusbar.SysuiStatusBarStateController
33 import com.android.systemui.util.concurrency.DelayableExecutor
34 import com.android.systemui.util.time.SystemClock
35 import java.util.concurrent.Executor
36 import java.util.concurrent.TimeUnit
37 import javax.inject.Inject
38 
39 @VisibleForTesting
40 val PAUSED_MEDIA_TIMEOUT =
41     SystemProperties.getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10))
42 
43 @VisibleForTesting
44 val RESUME_MEDIA_TIMEOUT =
45     SystemProperties.getLong("debug.sysui.media_timeout_resume", TimeUnit.DAYS.toMillis(2))
46 
47 /** Controller responsible for keeping track of playback states and expiring inactive streams. */
48 @SysUISingleton
49 class MediaTimeoutListener
50 @Inject
51 constructor(
52     private val mediaControllerFactory: MediaControllerFactory,
53     @Background private val bgExecutor: Executor,
54     @Main private val uiExecutor: Executor,
55     @Main private val mainExecutor: DelayableExecutor,
56     private val logger: MediaTimeoutLogger,
57     statusBarStateController: SysuiStatusBarStateController,
58     private val systemClock: SystemClock,
59 ) : MediaDataManager.Listener {
60 
61     private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()
62 
63     /**
64      * Callback representing that a media object is now expired:
65      *
66      * @param key Media control unique identifier
67      * @param timedOut True when expired for {@code PAUSED_MEDIA_TIMEOUT} for active media,
68      * ```
69      *                 or {@code RESUME_MEDIA_TIMEOUT} for resume media
70      * ```
71      */
72     lateinit var timeoutCallback: (String, Boolean) -> Unit
73 
74     /**
75      * Callback representing that a media object [PlaybackState] has changed.
76      *
77      * @param key Media control unique identifier
78      * @param state The new [PlaybackState]
79      */
80     lateinit var stateCallback: (String, PlaybackState) -> Unit
81 
82     /**
83      * Callback representing that the [MediaSession] for an active control has been destroyed
84      *
85      * @param key Media control unique identifier
86      */
87     lateinit var sessionCallback: (String) -> Unit
88 
89     init {
90         statusBarStateController.addCallback(
91             object : StatusBarStateController.StateListener {
92                 override fun onDozingChanged(isDozing: Boolean) {
93                     if (!isDozing) {
94                         // Check whether any timeouts should have expired
95                         mediaListeners.forEach { (key, listener) ->
96                             if (
97                                 listener.cancellation != null &&
98                                     listener.expiration <= systemClock.elapsedRealtime()
99                             ) {
100                                 // We dozed too long - timeout now, and cancel the pending one
101                                 listener.expireMediaTimeout(key, "timeout happened while dozing")
102                                 listener.doTimeout()
103                             }
104                         }
105                     }
106                 }
107             }
108         )
109     }
110 
111     override fun onMediaDataLoaded(
112         key: String,
113         oldKey: String?,
114         data: MediaData,
115         immediately: Boolean,
116         receivedSmartspaceCardLatency: Int,
117         isSsReactivated: Boolean,
118     ) {
119         var reusedListener: PlaybackStateListener? = null
120 
121         // First check if we already have a listener
122         mediaListeners.get(key)?.let {
123             if (!it.destroyed) {
124                 return
125             }
126 
127             // If listener was destroyed previously, we'll need to re-register it
128             logger.logReuseListener(key)
129             reusedListener = it
130         }
131 
132         // Having an old key means that we're migrating from/to resumption. We should update
133         // the old listener to make sure that events will be dispatched to the new location.
134         val migrating = oldKey != null && key != oldKey
135         if (migrating) {
136             reusedListener = mediaListeners.remove(oldKey)
137             logger.logMigrateListener(oldKey, key, reusedListener != null)
138         }
139 
140         reusedListener?.let {
141             bgExecutor.execute {
142                 val wasPlaying = it.isPlaying()
143                 logger.logUpdateListener(key, wasPlaying)
144                 it.setMediaData(data)
145                 it.key = key
146                 mediaListeners[key] = it
147                 if (wasPlaying != it.isPlaying()) {
148                     // If a player becomes active because of a migration, we'll need to broadcast
149                     // its state. Doing it now would lead to reentrant callbacks, so let's wait
150                     // until we're done.
151                     mainExecutor.execute {
152                         if (mediaListeners[key]?.isPlaying() == true) {
153                             logger.logDelayedUpdate(key)
154                             timeoutCallback.invoke(key, false /* timedOut */)
155                         }
156                     }
157                 }
158             }
159             return
160         }
161 
162         mediaListeners[key] = PlaybackStateListener(key, data)
163     }
164 
165     override fun onMediaDataRemoved(key: String, userInitiated: Boolean) {
166         mediaListeners.remove(key)?.destroy()
167     }
168 
169     fun isTimedOut(key: String): Boolean {
170         return mediaListeners[key]?.timedOut ?: false
171     }
172 
173     private inner class PlaybackStateListener(var key: String, data: MediaData) :
174         MediaController.Callback() {
175 
176         var timedOut = false
177         var lastState: PlaybackState? = null
178         var resumption: Boolean? = null
179         var destroyed = false
180         var expiration = Long.MAX_VALUE
181         var sessionToken: MediaSession.Token? = null
182 
183         // Resume controls may have null token
184         private var mediaController: MediaController? = null
185         var cancellation: Runnable? = null
186             private set
187 
188         fun Int.isPlaying() = isPlayingState(this)
189 
190         fun isPlaying() = lastState?.state?.isPlaying() ?: false
191 
192         init {
193             bgExecutor.execute { setMediaData(data) }
194         }
195 
196         fun destroy() {
197             bgExecutor.execute { mediaController?.unregisterCallback(this) }
198             cancellation?.run()
199             destroyed = true
200         }
201 
202         @WorkerThread
203         fun setMediaData(data: MediaData) {
204             sessionToken = data.token
205             destroyed = false
206             mediaController?.unregisterCallback(this)
207             mediaController =
208                 if (data.token != null) {
209                     mediaControllerFactory.create(data.token)
210                 } else {
211                     null
212                 }
213             mediaController?.registerCallback(this)
214             // Let's register the cancellations, but not dispatch events now.
215             // Timeouts didn't happen yet and reentrant events are troublesome.
216             processState(
217                 mediaController?.playbackState,
218                 dispatchEvents = false,
219                 currentResumption = data.resumption,
220             )
221         }
222 
223         override fun onPlaybackStateChanged(state: PlaybackState?) {
224             bgExecutor.execute {
225                 processState(state, dispatchEvents = true, currentResumption = resumption)
226             }
227         }
228 
229         override fun onSessionDestroyed() {
230             logger.logSessionDestroyed(key)
231             if (resumption == true) {
232                 // Some apps create a session when MBS is queried. We should unregister the
233                 // controller since it will no longer be valid, but don't cancel the timeout
234                 bgExecutor.execute { mediaController?.unregisterCallback(this) }
235             } else {
236                 // For active controls, if the session is destroyed, clean up everything since we
237                 // will need to recreate it if this key is updated later
238                 sessionCallback.invoke(key)
239                 destroy()
240             }
241         }
242 
243         @WorkerThread
244         private fun processState(
245             state: PlaybackState?,
246             dispatchEvents: Boolean,
247             currentResumption: Boolean?,
248         ) {
249             logger.logPlaybackState(key, state)
250 
251             val playingStateSame = (state?.state?.isPlaying() == isPlaying())
252             val actionsSame =
253                 (lastState?.actions == state?.actions) &&
254                     areCustomActionListsEqual(lastState?.customActions, state?.customActions)
255             val resumptionChanged = resumption != currentResumption
256 
257             lastState = state
258 
259             if ((!actionsSame || !playingStateSame) && state != null && dispatchEvents) {
260                 logger.logStateCallback(key)
261                 uiExecutor.execute { stateCallback.invoke(key, state) }
262             }
263 
264             if (playingStateSame && !resumptionChanged) {
265                 return
266             }
267             resumption = currentResumption
268 
269             val playing = isPlaying()
270             if (!playing) {
271                 logger.logScheduleTimeout(key, playing, resumption!!)
272                 if (cancellation != null && !resumptionChanged) {
273                     // if the media changed resume state, we'll need to adjust the timeout length
274                     logger.logCancelIgnored(key)
275                     return
276                 }
277                 expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state, $resumption")
278                 val timeout =
279                     if (currentResumption == true) {
280                         RESUME_MEDIA_TIMEOUT
281                     } else {
282                         PAUSED_MEDIA_TIMEOUT
283                     }
284                 expiration = systemClock.elapsedRealtime() + timeout
285                 cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout)
286             } else {
287                 expireMediaTimeout(key, "playback started - $state, $key")
288                 timedOut = false
289                 if (dispatchEvents) {
290                     uiExecutor.execute { timeoutCallback(key, timedOut) }
291                 }
292             }
293         }
294 
295         fun doTimeout() {
296             cancellation = null
297             logger.logTimeout(key)
298             timedOut = true
299             expiration = Long.MAX_VALUE
300             // this event is async, so it's safe even when `dispatchEvents` is false
301             timeoutCallback(key, timedOut)
302         }
303 
304         fun expireMediaTimeout(mediaKey: String, reason: String) {
305             cancellation?.apply {
306                 logger.logTimeoutCancelled(mediaKey, reason)
307                 run()
308             }
309             expiration = Long.MAX_VALUE
310             cancellation = null
311         }
312     }
313 }
314