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