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