• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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