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