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