• 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
18 
19 import android.app.Notification
20 import android.app.PendingIntent
21 import android.content.BroadcastReceiver
22 import android.content.ContentResolver
23 import android.content.Context
24 import android.content.Intent
25 import android.content.IntentFilter
26 import android.graphics.Bitmap
27 import android.graphics.Canvas
28 import android.graphics.Color
29 import android.graphics.ImageDecoder
30 import android.graphics.drawable.Drawable
31 import android.graphics.drawable.Icon
32 import android.media.MediaDescription
33 import android.media.MediaMetadata
34 import android.media.session.MediaController
35 import android.media.session.MediaSession
36 import android.net.Uri
37 import android.os.UserHandle
38 import android.service.notification.StatusBarNotification
39 import android.text.TextUtils
40 import android.util.Log
41 import com.android.internal.graphics.ColorUtils
42 import com.android.systemui.Dumpable
43 import com.android.systemui.R
44 import com.android.systemui.broadcast.BroadcastDispatcher
45 import com.android.systemui.dagger.qualifiers.Background
46 import com.android.systemui.dagger.qualifiers.Main
47 import com.android.systemui.dump.DumpManager
48 import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
49 import com.android.systemui.statusbar.notification.MediaNotificationProcessor
50 import com.android.systemui.statusbar.notification.row.HybridGroupManager
51 import com.android.systemui.util.Assert
52 import com.android.systemui.util.Utils
53 import com.android.systemui.util.concurrency.DelayableExecutor
54 import java.io.FileDescriptor
55 import java.io.IOException
56 import java.io.PrintWriter
57 import java.util.concurrent.Executor
58 import javax.inject.Inject
59 import javax.inject.Singleton
60 
61 // URI fields to try loading album art from
62 private val ART_URIS = arrayOf(
63         MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
64         MediaMetadata.METADATA_KEY_ART_URI,
65         MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI
66 )
67 
68 private const val TAG = "MediaDataManager"
69 private const val DEBUG = true
70 private const val DEFAULT_LUMINOSITY = 0.25f
71 private const val LUMINOSITY_THRESHOLD = 0.05f
72 private const val SATURATION_MULTIPLIER = 0.8f
73 const val DEFAULT_COLOR = Color.DKGRAY
74 
75 private val LOADING = MediaData(-1, false, 0, null, null, null, null, null,
76         emptyList(), emptyList(), "INVALID", null, null, null, true, null)
77 
78 fun isMediaNotification(sbn: StatusBarNotification): Boolean {
79     if (!sbn.notification.hasMediaSession()) {
80         return false
81     }
82     val notificationStyle = sbn.notification.notificationStyle
83     if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) ||
84             Notification.MediaStyle::class.java.equals(notificationStyle)) {
85         return true
86     }
87     return false
88 }
89 
90 /**
91  * A class that facilitates management and loading of Media Data, ready for binding.
92  */
93 @Singleton
94 class MediaDataManager(
95     private val context: Context,
96     @Background private val backgroundExecutor: Executor,
97     @Main private val foregroundExecutor: DelayableExecutor,
98     private val mediaControllerFactory: MediaControllerFactory,
99     private val broadcastDispatcher: BroadcastDispatcher,
100     dumpManager: DumpManager,
101     mediaTimeoutListener: MediaTimeoutListener,
102     mediaResumeListener: MediaResumeListener,
103     mediaSessionBasedFilter: MediaSessionBasedFilter,
104     mediaDeviceManager: MediaDeviceManager,
105     mediaDataCombineLatest: MediaDataCombineLatest,
106     private val mediaDataFilter: MediaDataFilter,
107     private var useMediaResumption: Boolean,
108     private val useQsMediaPlayer: Boolean
109 ) : Dumpable {
110 
111     // Internal listeners are part of the internal pipeline. External listeners (those registered
112     // with [MediaDeviceManager.addListener]) receive events after they have propagated through
113     // the internal pipeline.
114     // Another way to think of the distinction between internal and external listeners is the
115     // following. Internal listeners are listeners that MediaDataManager depends on, and external
116     // listeners are listeners that depend on MediaDataManager.
117     // TODO(b/159539991#comment5): Move internal listeners to separate package.
118     private val internalListeners: MutableSet<Listener> = mutableSetOf()
119     private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
120 
121     @Inject
122     constructor(
123         context: Context,
124         @Background backgroundExecutor: Executor,
125         @Main foregroundExecutor: DelayableExecutor,
126         mediaControllerFactory: MediaControllerFactory,
127         dumpManager: DumpManager,
128         broadcastDispatcher: BroadcastDispatcher,
129         mediaTimeoutListener: MediaTimeoutListener,
130         mediaResumeListener: MediaResumeListener,
131         mediaSessionBasedFilter: MediaSessionBasedFilter,
132         mediaDeviceManager: MediaDeviceManager,
133         mediaDataCombineLatest: MediaDataCombineLatest,
134         mediaDataFilter: MediaDataFilter
135     ) : this(context, backgroundExecutor, foregroundExecutor, mediaControllerFactory,
136             broadcastDispatcher, dumpManager, mediaTimeoutListener, mediaResumeListener,
137             mediaSessionBasedFilter, mediaDeviceManager, mediaDataCombineLatest, mediaDataFilter,
138             Utils.useMediaResumption(context), Utils.useQsMediaPlayer(context))
139 
140     private val appChangeReceiver = object : BroadcastReceiver() {
onReceivenull141         override fun onReceive(context: Context, intent: Intent) {
142             when (intent.action) {
143                 Intent.ACTION_PACKAGES_SUSPENDED -> {
144                     val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
145                     packages?.forEach {
146                         removeAllForPackage(it)
147                     }
148                 }
149                 Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_RESTARTED -> {
150                     intent.data?.encodedSchemeSpecificPart?.let {
151                         removeAllForPackage(it)
152                     }
153                 }
154             }
155         }
156     }
157 
158     init {
159         dumpManager.registerDumpable(TAG, this)
160 
161         // Initialize the internal processing pipeline. The listeners at the front of the pipeline
162         // are set as internal listeners so that they receive events. From there, events are
163         // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
164         // so it is responsible for dispatching events to external listeners. To achieve this,
165         // external listeners that are registered with [MediaDataManager.addListener] are actually
166         // registered as listeners to mediaDataFilter.
167         addInternalListener(mediaTimeoutListener)
168         addInternalListener(mediaResumeListener)
169         addInternalListener(mediaSessionBasedFilter)
170         mediaSessionBasedFilter.addListener(mediaDeviceManager)
171         mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
172         mediaDeviceManager.addListener(mediaDataCombineLatest)
173         mediaDataCombineLatest.addListener(mediaDataFilter)
174 
175         // Set up links back into the pipeline for listeners that need to send events upstream.
timedOutnull176         mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean ->
177             setTimedOut(token, timedOut) }
178         mediaResumeListener.setManager(this)
179         mediaDataFilter.mediaDataManager = this
180 
181         val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
182         broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
183 
<lambda>null184         val uninstallFilter = IntentFilter().apply {
185             addAction(Intent.ACTION_PACKAGE_REMOVED)
186             addAction(Intent.ACTION_PACKAGE_RESTARTED)
187             addDataScheme("package")
188         }
189         // BroadcastDispatcher does not allow filters with data schemes
190         context.registerReceiver(appChangeReceiver, uninstallFilter)
191     }
192 
destroynull193     fun destroy() {
194         context.unregisterReceiver(appChangeReceiver)
195     }
196 
onNotificationAddednull197     fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
198         if (useQsMediaPlayer && isMediaNotification(sbn)) {
199             Assert.isMainThread()
200             val oldKey = findExistingEntry(key, sbn.packageName)
201             if (oldKey == null) {
202                 val temp = LOADING.copy(packageName = sbn.packageName)
203                 mediaEntries.put(key, temp)
204             } else if (oldKey != key) {
205                 // Move to new key
206                 val oldData = mediaEntries.remove(oldKey)!!
207                 mediaEntries.put(key, oldData)
208             }
209             loadMediaData(key, sbn, oldKey)
210         } else {
211             onNotificationRemoved(key)
212         }
213     }
214 
removeAllForPackagenull215     private fun removeAllForPackage(packageName: String) {
216         Assert.isMainThread()
217         val toRemove = mediaEntries.filter { it.value.packageName == packageName }
218         toRemove.forEach {
219             removeEntry(it.key)
220         }
221     }
222 
setResumeActionnull223     fun setResumeAction(key: String, action: Runnable?) {
224         mediaEntries.get(key)?.let {
225             it.resumeAction = action
226             it.hasCheckedForResume = true
227         }
228     }
229 
addResumptionControlsnull230     fun addResumptionControls(
231         userId: Int,
232         desc: MediaDescription,
233         action: Runnable,
234         token: MediaSession.Token,
235         appName: String,
236         appIntent: PendingIntent,
237         packageName: String
238     ) {
239         // Resume controls don't have a notification key, so store by package name instead
240         if (!mediaEntries.containsKey(packageName)) {
241             val resumeData = LOADING.copy(packageName = packageName, resumeAction = action,
242                 hasCheckedForResume = true)
243             mediaEntries.put(packageName, resumeData)
244         }
245         backgroundExecutor.execute {
246             loadMediaDataInBgForResumption(userId, desc, action, token, appName, appIntent,
247                 packageName)
248         }
249     }
250 
251     /**
252      * Check if there is an existing entry that matches the key or package name.
253      * Returns the key that matches, or null if not found.
254      */
findExistingEntrynull255     private fun findExistingEntry(key: String, packageName: String): String? {
256         if (mediaEntries.containsKey(key)) {
257             return key
258         }
259         // Check if we already had a resume player
260         if (mediaEntries.containsKey(packageName)) {
261             return packageName
262         }
263         return null
264     }
265 
loadMediaDatanull266     private fun loadMediaData(
267         key: String,
268         sbn: StatusBarNotification,
269         oldKey: String?
270     ) {
271         backgroundExecutor.execute {
272             loadMediaDataInBg(key, sbn, oldKey)
273         }
274     }
275 
276     /**
277      * Add a listener for changes in this class
278      */
addListenernull279     fun addListener(listener: Listener) {
280         // mediaDataFilter is the current end of the internal pipeline. Register external
281         // listeners as listeners to it.
282         mediaDataFilter.addListener(listener)
283     }
284 
285     /**
286      * Remove a listener for changes in this class
287      */
removeListenernull288     fun removeListener(listener: Listener) {
289         // Since mediaDataFilter is the current end of the internal pipelie, external listeners
290         // have been registered to it. So, they need to be removed from it too.
291         mediaDataFilter.removeListener(listener)
292     }
293 
294     /**
295      * Add a listener for internal events.
296      */
addInternalListenernull297     private fun addInternalListener(listener: Listener) = internalListeners.add(listener)
298 
299     /**
300      * Notify internal listeners of loaded event.
301      *
302      * External listeners registered with [addListener] will be notified after the event propagates
303      * through the internal listener pipeline.
304      */
305     private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
306         internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
307     }
308 
309     /**
310      * Notify internal listeners of removed event.
311      *
312      * External listeners registered with [addListener] will be notified after the event propagates
313      * through the internal listener pipeline.
314      */
notifyMediaDataRemovednull315     private fun notifyMediaDataRemoved(key: String) {
316         internalListeners.forEach { it.onMediaDataRemoved(key) }
317     }
318 
319     /**
320      * Called whenever the player has been paused or stopped for a while, or swiped from QQS.
321      * This will make the player not active anymore, hiding it from QQS and Keyguard.
322      * @see MediaData.active
323      */
setTimedOutnull324     internal fun setTimedOut(token: String, timedOut: Boolean) {
325         mediaEntries[token]?.let {
326             if (it.active == !timedOut) {
327                 return
328             }
329             it.active = !timedOut
330             if (DEBUG) Log.d(TAG, "Updating $token timedOut: $timedOut")
331             onMediaDataLoaded(token, token, it)
332         }
333     }
334 
removeEntrynull335     private fun removeEntry(key: String) {
336         mediaEntries.remove(key)
337         notifyMediaDataRemoved(key)
338     }
339 
dismissMediaDatanull340     fun dismissMediaData(key: String, delay: Long) {
341         backgroundExecutor.execute {
342             mediaEntries[key]?.let { mediaData ->
343                 if (mediaData.isLocalSession) {
344                     mediaData.token?.let {
345                         val mediaController = mediaControllerFactory.create(it)
346                         mediaController.transportControls.stop()
347                     }
348                 }
349             }
350         }
351         foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
352     }
353 
loadMediaDataInBgForResumptionnull354     private fun loadMediaDataInBgForResumption(
355         userId: Int,
356         desc: MediaDescription,
357         resumeAction: Runnable,
358         token: MediaSession.Token,
359         appName: String,
360         appIntent: PendingIntent,
361         packageName: String
362     ) {
363         if (TextUtils.isEmpty(desc.title)) {
364             Log.e(TAG, "Description incomplete")
365             // Delete the placeholder entry
366             mediaEntries.remove(packageName)
367             return
368         }
369 
370         if (DEBUG) {
371             Log.d(TAG, "adding track for $userId from browser: $desc")
372         }
373 
374         // Album art
375         var artworkBitmap = desc.iconBitmap
376         if (artworkBitmap == null && desc.iconUri != null) {
377             artworkBitmap = loadBitmapFromUri(desc.iconUri!!)
378         }
379         val artworkIcon = if (artworkBitmap != null) {
380             Icon.createWithBitmap(artworkBitmap)
381         } else {
382             null
383         }
384         val bgColor = artworkBitmap?.let { computeBackgroundColor(it) } ?: DEFAULT_COLOR
385 
386         val mediaAction = getResumeMediaAction(resumeAction)
387         foregroundExecutor.execute {
388             onMediaDataLoaded(packageName, null, MediaData(userId, true, bgColor, appName,
389                     null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0),
390                     packageName, token, appIntent, device = null, active = false,
391                     resumeAction = resumeAction, resumption = true, notificationKey = packageName,
392                     hasCheckedForResume = true))
393         }
394     }
395 
loadMediaDataInBgnull396     private fun loadMediaDataInBg(
397         key: String,
398         sbn: StatusBarNotification,
399         oldKey: String?
400     ) {
401         val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
402                 as MediaSession.Token?
403         val mediaController = mediaControllerFactory.create(token)
404         val metadata = mediaController.metadata
405 
406         // Foreground and Background colors computed from album art
407         val notif: Notification = sbn.notification
408         var artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
409         if (artworkBitmap == null) {
410             artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
411         }
412         if (artworkBitmap == null && metadata != null) {
413             artworkBitmap = loadBitmapFromUri(metadata)
414         }
415         val artWorkIcon = if (artworkBitmap == null) {
416             notif.getLargeIcon()
417         } else {
418             Icon.createWithBitmap(artworkBitmap)
419         }
420         if (artWorkIcon != null) {
421             // If we have art, get colors from that
422             if (artworkBitmap == null) {
423                 if (artWorkIcon.type == Icon.TYPE_BITMAP ||
424                         artWorkIcon.type == Icon.TYPE_ADAPTIVE_BITMAP) {
425                     artworkBitmap = artWorkIcon.bitmap
426                 } else {
427                     val drawable: Drawable = artWorkIcon.loadDrawable(context)
428                     artworkBitmap = Bitmap.createBitmap(
429                             drawable.intrinsicWidth,
430                             drawable.intrinsicHeight,
431                             Bitmap.Config.ARGB_8888)
432                     val canvas = Canvas(artworkBitmap)
433                     drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
434                     drawable.draw(canvas)
435                 }
436             }
437         }
438         val bgColor = computeBackgroundColor(artworkBitmap)
439 
440         // App name
441         val builder = Notification.Builder.recoverBuilder(context, notif)
442         val app = builder.loadHeaderAppName()
443 
444         // App Icon
445         val smallIconDrawable: Drawable = sbn.notification.smallIcon.loadDrawable(context)
446 
447         // Song name
448         var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
449         if (song == null) {
450             song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
451         }
452         if (song == null) {
453             song = HybridGroupManager.resolveTitle(notif)
454         }
455 
456         // Artist name
457         var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
458         if (artist == null) {
459             artist = HybridGroupManager.resolveText(notif)
460         }
461 
462         // Control buttons
463         val actionIcons: MutableList<MediaAction> = ArrayList()
464         val actions = notif.actions
465         val actionsToShowCollapsed = notif.extras.getIntArray(
466                 Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() ?: mutableListOf<Int>()
467         // TODO: b/153736623 look into creating actions when this isn't a media style notification
468 
469         val packageContext: Context = sbn.getPackageContext(context)
470         if (actions != null) {
471             for ((index, action) in actions.withIndex()) {
472                 if (action.getIcon() == null) {
473                     if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}")
474                     actionsToShowCollapsed.remove(index)
475                     continue
476                 }
477                 val runnable = if (action.actionIntent != null) {
478                     Runnable {
479                         try {
480                             action.actionIntent.send()
481                         } catch (e: PendingIntent.CanceledException) {
482                             Log.d(TAG, "Intent canceled", e)
483                         }
484                     }
485                 } else {
486                     null
487                 }
488                 val mediaAction = MediaAction(
489                         action.getIcon().loadDrawable(packageContext),
490                         runnable,
491                         action.title)
492                 actionIcons.add(mediaAction)
493             }
494         }
495 
496         val isLocalSession = mediaController.playbackInfo?.playbackType ==
497             MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL ?: true
498         val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
499 
500         foregroundExecutor.execute {
501             val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
502             val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
503             val active = mediaEntries[key]?.active ?: true
504             onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app,
505                     smallIconDrawable, artist, song, artWorkIcon, actionIcons,
506                     actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null,
507                     active, resumeAction = resumeAction, isLocalSession = isLocalSession,
508                     notificationKey = key, hasCheckedForResume = hasCheckedForResume,
509                     isPlaying = isPlaying, isClearable = sbn.isClearable()))
510         }
511     }
512 
513     /**
514      * Load a bitmap from the various Art metadata URIs
515      */
loadBitmapFromUrinull516     private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
517         for (uri in ART_URIS) {
518             val uriString = metadata.getString(uri)
519             if (!TextUtils.isEmpty(uriString)) {
520                 val albumArt = loadBitmapFromUri(Uri.parse(uriString))
521                 if (albumArt != null) {
522                     if (DEBUG) Log.d(TAG, "loaded art from $uri")
523                     return albumArt
524                 }
525             }
526         }
527         return null
528     }
529 
530     /**
531      * Load a bitmap from a URI
532      * @param uri the uri to load
533      * @return bitmap, or null if couldn't be loaded
534      */
loadBitmapFromUrinull535     private fun loadBitmapFromUri(uri: Uri): Bitmap? {
536         // ImageDecoder requires a scheme of the following types
537         if (uri.scheme == null) {
538             return null
539         }
540 
541         if (!uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
542                 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
543                 !uri.scheme.equals(ContentResolver.SCHEME_FILE)) {
544             return null
545         }
546 
547         val source = ImageDecoder.createSource(context.getContentResolver(), uri)
548         return try {
549             ImageDecoder.decodeBitmap(source) {
550                 decoder, info, source -> decoder.isMutableRequired = true
551             }
552         } catch (e: IOException) {
553             Log.e(TAG, "Unable to load bitmap", e)
554             null
555         } catch (e: RuntimeException) {
556             Log.e(TAG, "Unable to load bitmap", e)
557             null
558         }
559     }
560 
computeBackgroundColornull561     private fun computeBackgroundColor(artworkBitmap: Bitmap?): Int {
562         var color = Color.WHITE
563         if (artworkBitmap != null && artworkBitmap.width > 1 && artworkBitmap.height > 1) {
564             // If we have valid art, get colors from that
565             val p = MediaNotificationProcessor.generateArtworkPaletteBuilder(artworkBitmap)
566                     .generate()
567             val swatch = MediaNotificationProcessor.findBackgroundSwatch(p)
568             color = swatch.rgb
569         } else {
570             return DEFAULT_COLOR
571         }
572         // Adapt background color, so it's always subdued and text is legible
573         val tmpHsl = floatArrayOf(0f, 0f, 0f)
574         ColorUtils.colorToHSL(color, tmpHsl)
575 
576         val l = tmpHsl[2]
577         // Colors with very low luminosity can have any saturation. This means that changing the
578         // luminosity can make a black become red. Let's remove the saturation of very light or
579         // very dark colors to avoid this issue.
580         if (l < LUMINOSITY_THRESHOLD || l > 1f - LUMINOSITY_THRESHOLD) {
581             tmpHsl[1] = 0f
582         }
583         tmpHsl[1] *= SATURATION_MULTIPLIER
584         tmpHsl[2] = DEFAULT_LUMINOSITY
585 
586         color = ColorUtils.HSLToColor(tmpHsl)
587         return color
588     }
589 
getResumeMediaActionnull590     private fun getResumeMediaAction(action: Runnable): MediaAction {
591         return MediaAction(
592             context.getDrawable(R.drawable.lb_ic_play),
593             action,
594             context.getString(R.string.controls_media_resume)
595         )
596     }
597 
onMediaDataLoadednull598     fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
599         Assert.isMainThread()
600         if (mediaEntries.containsKey(key)) {
601             // Otherwise this was removed already
602             mediaEntries.put(key, data)
603             notifyMediaDataLoaded(key, oldKey, data)
604         }
605     }
606 
onNotificationRemovednull607     fun onNotificationRemoved(key: String) {
608         Assert.isMainThread()
609         val removed = mediaEntries.remove(key)
610         if (useMediaResumption && removed?.resumeAction != null) {
611             if (DEBUG) Log.d(TAG, "Not removing $key because resumable")
612             // Move to resume key (aka package name) if that key doesn't already exist.
613             val resumeAction = getResumeMediaAction(removed.resumeAction!!)
614             val updated = removed.copy(token = null, actions = listOf(resumeAction),
615                     actionsToShowInCompact = listOf(0), active = false, resumption = true,
616                     isClearable = true)
617             val pkg = removed?.packageName
618             val migrate = mediaEntries.put(pkg, updated) == null
619             // Notify listeners of "new" controls when migrating or removed and update when not
620             if (migrate) {
621                 notifyMediaDataLoaded(pkg, key, updated)
622             } else {
623                 // Since packageName is used for the key of the resumption controls, it is
624                 // possible that another notification has already been reused for the resumption
625                 // controls of this package. In this case, rather than renaming this player as
626                 // packageName, just remove it and then send a update to the existing resumption
627                 // controls.
628                 notifyMediaDataRemoved(key)
629                 notifyMediaDataLoaded(pkg, pkg, updated)
630             }
631             return
632         }
633         if (removed != null) {
634             notifyMediaDataRemoved(key)
635         }
636     }
637 
setMediaResumptionEnablednull638     fun setMediaResumptionEnabled(isEnabled: Boolean) {
639         if (useMediaResumption == isEnabled) {
640             return
641         }
642 
643         useMediaResumption = isEnabled
644 
645         if (!useMediaResumption) {
646             // Remove any existing resume controls
647             val filtered = mediaEntries.filter { !it.value.active }
648             filtered.forEach {
649                 mediaEntries.remove(it.key)
650                 notifyMediaDataRemoved(it.key)
651             }
652         }
653     }
654 
655     /**
656      * Invoked when the user has dismissed the media carousel
657      */
onSwipeToDismissnull658     fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss()
659 
660     /**
661      * Are there any media notifications active?
662      */
663     fun hasActiveMedia() = mediaDataFilter.hasActiveMedia()
664 
665     /**
666      * Are there any media entries we should display?
667      * If resumption is enabled, this will include inactive players
668      * If resumption is disabled, we only want to show active players
669      */
670     fun hasAnyMedia() = mediaDataFilter.hasAnyMedia()
671 
672     interface Listener {
673 
674         /**
675          * Called whenever there's new MediaData Loaded for the consumption in views.
676          *
677          * oldKey is provided to check whether the view has changed keys, which can happen when a
678          * player has gone from resume state (key is package name) to active state (key is
679          * notification key) or vice versa.
680          */
681         fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {}
682 
683         /**
684          * Called whenever a previously existing Media notification was removed
685          */
686         fun onMediaDataRemoved(key: String) {}
687     }
688 
dumpnull689     override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
690         pw.apply {
691             println("internalListeners: $internalListeners")
692             println("externalListeners: ${mediaDataFilter.listeners}")
693             println("mediaEntries: $mediaEntries")
694             println("useMediaResumption: $useMediaResumption")
695         }
696     }
697 }
698