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