• 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.pipeline
18 
19 import android.media.session.MediaController
20 import android.media.session.PlaybackState
21 import android.os.SystemProperties
22 import com.android.internal.annotations.VisibleForTesting
23 import com.android.systemui.dagger.SysUISingleton
24 import com.android.systemui.dagger.qualifiers.Main
25 import com.android.systemui.media.controls.models.player.MediaData
26 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
27 import com.android.systemui.media.controls.util.MediaControllerFactory
28 import com.android.systemui.media.controls.util.MediaFlags
29 import com.android.systemui.plugins.statusbar.StatusBarStateController
30 import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
31 import com.android.systemui.statusbar.SysuiStatusBarStateController
32 import com.android.systemui.util.concurrency.DelayableExecutor
33 import com.android.systemui.util.time.SystemClock
34 import java.util.concurrent.TimeUnit
35 import javax.inject.Inject
36 
37 @VisibleForTesting
38 val PAUSED_MEDIA_TIMEOUT =
39     SystemProperties.getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10))
40 
41 @VisibleForTesting
42 val RESUME_MEDIA_TIMEOUT =
43     SystemProperties.getLong("debug.sysui.media_timeout_resume", TimeUnit.DAYS.toMillis(2))
44 
45 /** Controller responsible for keeping track of playback states and expiring inactive streams. */
46 @SysUISingleton
47 class MediaTimeoutListener
48 @Inject
49 constructor(
50     private val mediaControllerFactory: MediaControllerFactory,
51     @Main private val mainExecutor: DelayableExecutor,
52     private val logger: MediaTimeoutLogger,
53     statusBarStateController: SysuiStatusBarStateController,
54     private val systemClock: SystemClock,
55     private val mediaFlags: MediaFlags,
56 ) : MediaDataManager.Listener {
57 
58     private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()
59     private val recommendationListeners: MutableMap<String, RecommendationListener> = mutableMapOf()
60 
61     /**
62      * Callback representing that a media object is now expired:
63      *
64      * @param key Media control unique identifier
65      * @param timedOut True when expired for {@code PAUSED_MEDIA_TIMEOUT} for active media,
66      * ```
67      *                 or {@code RESUME_MEDIA_TIMEOUT} for resume media
68      * ```
69      */
70     lateinit var timeoutCallback: (String, Boolean) -> Unit
71 
72     /**
73      * Callback representing that a media object [PlaybackState] has changed.
74      *
75      * @param key Media control unique identifier
76      * @param state The new [PlaybackState]
77      */
78     lateinit var stateCallback: (String, PlaybackState) -> Unit
79 
80     /**
81      * Callback representing that the [MediaSession] for an active control has been destroyed
82      *
83      * @param key Media control unique identifier
84      */
85     lateinit var sessionCallback: (String) -> Unit
86 
87     init {
88         statusBarStateController.addCallback(
89             object : StatusBarStateController.StateListener {
90                 override fun onDozingChanged(isDozing: Boolean) {
91                     if (!isDozing) {
92                         // Check whether any timeouts should have expired
93                         mediaListeners.forEach { (key, listener) ->
94                             if (
95                                 listener.cancellation != null &&
96                                     listener.expiration <= systemClock.elapsedRealtime()
97                             ) {
98                                 // We dozed too long - timeout now, and cancel the pending one
99                                 listener.expireMediaTimeout(key, "timeout happened while dozing")
100                                 listener.doTimeout()
101                             }
102                         }
103 
104                         recommendationListeners.forEach { (key, listener) ->
105                             if (
106                                 listener.cancellation != null &&
107                                     listener.expiration <= systemClock.currentTimeMillis()
108                             ) {
109                                 logger.logTimeoutCancelled(key, "Timed out while dozing")
110                                 listener.doTimeout()
111                             }
112                         }
113                     }
114                 }
115             }
116         )
117     }
118 
119     override fun onMediaDataLoaded(
120         key: String,
121         oldKey: String?,
122         data: MediaData,
123         immediately: Boolean,
124         receivedSmartspaceCardLatency: Int,
125         isSsReactivated: Boolean
126     ) {
127         var reusedListener: PlaybackStateListener? = null
128 
129         // First check if we already have a listener
130         mediaListeners.get(key)?.let {
131             if (!it.destroyed) {
132                 return
133             }
134 
135             // If listener was destroyed previously, we'll need to re-register it
136             logger.logReuseListener(key)
137             reusedListener = it
138         }
139 
140         // Having an old key means that we're migrating from/to resumption. We should update
141         // the old listener to make sure that events will be dispatched to the new location.
142         val migrating = oldKey != null && key != oldKey
143         if (migrating) {
144             reusedListener = mediaListeners.remove(oldKey)
145             logger.logMigrateListener(oldKey, key, reusedListener != null)
146         }
147 
148         reusedListener?.let {
149             val wasPlaying = it.isPlaying()
150             logger.logUpdateListener(key, wasPlaying)
151             it.mediaData = data
152             it.key = key
153             mediaListeners[key] = it
154             if (wasPlaying != it.isPlaying()) {
155                 // If a player becomes active because of a migration, we'll need to broadcast
156                 // its state. Doing it now would lead to reentrant callbacks, so let's wait
157                 // until we're done.
158                 mainExecutor.execute {
159                     if (mediaListeners[key]?.isPlaying() == true) {
160                         logger.logDelayedUpdate(key)
161                         timeoutCallback.invoke(key, false /* timedOut */)
162                     }
163                 }
164             }
165             return
166         }
167 
168         mediaListeners[key] = PlaybackStateListener(key, data)
169     }
170 
171     override fun onMediaDataRemoved(key: String) {
172         mediaListeners.remove(key)?.destroy()
173     }
174 
175     override fun onSmartspaceMediaDataLoaded(
176         key: String,
177         data: SmartspaceMediaData,
178         shouldPrioritize: Boolean
179     ) {
180         if (!mediaFlags.isPersistentSsCardEnabled()) return
181 
182         // First check if we already have a listener
183         recommendationListeners.get(key)?.let {
184             if (!it.destroyed) {
185                 it.recommendationData = data
186                 return
187             }
188         }
189 
190         // Otherwise, create a new one
191         recommendationListeners[key] = RecommendationListener(key, data)
192     }
193 
194     override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
195         if (!mediaFlags.isPersistentSsCardEnabled()) return
196         recommendationListeners.remove(key)?.destroy()
197     }
198 
199     fun isTimedOut(key: String): Boolean {
200         return mediaListeners[key]?.timedOut ?: false
201     }
202 
203     private inner class PlaybackStateListener(var key: String, data: MediaData) :
204         MediaController.Callback() {
205 
206         var timedOut = false
207         var lastState: PlaybackState? = null
208         var resumption: Boolean? = null
209         var destroyed = false
210         var expiration = Long.MAX_VALUE
211 
212         var mediaData: MediaData = data
213             set(value) {
214                 destroyed = false
215                 mediaController?.unregisterCallback(this)
216                 field = value
217                 val token = field.token
218                 mediaController =
219                     if (token != null) {
220                         mediaControllerFactory.create(token)
221                     } else {
222                         null
223                     }
224                 mediaController?.registerCallback(this)
225                 // Let's register the cancellations, but not dispatch events now.
226                 // Timeouts didn't happen yet and reentrant events are troublesome.
227                 processState(mediaController?.playbackState, dispatchEvents = false)
228             }
229 
230         // Resume controls may have null token
231         private var mediaController: MediaController? = null
232         var cancellation: Runnable? = null
233             private set
234 
235         fun Int.isPlaying() = isPlayingState(this)
236         fun isPlaying() = lastState?.state?.isPlaying() ?: false
237 
238         init {
239             mediaData = data
240         }
241 
242         fun destroy() {
243             mediaController?.unregisterCallback(this)
244             cancellation?.run()
245             destroyed = true
246         }
247 
248         override fun onPlaybackStateChanged(state: PlaybackState?) {
249             processState(state, dispatchEvents = true)
250         }
251 
252         override fun onSessionDestroyed() {
253             logger.logSessionDestroyed(key)
254             if (resumption == true) {
255                 // Some apps create a session when MBS is queried. We should unregister the
256                 // controller since it will no longer be valid, but don't cancel the timeout
257                 mediaController?.unregisterCallback(this)
258             } else {
259                 // For active controls, if the session is destroyed, clean up everything since we
260                 // will need to recreate it if this key is updated later
261                 sessionCallback.invoke(key)
262                 destroy()
263             }
264         }
265 
266         private fun processState(state: PlaybackState?, dispatchEvents: Boolean) {
267             logger.logPlaybackState(key, state)
268 
269             val playingStateSame = (state?.state?.isPlaying() == isPlaying())
270             val actionsSame =
271                 (lastState?.actions == state?.actions) &&
272                     areCustomActionListsEqual(lastState?.customActions, state?.customActions)
273             val resumptionChanged = resumption != mediaData.resumption
274 
275             lastState = state
276 
277             if ((!actionsSame || !playingStateSame) && state != null && dispatchEvents) {
278                 logger.logStateCallback(key)
279                 stateCallback.invoke(key, state)
280             }
281 
282             if (playingStateSame && !resumptionChanged) {
283                 return
284             }
285             resumption = mediaData.resumption
286 
287             val playing = isPlaying()
288             if (!playing) {
289                 logger.logScheduleTimeout(key, playing, resumption!!)
290                 if (cancellation != null && !resumptionChanged) {
291                     // if the media changed resume state, we'll need to adjust the timeout length
292                     logger.logCancelIgnored(key)
293                     return
294                 }
295                 expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state, $resumption")
296                 val timeout =
297                     if (mediaData.resumption) {
298                         RESUME_MEDIA_TIMEOUT
299                     } else {
300                         PAUSED_MEDIA_TIMEOUT
301                     }
302                 expiration = systemClock.elapsedRealtime() + timeout
303                 cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout)
304             } else {
305                 expireMediaTimeout(key, "playback started - $state, $key")
306                 timedOut = false
307                 if (dispatchEvents) {
308                     timeoutCallback(key, timedOut)
309                 }
310             }
311         }
312 
313         fun doTimeout() {
314             cancellation = null
315             logger.logTimeout(key)
316             timedOut = true
317             expiration = Long.MAX_VALUE
318             // this event is async, so it's safe even when `dispatchEvents` is false
319             timeoutCallback(key, timedOut)
320         }
321 
322         fun expireMediaTimeout(mediaKey: String, reason: String) {
323             cancellation?.apply {
324                 logger.logTimeoutCancelled(mediaKey, reason)
325                 run()
326             }
327             expiration = Long.MAX_VALUE
328             cancellation = null
329         }
330     }
331 
332     private fun areCustomActionListsEqual(
333         first: List<PlaybackState.CustomAction>?,
334         second: List<PlaybackState.CustomAction>?
335     ): Boolean {
336         // Same object, or both null
337         if (first === second) {
338             return true
339         }
340 
341         // Only one null, or different number of actions
342         if ((first == null || second == null) || (first.size != second.size)) {
343             return false
344         }
345 
346         // Compare individual actions
347         first.asSequence().zip(second.asSequence()).forEach { (firstAction, secondAction) ->
348             if (!areCustomActionsEqual(firstAction, secondAction)) {
349                 return false
350             }
351         }
352         return true
353     }
354 
355     private fun areCustomActionsEqual(
356         firstAction: PlaybackState.CustomAction,
357         secondAction: PlaybackState.CustomAction
358     ): Boolean {
359         if (
360             firstAction.action != secondAction.action ||
361                 firstAction.name != secondAction.name ||
362                 firstAction.icon != secondAction.icon
363         ) {
364             return false
365         }
366 
367         if ((firstAction.extras == null) != (secondAction.extras == null)) {
368             return false
369         }
370         if (firstAction.extras != null) {
371             firstAction.extras.keySet().forEach { key ->
372                 if (firstAction.extras.get(key) != secondAction.extras.get(key)) {
373                     return false
374                 }
375             }
376         }
377         return true
378     }
379 
380     /** Listens to changes in recommendation card data and schedules a timeout for its expiration */
381     private inner class RecommendationListener(var key: String, data: SmartspaceMediaData) {
382         private var timedOut = false
383         var destroyed = false
384         var expiration = Long.MAX_VALUE
385             private set
386         var cancellation: Runnable? = null
387             private set
388 
389         var recommendationData: SmartspaceMediaData = data
390             set(value) {
391                 destroyed = false
392                 field = value
393                 processUpdate()
394             }
395 
396         init {
397             recommendationData = data
398         }
399 
400         fun destroy() {
401             cancellation?.run()
402             cancellation = null
403             destroyed = true
404         }
405 
406         private fun processUpdate() {
407             if (recommendationData.expiryTimeMs != expiration) {
408                 // The expiry time changed - cancel and reschedule
409                 val timeout =
410                     recommendationData.expiryTimeMs -
411                         recommendationData.headphoneConnectionTimeMillis
412                 logger.logRecommendationTimeoutScheduled(key, timeout)
413                 cancellation?.run()
414                 cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout)
415                 expiration = recommendationData.expiryTimeMs
416             }
417         }
418 
419         fun doTimeout() {
420             cancellation?.run()
421             cancellation = null
422             logger.logTimeout(key)
423             timedOut = true
424             expiration = Long.MAX_VALUE
425             timeoutCallback(key, timedOut)
426         }
427     }
428 }
429