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