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