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