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.app.smartspace.SmartspaceConfig 22 import android.app.smartspace.SmartspaceManager 23 import android.app.smartspace.SmartspaceSession 24 import android.app.smartspace.SmartspaceTarget 25 import android.content.BroadcastReceiver 26 import android.content.ContentResolver 27 import android.content.Context 28 import android.content.Intent 29 import android.content.IntentFilter 30 import android.graphics.Bitmap 31 import android.graphics.Canvas 32 import android.graphics.ImageDecoder 33 import android.graphics.drawable.Drawable 34 import android.graphics.drawable.Icon 35 import android.media.MediaDescription 36 import android.media.MediaMetadata 37 import android.media.session.MediaController 38 import android.media.session.MediaSession 39 import android.net.Uri 40 import android.os.Parcelable 41 import android.os.UserHandle 42 import android.provider.Settings 43 import android.service.notification.StatusBarNotification 44 import android.text.TextUtils 45 import android.util.Log 46 import com.android.internal.annotations.VisibleForTesting 47 import com.android.systemui.Dumpable 48 import com.android.systemui.R 49 import com.android.systemui.broadcast.BroadcastDispatcher 50 import com.android.systemui.dagger.SysUISingleton 51 import com.android.systemui.dagger.qualifiers.Background 52 import com.android.systemui.dagger.qualifiers.Main 53 import com.android.systemui.dump.DumpManager 54 import com.android.systemui.plugins.ActivityStarter 55 import com.android.systemui.plugins.BcSmartspaceDataPlugin 56 import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState 57 import com.android.systemui.statusbar.notification.row.HybridGroupManager 58 import com.android.systemui.tuner.TunerService 59 import com.android.systemui.util.Assert 60 import com.android.systemui.util.Utils 61 import com.android.systemui.util.concurrency.DelayableExecutor 62 import com.android.systemui.util.time.SystemClock 63 import java.io.FileDescriptor 64 import java.io.IOException 65 import java.io.PrintWriter 66 import java.util.concurrent.Executor 67 import java.util.concurrent.Executors 68 import javax.inject.Inject 69 70 // URI fields to try loading album art from 71 private val ART_URIS = arrayOf( 72 MediaMetadata.METADATA_KEY_ALBUM_ART_URI, 73 MediaMetadata.METADATA_KEY_ART_URI, 74 MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI 75 ) 76 77 private const val TAG = "MediaDataManager" 78 private const val DEBUG = true 79 80 private val LOADING = MediaData(-1, false, 0, null, null, null, null, null, 81 emptyList(), emptyList(), "INVALID", null, null, null, true, null) 82 @VisibleForTesting 83 internal val EMPTY_SMARTSPACE_MEDIA_DATA = SmartspaceMediaData("INVALID", false, false, 84 "INVALID", null, emptyList(), 0) 85 86 fun isMediaNotification(sbn: StatusBarNotification): Boolean { 87 if (!sbn.notification.hasMediaSession()) { 88 return false 89 } 90 val notificationStyle = sbn.notification.notificationStyle 91 if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) || 92 Notification.MediaStyle::class.java.equals(notificationStyle)) { 93 return true 94 } 95 return false 96 } 97 98 /** 99 * A class that facilitates management and loading of Media Data, ready for binding. 100 */ 101 @SysUISingleton 102 class MediaDataManager( 103 private val context: Context, 104 @Background private val backgroundExecutor: Executor, 105 @Main private val foregroundExecutor: DelayableExecutor, 106 private val mediaControllerFactory: MediaControllerFactory, 107 private val broadcastDispatcher: BroadcastDispatcher, 108 dumpManager: DumpManager, 109 mediaTimeoutListener: MediaTimeoutListener, 110 mediaResumeListener: MediaResumeListener, 111 mediaSessionBasedFilter: MediaSessionBasedFilter, 112 mediaDeviceManager: MediaDeviceManager, 113 mediaDataCombineLatest: MediaDataCombineLatest, 114 private val mediaDataFilter: MediaDataFilter, 115 private val activityStarter: ActivityStarter, 116 private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider, 117 private var useMediaResumption: Boolean, 118 private val useQsMediaPlayer: Boolean, 119 private val systemClock: SystemClock, 120 private val tunerService: TunerService 121 ) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener { 122 123 companion object { 124 // UI surface label for subscribing Smartspace updates. 125 @JvmField 126 val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager" 127 128 // Smartspace package name's extra key. 129 @JvmField 130 val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name" 131 132 // Maximum number of actions allowed in compact view 133 @JvmField 134 val MAX_COMPACT_ACTIONS = 3 135 } 136 137 private val themeText = com.android.settingslib.Utils.getColorAttr(context, 138 com.android.internal.R.attr.textColorPrimary).defaultColor 139 private val bgColor = context.getColor(android.R.color.system_accent2_50) 140 141 // Internal listeners are part of the internal pipeline. External listeners (those registered 142 // with [MediaDeviceManager.addListener]) receive events after they have propagated through 143 // the internal pipeline. 144 // Another way to think of the distinction between internal and external listeners is the 145 // following. Internal listeners are listeners that MediaDataManager depends on, and external 146 // listeners are listeners that depend on MediaDataManager. 147 // TODO(b/159539991#comment5): Move internal listeners to separate package. 148 private val internalListeners: MutableSet<Listener> = mutableSetOf() 149 private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap() 150 // There should ONLY be at most one Smartspace media recommendation. 151 var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA 152 private var smartspaceSession: SmartspaceSession? = null 153 private var allowMediaRecommendations = Utils.allowMediaRecommendations(context) 154 155 @Inject 156 constructor( 157 context: Context, 158 @Background backgroundExecutor: Executor, 159 @Main foregroundExecutor: DelayableExecutor, 160 mediaControllerFactory: MediaControllerFactory, 161 dumpManager: DumpManager, 162 broadcastDispatcher: BroadcastDispatcher, 163 mediaTimeoutListener: MediaTimeoutListener, 164 mediaResumeListener: MediaResumeListener, 165 mediaSessionBasedFilter: MediaSessionBasedFilter, 166 mediaDeviceManager: MediaDeviceManager, 167 mediaDataCombineLatest: MediaDataCombineLatest, 168 mediaDataFilter: MediaDataFilter, 169 activityStarter: ActivityStarter, 170 smartspaceMediaDataProvider: SmartspaceMediaDataProvider, 171 clock: SystemClock, 172 tunerService: TunerService 173 ) : this(context, backgroundExecutor, foregroundExecutor, mediaControllerFactory, 174 broadcastDispatcher, dumpManager, mediaTimeoutListener, mediaResumeListener, 175 mediaSessionBasedFilter, mediaDeviceManager, mediaDataCombineLatest, mediaDataFilter, 176 activityStarter, smartspaceMediaDataProvider, Utils.useMediaResumption(context), 177 Utils.useQsMediaPlayer(context), clock, tunerService) 178 179 private val appChangeReceiver = object : BroadcastReceiver() { onReceivenull180 override fun onReceive(context: Context, intent: Intent) { 181 when (intent.action) { 182 Intent.ACTION_PACKAGES_SUSPENDED -> { 183 val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST) 184 packages?.forEach { 185 removeAllForPackage(it) 186 } 187 } 188 Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_RESTARTED -> { 189 intent.data?.encodedSchemeSpecificPart?.let { 190 removeAllForPackage(it) 191 } 192 } 193 } 194 } 195 } 196 197 init { 198 dumpManager.registerDumpable(TAG, this) 199 200 // Initialize the internal processing pipeline. The listeners at the front of the pipeline 201 // are set as internal listeners so that they receive events. From there, events are 202 // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter, 203 // so it is responsible for dispatching events to external listeners. To achieve this, 204 // external listeners that are registered with [MediaDataManager.addListener] are actually 205 // registered as listeners to mediaDataFilter. 206 addInternalListener(mediaTimeoutListener) 207 addInternalListener(mediaResumeListener) 208 addInternalListener(mediaSessionBasedFilter) 209 mediaSessionBasedFilter.addListener(mediaDeviceManager) 210 mediaSessionBasedFilter.addListener(mediaDataCombineLatest) 211 mediaDeviceManager.addListener(mediaDataCombineLatest) 212 mediaDataCombineLatest.addListener(mediaDataFilter) 213 214 // Set up links back into the pipeline for listeners that need to send events upstream. timedOutnull215 mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean -> 216 setTimedOut(token, timedOut) } 217 mediaResumeListener.setManager(this) 218 mediaDataFilter.mediaDataManager = this 219 220 val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED) 221 broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL) 222 <lambda>null223 val uninstallFilter = IntentFilter().apply { 224 addAction(Intent.ACTION_PACKAGE_REMOVED) 225 addAction(Intent.ACTION_PACKAGE_RESTARTED) 226 addDataScheme("package") 227 } 228 // BroadcastDispatcher does not allow filters with data schemes 229 context.registerReceiver(appChangeReceiver, uninstallFilter) 230 231 // Register for Smartspace data updates. 232 smartspaceMediaDataProvider.registerListener(this) 233 val smartspaceManager: SmartspaceManager = 234 context.getSystemService(SmartspaceManager::class.java) 235 smartspaceSession = smartspaceManager.createSmartspaceSession( 236 SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build()) <lambda>null237 smartspaceSession?.let { 238 it.addOnTargetsAvailableListener( 239 // Use a new thread listening to Smartspace updates instead of using the existing 240 // backgroundExecutor. SmartspaceSession has scheduled routine updates which can be 241 // unpredictable on test simulators, using the backgroundExecutor makes it's hard to 242 // test the threads numbers. 243 // Switch to use backgroundExecutor when SmartspaceSession has a good way to be 244 // mocked. 245 Executors.newCachedThreadPool(), 246 SmartspaceSession.OnTargetsAvailableListener { targets -> 247 smartspaceMediaDataProvider.onTargetsAvailable(targets) 248 }) 249 } <lambda>null250 smartspaceSession?.let { it.requestSmartspaceUpdate() } 251 tunerService.addTunable(object : TunerService.Tunable { onTuningChangednull252 override fun onTuningChanged(key: String?, newValue: String?) { 253 allowMediaRecommendations = Utils.allowMediaRecommendations(context) 254 if (!allowMediaRecommendations) { 255 dismissSmartspaceRecommendation(key = smartspaceMediaData.targetId, delay = 0L) 256 } 257 } 258 }, Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION) 259 } 260 destroynull261 fun destroy() { 262 smartspaceMediaDataProvider.unregisterListener(this) 263 context.unregisterReceiver(appChangeReceiver) 264 } 265 onNotificationAddednull266 fun onNotificationAdded(key: String, sbn: StatusBarNotification) { 267 if (useQsMediaPlayer && isMediaNotification(sbn)) { 268 Assert.isMainThread() 269 val oldKey = findExistingEntry(key, sbn.packageName) 270 if (oldKey == null) { 271 val temp = LOADING.copy(packageName = sbn.packageName) 272 mediaEntries.put(key, temp) 273 } else if (oldKey != key) { 274 // Move to new key 275 val oldData = mediaEntries.remove(oldKey)!! 276 mediaEntries.put(key, oldData) 277 } 278 loadMediaData(key, sbn, oldKey) 279 } else { 280 onNotificationRemoved(key) 281 } 282 } 283 removeAllForPackagenull284 private fun removeAllForPackage(packageName: String) { 285 Assert.isMainThread() 286 val toRemove = mediaEntries.filter { it.value.packageName == packageName } 287 toRemove.forEach { 288 removeEntry(it.key) 289 } 290 } 291 setResumeActionnull292 fun setResumeAction(key: String, action: Runnable?) { 293 mediaEntries.get(key)?.let { 294 it.resumeAction = action 295 it.hasCheckedForResume = true 296 } 297 } 298 addResumptionControlsnull299 fun addResumptionControls( 300 userId: Int, 301 desc: MediaDescription, 302 action: Runnable, 303 token: MediaSession.Token, 304 appName: String, 305 appIntent: PendingIntent, 306 packageName: String 307 ) { 308 // Resume controls don't have a notification key, so store by package name instead 309 if (!mediaEntries.containsKey(packageName)) { 310 val resumeData = LOADING.copy(packageName = packageName, resumeAction = action, 311 hasCheckedForResume = true) 312 mediaEntries.put(packageName, resumeData) 313 } 314 backgroundExecutor.execute { 315 loadMediaDataInBgForResumption(userId, desc, action, token, appName, appIntent, 316 packageName) 317 } 318 } 319 320 /** 321 * Check if there is an existing entry that matches the key or package name. 322 * Returns the key that matches, or null if not found. 323 */ findExistingEntrynull324 private fun findExistingEntry(key: String, packageName: String): String? { 325 if (mediaEntries.containsKey(key)) { 326 return key 327 } 328 // Check if we already had a resume player 329 if (mediaEntries.containsKey(packageName)) { 330 return packageName 331 } 332 return null 333 } 334 loadMediaDatanull335 private fun loadMediaData( 336 key: String, 337 sbn: StatusBarNotification, 338 oldKey: String? 339 ) { 340 backgroundExecutor.execute { 341 loadMediaDataInBg(key, sbn, oldKey) 342 } 343 } 344 345 /** 346 * Add a listener for changes in this class 347 */ addListenernull348 fun addListener(listener: Listener) { 349 // mediaDataFilter is the current end of the internal pipeline. Register external 350 // listeners as listeners to it. 351 mediaDataFilter.addListener(listener) 352 } 353 354 /** 355 * Remove a listener for changes in this class 356 */ removeListenernull357 fun removeListener(listener: Listener) { 358 // Since mediaDataFilter is the current end of the internal pipelie, external listeners 359 // have been registered to it. So, they need to be removed from it too. 360 mediaDataFilter.removeListener(listener) 361 } 362 363 /** 364 * Add a listener for internal events. 365 */ addInternalListenernull366 private fun addInternalListener(listener: Listener) = internalListeners.add(listener) 367 368 /** 369 * Notify internal listeners of media loaded event. 370 * 371 * External listeners registered with [addListener] will be notified after the event propagates 372 * through the internal listener pipeline. 373 */ 374 private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) { 375 internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) } 376 } 377 378 /** 379 * Notify internal listeners of Smartspace media loaded event. 380 * 381 * External listeners registered with [addListener] will be notified after the event propagates 382 * through the internal listener pipeline. 383 */ notifySmartspaceMediaDataLoadednull384 private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) { 385 internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) } 386 } 387 388 /** 389 * Notify internal listeners of media removed event. 390 * 391 * External listeners registered with [addListener] will be notified after the event propagates 392 * through the internal listener pipeline. 393 */ notifyMediaDataRemovednull394 private fun notifyMediaDataRemoved(key: String) { 395 internalListeners.forEach { it.onMediaDataRemoved(key) } 396 } 397 398 /** 399 * Notify internal listeners of Smartspace media removed event. 400 * 401 * External listeners registered with [addListener] will be notified after the event propagates 402 * through the internal listener pipeline. 403 * 404 * @param immediately indicates should apply the UI changes immediately, otherwise wait until 405 * the next refresh-round before UI becomes visible. Should only be true if the update is 406 * initiated by user's interaction. 407 */ notifySmartspaceMediaDataRemovednull408 private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) { 409 internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) } 410 } 411 412 /** 413 * Called whenever the player has been paused or stopped for a while, or swiped from QQS. 414 * This will make the player not active anymore, hiding it from QQS and Keyguard. 415 * @see MediaData.active 416 */ setTimedOutnull417 internal fun setTimedOut(token: String, timedOut: Boolean, forceUpdate: Boolean = false) { 418 mediaEntries[token]?.let { 419 if (it.active == !timedOut && !forceUpdate) { 420 return 421 } 422 it.active = !timedOut 423 if (DEBUG) Log.d(TAG, "Updating $token timedOut: $timedOut") 424 onMediaDataLoaded(token, token, it) 425 } 426 } 427 removeEntrynull428 private fun removeEntry(key: String) { 429 mediaEntries.remove(key) 430 notifyMediaDataRemoved(key) 431 } 432 433 /** 434 * Dismiss a media entry. Returns false if the key was not found. 435 */ dismissMediaDatanull436 fun dismissMediaData(key: String, delay: Long): Boolean { 437 val existed = mediaEntries[key] != null 438 backgroundExecutor.execute { 439 mediaEntries[key]?.let { mediaData -> 440 if (mediaData.isLocalSession) { 441 mediaData.token?.let { 442 val mediaController = mediaControllerFactory.create(it) 443 mediaController.transportControls.stop() 444 } 445 } 446 } 447 } 448 foregroundExecutor.executeDelayed({ removeEntry(key) }, delay) 449 return existed 450 } 451 452 /** 453 * Called whenever the recommendation has been expired, or swiped from QQS. 454 * This will make the recommendation view to not be shown anymore during this headphone 455 * connection session. 456 */ dismissSmartspaceRecommendationnull457 fun dismissSmartspaceRecommendation(key: String, delay: Long) { 458 if (smartspaceMediaData.targetId != key) { 459 return 460 } 461 462 if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target") 463 if (smartspaceMediaData.isActive) { 464 smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA.copy( 465 targetId = smartspaceMediaData.targetId) 466 } 467 foregroundExecutor.executeDelayed( 468 { notifySmartspaceMediaDataRemoved( 469 smartspaceMediaData.targetId, immediately = true) }, delay) 470 } 471 loadMediaDataInBgForResumptionnull472 private fun loadMediaDataInBgForResumption( 473 userId: Int, 474 desc: MediaDescription, 475 resumeAction: Runnable, 476 token: MediaSession.Token, 477 appName: String, 478 appIntent: PendingIntent, 479 packageName: String 480 ) { 481 if (TextUtils.isEmpty(desc.title)) { 482 Log.e(TAG, "Description incomplete") 483 // Delete the placeholder entry 484 mediaEntries.remove(packageName) 485 return 486 } 487 488 if (DEBUG) { 489 Log.d(TAG, "adding track for $userId from browser: $desc") 490 } 491 492 // Album art 493 var artworkBitmap = desc.iconBitmap 494 if (artworkBitmap == null && desc.iconUri != null) { 495 artworkBitmap = loadBitmapFromUri(desc.iconUri!!) 496 } 497 val artworkIcon = if (artworkBitmap != null) { 498 Icon.createWithBitmap(artworkBitmap) 499 } else { 500 null 501 } 502 503 val mediaAction = getResumeMediaAction(resumeAction) 504 val lastActive = systemClock.elapsedRealtime() 505 foregroundExecutor.execute { 506 onMediaDataLoaded(packageName, null, MediaData(userId, true, bgColor, appName, 507 null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0), 508 packageName, token, appIntent, device = null, active = false, 509 resumeAction = resumeAction, resumption = true, notificationKey = packageName, 510 hasCheckedForResume = true, lastActive = lastActive)) 511 } 512 } 513 loadMediaDataInBgnull514 private fun loadMediaDataInBg( 515 key: String, 516 sbn: StatusBarNotification, 517 oldKey: String? 518 ) { 519 val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) 520 as MediaSession.Token? 521 val mediaController = mediaControllerFactory.create(token) 522 val metadata = mediaController.metadata 523 524 // Foreground and Background colors computed from album art 525 val notif: Notification = sbn.notification 526 var artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART) 527 if (artworkBitmap == null) { 528 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) 529 } 530 if (artworkBitmap == null && metadata != null) { 531 artworkBitmap = loadBitmapFromUri(metadata) 532 } 533 val artWorkIcon = if (artworkBitmap == null) { 534 notif.getLargeIcon() 535 } else { 536 Icon.createWithBitmap(artworkBitmap) 537 } 538 if (artWorkIcon != null) { 539 // If we have art, get colors from that 540 if (artworkBitmap == null) { 541 if (artWorkIcon.type == Icon.TYPE_BITMAP || 542 artWorkIcon.type == Icon.TYPE_ADAPTIVE_BITMAP) { 543 artworkBitmap = artWorkIcon.bitmap 544 } else { 545 val drawable: Drawable = artWorkIcon.loadDrawable(context) 546 artworkBitmap = Bitmap.createBitmap( 547 drawable.intrinsicWidth, 548 drawable.intrinsicHeight, 549 Bitmap.Config.ARGB_8888) 550 val canvas = Canvas(artworkBitmap) 551 drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) 552 drawable.draw(canvas) 553 } 554 } 555 } 556 557 // App name 558 val builder = Notification.Builder.recoverBuilder(context, notif) 559 val app = builder.loadHeaderAppName() 560 561 // App Icon 562 val smallIcon = sbn.notification.smallIcon 563 564 // Song name 565 var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) 566 if (song == null) { 567 song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) 568 } 569 if (song == null) { 570 song = HybridGroupManager.resolveTitle(notif) 571 } 572 573 // Artist name 574 var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) 575 if (artist == null) { 576 artist = HybridGroupManager.resolveText(notif) 577 } 578 579 // Control buttons 580 val actionIcons: MutableList<MediaAction> = ArrayList() 581 val actions = notif.actions 582 var actionsToShowCollapsed = notif.extras.getIntArray( 583 Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() ?: mutableListOf<Int>() 584 if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) { 585 Log.e(TAG, "Too many compact actions for $key, limiting to first $MAX_COMPACT_ACTIONS") 586 actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS) 587 } 588 // TODO: b/153736623 look into creating actions when this isn't a media style notification 589 590 if (actions != null) { 591 for ((index, action) in actions.withIndex()) { 592 if (action.getIcon() == null) { 593 if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}") 594 actionsToShowCollapsed.remove(index) 595 continue 596 } 597 val runnable = if (action.actionIntent != null) { 598 Runnable { 599 if (action.isAuthenticationRequired()) { 600 activityStarter.dismissKeyguardThenExecute({ 601 var result = sendPendingIntent(action.actionIntent) 602 result 603 }, {}, true) 604 } else { 605 sendPendingIntent(action.actionIntent) 606 } 607 } 608 } else { 609 null 610 } 611 val mediaActionIcon = if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) { 612 Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId()) 613 } else { 614 action.getIcon() 615 }.setTint(themeText) 616 val mediaAction = MediaAction( 617 mediaActionIcon, 618 runnable, 619 action.title) 620 actionIcons.add(mediaAction) 621 } 622 } 623 624 val isLocalSession = mediaController.playbackInfo?.playbackType == 625 MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL 626 val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null 627 val lastActive = systemClock.elapsedRealtime() 628 foregroundExecutor.execute { 629 val resumeAction: Runnable? = mediaEntries[key]?.resumeAction 630 val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true 631 val active = mediaEntries[key]?.active ?: true 632 onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app, 633 smallIcon, artist, song, artWorkIcon, actionIcons, 634 actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null, 635 active, resumeAction = resumeAction, isLocalSession = isLocalSession, 636 notificationKey = key, hasCheckedForResume = hasCheckedForResume, 637 isPlaying = isPlaying, isClearable = sbn.isClearable(), 638 lastActive = lastActive)) 639 } 640 } 641 642 /** 643 * Load a bitmap from the various Art metadata URIs 644 */ loadBitmapFromUrinull645 private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? { 646 for (uri in ART_URIS) { 647 val uriString = metadata.getString(uri) 648 if (!TextUtils.isEmpty(uriString)) { 649 val albumArt = loadBitmapFromUri(Uri.parse(uriString)) 650 if (albumArt != null) { 651 if (DEBUG) Log.d(TAG, "loaded art from $uri") 652 return albumArt 653 } 654 } 655 } 656 return null 657 } 658 sendPendingIntentnull659 private fun sendPendingIntent(intent: PendingIntent): Boolean { 660 return try { 661 intent.send() 662 true 663 } catch (e: PendingIntent.CanceledException) { 664 Log.d(TAG, "Intent canceled", e) 665 false 666 } 667 } 668 /** 669 * Load a bitmap from a URI 670 * @param uri the uri to load 671 * @return bitmap, or null if couldn't be loaded 672 */ loadBitmapFromUrinull673 private fun loadBitmapFromUri(uri: Uri): Bitmap? { 674 // ImageDecoder requires a scheme of the following types 675 if (uri.scheme == null) { 676 return null 677 } 678 679 if (!uri.scheme.equals(ContentResolver.SCHEME_CONTENT) && 680 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) && 681 !uri.scheme.equals(ContentResolver.SCHEME_FILE)) { 682 return null 683 } 684 685 val source = ImageDecoder.createSource(context.getContentResolver(), uri) 686 return try { 687 ImageDecoder.decodeBitmap(source) { 688 decoder, info, source -> decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE 689 } 690 } catch (e: IOException) { 691 Log.e(TAG, "Unable to load bitmap", e) 692 null 693 } catch (e: RuntimeException) { 694 Log.e(TAG, "Unable to load bitmap", e) 695 null 696 } 697 } 698 getResumeMediaActionnull699 private fun getResumeMediaAction(action: Runnable): MediaAction { 700 return MediaAction( 701 Icon.createWithResource(context, R.drawable.lb_ic_play).setTint(themeText), 702 action, 703 context.getString(R.string.controls_media_resume) 704 ) 705 } 706 onMediaDataLoadednull707 fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) { 708 Assert.isMainThread() 709 if (mediaEntries.containsKey(key)) { 710 // Otherwise this was removed already 711 mediaEntries.put(key, data) 712 notifyMediaDataLoaded(key, oldKey, data) 713 } 714 } 715 onSmartspaceTargetsUpdatednull716 override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) { 717 if (!allowMediaRecommendations) { 718 if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.") 719 return 720 } 721 722 val mediaTargets = targets.filterIsInstance<SmartspaceTarget>() 723 when (mediaTargets.size) { 724 0 -> { 725 if (!smartspaceMediaData.isActive) { 726 return 727 } 728 if (DEBUG) { 729 Log.d(TAG, "Set Smartspace media to be inactive for the data update") 730 } 731 smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA.copy( 732 targetId = smartspaceMediaData.targetId) 733 notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = false) 734 } 735 1 -> { 736 val newMediaTarget = mediaTargets.get(0) 737 if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) { 738 // The same Smartspace updates can be received. Skip the duplicate updates. 739 return 740 } 741 if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.") 742 smartspaceMediaData = toSmartspaceMediaData(newMediaTarget, isActive = true) 743 notifySmartspaceMediaDataLoaded( 744 smartspaceMediaData.targetId, smartspaceMediaData) 745 } 746 else -> { 747 // There should NOT be more than 1 Smartspace media update. When it happens, it 748 // indicates a bad state or an error. Reset the status accordingly. 749 Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...") 750 notifySmartspaceMediaDataRemoved( 751 smartspaceMediaData.targetId, false /* immediately */) 752 smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA 753 } 754 } 755 } 756 onNotificationRemovednull757 fun onNotificationRemoved(key: String) { 758 Assert.isMainThread() 759 val removed = mediaEntries.remove(key) 760 if (useMediaResumption && removed?.resumeAction != null && removed?.isLocalSession) { 761 Log.d(TAG, "Not removing $key because resumable") 762 // Move to resume key (aka package name) if that key doesn't already exist. 763 val resumeAction = getResumeMediaAction(removed.resumeAction!!) 764 val updated = removed.copy(token = null, actions = listOf(resumeAction), 765 actionsToShowInCompact = listOf(0), active = false, resumption = true, 766 isClearable = true) 767 val pkg = removed.packageName 768 val migrate = mediaEntries.put(pkg, updated) == null 769 // Notify listeners of "new" controls when migrating or removed and update when not 770 if (migrate) { 771 notifyMediaDataLoaded(pkg, key, updated) 772 } else { 773 // Since packageName is used for the key of the resumption controls, it is 774 // possible that another notification has already been reused for the resumption 775 // controls of this package. In this case, rather than renaming this player as 776 // packageName, just remove it and then send a update to the existing resumption 777 // controls. 778 notifyMediaDataRemoved(key) 779 notifyMediaDataLoaded(pkg, pkg, updated) 780 } 781 return 782 } 783 if (removed != null) { 784 notifyMediaDataRemoved(key) 785 } 786 } 787 setMediaResumptionEnablednull788 fun setMediaResumptionEnabled(isEnabled: Boolean) { 789 if (useMediaResumption == isEnabled) { 790 return 791 } 792 793 useMediaResumption = isEnabled 794 795 if (!useMediaResumption) { 796 // Remove any existing resume controls 797 val filtered = mediaEntries.filter { !it.value.active } 798 filtered.forEach { 799 mediaEntries.remove(it.key) 800 notifyMediaDataRemoved(it.key) 801 } 802 } 803 } 804 805 /** 806 * Invoked when the user has dismissed the media carousel 807 */ onSwipeToDismissnull808 fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss() 809 810 /** 811 * Are there any media notifications active? 812 */ 813 fun hasActiveMedia() = mediaDataFilter.hasActiveMedia() 814 815 /** 816 * Are there any media entries we should display? 817 * If resumption is enabled, this will include inactive players 818 * If resumption is disabled, we only want to show active players 819 */ 820 fun hasAnyMedia() = mediaDataFilter.hasAnyMedia() 821 822 interface Listener { 823 824 /** 825 * Called whenever there's new MediaData Loaded for the consumption in views. 826 * 827 * oldKey is provided to check whether the view has changed keys, which can happen when a 828 * player has gone from resume state (key is package name) to active state (key is 829 * notification key) or vice versa. 830 * 831 * @param immediately indicates should apply the UI changes immediately, otherwise wait 832 * until the next refresh-round before UI becomes visible. True by default to take in place 833 * immediately. 834 * 835 * @param isSsReactivated indicates transition from a state with no active media players to 836 * a state with active media players upon receiving Smartspace media data. 837 */ 838 fun onMediaDataLoaded( 839 key: String, 840 oldKey: String?, 841 data: MediaData, 842 immediately: Boolean = true, 843 isSsReactivated: Boolean = false 844 ) {} 845 846 /** 847 * Called whenever there's new Smartspace media data loaded. 848 * 849 * @param shouldPrioritize indicates the sorting priority of the Smartspace card. If true, 850 * it will be prioritized as the first card. Otherwise, it will show up as the last card as 851 * default. 852 */ 853 fun onSmartspaceMediaDataLoaded( 854 key: String, 855 data: SmartspaceMediaData, 856 shouldPrioritize: Boolean = false 857 ) {} 858 859 /** Called whenever a previously existing Media notification was removed. */ 860 fun onMediaDataRemoved(key: String) {} 861 862 /** 863 * Called whenever a previously existing Smartspace media data was removed. 864 * 865 * @param immediately indicates should apply the UI changes immediately, otherwise wait 866 * until the next refresh-round before UI becomes visible. True by default to take in place 867 * immediately. 868 */ 869 fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true) {} 870 } 871 872 /** 873 * Converts the pass-in SmartspaceTarget to SmartspaceMediaData with the pass-in active status. 874 * 875 * @return An empty SmartspaceMediaData with the valid target Id is returned if the 876 * SmartspaceTarget's data is invalid. 877 */ toSmartspaceMediaDatanull878 private fun toSmartspaceMediaData( 879 target: SmartspaceTarget, 880 isActive: Boolean 881 ): SmartspaceMediaData { 882 packageName(target)?.let { 883 return SmartspaceMediaData(target.smartspaceTargetId, isActive, true, it, 884 target.baseAction, target.iconGrid, 0) 885 } 886 return EMPTY_SMARTSPACE_MEDIA_DATA 887 .copy(targetId = target.smartspaceTargetId, isActive = isActive) 888 } 889 packageNamenull890 private fun packageName(target: SmartspaceTarget): String? { 891 val recommendationList = target.iconGrid 892 if (recommendationList == null || recommendationList.isEmpty()) { 893 Log.w(TAG, "Empty or null media recommendation list.") 894 return null 895 } 896 for (recommendation in recommendationList) { 897 val extras = recommendation.extras 898 extras?.let { 899 it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { 900 packageName -> return packageName } 901 } 902 } 903 Log.w(TAG, "No valid package name is provided.") 904 return null 905 } 906 dumpnull907 override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) { 908 pw.apply { 909 println("internalListeners: $internalListeners") 910 println("externalListeners: ${mediaDataFilter.listeners}") 911 println("mediaEntries: $mediaEntries") 912 println("useMediaResumption: $useMediaResumption") 913 } 914 } 915 } 916