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