1 /* 2 * 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.WorkerThread 20 import android.app.Notification 21 import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME 22 import android.app.PendingIntent 23 import android.app.StatusBarManager 24 import android.app.UriGrantsManager 25 import android.content.ContentProvider 26 import android.content.ContentResolver 27 import android.content.Context 28 import android.content.Intent 29 import android.content.pm.ApplicationInfo 30 import android.content.pm.PackageManager 31 import android.graphics.Bitmap 32 import android.graphics.ImageDecoder 33 import android.graphics.drawable.Icon 34 import android.media.MediaDescription 35 import android.media.MediaMetadata 36 import android.media.session.MediaController 37 import android.media.session.MediaSession 38 import android.net.Uri 39 import android.os.Process 40 import android.os.UserHandle 41 import android.service.notification.StatusBarNotification 42 import android.support.v4.media.MediaMetadataCompat 43 import android.text.TextUtils 44 import android.util.Log 45 import androidx.media.utils.MediaConstants 46 import com.android.app.tracing.coroutines.asyncTraced as async 47 import com.android.app.tracing.coroutines.traceCoroutine 48 import com.android.systemui.Flags 49 import com.android.systemui.dagger.SysUISingleton 50 import com.android.systemui.dagger.qualifiers.Application 51 import com.android.systemui.dagger.qualifiers.Background 52 import com.android.systemui.dagger.qualifiers.Main 53 import com.android.systemui.graphics.ImageLoader 54 import com.android.systemui.media.NotificationMediaManager.isPlayingState 55 import com.android.systemui.media.controls.shared.model.MediaAction 56 import com.android.systemui.media.controls.shared.model.MediaButton 57 import com.android.systemui.media.controls.shared.model.MediaData 58 import com.android.systemui.media.controls.shared.model.MediaDeviceData 59 import com.android.systemui.media.controls.shared.model.MediaNotificationAction 60 import com.android.systemui.media.controls.util.MediaControllerFactory 61 import com.android.systemui.media.controls.util.MediaDataUtils 62 import com.android.systemui.media.controls.util.MediaFlags 63 import com.android.systemui.res.R 64 import com.android.systemui.statusbar.notification.row.HybridGroupManager 65 import com.android.systemui.util.kotlin.logD 66 import java.util.concurrent.ConcurrentHashMap 67 import javax.inject.Inject 68 import kotlin.coroutines.coroutineContext 69 import kotlinx.coroutines.CoroutineDispatcher 70 import kotlinx.coroutines.CoroutineScope 71 import kotlinx.coroutines.Job 72 import kotlinx.coroutines.cancel 73 import kotlinx.coroutines.delay 74 import kotlinx.coroutines.ensureActive 75 76 /** Loads media information from media style [StatusBarNotification] classes. */ 77 @SysUISingleton 78 class MediaDataLoader 79 @Inject 80 constructor( 81 @Application val context: Context, 82 @Main val mainDispatcher: CoroutineDispatcher, 83 @Background val backgroundScope: CoroutineScope, 84 private val mediaControllerFactory: MediaControllerFactory, 85 private val mediaFlags: MediaFlags, 86 private val imageLoader: ImageLoader, 87 private val statusBarManager: StatusBarManager, 88 private val media3ActionFactory: Media3ActionFactory, 89 ) { 90 private val mediaProcessingJobs = ConcurrentHashMap<String, Job>() 91 92 private val artworkWidth: Int = 93 context.resources.getDimensionPixelSize( 94 com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize 95 ) 96 private val artworkHeight: Int = 97 context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded) 98 99 private val themeText = 100 com.android.settingslib.Utils.getColorAttr( 101 context, 102 com.android.internal.R.attr.textColorPrimary, 103 ) 104 .defaultColor 105 106 /** 107 * Loads media data for a given [StatusBarNotification]. It does the loading on the background 108 * thread. 109 * 110 * Returns a [MediaDataLoaderResult] if loaded data or `null` if loading failed. The method 111 * suspends until loading has completed or failed. 112 * 113 * If a new [loadMediaData] is issued while existing load is in progress, the existing (old) 114 * load will be cancelled. 115 */ loadMediaDatanull116 suspend fun loadMediaData( 117 key: String, 118 sbn: StatusBarNotification, 119 isConvertingToActive: Boolean = false, 120 ): MediaDataLoaderResult? { 121 val loadMediaJob = 122 backgroundScope.async { loadMediaDataInBackground(key, sbn, isConvertingToActive) } 123 loadMediaJob.invokeOnCompletion { 124 // We need to make sure we're removing THIS job after cancellation, not 125 // a job that we created later. 126 mediaProcessingJobs.remove(key, loadMediaJob) 127 } 128 var existingJob: Job? = null 129 // Do not cancel loading jobs that convert resume players to active. 130 if (!isConvertingToActive) { 131 existingJob = mediaProcessingJobs.put(key, loadMediaJob) 132 existingJob?.cancel("New processing job incoming.") 133 } 134 logD(TAG) { "Loading media data for $key... / existing job: $existingJob" } 135 136 return loadMediaJob.await() 137 } 138 139 /** Loads media data, should be called from [backgroundScope]. */ 140 @WorkerThread loadMediaDataInBackgroundnull141 private suspend fun loadMediaDataInBackground( 142 key: String, 143 sbn: StatusBarNotification, 144 isConvertingToActive: Boolean = false, 145 ): MediaDataLoaderResult? = 146 traceCoroutine("MediaDataLoader#loadMediaData") { 147 // We have apps spamming us with quick notification updates which can cause 148 // us to spend significant CPU time loading duplicate data. This debounces 149 // those requests at the cost of a bit of latency. 150 // No delay needed to load jobs converting resume players to active. 151 if (!isConvertingToActive) { 152 delay(DEBOUNCE_DELAY_MS) 153 } 154 155 val token = 156 sbn.notification.extras.getParcelable( 157 Notification.EXTRA_MEDIA_SESSION, 158 MediaSession.Token::class.java, 159 ) 160 if (token == null) { 161 Log.i(TAG, "Token was null, not loading media info") 162 return null 163 } 164 val mediaController = mediaControllerFactory.create(token) 165 val metadata = mediaController.metadata 166 val notification: Notification = sbn.notification 167 168 val appInfo = 169 notification.extras.getParcelable( 170 Notification.EXTRA_BUILDER_APPLICATION_INFO, 171 ApplicationInfo::class.java, 172 ) ?: getAppInfoFromPackage(sbn.packageName) 173 174 // App name 175 val appName = getAppName(sbn, appInfo) 176 177 // Song name 178 var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) 179 if (song.isNullOrBlank()) { 180 song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) 181 } 182 if (song.isNullOrBlank()) { 183 song = HybridGroupManager.resolveTitle(notification) 184 } 185 if (song.isNullOrBlank()) { 186 // For apps that don't include a title, log and add a placeholder 187 song = context.getString(R.string.controls_media_empty_title, appName) 188 try { 189 statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier) 190 } catch (e: RuntimeException) { 191 Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}") 192 } 193 } 194 195 // Don't attempt to load bitmaps if the job was cancelled. 196 coroutineContext.ensureActive() 197 198 // Album art 199 var artworkBitmap = metadata?.let { loadBitmapFromUri(it) } 200 if (artworkBitmap == null) { 201 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART) 202 } 203 if (artworkBitmap == null) { 204 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) 205 } 206 val artworkIcon = 207 if (artworkBitmap == null) { 208 notification.getLargeIcon() 209 } else { 210 Icon.createWithBitmap(artworkBitmap) 211 } 212 213 // Don't continue if we were cancelled during slow bitmap load. 214 coroutineContext.ensureActive() 215 216 // App Icon 217 val smallIcon = sbn.notification.smallIcon 218 219 // Explicit Indicator 220 val isExplicit = 221 MediaMetadataCompat.fromMediaMetadata(metadata) 222 ?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) == 223 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT 224 225 // Artist name 226 var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) 227 if (artist.isNullOrBlank()) { 228 artist = HybridGroupManager.resolveText(notification) 229 } 230 231 // Device name (used for remote cast notifications) 232 val device: MediaDeviceData? = getDeviceInfoForRemoteCast(key, sbn) 233 234 // Control buttons 235 // If controller has a PlaybackState, create actions from session info 236 // Otherwise, use the notification actions 237 var actionIcons: List<MediaNotificationAction> = emptyList() 238 var actionsToShowCollapsed: List<Int> = emptyList() 239 val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user) 240 logD(TAG) { "Semantic actions: $semanticActions" } 241 if (semanticActions == null) { 242 val actions = createActionsFromNotification(context, sbn) 243 actionIcons = actions.first 244 actionsToShowCollapsed = actions.second 245 logD(TAG) { "[!!] Semantic actions: $semanticActions" } 246 } 247 248 val playbackLocation = getPlaybackLocation(sbn, mediaController) 249 val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } 250 251 val appUid = appInfo?.uid ?: Process.INVALID_UID 252 return MediaDataLoaderResult( 253 appName = appName, 254 appIcon = smallIcon, 255 artist = artist, 256 song = song, 257 artworkIcon = artworkIcon, 258 actionIcons = actionIcons, 259 actionsToShowInCompact = actionsToShowCollapsed, 260 semanticActions = semanticActions, 261 token = token, 262 clickIntent = notification.contentIntent, 263 device = device, 264 playbackLocation = playbackLocation, 265 isPlaying = isPlaying, 266 appUid = appUid, 267 isExplicit = isExplicit, 268 ) 269 } 270 271 /** 272 * Loads media data in background for a given set of resumption parameters. The method suspends 273 * until loading is complete or fails. 274 * 275 * Returns a [MediaDataLoaderResult] if loaded data or `null` if loading failed. 276 */ loadMediaDataForResumptionnull277 suspend fun loadMediaDataForResumption( 278 userId: Int, 279 desc: MediaDescription, 280 resumeAction: Runnable, 281 currentEntry: MediaData?, 282 token: MediaSession.Token, 283 appName: String, 284 appIntent: PendingIntent, 285 packageName: String, 286 ): MediaDataLoaderResult? { 287 val mediaData = 288 backgroundScope.async { 289 loadMediaDataForResumptionInBackground( 290 userId, 291 desc, 292 resumeAction, 293 currentEntry, 294 token, 295 appName, 296 appIntent, 297 packageName, 298 ) 299 } 300 return mediaData.await() 301 } 302 303 /** Loads media data for resumption, should be called from [backgroundScope]. */ 304 @WorkerThread loadMediaDataForResumptionInBackgroundnull305 private suspend fun loadMediaDataForResumptionInBackground( 306 userId: Int, 307 desc: MediaDescription, 308 resumeAction: Runnable, 309 currentEntry: MediaData?, 310 token: MediaSession.Token, 311 appName: String, 312 appIntent: PendingIntent, 313 packageName: String, 314 ): MediaDataLoaderResult? = 315 traceCoroutine("MediaDataLoader#loadMediaDataForResumption") { 316 if (desc.title.isNullOrBlank()) { 317 Log.e(TAG, "Description incomplete") 318 return null 319 } 320 321 logD(TAG) { "adding track for $userId from browser: $desc" } 322 323 val appUid = currentEntry?.appUid ?: Process.INVALID_UID 324 325 // Album art 326 var artworkBitmap = desc.iconBitmap 327 if (artworkBitmap == null && desc.iconUri != null) { 328 artworkBitmap = 329 loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName) 330 } 331 val artworkIcon = 332 if (artworkBitmap != null) { 333 Icon.createWithBitmap(artworkBitmap) 334 } else { 335 null 336 } 337 338 val isExplicit = 339 desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) == 340 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT 341 342 val progress = MediaDataUtils.getDescriptionProgress(desc.extras) 343 val mediaAction = getResumeMediaAction(resumeAction) 344 return MediaDataLoaderResult( 345 appName = appName, 346 appIcon = null, 347 artist = desc.subtitle, 348 song = desc.title, 349 artworkIcon = artworkIcon, 350 actionIcons = listOf(), 351 actionsToShowInCompact = listOf(0), 352 semanticActions = MediaButton(playOrPause = mediaAction), 353 token = token, 354 clickIntent = appIntent, 355 device = null, 356 playbackLocation = 0, 357 isPlaying = null, 358 appUid = appUid, 359 isExplicit = isExplicit, 360 resumeAction = resumeAction, 361 resumeProgress = progress, 362 ) 363 } 364 createActionsFromStatenull365 private suspend fun createActionsFromState( 366 packageName: String, 367 controller: MediaController, 368 user: UserHandle, 369 ): MediaButton? { 370 if (!mediaFlags.areMediaSessionActionsEnabled(packageName, user)) { 371 return null 372 } 373 374 if (mediaFlags.areMedia3ActionsEnabled(packageName, user)) { 375 return media3ActionFactory.createActionsFromSession( 376 packageName, 377 controller.sessionToken, 378 ) 379 } 380 return createActionsFromState(context, packageName, controller) 381 } 382 getPlaybackLocationnull383 private fun getPlaybackLocation(sbn: StatusBarNotification, mediaController: MediaController) = 384 when { 385 isRemoteCastNotification(sbn) -> MediaData.PLAYBACK_CAST_REMOTE 386 mediaController.playbackInfo?.playbackType == 387 MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL -> MediaData.PLAYBACK_LOCAL 388 else -> MediaData.PLAYBACK_CAST_LOCAL 389 } 390 391 /** 392 * Returns [MediaDeviceData] if the [StatusBarNotification] is a remote cast notification. 393 * `null` otherwise. 394 */ getDeviceInfoForRemoteCastnull395 private fun getDeviceInfoForRemoteCast( 396 key: String, 397 sbn: StatusBarNotification, 398 ): MediaDeviceData? { 399 val extras = sbn.notification.extras 400 val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null) 401 val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1) 402 val deviceIntent = 403 extras.getParcelable(Notification.EXTRA_MEDIA_REMOTE_INTENT, PendingIntent::class.java) 404 logD(TAG) { "$key is RCN for $deviceName" } 405 406 if (deviceName != null && deviceIcon > -1) { 407 // Name and icon must be present, but intent may be null 408 val enabled = deviceIntent != null && deviceIntent.isActivity 409 val deviceDrawable = 410 Icon.createWithResource(sbn.packageName, deviceIcon) 411 .loadDrawable(sbn.getPackageContext(context)) 412 return MediaDeviceData( 413 enabled, 414 deviceDrawable, 415 deviceName, 416 deviceIntent, 417 showBroadcastButton = false, 418 ) 419 } 420 return null 421 } 422 getAppInfoFromPackagenull423 private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? { 424 try { 425 return context.packageManager.getApplicationInfo(packageName, 0) 426 } catch (e: PackageManager.NameNotFoundException) { 427 Log.w(TAG, "Could not get app info for $packageName", e) 428 return null 429 } 430 } 431 getAppNamenull432 private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String { 433 val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME) 434 return when { 435 name != null -> name 436 appInfo != null -> context.packageManager.getApplicationLabel(appInfo).toString() 437 else -> sbn.packageName 438 } 439 } 440 441 /** Load a bitmap from the various Art metadata URIs */ loadBitmapFromUrinull442 private suspend fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? { 443 for (uri in ART_URIS) { 444 val uriString = metadata.getString(uri) 445 if (!TextUtils.isEmpty(uriString)) { 446 val albumArt = loadBitmapFromUri(Uri.parse(uriString)) 447 // If we got cancelled during slow album art load, cancel the rest of 448 // the process. 449 coroutineContext.ensureActive() 450 if (albumArt != null) { 451 if (Log.isLoggable(TAG, Log.DEBUG)) { 452 Log.d(TAG, "loaded art from $uri") 453 } 454 return albumArt 455 } 456 } 457 } 458 return null 459 } 460 loadBitmapFromUrinull461 private suspend fun loadBitmapFromUri(uri: Uri): Bitmap? { 462 // ImageDecoder requires a scheme of the following types 463 if ( 464 uri.scheme !in 465 listOf( 466 ContentResolver.SCHEME_CONTENT, 467 ContentResolver.SCHEME_ANDROID_RESOURCE, 468 ContentResolver.SCHEME_FILE, 469 ) 470 ) { 471 Log.w(TAG, "Invalid album art uri $uri") 472 return null 473 } 474 475 val source = ImageLoader.Uri(uri) 476 return imageLoader.loadBitmap( 477 source, 478 artworkWidth, 479 artworkHeight, 480 allocator = ImageDecoder.ALLOCATOR_SOFTWARE, 481 ) 482 } 483 loadBitmapFromUriForUsernull484 private suspend fun loadBitmapFromUriForUser( 485 uri: Uri, 486 userId: Int, 487 appUid: Int, 488 packageName: String, 489 ): Bitmap? { 490 try { 491 val ugm = UriGrantsManager.getService() 492 ugm.checkGrantUriPermission_ignoreNonSystem( 493 appUid, 494 packageName, 495 ContentProvider.getUriWithoutUserId(uri), 496 Intent.FLAG_GRANT_READ_URI_PERMISSION, 497 ContentProvider.getUserIdFromUri(uri, userId), 498 ) 499 return loadBitmapFromUri(uri) 500 } catch (e: SecurityException) { 501 Log.e(TAG, "Failed to get URI permission: $e") 502 } 503 return null 504 } 505 506 /** Check whether this notification is an RCN */ isRemoteCastNotificationnull507 private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean = 508 sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE) 509 510 private fun getResumeMediaAction(action: Runnable): MediaAction { 511 val iconId = 512 if (Flags.mediaControlsUiUpdate()) { 513 R.drawable.ic_media_play_button 514 } else { 515 R.drawable.ic_media_play 516 } 517 return MediaAction( 518 Icon.createWithResource(context, iconId).setTint(themeText).loadDrawable(context), 519 action, 520 context.getString(R.string.controls_media_button_play), 521 if (Flags.mediaControlsUiUpdate()) { 522 context.getDrawable(R.drawable.ic_media_play_button_container) 523 } else { 524 context.getDrawable(R.drawable.ic_media_play_container) 525 }, 526 ) 527 } 528 529 companion object { 530 private const val TAG = "MediaDataLoader" 531 private val ART_URIS = 532 arrayOf( 533 MediaMetadata.METADATA_KEY_ALBUM_ART_URI, 534 MediaMetadata.METADATA_KEY_ART_URI, 535 MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, 536 ) 537 538 private const val DEBOUNCE_DELAY_MS = 200L 539 } 540 541 /** Returned data from loader. */ 542 data class MediaDataLoaderResult( 543 val appName: String?, 544 val appIcon: Icon?, 545 val artist: CharSequence?, 546 val song: CharSequence?, 547 val artworkIcon: Icon?, 548 val actionIcons: List<MediaNotificationAction>, 549 val actionsToShowInCompact: List<Int>, 550 val semanticActions: MediaButton?, 551 val token: MediaSession.Token?, 552 val clickIntent: PendingIntent?, 553 val device: MediaDeviceData?, 554 val playbackLocation: Int, 555 val isPlaying: Boolean?, 556 val appUid: Int, 557 val isExplicit: Boolean, 558 val resumeAction: Runnable? = null, 559 val resumeProgress: Double? = null, 560 ) 561 } 562