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