1 /*
2  * 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
18 
19 import android.media.session.MediaController
20 import android.media.session.PlaybackState
21 import android.os.SystemProperties
22 import android.util.Log
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.statusbar.NotificationMediaManager.isPlayingState
27 import com.android.systemui.util.concurrency.DelayableExecutor
28 import java.util.concurrent.TimeUnit
29 import javax.inject.Inject
30 
31 private const val DEBUG = true
32 private const val TAG = "MediaTimeout"
33 
34 @VisibleForTesting
35 val PAUSED_MEDIA_TIMEOUT = SystemProperties
36         .getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10))
37 
38 @VisibleForTesting
39 val RESUME_MEDIA_TIMEOUT = SystemProperties
40         .getLong("debug.sysui.media_timeout_resume", TimeUnit.DAYS.toMillis(3))
41 
42 /**
43  * Controller responsible for keeping track of playback states and expiring inactive streams.
44  */
45 @SysUISingleton
46 class MediaTimeoutListener @Inject constructor(
47     private val mediaControllerFactory: MediaControllerFactory,
48     @Main private val mainExecutor: DelayableExecutor
49 ) : MediaDataManager.Listener {
50 
51     private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()
52 
53     /**
54      * Callback representing that a media object is now expired:
55      * @param key Media control unique identifier
56      * @param timedOut True when expired for {@code PAUSED_MEDIA_TIMEOUT} for active media,
57      *                 or {@code RESUME_MEDIA_TIMEOUT} for resume media
58      */
59     lateinit var timeoutCallback: (String, Boolean) -> Unit
60 
61     override fun onMediaDataLoaded(
62         key: String,
63         oldKey: String?,
64         data: MediaData,
65         immediately: Boolean,
66         receivedSmartspaceCardLatency: Int
67     ) {
68         var reusedListener: PlaybackStateListener? = null
69 
70         // First check if we already have a listener
71         mediaListeners.get(key)?.let {
72             if (!it.destroyed) {
73                 return
74             }
75 
76             // If listener was destroyed previously, we'll need to re-register it
77             if (DEBUG) {
78                 Log.d(TAG, "Reusing destroyed listener $key")
79             }
80             reusedListener = it
81         }
82 
83         // Having an old key means that we're migrating from/to resumption. We should update
84         // the old listener to make sure that events will be dispatched to the new location.
85         val migrating = oldKey != null && key != oldKey
86         if (migrating) {
87             reusedListener = mediaListeners.remove(oldKey)
88             if (reusedListener != null) {
89                 if (DEBUG) Log.d(TAG, "migrating key $oldKey to $key, for resumption")
90             } else {
91                 Log.w(TAG, "Old key $oldKey for player $key doesn't exist. Continuing...")
92             }
93         }
94 
95         reusedListener?.let {
96             val wasPlaying = it.playing ?: false
97             if (DEBUG) Log.d(TAG, "updating listener for $key, was playing? $wasPlaying")
98             it.mediaData = data
99             it.key = key
100             mediaListeners[key] = it
101             if (wasPlaying != it.playing) {
102                 // If a player becomes active because of a migration, we'll need to broadcast
103                 // its state. Doing it now would lead to reentrant callbacks, so let's wait
104                 // until we're done.
105                 mainExecutor.execute {
106                     if (mediaListeners[key]?.playing == true) {
107                         if (DEBUG) Log.d(TAG, "deliver delayed playback state for $key")
108                         timeoutCallback.invoke(key, false /* timedOut */)
109                     }
110                 }
111             }
112             return
113         }
114 
115         mediaListeners[key] = PlaybackStateListener(key, data)
116     }
117 
118     override fun onMediaDataRemoved(key: String) {
119         mediaListeners.remove(key)?.destroy()
120     }
121 
122     fun isTimedOut(key: String): Boolean {
123         return mediaListeners[key]?.timedOut ?: false
124     }
125 
126     private inner class PlaybackStateListener(
127         var key: String,
128         data: MediaData
129     ) : MediaController.Callback() {
130 
131         var timedOut = false
132         var playing: Boolean? = null
133         var resumption: Boolean? = null
134         var destroyed = false
135 
136         var mediaData: MediaData = data
137             set(value) {
138                 destroyed = false
139                 mediaController?.unregisterCallback(this)
140                 field = value
141                 mediaController = if (field.token != null) {
142                     mediaControllerFactory.create(field.token)
143                 } else {
144                     null
145                 }
146                 mediaController?.registerCallback(this)
147                 // Let's register the cancellations, but not dispatch events now.
148                 // Timeouts didn't happen yet and reentrant events are troublesome.
149                 processState(mediaController?.playbackState, dispatchEvents = false)
150             }
151 
152         // Resume controls may have null token
153         private var mediaController: MediaController? = null
154         private var cancellation: Runnable? = null
155 
156         init {
157             mediaData = data
158         }
159 
160         fun destroy() {
161             mediaController?.unregisterCallback(this)
162             cancellation?.run()
163             destroyed = true
164         }
165 
166         override fun onPlaybackStateChanged(state: PlaybackState?) {
167             processState(state, dispatchEvents = true)
168         }
169 
170         override fun onSessionDestroyed() {
171             if (DEBUG) {
172                 Log.d(TAG, "Session destroyed for $key")
173             }
174 
175             if (resumption == true) {
176                 // Some apps create a session when MBS is queried. We should unregister the
177                 // controller since it will no longer be valid, but don't cancel the timeout
178                 mediaController?.unregisterCallback(this)
179             } else {
180                 // For active controls, if the session is destroyed, clean up everything since we
181                 // will need to recreate it if this key is updated later
182                 destroy()
183             }
184         }
185 
186         private fun processState(state: PlaybackState?, dispatchEvents: Boolean) {
187             if (DEBUG) {
188                 Log.v(TAG, "processState $key: $state")
189             }
190 
191             val isPlaying = state != null && isPlayingState(state.state)
192             val resumptionChanged = resumption != mediaData.resumption
193             if (playing == isPlaying && playing != null && !resumptionChanged) {
194                 return
195             }
196             playing = isPlaying
197             resumption = mediaData.resumption
198 
199             if (!isPlaying) {
200                 if (DEBUG) {
201                     Log.v(TAG, "schedule timeout for $key playing $isPlaying, $resumption")
202                 }
203                 if (cancellation != null && !resumptionChanged) {
204                     // if the media changed resume state, we'll need to adjust the timeout length
205                     if (DEBUG) Log.d(TAG, "cancellation already exists, continuing.")
206                     return
207                 }
208                 expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state, $resumption")
209                 val timeout = if (mediaData.resumption) {
210                     RESUME_MEDIA_TIMEOUT
211                 } else {
212                     PAUSED_MEDIA_TIMEOUT
213                 }
214                 cancellation = mainExecutor.executeDelayed({
215                     cancellation = null
216                     if (DEBUG) {
217                         Log.v(TAG, "Execute timeout for $key")
218                     }
219                     timedOut = true
220                     // this event is async, so it's safe even when `dispatchEvents` is false
221                     timeoutCallback(key, timedOut)
222                 }, timeout)
223             } else {
224                 expireMediaTimeout(key, "playback started - $state, $key")
225                 timedOut = false
226                 if (dispatchEvents) {
227                     timeoutCallback(key, timedOut)
228                 }
229             }
230         }
231 
232         private fun expireMediaTimeout(mediaKey: String, reason: String) {
233             cancellation?.apply {
234                 if (DEBUG) {
235                     Log.v(TAG, "media timeout cancelled for  $mediaKey, reason: $reason")
236                 }
237                 run()
238             }
239             cancellation = null
240         }
241     }
242 }
243