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