/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.media.controls.domain.pipeline import android.annotation.MainThread import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.media.MediaDescription import android.media.session.MediaController import android.media.session.MediaSession import android.media.session.PlaybackState import android.os.Process import android.os.UserHandle import android.service.notification.StatusBarNotification import android.util.Log import com.android.app.tracing.coroutines.launchTraced as launch import com.android.app.tracing.traceSection import com.android.internal.logging.InstanceId import com.android.keyguard.KeyguardUpdateMonitor import com.android.systemui.Dumpable import com.android.systemui.broadcast.BroadcastDispatcher import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dump.DumpManager import com.android.systemui.media.NotificationMediaManager.isPlayingState import com.android.systemui.media.controls.domain.pipeline.MediaDataManager.Companion.isMediaNotification import com.android.systemui.media.controls.domain.resume.MediaResumeListener import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser import com.android.systemui.media.controls.shared.MediaLogger import com.android.systemui.media.controls.shared.getActiveTimestamp import com.android.systemui.media.controls.shared.isSameMediaData import com.android.systemui.media.controls.shared.model.MediaAction import com.android.systemui.media.controls.shared.model.MediaButton import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.controls.ui.view.MediaViewHolder import com.android.systemui.media.controls.util.MediaControllerFactory import com.android.systemui.media.controls.util.MediaFlags import com.android.systemui.media.controls.util.MediaUiEventLogger import com.android.systemui.res.R import com.android.systemui.util.Assert import com.android.systemui.util.Utils import com.android.systemui.util.concurrency.DelayableExecutor import com.android.systemui.util.concurrency.ThreadFactory import com.android.systemui.util.time.SystemClock import java.io.PrintWriter import java.util.Collections import java.util.concurrent.Executor import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext private const val TAG = "MediaDataManager" private const val DEBUG = true private val LOADING = MediaData( userId = -1, initialized = false, app = null, appIcon = null, artist = null, song = null, artwork = null, actions = emptyList(), actionsToShowInCompact = emptyList(), packageName = "INVALID", token = null, clickIntent = null, device = null, active = true, resumeAction = null, instanceId = InstanceId.fakeInstanceId(-1), appUid = Process.INVALID_UID, ) /** A class that facilitates management and loading of Media Data, ready for binding. */ @SysUISingleton class LegacyMediaDataManagerImpl( private val context: Context, @Background private val backgroundExecutor: Executor, @Background private val backgroundDispatcher: CoroutineDispatcher, @Main private val foregroundExecutor: DelayableExecutor, @Main private val mainDispatcher: CoroutineDispatcher, @Application private val applicationScope: CoroutineScope, private val mediaControllerFactory: MediaControllerFactory, private val broadcastDispatcher: BroadcastDispatcher, dumpManager: DumpManager, mediaTimeoutListener: MediaTimeoutListener, mediaResumeListener: MediaResumeListener, mediaSessionBasedFilter: MediaSessionBasedFilter, private val mediaDeviceManager: MediaDeviceManager, mediaDataCombineLatest: MediaDataCombineLatest, private val mediaDataFilter: LegacyMediaDataFilterImpl, private var useMediaResumption: Boolean, private val useQsMediaPlayer: Boolean, private val systemClock: SystemClock, private val mediaFlags: MediaFlags, private val logger: MediaUiEventLogger, private val keyguardUpdateMonitor: KeyguardUpdateMonitor, private val mediaDataLoader: dagger.Lazy, private val mediaLogger: MediaLogger, ) : Dumpable, MediaDataManager { companion object { // Maximum number of actions allowed in compact view @JvmField val MAX_COMPACT_ACTIONS = 3 // Maximum number of actions allowed in expanded view @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size } private val themeText = com.android.settingslib.Utils.getColorAttr( context, com.android.internal.R.attr.textColorPrimary, ) .defaultColor // Internal listeners are part of the internal pipeline. External listeners (those registered // with [MediaDeviceManager.addListener]) receive events after they have propagated through // the internal pipeline. // Another way to think of the distinction between internal and external listeners is the // following. Internal listeners are listeners that MediaDataManager depends on, and external // listeners are listeners that depend on MediaDataManager. // TODO(b/159539991#comment5): Move internal listeners to separate package. private val internalListeners: MutableSet = mutableSetOf() private val mediaEntries: MutableMap = Collections.synchronizedMap(LinkedHashMap()) @Inject constructor( context: Context, threadFactory: ThreadFactory, @Background backgroundDispatcher: CoroutineDispatcher, @Main foregroundExecutor: DelayableExecutor, @Main mainDispatcher: CoroutineDispatcher, @Application applicationScope: CoroutineScope, mediaControllerFactory: MediaControllerFactory, dumpManager: DumpManager, broadcastDispatcher: BroadcastDispatcher, mediaTimeoutListener: MediaTimeoutListener, mediaResumeListener: MediaResumeListener, mediaSessionBasedFilter: MediaSessionBasedFilter, mediaDeviceManager: MediaDeviceManager, mediaDataCombineLatest: MediaDataCombineLatest, mediaDataFilter: LegacyMediaDataFilterImpl, clock: SystemClock, mediaFlags: MediaFlags, logger: MediaUiEventLogger, keyguardUpdateMonitor: KeyguardUpdateMonitor, mediaDataLoader: dagger.Lazy, mediaLogger: MediaLogger, ) : this( context, // Loading bitmap for UMO background can take longer time, so it cannot run on the default // background thread. Use a custom thread for media. threadFactory.buildExecutorOnNewThread(TAG), backgroundDispatcher, foregroundExecutor, mainDispatcher, applicationScope, mediaControllerFactory, broadcastDispatcher, dumpManager, mediaTimeoutListener, mediaResumeListener, mediaSessionBasedFilter, mediaDeviceManager, mediaDataCombineLatest, mediaDataFilter, Utils.useMediaResumption(context), Utils.useQsMediaPlayer(context), clock, mediaFlags, logger, keyguardUpdateMonitor, mediaDataLoader, mediaLogger, ) private val appChangeReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Intent.ACTION_PACKAGES_SUSPENDED -> { val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST) packages?.forEach { removeAllForPackage(it) } } Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_RESTARTED -> { intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) } } } } } init { dumpManager.registerNormalDumpable(TAG, this) // Initialize the internal processing pipeline. The listeners at the front of the pipeline // are set as internal listeners so that they receive events. From there, events are // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter, // so it is responsible for dispatching events to external listeners. To achieve this, // external listeners that are registered with [MediaDataManager.addListener] are actually // registered as listeners to mediaDataFilter. addInternalListener(mediaTimeoutListener) addInternalListener(mediaResumeListener) addInternalListener(mediaSessionBasedFilter) mediaSessionBasedFilter.addListener(mediaDeviceManager) mediaSessionBasedFilter.addListener(mediaDataCombineLatest) mediaDeviceManager.addListener(mediaDataCombineLatest) mediaDataCombineLatest.addListener(mediaDataFilter) // Set up links back into the pipeline for listeners that need to send events upstream. mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> setInactive(key, timedOut) } mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState -> updateState(key, state) } mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) } mediaResumeListener.setManager(this) mediaDataFilter.mediaDataManager = this val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED) broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL) val uninstallFilter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_RESTARTED) addDataScheme("package") } // BroadcastDispatcher does not allow filters with data schemes context.registerReceiver(appChangeReceiver, uninstallFilter) } override fun destroy() { context.unregisterReceiver(appChangeReceiver) } override fun onNotificationAdded(key: String, sbn: StatusBarNotification) { if (useQsMediaPlayer && isMediaNotification(sbn)) { var isNewlyActiveEntry = false var isConvertingToActive = false Assert.isMainThread() val oldKey = findExistingEntry(key, sbn.packageName) if (oldKey == null) { val instanceId = logger.getNewInstanceId() val temp = LOADING.copy( packageName = sbn.packageName, instanceId = instanceId, createdTimestampMillis = systemClock.currentTimeMillis(), ) mediaEntries.put(key, temp) isNewlyActiveEntry = true } else if (oldKey != key) { // Resume -> active conversion; move to new key val oldData = mediaEntries.remove(oldKey)!! isNewlyActiveEntry = true isConvertingToActive = true mediaEntries.put(key, oldData) } loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } else { onNotificationRemoved(key) } } private fun removeAllForPackage(packageName: String) { Assert.isMainThread() val toRemove = mediaEntries.filter { it.value.packageName == packageName } toRemove.forEach { removeEntry(it.key) } } override fun setResumeAction(key: String, action: Runnable?) { mediaEntries.get(key)?.let { it.resumeAction = action it.hasCheckedForResume = true } } override fun addResumptionControls( userId: Int, desc: MediaDescription, action: Runnable, token: MediaSession.Token, appName: String, appIntent: PendingIntent, packageName: String, ) { // Resume controls don't have a notification key, so store by package name instead if (!mediaEntries.containsKey(packageName)) { val instanceId = logger.getNewInstanceId() val appUid = try { context.packageManager.getApplicationInfo(packageName, 0)?.uid!! } catch (e: PackageManager.NameNotFoundException) { Log.w(TAG, "Could not get app UID for $packageName", e) Process.INVALID_UID } val resumeData = LOADING.copy( packageName = packageName, resumeAction = action, hasCheckedForResume = true, instanceId = instanceId, appUid = appUid, createdTimestampMillis = systemClock.currentTimeMillis(), ) mediaEntries.put(packageName, resumeData) logSingleVsMultipleMediaAdded(appUid, packageName, instanceId) logger.logResumeMediaAdded(appUid, packageName, instanceId) } applicationScope.launch { loadMediaDataForResumption(userId, desc, action, token, appName, appIntent, packageName) } } /** * Check if there is an existing entry that matches the key or package name. Returns the key * that matches, or null if not found. */ private fun findExistingEntry(key: String, packageName: String): String? { if (mediaEntries.containsKey(key)) { return key } // Check if we already had a resume player if (mediaEntries.containsKey(packageName)) { return packageName } return null } private fun loadMediaData( key: String, sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, isConvertingToActive: Boolean = false, ) { applicationScope.launch { loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } } private suspend fun loadMediaDataWithLoader( key: String, sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, isConvertingToActive: Boolean = false, ) = withContext(backgroundDispatcher) { val lastActive = getActiveTimestamp(systemClock) val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive) if (result == null) { Log.d(TAG, "No result from loadMediaData") return@withContext } val currentEntry = mediaEntries[key] val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId() val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L val resumeAction: Runnable? = currentEntry?.resumeAction val hasCheckedForResume = currentEntry?.hasCheckedForResume == true val active = currentEntry?.active ?: true val mediaController = mediaControllerFactory.create(result.token!!) val mediaData = MediaData( userId = sbn.normalizedUserId, initialized = true, app = result.appName, appIcon = result.appIcon, artist = result.artist, song = result.song, artwork = result.artworkIcon, actions = result.actionIcons, actionsToShowInCompact = result.actionsToShowInCompact, semanticActions = result.semanticActions, packageName = sbn.packageName, token = result.token, clickIntent = result.clickIntent, device = result.device, active = active, resumeAction = resumeAction, playbackLocation = result.playbackLocation, notificationKey = key, hasCheckedForResume = hasCheckedForResume, isPlaying = result.isPlaying, isClearable = !sbn.isOngoing, lastActive = lastActive, createdTimestampMillis = createdTimestampMillis, instanceId = instanceId, appUid = result.appUid, isExplicit = result.isExplicit, ) if (isSameMediaData(context, mediaController, mediaData, currentEntry)) { mediaLogger.logDuplicateMediaNotification(key) return@withContext } // We need to log the correct media added. if (isNewlyActiveEntry) { logSingleVsMultipleMediaAdded(result.appUid, sbn.packageName, instanceId) logger.logActiveMediaAdded( result.appUid, sbn.packageName, instanceId, result.playbackLocation, ) } else if (result.playbackLocation != currentEntry?.playbackLocation) { logger.logPlaybackLocationChange( result.appUid, sbn.packageName, instanceId, result.playbackLocation, ) } withContext(mainDispatcher) { onMediaDataLoaded(key, oldKey, mediaData) } } /** Add a listener for changes in this class */ override fun addListener(listener: MediaDataManager.Listener) { // mediaDataFilter is the current end of the internal pipeline. Register external // listeners as listeners to it. mediaDataFilter.addListener(listener) } /** Remove a listener for changes in this class */ override fun removeListener(listener: MediaDataManager.Listener) { // Since mediaDataFilter is the current end of the internal pipelie, external listeners // have been registered to it. So, they need to be removed from it too. mediaDataFilter.removeListener(listener) } /** Add a listener for internal events. */ private fun addInternalListener(listener: MediaDataManager.Listener) = internalListeners.add(listener) /** * Notify internal listeners of media loaded event. * * External listeners registered with [addListener] will be notified after the event propagates * through the internal listener pipeline. */ private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) { internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) } } /** * Notify internal listeners of media removed event. * * External listeners registered with [addListener] will be notified after the event propagates * through the internal listener pipeline. */ private fun notifyMediaDataRemoved(key: String, userInitiated: Boolean = false) { internalListeners.forEach { it.onMediaDataRemoved(key, userInitiated) } } /** * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This * will make the player not active anymore, hiding it from QQS and Keyguard. * * @see MediaData.active */ override fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean) { mediaEntries[key]?.let { if (timedOut && !forceUpdate) { // Only log this event when media expires on its own logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId) } if (it.active == !timedOut && !forceUpdate) { if (it.resumption) { if (DEBUG) Log.d(TAG, "timing out resume player $key") dismissMediaData(key, delay = 0L, userInitiated = false) } return } // Update last active if media was still active. if (it.active) { it.lastActive = getActiveTimestamp(systemClock) } it.active = !timedOut if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut") onMediaDataLoaded(key, key, it) } } /** Called when the player's [PlaybackState] has been updated with new actions and/or state */ private fun updateState(key: String, state: PlaybackState) { mediaEntries.get(key)?.let { backgroundExecutor.execute { val token = it.token if (token == null) { if (DEBUG) Log.d(TAG, "State updated, but token was null") return@execute } val actions = createActionsFromState( it.packageName, mediaControllerFactory.create(it.token), UserHandle(it.userId), ) // Control buttons // If flag is enabled and controller has a PlaybackState, // create actions from session info // otherwise, no need to update semantic actions. val data = if (actions != null) { it.copy( semanticActions = actions, isPlaying = isPlayingState(state.state), lastActive = getActiveTimestamp(systemClock), ) } else { it.copy( isPlaying = isPlayingState(state.state), lastActive = getActiveTimestamp(systemClock), ) } if (DEBUG) Log.d(TAG, "State updated outside of notification") foregroundExecutor.execute { onMediaDataLoaded(key, key, data) } } } } private fun removeEntry(key: String, logEvent: Boolean = true, userInitiated: Boolean = false) { mediaEntries.remove(key)?.let { if (logEvent) { logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) } } notifyMediaDataRemoved(key, userInitiated) } /** Dismiss a media entry. Returns false if the key was not found. */ override fun dismissMediaData(key: String, delay: Long, userInitiated: Boolean): Boolean { val existed = mediaEntries[key] != null backgroundExecutor.execute { mediaEntries[key]?.let { mediaData -> if (mediaData.isLocalSession()) { mediaData.token?.let { val mediaController = mediaControllerFactory.create(it) mediaController.transportControls.stop() } } } } foregroundExecutor.executeDelayed( { removeEntry(key = key, userInitiated = userInitiated) }, delay, ) return existed } private suspend fun loadMediaDataForResumption( userId: Int, desc: MediaDescription, resumeAction: Runnable, token: MediaSession.Token, appName: String, appIntent: PendingIntent, packageName: String, ) = withContext(backgroundDispatcher) { val lastActive = getActiveTimestamp(systemClock) val currentEntry = mediaEntries[packageName] val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L val result = mediaDataLoader .get() .loadMediaDataForResumption( userId, desc, resumeAction, currentEntry, token, appName, appIntent, packageName, ) if (result == null || desc.title.isNullOrBlank()) { Log.d(TAG, "No MediaData result for resumption") mediaEntries.remove(packageName) return@withContext } val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId() withContext(mainDispatcher) { onMediaDataLoaded( packageName, null, MediaData( userId = userId, initialized = true, app = result.appName, appIcon = null, artist = result.artist, song = result.song, artwork = result.artworkIcon, actions = result.actionIcons, actionsToShowInCompact = result.actionsToShowInCompact, semanticActions = result.semanticActions, packageName = packageName, token = result.token, clickIntent = result.clickIntent, device = result.device, active = false, resumeAction = resumeAction, resumption = true, notificationKey = packageName, hasCheckedForResume = true, lastActive = lastActive, createdTimestampMillis = createdTimestampMillis, instanceId = instanceId, appUid = result.appUid, isExplicit = result.isExplicit, resumeProgress = result.resumeProgress, ), ) } } private fun logSingleVsMultipleMediaAdded( appUid: Int, packageName: String, instanceId: InstanceId, ) { if (mediaEntries.size == 1) { logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId) } else if (mediaEntries.size == 2) { // Since this method is only called when there is a new media session added. // logging needed once there is more than one media session in carousel. logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId) } } private fun createActionsFromState( packageName: String, controller: MediaController, user: UserHandle, ): MediaButton? { if (!mediaFlags.areMediaSessionActionsEnabled(packageName, user)) { return null } return createActionsFromState(context, packageName, controller) } private fun getResumeMediaAction(action: Runnable): MediaAction { val iconId = R.drawable.ic_media_play_button return MediaAction( Icon.createWithResource(context, iconId).setTint(themeText).loadDrawable(context), action, context.getString(R.string.controls_media_button_play), context.getDrawable(R.drawable.ic_media_play_button_container), ) } @MainThread fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) = traceSection("MediaDataManager#onMediaDataLoaded") { Assert.isMainThread() if (mediaEntries.containsKey(key)) { // Otherwise this was removed already mediaEntries.put(key, data) notifyMediaDataLoaded(key, oldKey, data) } } override fun onNotificationRemoved(key: String) { Assert.isMainThread() val removed = mediaEntries.remove(key) ?: return if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) { logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } else if (isAbleToResume(removed)) { convertToResumePlayer(key, removed) } else { notifyMediaDataRemoved(key) logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } } private fun onSessionDestroyed(key: String) { if (DEBUG) Log.d(TAG, "session destroyed for $key") val entry = mediaEntries.remove(key) ?: return // Clear token since the session is no longer valid val updated = entry.copy(token = null) handlePossibleRemoval(key, updated) } private fun isAbleToResume(data: MediaData): Boolean { val isEligibleForResume = data.isLocalSession() return useMediaResumption && data.resumeAction != null && isEligibleForResume } /** * Convert to resume state if the player is no longer valid and active, then notify listeners * that the data was updated. Does not convert to resume state if the player is still valid, or * if it was removed before becoming inactive. (Assumes that [removed] was removed from * [mediaEntries] before this function was called) */ private fun handlePossibleRemoval(key: String, removed: MediaData) { val hasSession = removed.token != null if (hasSession && removed.semanticActions != null) { // The app was using session actions, and the session is still valid: keep player if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key") mediaEntries.put(key, removed) notifyMediaDataLoaded(key, key, removed) } else if (removed.semanticActions == null) { // The app was using notification actions, and notif wasn't removed yet: keep player if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key") mediaEntries.put(key, removed) notifyMediaDataLoaded(key, key, removed) } else if (removed.active && !isAbleToResume(removed)) { // This player was still active - it didn't last long enough to time out, // and its app doesn't normally support resume: remove if (DEBUG) Log.d(TAG, "Removing still-active player $key") notifyMediaDataRemoved(key) logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } else if (isAbleToResume(removed)) { // Convert to resume if (DEBUG) { Log.d(TAG, "Session ($hasSession) gone for inactive player $key") } convertToResumePlayer(key, removed) } else { // Retaining players flag is off and app doesn't support resume: remove player. if (DEBUG) Log.d(TAG, "Removing player $key") notifyMediaDataRemoved(key) logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) } } /** Set the given [MediaData] as a resume state player and notify listeners */ private fun convertToResumePlayer(key: String, data: MediaData) { if (DEBUG) Log.d(TAG, "Converting $key to resume") // Resumption controls must have a title. if (data.song.isNullOrBlank()) { Log.e(TAG, "Description incomplete") notifyMediaDataRemoved(key) logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId) return } // Move to resume key (aka package name) if that key doesn't already exist. val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) } val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList() val launcherIntent = context.packageManager.getLaunchIntentForPackage(data.packageName)?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) } val lastActive = if (data.active) { getActiveTimestamp(systemClock) } else { data.lastActive } val updated = data.copy( token = null, actions = listOf(), semanticActions = MediaButton(playOrPause = resumeAction), actionsToShowInCompact = listOf(0), active = false, resumption = true, isPlaying = false, isClearable = true, clickIntent = launcherIntent, lastActive = lastActive, ) val pkg = data.packageName val migrate = mediaEntries.put(pkg, updated) == null // Notify listeners of "new" controls when migrating or removed and update when not Log.d(TAG, "migrating? $migrate from $key -> $pkg") if (migrate) { notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated) } else { // Since packageName is used for the key of the resumption controls, it is // possible that another notification has already been reused for the resumption // controls of this package. In this case, rather than renaming this player as // packageName, just remove it and then send a update to the existing resumption // controls. notifyMediaDataRemoved(key) notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated) } logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId) // Limit total number of resume controls val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption } val numResume = resumeEntries.size if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) { resumeEntries .toList() .sortedBy { (key, data) -> data.lastActive } .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) .forEach { (key, data) -> Log.d(TAG, "Removing excess control $key") mediaEntries.remove(key) notifyMediaDataRemoved(key) logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId) } } } override fun setMediaResumptionEnabled(isEnabled: Boolean) { if (useMediaResumption == isEnabled) { return } useMediaResumption = isEnabled if (!useMediaResumption) { // Remove any existing resume controls val filtered = mediaEntries.filter { !it.value.active } filtered.forEach { mediaEntries.remove(it.key) notifyMediaDataRemoved(it.key) logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId) } } } /** Invoked when the user has dismissed the media carousel */ override fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss() /** Are there any active media notifications? */ override fun hasActiveMedia() = mediaDataFilter.hasActiveMedia() /** * Are there any media notifications, including inactive ones? * - If resumption is enabled, this will include inactive players * - If resumption is disabled, we only want to show active players */ override fun hasAnyMedia() = mediaDataFilter.hasAnyMedia() override fun dump(pw: PrintWriter, args: Array) { pw.apply { println("internalListeners: $internalListeners") println("externalListeners: ${mediaDataFilter.listeners}") println("mediaEntries: $mediaEntries") println("useMediaResumption: $useMediaResumption") } mediaDeviceManager.dump(pw) } }