• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.MainThread
20 import android.annotation.SuppressLint
21 import android.app.Notification
22 import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME
23 import android.app.PendingIntent
24 import android.app.StatusBarManager
25 import android.app.UriGrantsManager
26 import android.content.BroadcastReceiver
27 import android.content.ContentProvider
28 import android.content.ContentResolver
29 import android.content.Context
30 import android.content.Intent
31 import android.content.IntentFilter
32 import android.content.pm.ApplicationInfo
33 import android.content.pm.PackageManager
34 import android.graphics.Bitmap
35 import android.graphics.ImageDecoder
36 import android.graphics.drawable.Icon
37 import android.media.MediaDescription
38 import android.media.MediaMetadata
39 import android.media.session.MediaController
40 import android.media.session.MediaSession
41 import android.media.session.PlaybackState
42 import android.net.Uri
43 import android.os.Process
44 import android.os.UserHandle
45 import android.service.notification.StatusBarNotification
46 import android.support.v4.media.MediaMetadataCompat
47 import android.text.TextUtils
48 import android.util.Log
49 import android.util.Pair as APair
50 import androidx.media.utils.MediaConstants
51 import com.android.app.tracing.coroutines.launchTraced as launch
52 import com.android.app.tracing.traceSection
53 import com.android.internal.logging.InstanceId
54 import com.android.keyguard.KeyguardUpdateMonitor
55 import com.android.systemui.Dumpable
56 import com.android.systemui.Flags
57 import com.android.systemui.broadcast.BroadcastDispatcher
58 import com.android.systemui.dagger.SysUISingleton
59 import com.android.systemui.dagger.qualifiers.Application
60 import com.android.systemui.dagger.qualifiers.Background
61 import com.android.systemui.dagger.qualifiers.Main
62 import com.android.systemui.dump.DumpManager
63 import com.android.systemui.media.NotificationMediaManager.isPlayingState
64 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager.Companion.isMediaNotification
65 import com.android.systemui.media.controls.domain.resume.MediaResumeListener
66 import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
67 import com.android.systemui.media.controls.shared.MediaLogger
68 import com.android.systemui.media.controls.shared.model.MediaAction
69 import com.android.systemui.media.controls.shared.model.MediaButton
70 import com.android.systemui.media.controls.shared.model.MediaData
71 import com.android.systemui.media.controls.shared.model.MediaDeviceData
72 import com.android.systemui.media.controls.shared.model.MediaNotificationAction
73 import com.android.systemui.media.controls.ui.view.MediaViewHolder
74 import com.android.systemui.media.controls.util.MediaControllerFactory
75 import com.android.systemui.media.controls.util.MediaDataUtils
76 import com.android.systemui.media.controls.util.MediaFlags
77 import com.android.systemui.media.controls.util.MediaUiEventLogger
78 import com.android.systemui.res.R
79 import com.android.systemui.statusbar.notification.row.HybridGroupManager
80 import com.android.systemui.util.Assert
81 import com.android.systemui.util.Utils
82 import com.android.systemui.util.concurrency.DelayableExecutor
83 import com.android.systemui.util.concurrency.ThreadFactory
84 import com.android.systemui.util.time.SystemClock
85 import java.io.IOException
86 import java.io.PrintWriter
87 import java.util.Collections
88 import java.util.concurrent.Executor
89 import javax.inject.Inject
90 import kotlinx.coroutines.CoroutineDispatcher
91 import kotlinx.coroutines.CoroutineScope
92 import kotlinx.coroutines.withContext
93 
94 // URI fields to try loading album art from
95 private val ART_URIS =
96     arrayOf(
97         MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
98         MediaMetadata.METADATA_KEY_ART_URI,
99         MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI,
100     )
101 
102 private const val TAG = "MediaDataManager"
103 private const val DEBUG = true
104 
105 private val LOADING =
106     MediaData(
107         userId = -1,
108         initialized = false,
109         app = null,
110         appIcon = null,
111         artist = null,
112         song = null,
113         artwork = null,
114         actions = emptyList(),
115         actionsToShowInCompact = emptyList(),
116         packageName = "INVALID",
117         token = null,
118         clickIntent = null,
119         device = null,
120         active = true,
121         resumeAction = null,
122         instanceId = InstanceId.fakeInstanceId(-1),
123         appUid = Process.INVALID_UID,
124     )
125 
126 /** A class that facilitates management and loading of Media Data, ready for binding. */
127 @SysUISingleton
128 class LegacyMediaDataManagerImpl(
129     private val context: Context,
130     @Background private val backgroundExecutor: Executor,
131     @Background private val backgroundDispatcher: CoroutineDispatcher,
132     @Main private val foregroundExecutor: DelayableExecutor,
133     @Main private val mainDispatcher: CoroutineDispatcher,
134     @Application private val applicationScope: CoroutineScope,
135     private val mediaControllerFactory: MediaControllerFactory,
136     private val broadcastDispatcher: BroadcastDispatcher,
137     dumpManager: DumpManager,
138     mediaTimeoutListener: MediaTimeoutListener,
139     mediaResumeListener: MediaResumeListener,
140     mediaSessionBasedFilter: MediaSessionBasedFilter,
141     private val mediaDeviceManager: MediaDeviceManager,
142     mediaDataCombineLatest: MediaDataCombineLatest,
143     private val mediaDataFilter: LegacyMediaDataFilterImpl,
144     private var useMediaResumption: Boolean,
145     private val useQsMediaPlayer: Boolean,
146     private val systemClock: SystemClock,
147     private val mediaFlags: MediaFlags,
148     private val logger: MediaUiEventLogger,
149     private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
150     private val mediaDataLoader: dagger.Lazy<MediaDataLoader>,
151     private val mediaLogger: MediaLogger,
152 ) : Dumpable, MediaDataManager {
153 
154     companion object {
155         // Maximum number of actions allowed in compact view
156         @JvmField val MAX_COMPACT_ACTIONS = 3
157 
158         // Maximum number of actions allowed in expanded view
159         @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size
160     }
161 
162     private val themeText =
163         com.android.settingslib.Utils.getColorAttr(
164                 context,
165                 com.android.internal.R.attr.textColorPrimary,
166             )
167             .defaultColor
168 
169     // Internal listeners are part of the internal pipeline. External listeners (those registered
170     // with [MediaDeviceManager.addListener]) receive events after they have propagated through
171     // the internal pipeline.
172     // Another way to think of the distinction between internal and external listeners is the
173     // following. Internal listeners are listeners that MediaDataManager depends on, and external
174     // listeners are listeners that depend on MediaDataManager.
175     // TODO(b/159539991#comment5): Move internal listeners to separate package.
176     private val internalListeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
177     private val mediaEntries: MutableMap<String, MediaData> =
178         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
179             Collections.synchronizedMap(LinkedHashMap())
180         } else {
181             LinkedHashMap()
182         }
183 
184     private val artworkWidth =
185         context.resources.getDimensionPixelSize(
186             com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize
187         )
188     private val artworkHeight =
189         context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded)
190 
191     @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE
192     private val statusBarManager =
193         context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager
194 
195     /** Check whether this notification is an RCN */
196     private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean {
197         return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE)
198     }
199 
200     @Inject
201     constructor(
202         context: Context,
203         threadFactory: ThreadFactory,
204         @Background backgroundDispatcher: CoroutineDispatcher,
205         @Main foregroundExecutor: DelayableExecutor,
206         @Main mainDispatcher: CoroutineDispatcher,
207         @Application applicationScope: CoroutineScope,
208         mediaControllerFactory: MediaControllerFactory,
209         dumpManager: DumpManager,
210         broadcastDispatcher: BroadcastDispatcher,
211         mediaTimeoutListener: MediaTimeoutListener,
212         mediaResumeListener: MediaResumeListener,
213         mediaSessionBasedFilter: MediaSessionBasedFilter,
214         mediaDeviceManager: MediaDeviceManager,
215         mediaDataCombineLatest: MediaDataCombineLatest,
216         mediaDataFilter: LegacyMediaDataFilterImpl,
217         clock: SystemClock,
218         mediaFlags: MediaFlags,
219         logger: MediaUiEventLogger,
220         keyguardUpdateMonitor: KeyguardUpdateMonitor,
221         mediaDataLoader: dagger.Lazy<MediaDataLoader>,
222         mediaLogger: MediaLogger,
223     ) : this(
224         context,
225         // Loading bitmap for UMO background can take longer time, so it cannot run on the default
226         // background thread. Use a custom thread for media.
227         threadFactory.buildExecutorOnNewThread(TAG),
228         backgroundDispatcher,
229         foregroundExecutor,
230         mainDispatcher,
231         applicationScope,
232         mediaControllerFactory,
233         broadcastDispatcher,
234         dumpManager,
235         mediaTimeoutListener,
236         mediaResumeListener,
237         mediaSessionBasedFilter,
238         mediaDeviceManager,
239         mediaDataCombineLatest,
240         mediaDataFilter,
241         Utils.useMediaResumption(context),
242         Utils.useQsMediaPlayer(context),
243         clock,
244         mediaFlags,
245         logger,
246         keyguardUpdateMonitor,
247         mediaDataLoader,
248         mediaLogger,
249     )
250 
251     private val appChangeReceiver =
252         object : BroadcastReceiver() {
253             override fun onReceive(context: Context, intent: Intent) {
254                 when (intent.action) {
255                     Intent.ACTION_PACKAGES_SUSPENDED -> {
256                         val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
257                         packages?.forEach { removeAllForPackage(it) }
258                     }
259                     Intent.ACTION_PACKAGE_REMOVED,
260                     Intent.ACTION_PACKAGE_RESTARTED -> {
261                         intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) }
262                     }
263                 }
264             }
265         }
266 
267     init {
268         dumpManager.registerNormalDumpable(TAG, this)
269 
270         // Initialize the internal processing pipeline. The listeners at the front of the pipeline
271         // are set as internal listeners so that they receive events. From there, events are
272         // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
273         // so it is responsible for dispatching events to external listeners. To achieve this,
274         // external listeners that are registered with [MediaDataManager.addListener] are actually
275         // registered as listeners to mediaDataFilter.
276         addInternalListener(mediaTimeoutListener)
277         addInternalListener(mediaResumeListener)
278         addInternalListener(mediaSessionBasedFilter)
279         mediaSessionBasedFilter.addListener(mediaDeviceManager)
280         mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
281         mediaDeviceManager.addListener(mediaDataCombineLatest)
282         mediaDataCombineLatest.addListener(mediaDataFilter)
283 
284         // Set up links back into the pipeline for listeners that need to send events upstream.
285         mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
286             setInactive(key, timedOut)
287         }
288         mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
289             updateState(key, state)
290         }
291         mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
292         mediaResumeListener.setManager(this)
293         mediaDataFilter.mediaDataManager = this
294 
295         val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
296         broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
297 
298         val uninstallFilter =
299             IntentFilter().apply {
300                 addAction(Intent.ACTION_PACKAGE_REMOVED)
301                 addAction(Intent.ACTION_PACKAGE_RESTARTED)
302                 addDataScheme("package")
303             }
304         // BroadcastDispatcher does not allow filters with data schemes
305         context.registerReceiver(appChangeReceiver, uninstallFilter)
306     }
307 
308     override fun destroy() {
309         context.unregisterReceiver(appChangeReceiver)
310     }
311 
312     override fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
313         if (useQsMediaPlayer && isMediaNotification(sbn)) {
314             var isNewlyActiveEntry = false
315             var isConvertingToActive = false
316             Assert.isMainThread()
317             val oldKey = findExistingEntry(key, sbn.packageName)
318             if (oldKey == null) {
319                 val instanceId = logger.getNewInstanceId()
320                 val temp =
321                     LOADING.copy(
322                         packageName = sbn.packageName,
323                         instanceId = instanceId,
324                         createdTimestampMillis = systemClock.currentTimeMillis(),
325                     )
326                 mediaEntries.put(key, temp)
327                 isNewlyActiveEntry = true
328             } else if (oldKey != key) {
329                 // Resume -> active conversion; move to new key
330                 val oldData = mediaEntries.remove(oldKey)!!
331                 isNewlyActiveEntry = true
332                 isConvertingToActive = true
333                 mediaEntries.put(key, oldData)
334             }
335             loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
336         } else {
337             onNotificationRemoved(key)
338         }
339     }
340 
341     private fun removeAllForPackage(packageName: String) {
342         Assert.isMainThread()
343         val toRemove = mediaEntries.filter { it.value.packageName == packageName }
344         toRemove.forEach { removeEntry(it.key) }
345     }
346 
347     override fun setResumeAction(key: String, action: Runnable?) {
348         mediaEntries.get(key)?.let {
349             it.resumeAction = action
350             it.hasCheckedForResume = true
351         }
352     }
353 
354     override fun addResumptionControls(
355         userId: Int,
356         desc: MediaDescription,
357         action: Runnable,
358         token: MediaSession.Token,
359         appName: String,
360         appIntent: PendingIntent,
361         packageName: String,
362     ) {
363         // Resume controls don't have a notification key, so store by package name instead
364         if (!mediaEntries.containsKey(packageName)) {
365             val instanceId = logger.getNewInstanceId()
366             val appUid =
367                 try {
368                     context.packageManager.getApplicationInfo(packageName, 0)?.uid!!
369                 } catch (e: PackageManager.NameNotFoundException) {
370                     Log.w(TAG, "Could not get app UID for $packageName", e)
371                     Process.INVALID_UID
372                 }
373 
374             val resumeData =
375                 LOADING.copy(
376                     packageName = packageName,
377                     resumeAction = action,
378                     hasCheckedForResume = true,
379                     instanceId = instanceId,
380                     appUid = appUid,
381                     createdTimestampMillis = systemClock.currentTimeMillis(),
382                 )
383             mediaEntries.put(packageName, resumeData)
384             logSingleVsMultipleMediaAdded(appUid, packageName, instanceId)
385             logger.logResumeMediaAdded(appUid, packageName, instanceId)
386         }
387 
388         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
389             applicationScope.launch {
390                 loadMediaDataForResumption(
391                     userId,
392                     desc,
393                     action,
394                     token,
395                     appName,
396                     appIntent,
397                     packageName,
398                 )
399             }
400         } else {
401             backgroundExecutor.execute {
402                 loadMediaDataInBgForResumption(
403                     userId,
404                     desc,
405                     action,
406                     token,
407                     appName,
408                     appIntent,
409                     packageName,
410                 )
411             }
412         }
413     }
414 
415     /**
416      * Check if there is an existing entry that matches the key or package name. Returns the key
417      * that matches, or null if not found.
418      */
419     private fun findExistingEntry(key: String, packageName: String): String? {
420         if (mediaEntries.containsKey(key)) {
421             return key
422         }
423         // Check if we already had a resume player
424         if (mediaEntries.containsKey(packageName)) {
425             return packageName
426         }
427         return null
428     }
429 
430     private fun loadMediaData(
431         key: String,
432         sbn: StatusBarNotification,
433         oldKey: String?,
434         isNewlyActiveEntry: Boolean = false,
435         isConvertingToActive: Boolean = false,
436     ) {
437         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
438             applicationScope.launch {
439                 loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
440             }
441         } else {
442             backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
443         }
444     }
445 
446     private suspend fun loadMediaDataWithLoader(
447         key: String,
448         sbn: StatusBarNotification,
449         oldKey: String?,
450         isNewlyActiveEntry: Boolean = false,
451         isConvertingToActive: Boolean = false,
452     ) =
453         withContext(backgroundDispatcher) {
454             val lastActive = systemClock.elapsedRealtime()
455             val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive)
456             if (result == null) {
457                 Log.d(TAG, "No result from loadMediaData")
458                 return@withContext
459             }
460 
461             val currentEntry = mediaEntries[key]
462             val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
463             val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
464             val resumeAction: Runnable? = currentEntry?.resumeAction
465             val hasCheckedForResume = currentEntry?.hasCheckedForResume == true
466             val active = currentEntry?.active ?: true
467             val mediaController = mediaControllerFactory.create(result.token!!)
468 
469             val mediaData =
470                 MediaData(
471                     userId = sbn.normalizedUserId,
472                     initialized = true,
473                     app = result.appName,
474                     appIcon = result.appIcon,
475                     artist = result.artist,
476                     song = result.song,
477                     artwork = result.artworkIcon,
478                     actions = result.actionIcons,
479                     actionsToShowInCompact = result.actionsToShowInCompact,
480                     semanticActions = result.semanticActions,
481                     packageName = sbn.packageName,
482                     token = result.token,
483                     clickIntent = result.clickIntent,
484                     device = result.device,
485                     active = active,
486                     resumeAction = resumeAction,
487                     playbackLocation = result.playbackLocation,
488                     notificationKey = key,
489                     hasCheckedForResume = hasCheckedForResume,
490                     isPlaying = result.isPlaying,
491                     isClearable = !sbn.isOngoing,
492                     lastActive = lastActive,
493                     createdTimestampMillis = createdTimestampMillis,
494                     instanceId = instanceId,
495                     appUid = result.appUid,
496                     isExplicit = result.isExplicit,
497                 )
498 
499             if (isSameMediaData(context, mediaController, mediaData, currentEntry)) {
500                 mediaLogger.logDuplicateMediaNotification(key)
501                 return@withContext
502             }
503 
504             // We need to log the correct media added.
505             if (isNewlyActiveEntry) {
506                 logSingleVsMultipleMediaAdded(result.appUid, sbn.packageName, instanceId)
507                 logger.logActiveMediaAdded(
508                     result.appUid,
509                     sbn.packageName,
510                     instanceId,
511                     result.playbackLocation,
512                 )
513             } else if (result.playbackLocation != currentEntry?.playbackLocation) {
514                 logger.logPlaybackLocationChange(
515                     result.appUid,
516                     sbn.packageName,
517                     instanceId,
518                     result.playbackLocation,
519                 )
520             }
521 
522             withContext(mainDispatcher) { onMediaDataLoaded(key, oldKey, mediaData) }
523         }
524 
525     /** Add a listener for changes in this class */
526     override fun addListener(listener: MediaDataManager.Listener) {
527         // mediaDataFilter is the current end of the internal pipeline. Register external
528         // listeners as listeners to it.
529         mediaDataFilter.addListener(listener)
530     }
531 
532     /** Remove a listener for changes in this class */
533     override fun removeListener(listener: MediaDataManager.Listener) {
534         // Since mediaDataFilter is the current end of the internal pipelie, external listeners
535         // have been registered to it. So, they need to be removed from it too.
536         mediaDataFilter.removeListener(listener)
537     }
538 
539     /** Add a listener for internal events. */
540     private fun addInternalListener(listener: MediaDataManager.Listener) =
541         internalListeners.add(listener)
542 
543     /**
544      * Notify internal listeners of media loaded event.
545      *
546      * External listeners registered with [addListener] will be notified after the event propagates
547      * through the internal listener pipeline.
548      */
549     private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
550         internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
551     }
552 
553     /**
554      * Notify internal listeners of media removed event.
555      *
556      * External listeners registered with [addListener] will be notified after the event propagates
557      * through the internal listener pipeline.
558      */
559     private fun notifyMediaDataRemoved(key: String, userInitiated: Boolean = false) {
560         internalListeners.forEach { it.onMediaDataRemoved(key, userInitiated) }
561     }
562 
563     /**
564      * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
565      * will make the player not active anymore, hiding it from QQS and Keyguard.
566      *
567      * @see MediaData.active
568      */
569     override fun setInactive(key: String, timedOut: Boolean, forceUpdate: Boolean) {
570         mediaEntries[key]?.let {
571             if (timedOut && !forceUpdate) {
572                 // Only log this event when media expires on its own
573                 logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId)
574             }
575             if (it.active == !timedOut && !forceUpdate) {
576                 if (it.resumption) {
577                     if (DEBUG) Log.d(TAG, "timing out resume player $key")
578                     dismissMediaData(key, delay = 0L, userInitiated = false)
579                 }
580                 return
581             }
582             // Update last active if media was still active.
583             if (it.active) {
584                 it.lastActive = systemClock.elapsedRealtime()
585             }
586             it.active = !timedOut
587             if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
588             onMediaDataLoaded(key, key, it)
589         }
590     }
591 
592     /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
593     private fun updateState(key: String, state: PlaybackState) {
594         mediaEntries.get(key)?.let {
595             backgroundExecutor.execute {
596                 val token = it.token
597                 if (token == null) {
598                     if (DEBUG) Log.d(TAG, "State updated, but token was null")
599                     return@execute
600                 }
601                 val actions =
602                     createActionsFromState(
603                         it.packageName,
604                         mediaControllerFactory.create(it.token),
605                         UserHandle(it.userId),
606                     )
607 
608                 // Control buttons
609                 // If flag is enabled and controller has a PlaybackState,
610                 // create actions from session info
611                 // otherwise, no need to update semantic actions.
612                 val data =
613                     if (actions != null) {
614                         it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state))
615                     } else {
616                         it.copy(isPlaying = isPlayingState(state.state))
617                     }
618                 if (DEBUG) Log.d(TAG, "State updated outside of notification")
619                 foregroundExecutor.execute { onMediaDataLoaded(key, key, data) }
620             }
621         }
622     }
623 
624     private fun removeEntry(key: String, logEvent: Boolean = true, userInitiated: Boolean = false) {
625         mediaEntries.remove(key)?.let {
626             if (logEvent) {
627                 logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
628             }
629         }
630         notifyMediaDataRemoved(key, userInitiated)
631     }
632 
633     /** Dismiss a media entry. Returns false if the key was not found. */
634     override fun dismissMediaData(key: String, delay: Long, userInitiated: Boolean): Boolean {
635         val existed = mediaEntries[key] != null
636         backgroundExecutor.execute {
637             mediaEntries[key]?.let { mediaData ->
638                 if (mediaData.isLocalSession()) {
639                     mediaData.token?.let {
640                         val mediaController = mediaControllerFactory.create(it)
641                         mediaController.transportControls.stop()
642                     }
643                 }
644             }
645         }
646         foregroundExecutor.executeDelayed(
647             { removeEntry(key = key, userInitiated = userInitiated) },
648             delay,
649         )
650         return existed
651     }
652 
653     /**
654      * Called whenever the recommendation has been expired or removed by the user. This will remove
655      * the recommendation card entirely from the carousel.
656      */
657     override fun dismissSmartspaceRecommendation(key: String, delay: Long) {
658         // TODO(b/382680767): remove
659     }
660 
661     private suspend fun loadMediaDataForResumption(
662         userId: Int,
663         desc: MediaDescription,
664         resumeAction: Runnable,
665         token: MediaSession.Token,
666         appName: String,
667         appIntent: PendingIntent,
668         packageName: String,
669     ) =
670         withContext(backgroundDispatcher) {
671             val lastActive = systemClock.elapsedRealtime()
672             val currentEntry = mediaEntries[packageName]
673             val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
674             val result =
675                 mediaDataLoader
676                     .get()
677                     .loadMediaDataForResumption(
678                         userId,
679                         desc,
680                         resumeAction,
681                         currentEntry,
682                         token,
683                         appName,
684                         appIntent,
685                         packageName,
686                     )
687             if (result == null || desc.title.isNullOrBlank()) {
688                 Log.d(TAG, "No MediaData result for resumption")
689                 mediaEntries.remove(packageName)
690                 return@withContext
691             }
692 
693             val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
694             withContext(mainDispatcher) {
695                 onMediaDataLoaded(
696                     packageName,
697                     null,
698                     MediaData(
699                         userId = userId,
700                         initialized = true,
701                         app = result.appName,
702                         appIcon = null,
703                         artist = result.artist,
704                         song = result.song,
705                         artwork = result.artworkIcon,
706                         actions = result.actionIcons,
707                         actionsToShowInCompact = result.actionsToShowInCompact,
708                         semanticActions = result.semanticActions,
709                         packageName = packageName,
710                         token = result.token,
711                         clickIntent = result.clickIntent,
712                         device = result.device,
713                         active = false,
714                         resumeAction = resumeAction,
715                         resumption = true,
716                         notificationKey = packageName,
717                         hasCheckedForResume = true,
718                         lastActive = lastActive,
719                         createdTimestampMillis = createdTimestampMillis,
720                         instanceId = instanceId,
721                         appUid = result.appUid,
722                         isExplicit = result.isExplicit,
723                         resumeProgress = result.resumeProgress,
724                     ),
725                 )
726             }
727         }
728 
729     @Deprecated("Cleanup when media_load_metadata_via_media_data_loader is cleaned up")
730     private fun loadMediaDataInBgForResumption(
731         userId: Int,
732         desc: MediaDescription,
733         resumeAction: Runnable,
734         token: MediaSession.Token,
735         appName: String,
736         appIntent: PendingIntent,
737         packageName: String,
738     ) {
739         if (desc.title.isNullOrBlank()) {
740             Log.e(TAG, "Description incomplete")
741             // Delete the placeholder entry
742             mediaEntries.remove(packageName)
743             return
744         }
745 
746         if (DEBUG) {
747             Log.d(TAG, "adding track for $userId from browser: $desc")
748         }
749 
750         val currentEntry = mediaEntries.get(packageName)
751         val appUid = currentEntry?.appUid ?: Process.INVALID_UID
752 
753         // Album art
754         var artworkBitmap = desc.iconBitmap
755         if (artworkBitmap == null && desc.iconUri != null) {
756             artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
757         }
758         val artworkIcon =
759             if (artworkBitmap != null) {
760                 Icon.createWithBitmap(artworkBitmap)
761             } else {
762                 null
763             }
764 
765         val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
766         val isExplicit =
767             desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
768                 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
769 
770         val progress = MediaDataUtils.getDescriptionProgress(desc.extras)
771         val mediaAction = getResumeMediaAction(resumeAction)
772         val lastActive = systemClock.elapsedRealtime()
773         val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
774         foregroundExecutor.execute {
775             onMediaDataLoaded(
776                 packageName,
777                 null,
778                 MediaData(
779                     userId,
780                     true,
781                     appName,
782                     null,
783                     desc.subtitle,
784                     desc.title,
785                     artworkIcon,
786                     listOf(),
787                     listOf(0),
788                     MediaButton(playOrPause = mediaAction),
789                     packageName,
790                     token,
791                     appIntent,
792                     device = null,
793                     active = false,
794                     resumeAction = resumeAction,
795                     resumption = true,
796                     notificationKey = packageName,
797                     hasCheckedForResume = true,
798                     lastActive = lastActive,
799                     createdTimestampMillis = createdTimestampMillis,
800                     instanceId = instanceId,
801                     appUid = appUid,
802                     isExplicit = isExplicit,
803                     resumeProgress = progress,
804                 ),
805             )
806         }
807     }
808 
809     @Deprecated("Cleanup when media_load_metadata_via_media_data_loader is cleaned up")
810     fun loadMediaDataInBg(
811         key: String,
812         sbn: StatusBarNotification,
813         oldKey: String?,
814         isNewlyActiveEntry: Boolean = false,
815     ) {
816         val token =
817             sbn.notification.extras.getParcelable(
818                 Notification.EXTRA_MEDIA_SESSION,
819                 MediaSession.Token::class.java,
820             )
821         if (token == null) {
822             return
823         }
824         val mediaController = mediaControllerFactory.create(token)
825         val metadata = mediaController.metadata
826         val notif: Notification = sbn.notification
827 
828         val appInfo =
829             notif.extras.getParcelable(
830                 Notification.EXTRA_BUILDER_APPLICATION_INFO,
831                 ApplicationInfo::class.java,
832             ) ?: getAppInfoFromPackage(sbn.packageName)
833 
834         // App name
835         val appName = getAppName(sbn, appInfo)
836 
837         // Song name
838         var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
839         if (song.isNullOrBlank()) {
840             song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
841         }
842         if (song.isNullOrBlank()) {
843             song = HybridGroupManager.resolveTitle(notif)
844         }
845         if (song.isNullOrBlank()) {
846             // For apps that don't include a title, log and add a placeholder
847             song = context.getString(R.string.controls_media_empty_title, appName)
848             try {
849                 statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier)
850             } catch (e: RuntimeException) {
851                 Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}")
852             }
853         }
854 
855         // Album art
856         var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
857         if (artworkBitmap == null) {
858             artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
859         }
860         if (artworkBitmap == null) {
861             artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
862         }
863         val artWorkIcon =
864             if (artworkBitmap == null) {
865                 notif.getLargeIcon()
866             } else {
867                 Icon.createWithBitmap(artworkBitmap)
868             }
869 
870         // App Icon
871         val smallIcon = sbn.notification.smallIcon
872 
873         // Explicit Indicator
874         var isExplicit = false
875         val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata)
876         isExplicit =
877             mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
878                 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
879 
880         // Artist name
881         var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
882         if (artist.isNullOrBlank()) {
883             artist = HybridGroupManager.resolveText(notif)
884         }
885 
886         // Device name (used for remote cast notifications)
887         var device: MediaDeviceData? = null
888         if (isRemoteCastNotification(sbn)) {
889             val extras = sbn.notification.extras
890             val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null)
891             val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1)
892             val deviceIntent =
893                 extras.getParcelable(
894                     Notification.EXTRA_MEDIA_REMOTE_INTENT,
895                     PendingIntent::class.java,
896                 )
897             Log.d(TAG, "$key is RCN for $deviceName")
898 
899             if (deviceName != null && deviceIcon > -1) {
900                 // Name and icon must be present, but intent may be null
901                 val enabled = deviceIntent != null && deviceIntent.isActivity
902                 val deviceDrawable =
903                     Icon.createWithResource(sbn.packageName, deviceIcon)
904                         .loadDrawable(sbn.getPackageContext(context))
905                 device =
906                     MediaDeviceData(
907                         enabled,
908                         deviceDrawable,
909                         deviceName,
910                         deviceIntent,
911                         showBroadcastButton = false,
912                     )
913             }
914         }
915 
916         // Control buttons
917         // If controller has a PlaybackState, create actions from session info
918         // Otherwise, use the notification actions
919         var actionIcons: List<MediaNotificationAction> = emptyList()
920         var actionsToShowCollapsed: List<Int> = emptyList()
921         val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
922         if (semanticActions == null) {
923             val actions = createActionsFromNotification(context, sbn)
924             actionIcons = actions.first
925             actionsToShowCollapsed = actions.second
926         }
927 
928         val playbackLocation =
929             if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
930             else if (
931                 mediaController.playbackInfo?.playbackType ==
932                     MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL
933             )
934                 MediaData.PLAYBACK_LOCAL
935             else MediaData.PLAYBACK_CAST_LOCAL
936         val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
937 
938         val currentEntry = mediaEntries.get(key)
939         val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
940         val appUid = appInfo?.uid ?: Process.INVALID_UID
941 
942         val lastActive = systemClock.elapsedRealtime()
943         val createdTimestampMillis = currentEntry?.createdTimestampMillis ?: 0L
944         val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
945         val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
946         val active = mediaEntries[key]?.active ?: true
947         var mediaData =
948             MediaData(
949                 sbn.normalizedUserId,
950                 true,
951                 appName,
952                 smallIcon,
953                 artist,
954                 song,
955                 artWorkIcon,
956                 actionIcons,
957                 actionsToShowCollapsed,
958                 semanticActions,
959                 sbn.packageName,
960                 token,
961                 notif.contentIntent,
962                 device,
963                 active,
964                 resumeAction = resumeAction,
965                 playbackLocation = playbackLocation,
966                 notificationKey = key,
967                 hasCheckedForResume = hasCheckedForResume,
968                 isPlaying = isPlaying,
969                 isClearable = !sbn.isOngoing,
970                 lastActive = lastActive,
971                 createdTimestampMillis = createdTimestampMillis,
972                 instanceId = instanceId,
973                 appUid = appUid,
974                 isExplicit = isExplicit,
975             )
976 
977         if (isSameMediaData(context, mediaController, mediaData, currentEntry)) {
978             mediaLogger.logDuplicateMediaNotification(key)
979             return
980         }
981 
982         if (isNewlyActiveEntry) {
983             logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
984             logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
985         } else if (playbackLocation != currentEntry?.playbackLocation) {
986             logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation)
987         }
988 
989         foregroundExecutor.execute {
990             val oldResumeAction: Runnable? = mediaEntries[key]?.resumeAction
991             val oldHasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
992             val oldActive = mediaEntries[key]?.active ?: true
993             mediaData =
994                 mediaData.copy(
995                     resumeAction = oldResumeAction,
996                     hasCheckedForResume = oldHasCheckedForResume,
997                     active = oldActive,
998                 )
999             onMediaDataLoaded(key, oldKey, mediaData)
1000         }
1001     }
1002 
1003     private fun logSingleVsMultipleMediaAdded(
1004         appUid: Int,
1005         packageName: String,
1006         instanceId: InstanceId,
1007     ) {
1008         if (mediaEntries.size == 1) {
1009             logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId)
1010         } else if (mediaEntries.size == 2) {
1011             // Since this method is only called when there is a new media session added.
1012             // logging needed once there is more than one media session in carousel.
1013             logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId)
1014         }
1015     }
1016 
1017     @Deprecated("Cleanup when media_load_metadata_via_media_data_loader is cleaned up")
1018     private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? {
1019         try {
1020             return context.packageManager.getApplicationInfo(packageName, 0)
1021         } catch (e: PackageManager.NameNotFoundException) {
1022             Log.w(TAG, "Could not get app info for $packageName", e)
1023         }
1024         return null
1025     }
1026 
1027     @Deprecated("Cleanup when media_load_metadata_via_media_data_loader is cleaned up")
1028     private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String {
1029         val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
1030         if (name != null) {
1031             return name
1032         }
1033 
1034         return if (appInfo != null) {
1035             context.packageManager.getApplicationLabel(appInfo).toString()
1036         } else {
1037             sbn.packageName
1038         }
1039     }
1040 
1041     private fun createActionsFromState(
1042         packageName: String,
1043         controller: MediaController,
1044         user: UserHandle,
1045     ): MediaButton? {
1046         if (!mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
1047             return null
1048         }
1049         return createActionsFromState(context, packageName, controller)
1050     }
1051 
1052     /** Load a bitmap from the various Art metadata URIs */
1053     @Deprecated("Cleanup when media_load_metadata_via_media_data_loader is cleaned up")
1054     private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
1055         for (uri in ART_URIS) {
1056             val uriString = metadata.getString(uri)
1057             if (!TextUtils.isEmpty(uriString)) {
1058                 val albumArt = loadBitmapFromUri(Uri.parse(uriString))
1059                 if (albumArt != null) {
1060                     if (DEBUG) Log.d(TAG, "loaded art from $uri")
1061                     return albumArt
1062                 }
1063             }
1064         }
1065         return null
1066     }
1067 
1068     /** Returns a bitmap if the user can access the given URI, else null */
1069     private fun loadBitmapFromUriForUser(
1070         uri: Uri,
1071         userId: Int,
1072         appUid: Int,
1073         packageName: String,
1074     ): Bitmap? {
1075         try {
1076             val ugm = UriGrantsManager.getService()
1077             ugm.checkGrantUriPermission_ignoreNonSystem(
1078                 appUid,
1079                 packageName,
1080                 ContentProvider.getUriWithoutUserId(uri),
1081                 Intent.FLAG_GRANT_READ_URI_PERMISSION,
1082                 ContentProvider.getUserIdFromUri(uri, userId),
1083             )
1084             return loadBitmapFromUri(uri)
1085         } catch (e: SecurityException) {
1086             Log.e(TAG, "Failed to get URI permission: $e")
1087         }
1088         return null
1089     }
1090 
1091     /**
1092      * Load a bitmap from a URI
1093      *
1094      * @param uri the uri to load
1095      * @return bitmap, or null if couldn't be loaded
1096      */
1097     private fun loadBitmapFromUri(uri: Uri): Bitmap? {
1098         // ImageDecoder requires a scheme of the following types
1099         if (uri.scheme == null) {
1100             return null
1101         }
1102 
1103         if (
1104             !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
1105                 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
1106                 !uri.scheme.equals(ContentResolver.SCHEME_FILE)
1107         ) {
1108             return null
1109         }
1110 
1111         val source = ImageDecoder.createSource(context.contentResolver, uri)
1112         return try {
1113             ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
1114                 val width = info.size.width
1115                 val height = info.size.height
1116                 val scale =
1117                     MediaDataUtils.getScaleFactor(
1118                         APair(width, height),
1119                         APair(artworkWidth, artworkHeight),
1120                     )
1121 
1122                 // Downscale if needed
1123                 if (scale != 0f && scale < 1) {
1124                     decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt())
1125                 }
1126                 decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
1127             }
1128         } catch (e: IOException) {
1129             Log.e(TAG, "Unable to load bitmap", e)
1130             null
1131         } catch (e: RuntimeException) {
1132             Log.e(TAG, "Unable to load bitmap", e)
1133             null
1134         }
1135     }
1136 
1137     private fun getResumeMediaAction(action: Runnable): MediaAction {
1138         val iconId =
1139             if (Flags.mediaControlsUiUpdate()) {
1140                 R.drawable.ic_media_play_button
1141             } else {
1142                 R.drawable.ic_media_play
1143             }
1144         return MediaAction(
1145             Icon.createWithResource(context, iconId).setTint(themeText).loadDrawable(context),
1146             action,
1147             context.getString(R.string.controls_media_button_play),
1148             if (Flags.mediaControlsUiUpdate()) {
1149                 context.getDrawable(R.drawable.ic_media_play_button_container)
1150             } else {
1151                 context.getDrawable(R.drawable.ic_media_play_container)
1152             },
1153         )
1154     }
1155 
1156     @MainThread
1157     fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) =
1158         traceSection("MediaDataManager#onMediaDataLoaded") {
1159             Assert.isMainThread()
1160             if (mediaEntries.containsKey(key)) {
1161                 // Otherwise this was removed already
1162                 mediaEntries.put(key, data)
1163                 notifyMediaDataLoaded(key, oldKey, data)
1164             }
1165         }
1166 
1167     override fun onNotificationRemoved(key: String) {
1168         Assert.isMainThread()
1169         val removed = mediaEntries.remove(key) ?: return
1170         if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) {
1171             logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1172         } else if (isAbleToResume(removed)) {
1173             convertToResumePlayer(key, removed)
1174         } else if (mediaFlags.isRetainingPlayersEnabled()) {
1175             handlePossibleRemoval(key, removed, notificationRemoved = true)
1176         } else {
1177             notifyMediaDataRemoved(key)
1178             logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1179         }
1180     }
1181 
1182     private fun onSessionDestroyed(key: String) {
1183         if (DEBUG) Log.d(TAG, "session destroyed for $key")
1184         val entry = mediaEntries.remove(key) ?: return
1185         // Clear token since the session is no longer valid
1186         val updated = entry.copy(token = null)
1187         handlePossibleRemoval(key, updated)
1188     }
1189 
1190     private fun isAbleToResume(data: MediaData): Boolean {
1191         val isEligibleForResume = data.isLocalSession()
1192         return useMediaResumption && data.resumeAction != null && isEligibleForResume
1193     }
1194 
1195     /**
1196      * Convert to resume state if the player is no longer valid and active, then notify listeners
1197      * that the data was updated. Does not convert to resume state if the player is still valid, or
1198      * if it was removed before becoming inactive. (Assumes that [removed] was removed from
1199      * [mediaEntries] before this function was called)
1200      */
1201     private fun handlePossibleRemoval(
1202         key: String,
1203         removed: MediaData,
1204         notificationRemoved: Boolean = false,
1205     ) {
1206         val hasSession = removed.token != null
1207         if (hasSession && removed.semanticActions != null) {
1208             // The app was using session actions, and the session is still valid: keep player
1209             if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
1210             mediaEntries.put(key, removed)
1211             notifyMediaDataLoaded(key, key, removed)
1212         } else if (!notificationRemoved && removed.semanticActions == null) {
1213             // The app was using notification actions, and notif wasn't removed yet: keep player
1214             if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
1215             mediaEntries.put(key, removed)
1216             notifyMediaDataLoaded(key, key, removed)
1217         } else if (removed.active && !isAbleToResume(removed)) {
1218             // This player was still active - it didn't last long enough to time out,
1219             // and its app doesn't normally support resume: remove
1220             if (DEBUG) Log.d(TAG, "Removing still-active player $key")
1221             notifyMediaDataRemoved(key)
1222             logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1223         } else if (mediaFlags.isRetainingPlayersEnabled() || isAbleToResume(removed)) {
1224             // Convert to resume
1225             if (DEBUG) {
1226                 Log.d(
1227                     TAG,
1228                     "Notification ($notificationRemoved) and/or session " +
1229                         "($hasSession) gone for inactive player $key",
1230                 )
1231             }
1232             convertToResumePlayer(key, removed)
1233         } else {
1234             // Retaining players flag is off and app doesn't support resume: remove player.
1235             if (DEBUG) Log.d(TAG, "Removing player $key")
1236             notifyMediaDataRemoved(key)
1237             logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1238         }
1239     }
1240 
1241     /** Set the given [MediaData] as a resume state player and notify listeners */
1242     private fun convertToResumePlayer(key: String, data: MediaData) {
1243         if (DEBUG) Log.d(TAG, "Converting $key to resume")
1244         // Resumption controls must have a title.
1245         if (data.song.isNullOrBlank()) {
1246             Log.e(TAG, "Description incomplete")
1247             notifyMediaDataRemoved(key)
1248             logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
1249             return
1250         }
1251         // Move to resume key (aka package name) if that key doesn't already exist.
1252         val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
1253         val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
1254         val launcherIntent =
1255             context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
1256                 PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
1257             }
1258         val lastActive =
1259             if (data.active) {
1260                 systemClock.elapsedRealtime()
1261             } else {
1262                 data.lastActive
1263             }
1264         val updated =
1265             data.copy(
1266                 token = null,
1267                 actions = listOf(),
1268                 semanticActions = MediaButton(playOrPause = resumeAction),
1269                 actionsToShowInCompact = listOf(0),
1270                 active = false,
1271                 resumption = true,
1272                 isPlaying = false,
1273                 isClearable = true,
1274                 clickIntent = launcherIntent,
1275                 lastActive = lastActive,
1276             )
1277         val pkg = data.packageName
1278         val migrate = mediaEntries.put(pkg, updated) == null
1279         // Notify listeners of "new" controls when migrating or removed and update when not
1280         Log.d(TAG, "migrating? $migrate from $key -> $pkg")
1281         if (migrate) {
1282             notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
1283         } else {
1284             // Since packageName is used for the key of the resumption controls, it is
1285             // possible that another notification has already been reused for the resumption
1286             // controls of this package. In this case, rather than renaming this player as
1287             // packageName, just remove it and then send a update to the existing resumption
1288             // controls.
1289             notifyMediaDataRemoved(key)
1290             notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
1291         }
1292         logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
1293 
1294         // Limit total number of resume controls
1295         val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption }
1296         val numResume = resumeEntries.size
1297         if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
1298             resumeEntries
1299                 .toList()
1300                 .sortedBy { (key, data) -> data.lastActive }
1301                 .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS)
1302                 .forEach { (key, data) ->
1303                     Log.d(TAG, "Removing excess control $key")
1304                     mediaEntries.remove(key)
1305                     notifyMediaDataRemoved(key)
1306                     logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
1307                 }
1308         }
1309     }
1310 
1311     override fun setMediaResumptionEnabled(isEnabled: Boolean) {
1312         if (useMediaResumption == isEnabled) {
1313             return
1314         }
1315 
1316         useMediaResumption = isEnabled
1317 
1318         if (!useMediaResumption) {
1319             // Remove any existing resume controls
1320             val filtered = mediaEntries.filter { !it.value.active }
1321             filtered.forEach {
1322                 mediaEntries.remove(it.key)
1323                 notifyMediaDataRemoved(it.key)
1324                 logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId)
1325             }
1326         }
1327     }
1328 
1329     /** Invoked when the user has dismissed the media carousel */
1330     override fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss()
1331 
1332     /** Are there any media notifications active, including the recommendations? */
1333     override fun hasActiveMediaOrRecommendation() = mediaDataFilter.hasActiveMedia()
1334 
1335     /**
1336      * Are there any media entries we should display, including the recommendations?
1337      * - If resumption is enabled, this will include inactive players
1338      * - If resumption is disabled, we only want to show active players
1339      */
1340     override fun hasAnyMediaOrRecommendation() = mediaDataFilter.hasAnyMedia()
1341 
1342     /** Are there any resume media notifications active, excluding the recommendations? */
1343     override fun hasActiveMedia() = mediaDataFilter.hasActiveMedia()
1344 
1345     /**
1346      * Are there any resume media notifications active, excluding the recommendations?
1347      * - If resumption is enabled, this will include inactive players
1348      * - If resumption is disabled, we only want to show active players
1349      */
1350     override fun hasAnyMedia() = mediaDataFilter.hasAnyMedia()
1351 
1352     override fun isRecommendationActive() = false
1353 
1354     override fun dump(pw: PrintWriter, args: Array<out String>) {
1355         pw.apply {
1356             println("internalListeners: $internalListeners")
1357             println("externalListeners: ${mediaDataFilter.listeners}")
1358             println("mediaEntries: $mediaEntries")
1359             println("useMediaResumption: $useMediaResumption")
1360         }
1361         mediaDeviceManager.dump(pw)
1362     }
1363 }
1364