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.controls.pipeline 18 19 import android.annotation.SuppressLint 20 import android.app.BroadcastOptions 21 import android.app.Notification 22 import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME 23 import android.app.PendingIntent 24 import android.app.StatusBarManager 25 import android.app.UriGrantsManager 26 import android.app.smartspace.SmartspaceAction 27 import android.app.smartspace.SmartspaceConfig 28 import android.app.smartspace.SmartspaceManager 29 import android.app.smartspace.SmartspaceSession 30 import android.app.smartspace.SmartspaceTarget 31 import android.content.BroadcastReceiver 32 import android.content.ContentProvider 33 import android.content.ContentResolver 34 import android.content.Context 35 import android.content.Intent 36 import android.content.IntentFilter 37 import android.content.pm.ApplicationInfo 38 import android.content.pm.PackageManager 39 import android.graphics.Bitmap 40 import android.graphics.ImageDecoder 41 import android.graphics.drawable.Animatable 42 import android.graphics.drawable.Icon 43 import android.media.MediaDescription 44 import android.media.MediaMetadata 45 import android.media.session.MediaController 46 import android.media.session.MediaSession 47 import android.media.session.PlaybackState 48 import android.net.Uri 49 import android.os.Parcelable 50 import android.os.Process 51 import android.os.UserHandle 52 import android.provider.Settings 53 import android.service.notification.StatusBarNotification 54 import android.support.v4.media.MediaMetadataCompat 55 import android.text.TextUtils 56 import android.util.Log 57 import android.util.Pair as APair 58 import androidx.media.utils.MediaConstants 59 import com.android.internal.annotations.Keep 60 import com.android.internal.logging.InstanceId 61 import com.android.keyguard.KeyguardUpdateMonitor 62 import com.android.systemui.Dumpable 63 import com.android.systemui.R 64 import com.android.systemui.broadcast.BroadcastDispatcher 65 import com.android.systemui.dagger.SysUISingleton 66 import com.android.systemui.dagger.qualifiers.Background 67 import com.android.systemui.dagger.qualifiers.Main 68 import com.android.systemui.dump.DumpManager 69 import com.android.systemui.media.controls.models.player.MediaAction 70 import com.android.systemui.media.controls.models.player.MediaButton 71 import com.android.systemui.media.controls.models.player.MediaData 72 import com.android.systemui.media.controls.models.player.MediaDeviceData 73 import com.android.systemui.media.controls.models.player.MediaViewHolder 74 import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_SOURCE 75 import com.android.systemui.media.controls.models.recommendation.EXTRA_VALUE_TRIGGER_PERIODIC 76 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData 77 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataProvider 78 import com.android.systemui.media.controls.resume.MediaResumeListener 79 import com.android.systemui.media.controls.resume.ResumeMediaBrowser 80 import com.android.systemui.media.controls.util.MediaControllerFactory 81 import com.android.systemui.media.controls.util.MediaDataUtils 82 import com.android.systemui.media.controls.util.MediaFlags 83 import com.android.systemui.media.controls.util.MediaUiEventLogger 84 import com.android.systemui.plugins.ActivityStarter 85 import com.android.systemui.plugins.BcSmartspaceDataPlugin 86 import com.android.systemui.statusbar.NotificationMediaManager.isConnectingState 87 import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState 88 import com.android.systemui.statusbar.notification.row.HybridGroupManager 89 import com.android.systemui.tuner.TunerService 90 import com.android.systemui.util.Assert 91 import com.android.systemui.util.Utils 92 import com.android.systemui.util.concurrency.DelayableExecutor 93 import com.android.systemui.util.time.SystemClock 94 import com.android.systemui.util.traceSection 95 import java.io.IOException 96 import java.io.PrintWriter 97 import java.util.concurrent.Executor 98 import javax.inject.Inject 99 100 // URI fields to try loading album art from 101 private val ART_URIS = 102 arrayOf( 103 MediaMetadata.METADATA_KEY_ALBUM_ART_URI, 104 MediaMetadata.METADATA_KEY_ART_URI, 105 MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI 106 ) 107 108 private const val TAG = "MediaDataManager" 109 private const val DEBUG = true 110 private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent" 111 112 private val LOADING = 113 MediaData( 114 userId = -1, 115 initialized = false, 116 app = null, 117 appIcon = null, 118 artist = null, 119 song = null, 120 artwork = null, 121 actions = emptyList(), 122 actionsToShowInCompact = emptyList(), 123 packageName = "INVALID", 124 token = null, 125 clickIntent = null, 126 device = null, 127 active = true, 128 resumeAction = null, 129 instanceId = InstanceId.fakeInstanceId(-1), 130 appUid = Process.INVALID_UID 131 ) 132 133 internal val EMPTY_SMARTSPACE_MEDIA_DATA = 134 SmartspaceMediaData( 135 targetId = "INVALID", 136 isActive = false, 137 packageName = "INVALID", 138 cardAction = null, 139 recommendations = emptyList(), 140 dismissIntent = null, 141 headphoneConnectionTimeMillis = 0, 142 instanceId = InstanceId.fakeInstanceId(-1), 143 expiryTimeMs = 0, 144 ) 145 146 const val MEDIA_TITLE_ERROR_MESSAGE = "Invalid media data: title is null or blank." 147 148 fun isMediaNotification(sbn: StatusBarNotification): Boolean { 149 return sbn.notification.isMediaNotification() 150 } 151 152 /** 153 * Allow recommendations from smartspace to show in media controls. Requires 154 * [Utils.useQsMediaPlayer] to be enabled. On by default, but can be disabled by setting to 0 155 */ 156 private fun allowMediaRecommendations(context: Context): Boolean { 157 val flag = 158 Settings.Secure.getInt( 159 context.contentResolver, 160 Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 161 1 162 ) 163 return Utils.useQsMediaPlayer(context) && flag > 0 164 } 165 166 /** A class that facilitates management and loading of Media Data, ready for binding. */ 167 @SysUISingleton 168 class MediaDataManager( 169 private val context: Context, 170 @Background private val backgroundExecutor: Executor, 171 @Main private val uiExecutor: Executor, 172 @Main private val foregroundExecutor: DelayableExecutor, 173 private val mediaControllerFactory: MediaControllerFactory, 174 private val broadcastDispatcher: BroadcastDispatcher, 175 dumpManager: DumpManager, 176 mediaTimeoutListener: MediaTimeoutListener, 177 mediaResumeListener: MediaResumeListener, 178 mediaSessionBasedFilter: MediaSessionBasedFilter, 179 mediaDeviceManager: MediaDeviceManager, 180 mediaDataCombineLatest: MediaDataCombineLatest, 181 private val mediaDataFilter: MediaDataFilter, 182 private val activityStarter: ActivityStarter, 183 private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider, 184 private var useMediaResumption: Boolean, 185 private val useQsMediaPlayer: Boolean, 186 private val systemClock: SystemClock, 187 private val tunerService: TunerService, 188 private val mediaFlags: MediaFlags, 189 private val logger: MediaUiEventLogger, 190 private val smartspaceManager: SmartspaceManager, 191 private val keyguardUpdateMonitor: KeyguardUpdateMonitor, 192 ) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener { 193 194 companion object { 195 // UI surface label for subscribing Smartspace updates. 196 @JvmField val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager" 197 198 // Smartspace package name's extra key. 199 @JvmField val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name" 200 201 // Maximum number of actions allowed in compact view 202 @JvmField val MAX_COMPACT_ACTIONS = 3 203 204 // Maximum number of actions allowed in expanded view 205 @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size 206 } 207 208 private val themeText = 209 com.android.settingslib.Utils.getColorAttr( 210 context, 211 com.android.internal.R.attr.textColorPrimary 212 ) 213 .defaultColor 214 215 // Internal listeners are part of the internal pipeline. External listeners (those registered 216 // with [MediaDeviceManager.addListener]) receive events after they have propagated through 217 // the internal pipeline. 218 // Another way to think of the distinction between internal and external listeners is the 219 // following. Internal listeners are listeners that MediaDataManager depends on, and external 220 // listeners are listeners that depend on MediaDataManager. 221 // TODO(b/159539991#comment5): Move internal listeners to separate package. 222 private val internalListeners: MutableSet<Listener> = mutableSetOf() 223 private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap() 224 // There should ONLY be at most one Smartspace media recommendation. 225 var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA 226 @Keep private var smartspaceSession: SmartspaceSession? = null 227 private var allowMediaRecommendations = allowMediaRecommendations(context) 228 229 private val artworkWidth = 230 context.resources.getDimensionPixelSize( 231 com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize 232 ) 233 private val artworkHeight = 234 context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded) 235 236 @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE 237 private val statusBarManager = 238 context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager 239 240 /** Check whether this notification is an RCN */ 241 private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean { 242 return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE) 243 } 244 245 @Inject 246 constructor( 247 context: Context, 248 @Background backgroundExecutor: Executor, 249 @Main uiExecutor: Executor, 250 @Main foregroundExecutor: DelayableExecutor, 251 mediaControllerFactory: MediaControllerFactory, 252 dumpManager: DumpManager, 253 broadcastDispatcher: BroadcastDispatcher, 254 mediaTimeoutListener: MediaTimeoutListener, 255 mediaResumeListener: MediaResumeListener, 256 mediaSessionBasedFilter: MediaSessionBasedFilter, 257 mediaDeviceManager: MediaDeviceManager, 258 mediaDataCombineLatest: MediaDataCombineLatest, 259 mediaDataFilter: MediaDataFilter, 260 activityStarter: ActivityStarter, 261 smartspaceMediaDataProvider: SmartspaceMediaDataProvider, 262 clock: SystemClock, 263 tunerService: TunerService, 264 mediaFlags: MediaFlags, 265 logger: MediaUiEventLogger, 266 smartspaceManager: SmartspaceManager, 267 keyguardUpdateMonitor: KeyguardUpdateMonitor, 268 ) : this( 269 context, 270 backgroundExecutor, 271 uiExecutor, 272 foregroundExecutor, 273 mediaControllerFactory, 274 broadcastDispatcher, 275 dumpManager, 276 mediaTimeoutListener, 277 mediaResumeListener, 278 mediaSessionBasedFilter, 279 mediaDeviceManager, 280 mediaDataCombineLatest, 281 mediaDataFilter, 282 activityStarter, 283 smartspaceMediaDataProvider, 284 Utils.useMediaResumption(context), 285 Utils.useQsMediaPlayer(context), 286 clock, 287 tunerService, 288 mediaFlags, 289 logger, 290 smartspaceManager, 291 keyguardUpdateMonitor, 292 ) 293 294 private val appChangeReceiver = 295 object : BroadcastReceiver() { 296 override fun onReceive(context: Context, intent: Intent) { 297 when (intent.action) { 298 Intent.ACTION_PACKAGES_SUSPENDED -> { 299 val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST) 300 packages?.forEach { removeAllForPackage(it) } 301 } 302 Intent.ACTION_PACKAGE_REMOVED, 303 Intent.ACTION_PACKAGE_RESTARTED -> { 304 intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) } 305 } 306 } 307 } 308 } 309 310 init { 311 dumpManager.registerDumpable(TAG, this) 312 313 // Initialize the internal processing pipeline. The listeners at the front of the pipeline 314 // are set as internal listeners so that they receive events. From there, events are 315 // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter, 316 // so it is responsible for dispatching events to external listeners. To achieve this, 317 // external listeners that are registered with [MediaDataManager.addListener] are actually 318 // registered as listeners to mediaDataFilter. 319 addInternalListener(mediaTimeoutListener) 320 addInternalListener(mediaResumeListener) 321 addInternalListener(mediaSessionBasedFilter) 322 mediaSessionBasedFilter.addListener(mediaDeviceManager) 323 mediaSessionBasedFilter.addListener(mediaDataCombineLatest) 324 mediaDeviceManager.addListener(mediaDataCombineLatest) 325 mediaDataCombineLatest.addListener(mediaDataFilter) 326 327 // Set up links back into the pipeline for listeners that need to send events upstream. 328 mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> 329 setTimedOut(key, timedOut) 330 } 331 mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState -> 332 updateState(key, state) 333 } 334 mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) } 335 mediaResumeListener.setManager(this) 336 mediaDataFilter.mediaDataManager = this 337 338 val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED) 339 broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL) 340 341 val uninstallFilter = 342 IntentFilter().apply { 343 addAction(Intent.ACTION_PACKAGE_REMOVED) 344 addAction(Intent.ACTION_PACKAGE_RESTARTED) 345 addDataScheme("package") 346 } 347 // BroadcastDispatcher does not allow filters with data schemes 348 context.registerReceiver(appChangeReceiver, uninstallFilter) 349 350 // Register for Smartspace data updates. 351 smartspaceMediaDataProvider.registerListener(this) 352 smartspaceSession = 353 smartspaceManager.createSmartspaceSession( 354 SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build() 355 ) 356 smartspaceSession?.let { 357 it.addOnTargetsAvailableListener( 358 // Use a main uiExecutor thread listening to Smartspace updates instead of using 359 // the existing background executor. 360 // SmartspaceSession has scheduled routine updates which can be unpredictable on 361 // test simulators, using the backgroundExecutor makes it's hard to test the threads 362 // numbers. 363 uiExecutor, 364 SmartspaceSession.OnTargetsAvailableListener { targets -> 365 smartspaceMediaDataProvider.onTargetsAvailable(targets) 366 } 367 ) 368 } 369 smartspaceSession?.let { it.requestSmartspaceUpdate() } 370 tunerService.addTunable( 371 object : TunerService.Tunable { 372 override fun onTuningChanged(key: String?, newValue: String?) { 373 allowMediaRecommendations = allowMediaRecommendations(context) 374 if (!allowMediaRecommendations) { 375 dismissSmartspaceRecommendation( 376 key = smartspaceMediaData.targetId, 377 delay = 0L 378 ) 379 } 380 } 381 }, 382 Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION 383 ) 384 } 385 386 fun destroy() { 387 smartspaceMediaDataProvider.unregisterListener(this) 388 smartspaceSession?.close() 389 smartspaceSession = null 390 context.unregisterReceiver(appChangeReceiver) 391 } 392 393 fun onNotificationAdded(key: String, sbn: StatusBarNotification) { 394 if (useQsMediaPlayer && isMediaNotification(sbn)) { 395 var isNewlyActiveEntry = false 396 Assert.isMainThread() 397 val oldKey = findExistingEntry(key, sbn.packageName) 398 if (oldKey == null) { 399 val instanceId = logger.getNewInstanceId() 400 val temp = LOADING.copy(packageName = sbn.packageName, instanceId = instanceId) 401 mediaEntries.put(key, temp) 402 isNewlyActiveEntry = true 403 } else if (oldKey != key) { 404 // Resume -> active conversion; move to new key 405 val oldData = mediaEntries.remove(oldKey)!! 406 isNewlyActiveEntry = true 407 mediaEntries.put(key, oldData) 408 } 409 loadMediaData(key, sbn, oldKey, isNewlyActiveEntry) 410 } else { 411 onNotificationRemoved(key) 412 } 413 } 414 415 private fun removeAllForPackage(packageName: String) { 416 Assert.isMainThread() 417 val toRemove = mediaEntries.filter { it.value.packageName == packageName } 418 toRemove.forEach { removeEntry(it.key) } 419 } 420 421 fun setResumeAction(key: String, action: Runnable?) { 422 mediaEntries.get(key)?.let { 423 it.resumeAction = action 424 it.hasCheckedForResume = true 425 } 426 } 427 428 fun addResumptionControls( 429 userId: Int, 430 desc: MediaDescription, 431 action: Runnable, 432 token: MediaSession.Token, 433 appName: String, 434 appIntent: PendingIntent, 435 packageName: String 436 ) { 437 // Resume controls don't have a notification key, so store by package name instead 438 if (!mediaEntries.containsKey(packageName)) { 439 val instanceId = logger.getNewInstanceId() 440 val appUid = 441 try { 442 context.packageManager.getApplicationInfo(packageName, 0)?.uid!! 443 } catch (e: PackageManager.NameNotFoundException) { 444 Log.w(TAG, "Could not get app UID for $packageName", e) 445 Process.INVALID_UID 446 } 447 448 val resumeData = 449 LOADING.copy( 450 packageName = packageName, 451 resumeAction = action, 452 hasCheckedForResume = true, 453 instanceId = instanceId, 454 appUid = appUid 455 ) 456 mediaEntries.put(packageName, resumeData) 457 logSingleVsMultipleMediaAdded(appUid, packageName, instanceId) 458 logger.logResumeMediaAdded(appUid, packageName, instanceId) 459 } 460 backgroundExecutor.execute { 461 loadMediaDataInBgForResumption( 462 userId, 463 desc, 464 action, 465 token, 466 appName, 467 appIntent, 468 packageName 469 ) 470 } 471 } 472 473 /** 474 * Check if there is an existing entry that matches the key or package name. Returns the key 475 * that matches, or null if not found. 476 */ 477 private fun findExistingEntry(key: String, packageName: String): String? { 478 if (mediaEntries.containsKey(key)) { 479 return key 480 } 481 // Check if we already had a resume player 482 if (mediaEntries.containsKey(packageName)) { 483 return packageName 484 } 485 return null 486 } 487 488 private fun loadMediaData( 489 key: String, 490 sbn: StatusBarNotification, 491 oldKey: String?, 492 isNewlyActiveEntry: Boolean = false, 493 ) { 494 backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) } 495 } 496 497 /** Add a listener for changes in this class */ 498 fun addListener(listener: Listener) { 499 // mediaDataFilter is the current end of the internal pipeline. Register external 500 // listeners as listeners to it. 501 mediaDataFilter.addListener(listener) 502 } 503 504 /** Remove a listener for changes in this class */ 505 fun removeListener(listener: Listener) { 506 // Since mediaDataFilter is the current end of the internal pipelie, external listeners 507 // have been registered to it. So, they need to be removed from it too. 508 mediaDataFilter.removeListener(listener) 509 } 510 511 /** Add a listener for internal events. */ 512 private fun addInternalListener(listener: Listener) = internalListeners.add(listener) 513 514 /** 515 * Notify internal listeners of media loaded event. 516 * 517 * External listeners registered with [addListener] will be notified after the event propagates 518 * through the internal listener pipeline. 519 */ 520 private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) { 521 internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) } 522 } 523 524 /** 525 * Notify internal listeners of Smartspace media loaded event. 526 * 527 * External listeners registered with [addListener] will be notified after the event propagates 528 * through the internal listener pipeline. 529 */ 530 private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) { 531 internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) } 532 } 533 534 /** 535 * Notify internal listeners of media removed event. 536 * 537 * External listeners registered with [addListener] will be notified after the event propagates 538 * through the internal listener pipeline. 539 */ 540 private fun notifyMediaDataRemoved(key: String) { 541 internalListeners.forEach { it.onMediaDataRemoved(key) } 542 } 543 544 /** 545 * Notify internal listeners of Smartspace media removed event. 546 * 547 * External listeners registered with [addListener] will be notified after the event propagates 548 * through the internal listener pipeline. 549 * 550 * @param immediately indicates should apply the UI changes immediately, otherwise wait until 551 * the next refresh-round before UI becomes visible. Should only be true if the update is 552 * initiated by user's interaction. 553 */ 554 private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) { 555 internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) } 556 } 557 558 /** 559 * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This 560 * will make the player not active anymore, hiding it from QQS and Keyguard. 561 * 562 * @see MediaData.active 563 */ 564 internal fun setTimedOut(key: String, timedOut: Boolean, forceUpdate: Boolean = false) { 565 mediaEntries[key]?.let { 566 if (timedOut && !forceUpdate) { 567 // Only log this event when media expires on its own 568 logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId) 569 } 570 if (it.active == !timedOut && !forceUpdate) { 571 if (it.resumption) { 572 if (DEBUG) Log.d(TAG, "timing out resume player $key") 573 dismissMediaData(key, 0L /* delay */) 574 } 575 return 576 } 577 it.active = !timedOut 578 if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut") 579 onMediaDataLoaded(key, key, it) 580 } 581 582 if (key == smartspaceMediaData.targetId) { 583 if (DEBUG) Log.d(TAG, "smartspace card expired") 584 dismissSmartspaceRecommendation(key, delay = 0L) 585 } 586 } 587 588 /** Called when the player's [PlaybackState] has been updated with new actions and/or state */ 589 private fun updateState(key: String, state: PlaybackState) { 590 mediaEntries.get(key)?.let { 591 val token = it.token 592 if (token == null) { 593 if (DEBUG) Log.d(TAG, "State updated, but token was null") 594 return 595 } 596 val actions = 597 createActionsFromState( 598 it.packageName, 599 mediaControllerFactory.create(it.token), 600 UserHandle(it.userId) 601 ) 602 603 // Control buttons 604 // If flag is enabled and controller has a PlaybackState, 605 // create actions from session info 606 // otherwise, no need to update semantic actions. 607 val data = 608 if (actions != null) { 609 it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state)) 610 } else { 611 it.copy(isPlaying = isPlayingState(state.state)) 612 } 613 if (DEBUG) Log.d(TAG, "State updated outside of notification") 614 onMediaDataLoaded(key, key, data) 615 } 616 } 617 618 private fun removeEntry(key: String, logEvent: Boolean = true) { 619 mediaEntries.remove(key)?.let { 620 if (logEvent) { 621 logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId) 622 } 623 } 624 notifyMediaDataRemoved(key) 625 } 626 627 /** Dismiss a media entry. Returns false if the key was not found. */ 628 fun dismissMediaData(key: String, delay: Long): Boolean { 629 val existed = mediaEntries[key] != null 630 backgroundExecutor.execute { 631 mediaEntries[key]?.let { mediaData -> 632 if (mediaData.isLocalSession()) { 633 mediaData.token?.let { 634 val mediaController = mediaControllerFactory.create(it) 635 mediaController.transportControls.stop() 636 } 637 } 638 } 639 } 640 foregroundExecutor.executeDelayed({ removeEntry(key) }, delay) 641 return existed 642 } 643 644 /** 645 * Called whenever the recommendation has been expired or removed by the user. This will remove 646 * the recommendation card entirely from the carousel. 647 */ 648 fun dismissSmartspaceRecommendation(key: String, delay: Long) { 649 if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) { 650 // If this doesn't match, or we've already invalidated the data, no action needed 651 return 652 } 653 654 if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target") 655 if (smartspaceMediaData.isActive) { 656 smartspaceMediaData = 657 EMPTY_SMARTSPACE_MEDIA_DATA.copy( 658 targetId = smartspaceMediaData.targetId, 659 instanceId = smartspaceMediaData.instanceId 660 ) 661 } 662 foregroundExecutor.executeDelayed( 663 { notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = true) }, 664 delay 665 ) 666 } 667 668 /** Called when the recommendation card should no longer be visible in QQS or lockscreen */ 669 fun setRecommendationInactive(key: String) { 670 if (!mediaFlags.isPersistentSsCardEnabled()) { 671 Log.e(TAG, "Only persistent recommendation can be inactive!") 672 return 673 } 674 if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive") 675 676 if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) { 677 // If this doesn't match, or we've already invalidated the data, no action needed 678 return 679 } 680 681 smartspaceMediaData = smartspaceMediaData.copy(isActive = false) 682 notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData) 683 } 684 685 private fun loadMediaDataInBgForResumption( 686 userId: Int, 687 desc: MediaDescription, 688 resumeAction: Runnable, 689 token: MediaSession.Token, 690 appName: String, 691 appIntent: PendingIntent, 692 packageName: String 693 ) { 694 if (desc.title.isNullOrBlank()) { 695 Log.e(TAG, "Description incomplete") 696 // Delete the placeholder entry 697 mediaEntries.remove(packageName) 698 return 699 } 700 701 if (DEBUG) { 702 Log.d(TAG, "adding track for $userId from browser: $desc") 703 } 704 705 val currentEntry = mediaEntries.get(packageName) 706 val appUid = currentEntry?.appUid ?: Process.INVALID_UID 707 708 // Album art 709 var artworkBitmap = desc.iconBitmap 710 if (artworkBitmap == null && desc.iconUri != null) { 711 artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName) 712 } 713 val artworkIcon = 714 if (artworkBitmap != null) { 715 Icon.createWithBitmap(artworkBitmap) 716 } else { 717 null 718 } 719 720 val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId() 721 val isExplicit = 722 desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) == 723 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT 724 725 val progress = 726 if (mediaFlags.isResumeProgressEnabled()) { 727 MediaDataUtils.getDescriptionProgress(desc.extras) 728 } else null 729 730 val mediaAction = getResumeMediaAction(resumeAction) 731 val lastActive = systemClock.elapsedRealtime() 732 foregroundExecutor.execute { 733 onMediaDataLoaded( 734 packageName, 735 null, 736 MediaData( 737 userId, 738 true, 739 appName, 740 null, 741 desc.subtitle, 742 desc.title, 743 artworkIcon, 744 listOf(mediaAction), 745 listOf(0), 746 MediaButton(playOrPause = mediaAction), 747 packageName, 748 token, 749 appIntent, 750 device = null, 751 active = false, 752 resumeAction = resumeAction, 753 resumption = true, 754 notificationKey = packageName, 755 hasCheckedForResume = true, 756 lastActive = lastActive, 757 instanceId = instanceId, 758 appUid = appUid, 759 isExplicit = isExplicit, 760 resumeProgress = progress, 761 ) 762 ) 763 } 764 } 765 766 fun loadMediaDataInBg( 767 key: String, 768 sbn: StatusBarNotification, 769 oldKey: String?, 770 isNewlyActiveEntry: Boolean = false, 771 ) { 772 val token = 773 sbn.notification.extras.getParcelable( 774 Notification.EXTRA_MEDIA_SESSION, 775 MediaSession.Token::class.java 776 ) 777 if (token == null) { 778 return 779 } 780 val mediaController = mediaControllerFactory.create(token) 781 val metadata = mediaController.metadata 782 val notif: Notification = sbn.notification 783 784 val appInfo = 785 notif.extras.getParcelable( 786 Notification.EXTRA_BUILDER_APPLICATION_INFO, 787 ApplicationInfo::class.java 788 ) 789 ?: getAppInfoFromPackage(sbn.packageName) 790 791 // App name 792 val appName = getAppName(sbn, appInfo) 793 794 // Song name 795 var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) 796 if (song.isNullOrBlank()) { 797 song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) 798 } 799 if (song.isNullOrBlank()) { 800 song = HybridGroupManager.resolveTitle(notif) 801 } 802 if (song.isNullOrBlank()) { 803 // For apps that don't include a title, log and add a placeholder 804 song = context.getString(R.string.controls_media_empty_title, appName) 805 try { 806 statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier) 807 } catch (e: RuntimeException) { 808 Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}") 809 } 810 } 811 812 // Album art 813 var artworkBitmap = metadata?.let { loadBitmapFromUri(it) } 814 if (artworkBitmap == null) { 815 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART) 816 } 817 if (artworkBitmap == null) { 818 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) 819 } 820 val artWorkIcon = 821 if (artworkBitmap == null) { 822 notif.getLargeIcon() 823 } else { 824 Icon.createWithBitmap(artworkBitmap) 825 } 826 827 // App Icon 828 val smallIcon = sbn.notification.smallIcon 829 830 // Explicit Indicator 831 var isExplicit = false 832 val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata) 833 isExplicit = 834 mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) == 835 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT 836 837 // Artist name 838 var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) 839 if (artist.isNullOrBlank()) { 840 artist = HybridGroupManager.resolveText(notif) 841 } 842 843 // Device name (used for remote cast notifications) 844 var device: MediaDeviceData? = null 845 if (isRemoteCastNotification(sbn)) { 846 val extras = sbn.notification.extras 847 val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null) 848 val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1) 849 val deviceIntent = 850 extras.getParcelable( 851 Notification.EXTRA_MEDIA_REMOTE_INTENT, 852 PendingIntent::class.java 853 ) 854 Log.d(TAG, "$key is RCN for $deviceName") 855 856 if (deviceName != null && deviceIcon > -1) { 857 // Name and icon must be present, but intent may be null 858 val enabled = deviceIntent != null && deviceIntent.isActivity 859 val deviceDrawable = 860 Icon.createWithResource(sbn.packageName, deviceIcon) 861 .loadDrawable(sbn.getPackageContext(context)) 862 device = 863 MediaDeviceData( 864 enabled, 865 deviceDrawable, 866 deviceName, 867 deviceIntent, 868 showBroadcastButton = false 869 ) 870 } 871 } 872 873 // Control buttons 874 // If flag is enabled and controller has a PlaybackState, create actions from session info 875 // Otherwise, use the notification actions 876 var actionIcons: List<MediaAction> = emptyList() 877 var actionsToShowCollapsed: List<Int> = emptyList() 878 val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user) 879 if (semanticActions == null) { 880 val actions = createActionsFromNotification(sbn) 881 actionIcons = actions.first 882 actionsToShowCollapsed = actions.second 883 } 884 885 val playbackLocation = 886 if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE 887 else if ( 888 mediaController.playbackInfo?.playbackType == 889 MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL 890 ) 891 MediaData.PLAYBACK_LOCAL 892 else MediaData.PLAYBACK_CAST_LOCAL 893 val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null 894 895 val currentEntry = mediaEntries.get(key) 896 val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId() 897 val appUid = appInfo?.uid ?: Process.INVALID_UID 898 899 if (isNewlyActiveEntry) { 900 logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId) 901 logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation) 902 } else if (playbackLocation != currentEntry?.playbackLocation) { 903 logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation) 904 } 905 906 val lastActive = systemClock.elapsedRealtime() 907 foregroundExecutor.execute { 908 val resumeAction: Runnable? = mediaEntries[key]?.resumeAction 909 val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true 910 val active = mediaEntries[key]?.active ?: true 911 onMediaDataLoaded( 912 key, 913 oldKey, 914 MediaData( 915 sbn.normalizedUserId, 916 true, 917 appName, 918 smallIcon, 919 artist, 920 song, 921 artWorkIcon, 922 actionIcons, 923 actionsToShowCollapsed, 924 semanticActions, 925 sbn.packageName, 926 token, 927 notif.contentIntent, 928 device, 929 active, 930 resumeAction = resumeAction, 931 playbackLocation = playbackLocation, 932 notificationKey = key, 933 hasCheckedForResume = hasCheckedForResume, 934 isPlaying = isPlaying, 935 isClearable = !sbn.isOngoing, 936 lastActive = lastActive, 937 instanceId = instanceId, 938 appUid = appUid, 939 isExplicit = isExplicit, 940 ) 941 ) 942 } 943 } 944 945 private fun logSingleVsMultipleMediaAdded( 946 appUid: Int, 947 packageName: String, 948 instanceId: InstanceId 949 ) { 950 if (mediaEntries.size == 1) { 951 logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId) 952 } else if (mediaEntries.size == 2) { 953 // Since this method is only called when there is a new media session added. 954 // logging needed once there is more than one media session in carousel. 955 logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId) 956 } 957 } 958 959 private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? { 960 try { 961 return context.packageManager.getApplicationInfo(packageName, 0) 962 } catch (e: PackageManager.NameNotFoundException) { 963 Log.w(TAG, "Could not get app info for $packageName", e) 964 } 965 return null 966 } 967 968 private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String { 969 val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME) 970 if (name != null) { 971 return name 972 } 973 974 return if (appInfo != null) { 975 context.packageManager.getApplicationLabel(appInfo).toString() 976 } else { 977 sbn.packageName 978 } 979 } 980 981 /** Generate action buttons based on notification actions */ 982 private fun createActionsFromNotification( 983 sbn: StatusBarNotification 984 ): Pair<List<MediaAction>, List<Int>> { 985 val notif = sbn.notification 986 val actionIcons: MutableList<MediaAction> = ArrayList() 987 val actions = notif.actions 988 var actionsToShowCollapsed = 989 notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() 990 ?: mutableListOf() 991 if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) { 992 Log.e( 993 TAG, 994 "Too many compact actions for ${sbn.key}," + 995 "limiting to first $MAX_COMPACT_ACTIONS" 996 ) 997 actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS) 998 } 999 1000 if (actions != null) { 1001 for ((index, action) in actions.withIndex()) { 1002 if (index == MAX_NOTIFICATION_ACTIONS) { 1003 Log.w( 1004 TAG, 1005 "Too many notification actions for ${sbn.key}," + 1006 " limiting to first $MAX_NOTIFICATION_ACTIONS" 1007 ) 1008 break 1009 } 1010 if (action.getIcon() == null) { 1011 if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}") 1012 actionsToShowCollapsed.remove(index) 1013 continue 1014 } 1015 val runnable = 1016 if (action.actionIntent != null) { 1017 Runnable { 1018 if (action.actionIntent.isActivity) { 1019 activityStarter.startPendingIntentDismissingKeyguard( 1020 action.actionIntent 1021 ) 1022 } else if (action.isAuthenticationRequired()) { 1023 activityStarter.dismissKeyguardThenExecute( 1024 { 1025 var result = sendPendingIntent(action.actionIntent) 1026 result 1027 }, 1028 {}, 1029 true 1030 ) 1031 } else { 1032 sendPendingIntent(action.actionIntent) 1033 } 1034 } 1035 } else { 1036 null 1037 } 1038 val mediaActionIcon = 1039 if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) { 1040 Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId()) 1041 } else { 1042 action.getIcon() 1043 } 1044 .setTint(themeText) 1045 .loadDrawable(context) 1046 val mediaAction = MediaAction(mediaActionIcon, runnable, action.title, null) 1047 actionIcons.add(mediaAction) 1048 } 1049 } 1050 return Pair(actionIcons, actionsToShowCollapsed) 1051 } 1052 1053 /** 1054 * Generates action button info for this media session based on the PlaybackState 1055 * 1056 * @param packageName Package name for the media app 1057 * @param controller MediaController for the current session 1058 * @return a Pair consisting of a list of media actions, and a list of ints representing which 1059 * 1060 * ``` 1061 * of those actions should be shown in the compact player 1062 * ``` 1063 */ 1064 private fun createActionsFromState( 1065 packageName: String, 1066 controller: MediaController, 1067 user: UserHandle 1068 ): MediaButton? { 1069 val state = controller.playbackState 1070 if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) { 1071 return null 1072 } 1073 1074 // First, check for standard actions 1075 val playOrPause = 1076 if (isConnectingState(state.state)) { 1077 // Spinner needs to be animating to render anything. Start it here. 1078 val drawable = 1079 context.getDrawable(com.android.internal.R.drawable.progress_small_material) 1080 (drawable as Animatable).start() 1081 MediaAction( 1082 drawable, 1083 null, // no action to perform when clicked 1084 context.getString(R.string.controls_media_button_connecting), 1085 context.getDrawable(R.drawable.ic_media_connecting_container), 1086 // Specify a rebind id to prevent the spinner from restarting on later binds. 1087 com.android.internal.R.drawable.progress_small_material 1088 ) 1089 } else if (isPlayingState(state.state)) { 1090 getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE) 1091 } else { 1092 getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY) 1093 } 1094 val prevButton = 1095 getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS) 1096 val nextButton = 1097 getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT) 1098 1099 // Then, create a way to build any custom actions that will be needed 1100 val customActions = 1101 state.customActions 1102 .asSequence() 1103 .filterNotNull() 1104 .map { getCustomAction(state, packageName, controller, it) } 1105 .iterator() 1106 fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null 1107 1108 // Finally, assign the remaining button slots: play/pause A B C D 1109 // A = previous, else custom action (if not reserved) 1110 // B = next, else custom action (if not reserved) 1111 // C and D are always custom actions 1112 val reservePrev = 1113 controller.extras?.getBoolean( 1114 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 1115 ) == true 1116 val reserveNext = 1117 controller.extras?.getBoolean( 1118 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT 1119 ) == true 1120 1121 val prevOrCustom = 1122 if (prevButton != null) { 1123 prevButton 1124 } else if (!reservePrev) { 1125 nextCustomAction() 1126 } else { 1127 null 1128 } 1129 1130 val nextOrCustom = 1131 if (nextButton != null) { 1132 nextButton 1133 } else if (!reserveNext) { 1134 nextCustomAction() 1135 } else { 1136 null 1137 } 1138 1139 return MediaButton( 1140 playOrPause, 1141 nextOrCustom, 1142 prevOrCustom, 1143 nextCustomAction(), 1144 nextCustomAction(), 1145 reserveNext, 1146 reservePrev 1147 ) 1148 } 1149 1150 /** 1151 * Create a [MediaAction] for a given action and media session 1152 * 1153 * @param controller MediaController for the session 1154 * @param stateActions The actions included with the session's [PlaybackState] 1155 * @param action A [PlaybackState.Actions] value representing what action to generate. One of: 1156 * ``` 1157 * [PlaybackState.ACTION_PLAY] 1158 * [PlaybackState.ACTION_PAUSE] 1159 * [PlaybackState.ACTION_SKIP_TO_PREVIOUS] 1160 * [PlaybackState.ACTION_SKIP_TO_NEXT] 1161 * @return 1162 * ``` 1163 * 1164 * A [MediaAction] with correct values set, or null if the state doesn't support it 1165 */ 1166 private fun getStandardAction( 1167 controller: MediaController, 1168 stateActions: Long, 1169 @PlaybackState.Actions action: Long 1170 ): MediaAction? { 1171 if (!includesAction(stateActions, action)) { 1172 return null 1173 } 1174 1175 return when (action) { 1176 PlaybackState.ACTION_PLAY -> { 1177 MediaAction( 1178 context.getDrawable(R.drawable.ic_media_play), 1179 { controller.transportControls.play() }, 1180 context.getString(R.string.controls_media_button_play), 1181 context.getDrawable(R.drawable.ic_media_play_container) 1182 ) 1183 } 1184 PlaybackState.ACTION_PAUSE -> { 1185 MediaAction( 1186 context.getDrawable(R.drawable.ic_media_pause), 1187 { controller.transportControls.pause() }, 1188 context.getString(R.string.controls_media_button_pause), 1189 context.getDrawable(R.drawable.ic_media_pause_container) 1190 ) 1191 } 1192 PlaybackState.ACTION_SKIP_TO_PREVIOUS -> { 1193 MediaAction( 1194 context.getDrawable(R.drawable.ic_media_prev), 1195 { controller.transportControls.skipToPrevious() }, 1196 context.getString(R.string.controls_media_button_prev), 1197 null 1198 ) 1199 } 1200 PlaybackState.ACTION_SKIP_TO_NEXT -> { 1201 MediaAction( 1202 context.getDrawable(R.drawable.ic_media_next), 1203 { controller.transportControls.skipToNext() }, 1204 context.getString(R.string.controls_media_button_next), 1205 null 1206 ) 1207 } 1208 else -> null 1209 } 1210 } 1211 1212 /** Check whether the actions from a [PlaybackState] include a specific action */ 1213 private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean { 1214 if ( 1215 (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) && 1216 (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L) 1217 ) { 1218 return true 1219 } 1220 return (stateActions and action != 0L) 1221 } 1222 1223 /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */ 1224 private fun getCustomAction( 1225 state: PlaybackState, 1226 packageName: String, 1227 controller: MediaController, 1228 customAction: PlaybackState.CustomAction 1229 ): MediaAction { 1230 return MediaAction( 1231 Icon.createWithResource(packageName, customAction.icon).loadDrawable(context), 1232 { controller.transportControls.sendCustomAction(customAction, customAction.extras) }, 1233 customAction.name, 1234 null 1235 ) 1236 } 1237 1238 /** Load a bitmap from the various Art metadata URIs */ 1239 private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? { 1240 for (uri in ART_URIS) { 1241 val uriString = metadata.getString(uri) 1242 if (!TextUtils.isEmpty(uriString)) { 1243 val albumArt = loadBitmapFromUri(Uri.parse(uriString)) 1244 if (albumArt != null) { 1245 if (DEBUG) Log.d(TAG, "loaded art from $uri") 1246 return albumArt 1247 } 1248 } 1249 } 1250 return null 1251 } 1252 1253 private fun sendPendingIntent(intent: PendingIntent): Boolean { 1254 return try { 1255 val options = BroadcastOptions.makeBasic() 1256 options.setInteractive(true) 1257 options.setPendingIntentBackgroundActivityStartMode( 1258 BroadcastOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED 1259 ) 1260 intent.send(options.toBundle()) 1261 true 1262 } catch (e: PendingIntent.CanceledException) { 1263 Log.d(TAG, "Intent canceled", e) 1264 false 1265 } 1266 } 1267 1268 /** Returns a bitmap if the user can access the given URI, else null */ 1269 private fun loadBitmapFromUriForUser( 1270 uri: Uri, 1271 userId: Int, 1272 appUid: Int, 1273 packageName: String, 1274 ): Bitmap? { 1275 try { 1276 val ugm = UriGrantsManager.getService() 1277 ugm.checkGrantUriPermission_ignoreNonSystem( 1278 appUid, 1279 packageName, 1280 ContentProvider.getUriWithoutUserId(uri), 1281 Intent.FLAG_GRANT_READ_URI_PERMISSION, 1282 ContentProvider.getUserIdFromUri(uri, userId) 1283 ) 1284 return loadBitmapFromUri(uri) 1285 } catch (e: SecurityException) { 1286 Log.e(TAG, "Failed to get URI permission: $e") 1287 } 1288 return null 1289 } 1290 1291 /** 1292 * Load a bitmap from a URI 1293 * 1294 * @param uri the uri to load 1295 * @return bitmap, or null if couldn't be loaded 1296 */ 1297 private fun loadBitmapFromUri(uri: Uri): Bitmap? { 1298 // ImageDecoder requires a scheme of the following types 1299 if (uri.scheme == null) { 1300 return null 1301 } 1302 1303 if ( 1304 !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) && 1305 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) && 1306 !uri.scheme.equals(ContentResolver.SCHEME_FILE) 1307 ) { 1308 return null 1309 } 1310 1311 val source = ImageDecoder.createSource(context.contentResolver, uri) 1312 return try { 1313 ImageDecoder.decodeBitmap(source) { decoder, info, _ -> 1314 val width = info.size.width 1315 val height = info.size.height 1316 val scale = 1317 MediaDataUtils.getScaleFactor( 1318 APair(width, height), 1319 APair(artworkWidth, artworkHeight) 1320 ) 1321 1322 // Downscale if needed 1323 if (scale != 0f && scale < 1) { 1324 decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt()) 1325 } 1326 decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE 1327 } 1328 } catch (e: IOException) { 1329 Log.e(TAG, "Unable to load bitmap", e) 1330 null 1331 } catch (e: RuntimeException) { 1332 Log.e(TAG, "Unable to load bitmap", e) 1333 null 1334 } 1335 } 1336 1337 private fun getResumeMediaAction(action: Runnable): MediaAction { 1338 return MediaAction( 1339 Icon.createWithResource(context, R.drawable.ic_media_play) 1340 .setTint(themeText) 1341 .loadDrawable(context), 1342 action, 1343 context.getString(R.string.controls_media_resume), 1344 context.getDrawable(R.drawable.ic_media_play_container) 1345 ) 1346 } 1347 1348 fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) = 1349 traceSection("MediaDataManager#onMediaDataLoaded") { 1350 Assert.isMainThread() 1351 if (mediaEntries.containsKey(key)) { 1352 // Otherwise this was removed already 1353 mediaEntries.put(key, data) 1354 notifyMediaDataLoaded(key, oldKey, data) 1355 } 1356 } 1357 1358 override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) { 1359 if (!allowMediaRecommendations) { 1360 if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.") 1361 return 1362 } 1363 1364 val mediaTargets = targets.filterIsInstance<SmartspaceTarget>() 1365 when (mediaTargets.size) { 1366 0 -> { 1367 if (!smartspaceMediaData.isActive) { 1368 return 1369 } 1370 if (DEBUG) { 1371 Log.d(TAG, "Set Smartspace media to be inactive for the data update") 1372 } 1373 if (mediaFlags.isPersistentSsCardEnabled()) { 1374 // Smartspace uses this signal to hide the card (e.g. when it expires or user 1375 // disconnects headphones), so treat as setting inactive when flag is on 1376 smartspaceMediaData = smartspaceMediaData.copy(isActive = false) 1377 notifySmartspaceMediaDataLoaded( 1378 smartspaceMediaData.targetId, 1379 smartspaceMediaData, 1380 ) 1381 } else { 1382 smartspaceMediaData = 1383 EMPTY_SMARTSPACE_MEDIA_DATA.copy( 1384 targetId = smartspaceMediaData.targetId, 1385 instanceId = smartspaceMediaData.instanceId, 1386 ) 1387 notifySmartspaceMediaDataRemoved( 1388 smartspaceMediaData.targetId, 1389 immediately = false, 1390 ) 1391 } 1392 } 1393 1 -> { 1394 val newMediaTarget = mediaTargets.get(0) 1395 if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) { 1396 // The same Smartspace updates can be received. Skip the duplicate updates. 1397 return 1398 } 1399 if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.") 1400 smartspaceMediaData = toSmartspaceMediaData(newMediaTarget) 1401 notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData) 1402 } 1403 else -> { 1404 // There should NOT be more than 1 Smartspace media update. When it happens, it 1405 // indicates a bad state or an error. Reset the status accordingly. 1406 Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...") 1407 notifySmartspaceMediaDataRemoved( 1408 smartspaceMediaData.targetId, 1409 immediately = false, 1410 ) 1411 smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA 1412 } 1413 } 1414 } 1415 1416 fun onNotificationRemoved(key: String) { 1417 Assert.isMainThread() 1418 val removed = mediaEntries.remove(key) ?: return 1419 if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) { 1420 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) 1421 } else if (isAbleToResume(removed)) { 1422 convertToResumePlayer(key, removed) 1423 } else if (mediaFlags.isRetainingPlayersEnabled()) { 1424 handlePossibleRemoval(key, removed, notificationRemoved = true) 1425 } else { 1426 notifyMediaDataRemoved(key) 1427 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) 1428 } 1429 } 1430 1431 private fun onSessionDestroyed(key: String) { 1432 if (!mediaFlags.isRetainingPlayersEnabled()) return 1433 1434 if (DEBUG) Log.d(TAG, "session destroyed for $key") 1435 val entry = mediaEntries.remove(key) ?: return 1436 // Clear token since the session is no longer valid 1437 val updated = entry.copy(token = null) 1438 handlePossibleRemoval(key, updated) 1439 } 1440 1441 private fun isAbleToResume(data: MediaData): Boolean { 1442 val isEligibleForResume = 1443 data.isLocalSession() || 1444 (mediaFlags.isRemoteResumeAllowed() && 1445 data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE) 1446 return useMediaResumption && data.resumeAction != null && isEligibleForResume 1447 } 1448 1449 /** 1450 * Convert to resume state if the player is no longer valid and active, then notify listeners 1451 * that the data was updated. Does not convert to resume state if the player is still valid, or 1452 * if it was removed before becoming inactive. (Assumes that [removed] was removed from 1453 * [mediaEntries] before this function was called) 1454 */ 1455 private fun handlePossibleRemoval( 1456 key: String, 1457 removed: MediaData, 1458 notificationRemoved: Boolean = false 1459 ) { 1460 val hasSession = removed.token != null 1461 if (hasSession && removed.semanticActions != null) { 1462 // The app was using session actions, and the session is still valid: keep player 1463 if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key") 1464 mediaEntries.put(key, removed) 1465 notifyMediaDataLoaded(key, key, removed) 1466 } else if (!notificationRemoved && removed.semanticActions == null) { 1467 // The app was using notification actions, and notif wasn't removed yet: keep player 1468 if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key") 1469 mediaEntries.put(key, removed) 1470 notifyMediaDataLoaded(key, key, removed) 1471 } else if (removed.active && !isAbleToResume(removed)) { 1472 // This player was still active - it didn't last long enough to time out, 1473 // and its app doesn't normally support resume: remove 1474 if (DEBUG) Log.d(TAG, "Removing still-active player $key") 1475 notifyMediaDataRemoved(key) 1476 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId) 1477 } else { 1478 // Convert to resume 1479 if (DEBUG) { 1480 Log.d( 1481 TAG, 1482 "Notification ($notificationRemoved) and/or session " + 1483 "($hasSession) gone for inactive player $key" 1484 ) 1485 } 1486 convertToResumePlayer(key, removed) 1487 } 1488 } 1489 1490 /** Set the given [MediaData] as a resume state player and notify listeners */ 1491 private fun convertToResumePlayer(key: String, data: MediaData) { 1492 if (DEBUG) Log.d(TAG, "Converting $key to resume") 1493 // Resumption controls must have a title. 1494 if (data.song.isNullOrBlank()) { 1495 Log.e(TAG, "Description incomplete") 1496 notifyMediaDataRemoved(key) 1497 logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId) 1498 return 1499 } 1500 // Move to resume key (aka package name) if that key doesn't already exist. 1501 val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) } 1502 val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList() 1503 val launcherIntent = 1504 context.packageManager.getLaunchIntentForPackage(data.packageName)?.let { 1505 PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) 1506 } 1507 val updated = 1508 data.copy( 1509 token = null, 1510 actions = actions, 1511 semanticActions = MediaButton(playOrPause = resumeAction), 1512 actionsToShowInCompact = listOf(0), 1513 active = false, 1514 resumption = true, 1515 isPlaying = false, 1516 isClearable = true, 1517 clickIntent = launcherIntent, 1518 ) 1519 val pkg = data.packageName 1520 val migrate = mediaEntries.put(pkg, updated) == null 1521 // Notify listeners of "new" controls when migrating or removed and update when not 1522 Log.d(TAG, "migrating? $migrate from $key -> $pkg") 1523 if (migrate) { 1524 notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated) 1525 } else { 1526 // Since packageName is used for the key of the resumption controls, it is 1527 // possible that another notification has already been reused for the resumption 1528 // controls of this package. In this case, rather than renaming this player as 1529 // packageName, just remove it and then send a update to the existing resumption 1530 // controls. 1531 notifyMediaDataRemoved(key) 1532 notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated) 1533 } 1534 logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId) 1535 1536 // Limit total number of resume controls 1537 val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption } 1538 val numResume = resumeEntries.size 1539 if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) { 1540 resumeEntries 1541 .toList() 1542 .sortedBy { (key, data) -> data.lastActive } 1543 .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) 1544 .forEach { (key, data) -> 1545 Log.d(TAG, "Removing excess control $key") 1546 mediaEntries.remove(key) 1547 notifyMediaDataRemoved(key) 1548 logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId) 1549 } 1550 } 1551 } 1552 1553 fun setMediaResumptionEnabled(isEnabled: Boolean) { 1554 if (useMediaResumption == isEnabled) { 1555 return 1556 } 1557 1558 useMediaResumption = isEnabled 1559 1560 if (!useMediaResumption) { 1561 // Remove any existing resume controls 1562 val filtered = mediaEntries.filter { !it.value.active } 1563 filtered.forEach { 1564 mediaEntries.remove(it.key) 1565 notifyMediaDataRemoved(it.key) 1566 logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId) 1567 } 1568 } 1569 } 1570 1571 /** Invoked when the user has dismissed the media carousel */ 1572 fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss() 1573 1574 /** Are there any media notifications active, including the recommendations? */ 1575 fun hasActiveMediaOrRecommendation() = mediaDataFilter.hasActiveMediaOrRecommendation() 1576 1577 /** 1578 * Are there any media entries we should display, including the recommendations? 1579 * - If resumption is enabled, this will include inactive players 1580 * - If resumption is disabled, we only want to show active players 1581 */ 1582 fun hasAnyMediaOrRecommendation() = mediaDataFilter.hasAnyMediaOrRecommendation() 1583 1584 /** Are there any resume media notifications active, excluding the recommendations? */ 1585 fun hasActiveMedia() = mediaDataFilter.hasActiveMedia() 1586 1587 /** 1588 * Are there any resume media notifications active, excluding the recommendations? 1589 * - If resumption is enabled, this will include inactive players 1590 * - If resumption is disabled, we only want to show active players 1591 */ 1592 fun hasAnyMedia() = mediaDataFilter.hasAnyMedia() 1593 1594 interface Listener { 1595 1596 /** 1597 * Called whenever there's new MediaData Loaded for the consumption in views. 1598 * 1599 * oldKey is provided to check whether the view has changed keys, which can happen when a 1600 * player has gone from resume state (key is package name) to active state (key is 1601 * notification key) or vice versa. 1602 * 1603 * @param immediately indicates should apply the UI changes immediately, otherwise wait 1604 * until the next refresh-round before UI becomes visible. True by default to take in 1605 * place immediately. 1606 * @param receivedSmartspaceCardLatency is the latency between headphone connects and sysUI 1607 * displays Smartspace media targets. Will be 0 if the data is not activated by Smartspace 1608 * signal. 1609 * @param isSsReactivated indicates resume media card is reactivated by Smartspace 1610 * recommendation signal 1611 */ 1612 fun onMediaDataLoaded( 1613 key: String, 1614 oldKey: String?, 1615 data: MediaData, 1616 immediately: Boolean = true, 1617 receivedSmartspaceCardLatency: Int = 0, 1618 isSsReactivated: Boolean = false 1619 ) {} 1620 1621 /** 1622 * Called whenever there's new Smartspace media data loaded. 1623 * 1624 * @param shouldPrioritize indicates the sorting priority of the Smartspace card. If true, 1625 * it will be prioritized as the first card. Otherwise, it will show up as the last card 1626 * as default. 1627 */ 1628 fun onSmartspaceMediaDataLoaded( 1629 key: String, 1630 data: SmartspaceMediaData, 1631 shouldPrioritize: Boolean = false 1632 ) {} 1633 1634 /** Called whenever a previously existing Media notification was removed. */ 1635 fun onMediaDataRemoved(key: String) {} 1636 1637 /** 1638 * Called whenever a previously existing Smartspace media data was removed. 1639 * 1640 * @param immediately indicates should apply the UI changes immediately, otherwise wait 1641 * until the next refresh-round before UI becomes visible. True by default to take in 1642 * place immediately. 1643 */ 1644 fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true) {} 1645 } 1646 1647 /** 1648 * Converts the pass-in SmartspaceTarget to SmartspaceMediaData 1649 * 1650 * @return An empty SmartspaceMediaData with the valid target Id is returned if the 1651 * SmartspaceTarget's data is invalid. 1652 */ 1653 private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData { 1654 val baseAction: SmartspaceAction? = target.baseAction 1655 val dismissIntent = 1656 baseAction?.extras?.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY) as Intent? 1657 1658 val isActive = 1659 when { 1660 !mediaFlags.isPersistentSsCardEnabled() -> true 1661 baseAction == null -> true 1662 else -> { 1663 val triggerSource = baseAction.extras?.getString(EXTRA_KEY_TRIGGER_SOURCE) 1664 triggerSource != EXTRA_VALUE_TRIGGER_PERIODIC 1665 } 1666 } 1667 1668 packageName(target)?.let { 1669 return SmartspaceMediaData( 1670 targetId = target.smartspaceTargetId, 1671 isActive = isActive, 1672 packageName = it, 1673 cardAction = target.baseAction, 1674 recommendations = target.iconGrid, 1675 dismissIntent = dismissIntent, 1676 headphoneConnectionTimeMillis = target.creationTimeMillis, 1677 instanceId = logger.getNewInstanceId(), 1678 expiryTimeMs = target.expiryTimeMillis, 1679 ) 1680 } 1681 return EMPTY_SMARTSPACE_MEDIA_DATA.copy( 1682 targetId = target.smartspaceTargetId, 1683 isActive = isActive, 1684 dismissIntent = dismissIntent, 1685 headphoneConnectionTimeMillis = target.creationTimeMillis, 1686 instanceId = logger.getNewInstanceId(), 1687 expiryTimeMs = target.expiryTimeMillis, 1688 ) 1689 } 1690 1691 private fun packageName(target: SmartspaceTarget): String? { 1692 val recommendationList = target.iconGrid 1693 if (recommendationList == null || recommendationList.isEmpty()) { 1694 Log.w(TAG, "Empty or null media recommendation list.") 1695 return null 1696 } 1697 for (recommendation in recommendationList) { 1698 val extras = recommendation.extras 1699 extras?.let { 1700 it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { packageName -> 1701 return packageName 1702 } 1703 } 1704 } 1705 Log.w(TAG, "No valid package name is provided.") 1706 return null 1707 } 1708 1709 override fun dump(pw: PrintWriter, args: Array<out String>) { 1710 pw.apply { 1711 println("internalListeners: $internalListeners") 1712 println("externalListeners: ${mediaDataFilter.listeners}") 1713 println("mediaEntries: $mediaEntries") 1714 println("useMediaResumption: $useMediaResumption") 1715 println("allowMediaRecommendations: $allowMediaRecommendations") 1716 } 1717 } 1718 } 1719