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