1 /* <lambda>null2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.media 18 19 import android.app.Notification 20 import android.app.PendingIntent 21 import android.content.BroadcastReceiver 22 import android.content.ContentResolver 23 import android.content.Context 24 import android.content.Intent 25 import android.content.IntentFilter 26 import android.graphics.Bitmap 27 import android.graphics.Canvas 28 import android.graphics.Color 29 import android.graphics.ImageDecoder 30 import android.graphics.drawable.Drawable 31 import android.graphics.drawable.Icon 32 import android.media.MediaDescription 33 import android.media.MediaMetadata 34 import android.media.session.MediaSession 35 import android.net.Uri 36 import android.os.UserHandle 37 import android.service.notification.StatusBarNotification 38 import android.text.TextUtils 39 import android.util.Log 40 import com.android.internal.graphics.ColorUtils 41 import com.android.systemui.Dumpable 42 import com.android.systemui.R 43 import com.android.systemui.broadcast.BroadcastDispatcher 44 import com.android.systemui.dagger.qualifiers.Background 45 import com.android.systemui.dagger.qualifiers.Main 46 import com.android.systemui.dump.DumpManager 47 import com.android.systemui.statusbar.notification.MediaNotificationProcessor 48 import com.android.systemui.statusbar.notification.row.HybridGroupManager 49 import com.android.systemui.util.Assert 50 import com.android.systemui.util.Utils 51 import java.io.FileDescriptor 52 import java.io.IOException 53 import java.io.PrintWriter 54 import java.util.concurrent.Executor 55 import javax.inject.Inject 56 import javax.inject.Singleton 57 58 // URI fields to try loading album art from 59 private val ART_URIS = arrayOf( 60 MediaMetadata.METADATA_KEY_ALBUM_ART_URI, 61 MediaMetadata.METADATA_KEY_ART_URI, 62 MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI 63 ) 64 65 private const val TAG = "MediaDataManager" 66 private const val DEFAULT_LUMINOSITY = 0.25f 67 private const val LUMINOSITY_THRESHOLD = 0.05f 68 private const val SATURATION_MULTIPLIER = 0.8f 69 70 private val LOADING = MediaData(-1, false, 0, null, null, null, null, null, 71 emptyList(), emptyList(), "INVALID", null, null, null, true, null) 72 73 fun isMediaNotification(sbn: StatusBarNotification): Boolean { 74 if (!sbn.notification.hasMediaSession()) { 75 return false 76 } 77 val notificationStyle = sbn.notification.notificationStyle 78 if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) || 79 Notification.MediaStyle::class.java.equals(notificationStyle)) { 80 return true 81 } 82 return false 83 } 84 85 /** 86 * A class that facilitates management and loading of Media Data, ready for binding. 87 */ 88 @Singleton 89 class MediaDataManager( 90 private val context: Context, 91 @Background private val backgroundExecutor: Executor, 92 @Main private val foregroundExecutor: Executor, 93 private val mediaControllerFactory: MediaControllerFactory, 94 private val broadcastDispatcher: BroadcastDispatcher, 95 dumpManager: DumpManager, 96 mediaTimeoutListener: MediaTimeoutListener, 97 mediaResumeListener: MediaResumeListener, 98 private var useMediaResumption: Boolean, 99 private val useQsMediaPlayer: Boolean 100 ) : Dumpable { 101 102 private val listeners: MutableSet<Listener> = mutableSetOf() 103 private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap() 104 105 @Inject 106 constructor( 107 context: Context, 108 @Background backgroundExecutor: Executor, 109 @Main foregroundExecutor: Executor, 110 mediaControllerFactory: MediaControllerFactory, 111 dumpManager: DumpManager, 112 broadcastDispatcher: BroadcastDispatcher, 113 mediaTimeoutListener: MediaTimeoutListener, 114 mediaResumeListener: MediaResumeListener 115 ) : this(context, backgroundExecutor, foregroundExecutor, mediaControllerFactory, 116 broadcastDispatcher, dumpManager, mediaTimeoutListener, mediaResumeListener, 117 Utils.useMediaResumption(context), Utils.useQsMediaPlayer(context)) 118 119 private val appChangeReceiver = object : BroadcastReceiver() { onReceivenull120 override fun onReceive(context: Context, intent: Intent) { 121 when (intent.action) { 122 Intent.ACTION_PACKAGES_SUSPENDED -> { 123 val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST) 124 packages?.forEach { 125 removeAllForPackage(it) 126 } 127 } 128 Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_RESTARTED -> { 129 intent.data?.encodedSchemeSpecificPart?.let { 130 removeAllForPackage(it) 131 } 132 } 133 } 134 } 135 } 136 137 init { 138 dumpManager.registerDumpable(TAG, this) timedOutnull139 mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean -> 140 setTimedOut(token, timedOut) } 141 addListener(mediaTimeoutListener) 142 143 mediaResumeListener.setManager(this) 144 addListener(mediaResumeListener) 145 146 val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED) 147 broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL) 148 <lambda>null149 val uninstallFilter = IntentFilter().apply { 150 addAction(Intent.ACTION_PACKAGE_REMOVED) 151 addAction(Intent.ACTION_PACKAGE_RESTARTED) 152 addDataScheme("package") 153 } 154 // BroadcastDispatcher does not allow filters with data schemes 155 context.registerReceiver(appChangeReceiver, uninstallFilter) 156 } 157 destroynull158 fun destroy() { 159 context.unregisterReceiver(appChangeReceiver) 160 } 161 onNotificationAddednull162 fun onNotificationAdded(key: String, sbn: StatusBarNotification) { 163 if (useQsMediaPlayer && isMediaNotification(sbn)) { 164 Assert.isMainThread() 165 val oldKey = findExistingEntry(key, sbn.packageName) 166 if (oldKey == null) { 167 val temp = LOADING.copy(packageName = sbn.packageName) 168 mediaEntries.put(key, temp) 169 } else if (oldKey != key) { 170 // Move to new key 171 val oldData = mediaEntries.remove(oldKey)!! 172 mediaEntries.put(key, oldData) 173 } 174 loadMediaData(key, sbn, oldKey) 175 } else { 176 onNotificationRemoved(key) 177 } 178 } 179 removeAllForPackagenull180 private fun removeAllForPackage(packageName: String) { 181 Assert.isMainThread() 182 val listenersCopy = listeners.toSet() 183 val toRemove = mediaEntries.filter { it.value.packageName == packageName } 184 toRemove.forEach { 185 mediaEntries.remove(it.key) 186 listenersCopy.forEach { listener -> 187 listener.onMediaDataRemoved(it.key) 188 } 189 } 190 } 191 setResumeActionnull192 fun setResumeAction(key: String, action: Runnable?) { 193 mediaEntries.get(key)?.let { 194 it.resumeAction = action 195 it.hasCheckedForResume = true 196 } 197 } 198 addResumptionControlsnull199 fun addResumptionControls( 200 userId: Int, 201 desc: MediaDescription, 202 action: Runnable, 203 token: MediaSession.Token, 204 appName: String, 205 appIntent: PendingIntent, 206 packageName: String 207 ) { 208 // Resume controls don't have a notification key, so store by package name instead 209 if (!mediaEntries.containsKey(packageName)) { 210 val resumeData = LOADING.copy(packageName = packageName, resumeAction = action, 211 hasCheckedForResume = true) 212 mediaEntries.put(packageName, resumeData) 213 } 214 backgroundExecutor.execute { 215 loadMediaDataInBgForResumption(userId, desc, action, token, appName, appIntent, 216 packageName) 217 } 218 } 219 220 /** 221 * Check if there is an existing entry that matches the key or package name. 222 * Returns the key that matches, or null if not found. 223 */ findExistingEntrynull224 private fun findExistingEntry(key: String, packageName: String): String? { 225 if (mediaEntries.containsKey(key)) { 226 return key 227 } 228 // Check if we already had a resume player 229 if (mediaEntries.containsKey(packageName)) { 230 return packageName 231 } 232 return null 233 } 234 loadMediaDatanull235 private fun loadMediaData( 236 key: String, 237 sbn: StatusBarNotification, 238 oldKey: String? 239 ) { 240 backgroundExecutor.execute { 241 loadMediaDataInBg(key, sbn, oldKey) 242 } 243 } 244 245 /** 246 * Add a listener for changes in this class 247 */ addListenernull248 fun addListener(listener: Listener) = listeners.add(listener) 249 250 /** 251 * Remove a listener for changes in this class 252 */ 253 fun removeListener(listener: Listener) = listeners.remove(listener) 254 255 /** 256 * Called whenever the player has been paused or stopped for a while. 257 * This will make the player not active anymore, hiding it from QQS and Keyguard. 258 * @see MediaData.active 259 */ 260 internal fun setTimedOut(token: String, timedOut: Boolean) { 261 mediaEntries[token]?.let { 262 if (it.active == !timedOut) { 263 return 264 } 265 it.active = !timedOut 266 onMediaDataLoaded(token, token, it) 267 } 268 } 269 loadMediaDataInBgForResumptionnull270 private fun loadMediaDataInBgForResumption( 271 userId: Int, 272 desc: MediaDescription, 273 resumeAction: Runnable, 274 token: MediaSession.Token, 275 appName: String, 276 appIntent: PendingIntent, 277 packageName: String 278 ) { 279 if (TextUtils.isEmpty(desc.title)) { 280 Log.e(TAG, "Description incomplete") 281 // Delete the placeholder entry 282 mediaEntries.remove(packageName) 283 return 284 } 285 286 Log.d(TAG, "adding track for $userId from browser: $desc") 287 288 // Album art 289 var artworkBitmap = desc.iconBitmap 290 if (artworkBitmap == null && desc.iconUri != null) { 291 artworkBitmap = loadBitmapFromUri(desc.iconUri!!) 292 } 293 val artworkIcon = if (artworkBitmap != null) { 294 Icon.createWithBitmap(artworkBitmap) 295 } else { 296 null 297 } 298 val bgColor = artworkBitmap?.let { computeBackgroundColor(it) } ?: Color.DKGRAY 299 300 val mediaAction = getResumeMediaAction(resumeAction) 301 foregroundExecutor.execute { 302 onMediaDataLoaded(packageName, null, MediaData(userId, true, bgColor, appName, 303 null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0), 304 packageName, token, appIntent, device = null, active = false, 305 resumeAction = resumeAction, resumption = true, notificationKey = packageName, 306 hasCheckedForResume = true)) 307 } 308 } 309 loadMediaDataInBgnull310 private fun loadMediaDataInBg( 311 key: String, 312 sbn: StatusBarNotification, 313 oldKey: String? 314 ) { 315 val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) 316 as MediaSession.Token? 317 val metadata = mediaControllerFactory.create(token).metadata 318 319 if (metadata == null) { 320 // TODO: handle this better, removing media notification 321 return 322 } 323 324 // Foreground and Background colors computed from album art 325 val notif: Notification = sbn.notification 326 var artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART) 327 if (artworkBitmap == null) { 328 artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) 329 } 330 if (artworkBitmap == null) { 331 artworkBitmap = loadBitmapFromUri(metadata) 332 } 333 val artWorkIcon = if (artworkBitmap == null) { 334 notif.getLargeIcon() 335 } else { 336 Icon.createWithBitmap(artworkBitmap) 337 } 338 if (artWorkIcon != null) { 339 // If we have art, get colors from that 340 if (artworkBitmap == null) { 341 if (artWorkIcon.type == Icon.TYPE_BITMAP || 342 artWorkIcon.type == Icon.TYPE_ADAPTIVE_BITMAP) { 343 artworkBitmap = artWorkIcon.bitmap 344 } else { 345 val drawable: Drawable = artWorkIcon.loadDrawable(context) 346 artworkBitmap = Bitmap.createBitmap( 347 drawable.intrinsicWidth, 348 drawable.intrinsicHeight, 349 Bitmap.Config.ARGB_8888) 350 val canvas = Canvas(artworkBitmap) 351 drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) 352 drawable.draw(canvas) 353 } 354 } 355 } 356 val bgColor = computeBackgroundColor(artworkBitmap) 357 358 // App name 359 val builder = Notification.Builder.recoverBuilder(context, notif) 360 val app = builder.loadHeaderAppName() 361 362 // App Icon 363 val smallIconDrawable: Drawable = sbn.notification.smallIcon.loadDrawable(context) 364 365 // Song name 366 var song: CharSequence? = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) 367 if (song == null) { 368 song = metadata.getString(MediaMetadata.METADATA_KEY_TITLE) 369 } 370 if (song == null) { 371 song = HybridGroupManager.resolveTitle(notif) 372 } 373 374 // Artist name 375 var artist: CharSequence? = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST) 376 if (artist == null) { 377 artist = HybridGroupManager.resolveText(notif) 378 } 379 380 // Control buttons 381 val actionIcons: MutableList<MediaAction> = ArrayList() 382 val actions = notif.actions 383 val actionsToShowCollapsed = notif.extras.getIntArray( 384 Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() ?: mutableListOf<Int>() 385 // TODO: b/153736623 look into creating actions when this isn't a media style notification 386 387 val packageContext: Context = sbn.getPackageContext(context) 388 if (actions != null) { 389 for ((index, action) in actions.withIndex()) { 390 if (action.getIcon() == null) { 391 Log.i(TAG, "No icon for action $index ${action.title}") 392 actionsToShowCollapsed.remove(index) 393 continue 394 } 395 val runnable = if (action.actionIntent != null) { 396 Runnable { 397 try { 398 action.actionIntent.send() 399 } catch (e: PendingIntent.CanceledException) { 400 Log.d(TAG, "Intent canceled", e) 401 } 402 } 403 } else { 404 null 405 } 406 val mediaAction = MediaAction( 407 action.getIcon().loadDrawable(packageContext), 408 runnable, 409 action.title) 410 actionIcons.add(mediaAction) 411 } 412 } 413 414 foregroundExecutor.execute { 415 val resumeAction: Runnable? = mediaEntries[key]?.resumeAction 416 val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true 417 val active = mediaEntries[key]?.active ?: true 418 onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app, 419 smallIconDrawable, artist, song, artWorkIcon, actionIcons, 420 actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null, 421 active, resumeAction = resumeAction, notificationKey = key, 422 hasCheckedForResume = hasCheckedForResume)) 423 } 424 } 425 426 /** 427 * Load a bitmap from the various Art metadata URIs 428 */ loadBitmapFromUrinull429 private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? { 430 for (uri in ART_URIS) { 431 val uriString = metadata.getString(uri) 432 if (!TextUtils.isEmpty(uriString)) { 433 val albumArt = loadBitmapFromUri(Uri.parse(uriString)) 434 if (albumArt != null) { 435 Log.d(TAG, "loaded art from $uri") 436 return albumArt 437 } 438 } 439 } 440 return null 441 } 442 443 /** 444 * Load a bitmap from a URI 445 * @param uri the uri to load 446 * @return bitmap, or null if couldn't be loaded 447 */ loadBitmapFromUrinull448 private fun loadBitmapFromUri(uri: Uri): Bitmap? { 449 // ImageDecoder requires a scheme of the following types 450 if (uri.scheme == null) { 451 return null 452 } 453 454 if (!uri.scheme.equals(ContentResolver.SCHEME_CONTENT) && 455 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) && 456 !uri.scheme.equals(ContentResolver.SCHEME_FILE)) { 457 return null 458 } 459 460 val source = ImageDecoder.createSource(context.getContentResolver(), uri) 461 return try { 462 ImageDecoder.decodeBitmap(source) { 463 decoder, info, source -> decoder.isMutableRequired = true 464 } 465 } catch (e: IOException) { 466 e.printStackTrace() 467 null 468 } 469 } 470 computeBackgroundColornull471 private fun computeBackgroundColor(artworkBitmap: Bitmap?): Int { 472 var color = Color.WHITE 473 if (artworkBitmap != null) { 474 // If we have art, get colors from that 475 val p = MediaNotificationProcessor.generateArtworkPaletteBuilder(artworkBitmap) 476 .generate() 477 val swatch = MediaNotificationProcessor.findBackgroundSwatch(p) 478 color = swatch.rgb 479 } 480 // Adapt background color, so it's always subdued and text is legible 481 val tmpHsl = floatArrayOf(0f, 0f, 0f) 482 ColorUtils.colorToHSL(color, tmpHsl) 483 484 val l = tmpHsl[2] 485 // Colors with very low luminosity can have any saturation. This means that changing the 486 // luminosity can make a black become red. Let's remove the saturation of very light or 487 // very dark colors to avoid this issue. 488 if (l < LUMINOSITY_THRESHOLD || l > 1f - LUMINOSITY_THRESHOLD) { 489 tmpHsl[1] = 0f 490 } 491 tmpHsl[1] *= SATURATION_MULTIPLIER 492 tmpHsl[2] = DEFAULT_LUMINOSITY 493 494 color = ColorUtils.HSLToColor(tmpHsl) 495 return color 496 } 497 getResumeMediaActionnull498 private fun getResumeMediaAction(action: Runnable): MediaAction { 499 return MediaAction( 500 context.getDrawable(R.drawable.lb_ic_play), 501 action, 502 context.getString(R.string.controls_media_resume) 503 ) 504 } 505 onMediaDataLoadednull506 fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) { 507 Assert.isMainThread() 508 if (mediaEntries.containsKey(key)) { 509 // Otherwise this was removed already 510 mediaEntries.put(key, data) 511 val listenersCopy = listeners.toSet() 512 listenersCopy.forEach { 513 it.onMediaDataLoaded(key, oldKey, data) 514 } 515 } 516 } 517 onNotificationRemovednull518 fun onNotificationRemoved(key: String) { 519 Assert.isMainThread() 520 val removed = mediaEntries.remove(key) 521 if (useMediaResumption && removed?.resumeAction != null) { 522 Log.d(TAG, "Not removing $key because resumable") 523 // Move to resume key (aka package name) if that key doesn't already exist. 524 val resumeAction = getResumeMediaAction(removed.resumeAction!!) 525 val updated = removed.copy(token = null, actions = listOf(resumeAction), 526 actionsToShowInCompact = listOf(0), active = false, resumption = true) 527 val pkg = removed?.packageName 528 val migrate = mediaEntries.put(pkg, updated) == null 529 // Notify listeners of "new" controls when migrating or removed and update when not 530 val listenersCopy = listeners.toSet() 531 if (migrate) { 532 listenersCopy.forEach { 533 it.onMediaDataLoaded(pkg, key, updated) 534 } 535 } else { 536 // Since packageName is used for the key of the resumption controls, it is 537 // possible that another notification has already been reused for the resumption 538 // controls of this package. In this case, rather than renaming this player as 539 // packageName, just remove it and then send a update to the existing resumption 540 // controls. 541 listenersCopy.forEach { 542 it.onMediaDataRemoved(key) 543 } 544 listenersCopy.forEach { 545 it.onMediaDataLoaded(pkg, pkg, updated) 546 } 547 } 548 return 549 } 550 if (removed != null) { 551 val listenersCopy = listeners.toSet() 552 listenersCopy.forEach { 553 it.onMediaDataRemoved(key) 554 } 555 } 556 } 557 setMediaResumptionEnablednull558 fun setMediaResumptionEnabled(isEnabled: Boolean) { 559 if (useMediaResumption == isEnabled) { 560 return 561 } 562 563 useMediaResumption = isEnabled 564 565 if (!useMediaResumption) { 566 // Remove any existing resume controls 567 val listenersCopy = listeners.toSet() 568 val filtered = mediaEntries.filter { !it.value.active } 569 filtered.forEach { 570 mediaEntries.remove(it.key) 571 listenersCopy.forEach { listener -> 572 listener.onMediaDataRemoved(it.key) 573 } 574 } 575 } 576 } 577 578 interface Listener { 579 580 /** 581 * Called whenever there's new MediaData Loaded for the consumption in views. 582 * 583 * oldKey is provided to check whether the view has changed keys, which can happen when a 584 * player has gone from resume state (key is package name) to active state (key is 585 * notification key) or vice versa. 586 */ onMediaDataLoadednull587 fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {} 588 589 /** 590 * Called whenever a previously existing Media notification was removed 591 */ onMediaDataRemovednull592 fun onMediaDataRemoved(key: String) {} 593 } 594 dumpnull595 override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) { 596 pw.apply { 597 println("listeners: $listeners") 598 println("mediaEntries: $mediaEntries") 599 println("useMediaResumption: $useMediaResumption") 600 } 601 } 602 } 603