1 /*
<lambda>null2 * Copyright (C) 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.media.controls.pipeline
18
19 import android.annotation.SuppressLint
20 import android.app.BroadcastOptions
21 import android.app.Notification
22 import android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME
23 import android.app.PendingIntent
24 import android.app.StatusBarManager
25 import android.app.UriGrantsManager
26 import android.app.smartspace.SmartspaceAction
27 import android.app.smartspace.SmartspaceConfig
28 import android.app.smartspace.SmartspaceManager
29 import android.app.smartspace.SmartspaceSession
30 import android.app.smartspace.SmartspaceTarget
31 import android.content.BroadcastReceiver
32 import android.content.ContentProvider
33 import android.content.ContentResolver
34 import android.content.Context
35 import android.content.Intent
36 import android.content.IntentFilter
37 import android.content.pm.ApplicationInfo
38 import android.content.pm.PackageManager
39 import android.graphics.Bitmap
40 import android.graphics.ImageDecoder
41 import android.graphics.drawable.Animatable
42 import android.graphics.drawable.Icon
43 import android.media.MediaDescription
44 import android.media.MediaMetadata
45 import android.media.session.MediaController
46 import android.media.session.MediaSession
47 import android.media.session.PlaybackState
48 import android.net.Uri
49 import android.os.Parcelable
50 import android.os.Process
51 import android.os.UserHandle
52 import android.provider.Settings
53 import android.service.notification.StatusBarNotification
54 import android.support.v4.media.MediaMetadataCompat
55 import android.text.TextUtils
56 import android.util.Log
57 import android.util.Pair as APair
58 import androidx.media.utils.MediaConstants
59 import com.android.internal.annotations.Keep
60 import com.android.internal.logging.InstanceId
61 import com.android.keyguard.KeyguardUpdateMonitor
62 import com.android.systemui.Dumpable
63 import com.android.systemui.R
64 import com.android.systemui.broadcast.BroadcastDispatcher
65 import com.android.systemui.dagger.SysUISingleton
66 import com.android.systemui.dagger.qualifiers.Background
67 import com.android.systemui.dagger.qualifiers.Main
68 import com.android.systemui.dump.DumpManager
69 import com.android.systemui.media.controls.models.player.MediaAction
70 import com.android.systemui.media.controls.models.player.MediaButton
71 import com.android.systemui.media.controls.models.player.MediaData
72 import com.android.systemui.media.controls.models.player.MediaDeviceData
73 import com.android.systemui.media.controls.models.player.MediaViewHolder
74 import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_SOURCE
75 import com.android.systemui.media.controls.models.recommendation.EXTRA_VALUE_TRIGGER_PERIODIC
76 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
77 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataProvider
78 import com.android.systemui.media.controls.resume.MediaResumeListener
79 import com.android.systemui.media.controls.resume.ResumeMediaBrowser
80 import com.android.systemui.media.controls.util.MediaControllerFactory
81 import com.android.systemui.media.controls.util.MediaDataUtils
82 import com.android.systemui.media.controls.util.MediaFlags
83 import com.android.systemui.media.controls.util.MediaUiEventLogger
84 import com.android.systemui.plugins.ActivityStarter
85 import com.android.systemui.plugins.BcSmartspaceDataPlugin
86 import com.android.systemui.statusbar.NotificationMediaManager.isConnectingState
87 import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
88 import com.android.systemui.statusbar.notification.row.HybridGroupManager
89 import com.android.systemui.tuner.TunerService
90 import com.android.systemui.util.Assert
91 import com.android.systemui.util.Utils
92 import com.android.systemui.util.concurrency.DelayableExecutor
93 import com.android.systemui.util.time.SystemClock
94 import com.android.systemui.util.traceSection
95 import java.io.IOException
96 import java.io.PrintWriter
97 import java.util.concurrent.Executor
98 import javax.inject.Inject
99
100 // URI fields to try loading album art from
101 private val ART_URIS =
102 arrayOf(
103 MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
104 MediaMetadata.METADATA_KEY_ART_URI,
105 MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI
106 )
107
108 private const val TAG = "MediaDataManager"
109 private const val DEBUG = true
110 private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent"
111
112 private val LOADING =
113 MediaData(
114 userId = -1,
115 initialized = false,
116 app = null,
117 appIcon = null,
118 artist = null,
119 song = null,
120 artwork = null,
121 actions = emptyList(),
122 actionsToShowInCompact = emptyList(),
123 packageName = "INVALID",
124 token = null,
125 clickIntent = null,
126 device = null,
127 active = true,
128 resumeAction = null,
129 instanceId = InstanceId.fakeInstanceId(-1),
130 appUid = Process.INVALID_UID
131 )
132
133 internal val EMPTY_SMARTSPACE_MEDIA_DATA =
134 SmartspaceMediaData(
135 targetId = "INVALID",
136 isActive = false,
137 packageName = "INVALID",
138 cardAction = null,
139 recommendations = emptyList(),
140 dismissIntent = null,
141 headphoneConnectionTimeMillis = 0,
142 instanceId = InstanceId.fakeInstanceId(-1),
143 expiryTimeMs = 0,
144 )
145
146 const val MEDIA_TITLE_ERROR_MESSAGE = "Invalid media data: title is null or blank."
147
148 fun isMediaNotification(sbn: StatusBarNotification): Boolean {
149 return sbn.notification.isMediaNotification()
150 }
151
152 /**
153 * Allow recommendations from smartspace to show in media controls. Requires
154 * [Utils.useQsMediaPlayer] to be enabled. On by default, but can be disabled by setting to 0
155 */
allowMediaRecommendationsnull156 private fun allowMediaRecommendations(context: Context): Boolean {
157 val flag =
158 Settings.Secure.getInt(
159 context.contentResolver,
160 Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
161 1
162 )
163 return Utils.useQsMediaPlayer(context) && flag > 0
164 }
165
166 /** A class that facilitates management and loading of Media Data, ready for binding. */
167 @SysUISingleton
168 class MediaDataManager(
169 private val context: Context,
170 @Background private val backgroundExecutor: Executor,
171 @Main private val uiExecutor: Executor,
172 @Main private val foregroundExecutor: DelayableExecutor,
173 private val mediaControllerFactory: MediaControllerFactory,
174 private val broadcastDispatcher: BroadcastDispatcher,
175 dumpManager: DumpManager,
176 mediaTimeoutListener: MediaTimeoutListener,
177 mediaResumeListener: MediaResumeListener,
178 mediaSessionBasedFilter: MediaSessionBasedFilter,
179 mediaDeviceManager: MediaDeviceManager,
180 mediaDataCombineLatest: MediaDataCombineLatest,
181 private val mediaDataFilter: MediaDataFilter,
182 private val activityStarter: ActivityStarter,
183 private val smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
184 private var useMediaResumption: Boolean,
185 private val useQsMediaPlayer: Boolean,
186 private val systemClock: SystemClock,
187 private val tunerService: TunerService,
188 private val mediaFlags: MediaFlags,
189 private val logger: MediaUiEventLogger,
190 private val smartspaceManager: SmartspaceManager,
191 private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
192 ) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener {
193
194 companion object {
195 // UI surface label for subscribing Smartspace updates.
196 @JvmField val SMARTSPACE_UI_SURFACE_LABEL = "media_data_manager"
197
198 // Smartspace package name's extra key.
199 @JvmField val EXTRAS_MEDIA_SOURCE_PACKAGE_NAME = "package_name"
200
201 // Maximum number of actions allowed in compact view
202 @JvmField val MAX_COMPACT_ACTIONS = 3
203
204 // Maximum number of actions allowed in expanded view
205 @JvmField val MAX_NOTIFICATION_ACTIONS = MediaViewHolder.genericButtonIds.size
206 }
207
208 private val themeText =
209 com.android.settingslib.Utils.getColorAttr(
210 context,
211 com.android.internal.R.attr.textColorPrimary
212 )
213 .defaultColor
214
215 // Internal listeners are part of the internal pipeline. External listeners (those registered
216 // with [MediaDeviceManager.addListener]) receive events after they have propagated through
217 // the internal pipeline.
218 // Another way to think of the distinction between internal and external listeners is the
219 // following. Internal listeners are listeners that MediaDataManager depends on, and external
220 // listeners are listeners that depend on MediaDataManager.
221 // TODO(b/159539991#comment5): Move internal listeners to separate package.
222 private val internalListeners: MutableSet<Listener> = mutableSetOf()
223 private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
224 // There should ONLY be at most one Smartspace media recommendation.
225 var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
226 @Keep private var smartspaceSession: SmartspaceSession? = null
227 private var allowMediaRecommendations = allowMediaRecommendations(context)
228
229 private val artworkWidth =
230 context.resources.getDimensionPixelSize(
231 com.android.internal.R.dimen.config_mediaMetadataBitmapMaxSize
232 )
233 private val artworkHeight =
234 context.resources.getDimensionPixelSize(R.dimen.qs_media_session_height_expanded)
235
236 @SuppressLint("WrongConstant") // sysui allowed to call STATUS_BAR_SERVICE
237 private val statusBarManager =
238 context.getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager
239
240 /** Check whether this notification is an RCN */
isRemoteCastNotificationnull241 private fun isRemoteCastNotification(sbn: StatusBarNotification): Boolean {
242 return sbn.notification.extras.containsKey(Notification.EXTRA_MEDIA_REMOTE_DEVICE)
243 }
244
245 @Inject
246 constructor(
247 context: Context,
248 @Background backgroundExecutor: Executor,
249 @Main uiExecutor: Executor,
250 @Main foregroundExecutor: DelayableExecutor,
251 mediaControllerFactory: MediaControllerFactory,
252 dumpManager: DumpManager,
253 broadcastDispatcher: BroadcastDispatcher,
254 mediaTimeoutListener: MediaTimeoutListener,
255 mediaResumeListener: MediaResumeListener,
256 mediaSessionBasedFilter: MediaSessionBasedFilter,
257 mediaDeviceManager: MediaDeviceManager,
258 mediaDataCombineLatest: MediaDataCombineLatest,
259 mediaDataFilter: MediaDataFilter,
260 activityStarter: ActivityStarter,
261 smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
262 clock: SystemClock,
263 tunerService: TunerService,
264 mediaFlags: MediaFlags,
265 logger: MediaUiEventLogger,
266 smartspaceManager: SmartspaceManager,
267 keyguardUpdateMonitor: KeyguardUpdateMonitor,
268 ) : this(
269 context,
270 backgroundExecutor,
271 uiExecutor,
272 foregroundExecutor,
273 mediaControllerFactory,
274 broadcastDispatcher,
275 dumpManager,
276 mediaTimeoutListener,
277 mediaResumeListener,
278 mediaSessionBasedFilter,
279 mediaDeviceManager,
280 mediaDataCombineLatest,
281 mediaDataFilter,
282 activityStarter,
283 smartspaceMediaDataProvider,
284 Utils.useMediaResumption(context),
285 Utils.useQsMediaPlayer(context),
286 clock,
287 tunerService,
288 mediaFlags,
289 logger,
290 smartspaceManager,
291 keyguardUpdateMonitor,
292 )
293
294 private val appChangeReceiver =
295 object : BroadcastReceiver() {
onReceivenull296 override fun onReceive(context: Context, intent: Intent) {
297 when (intent.action) {
298 Intent.ACTION_PACKAGES_SUSPENDED -> {
299 val packages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST)
300 packages?.forEach { removeAllForPackage(it) }
301 }
302 Intent.ACTION_PACKAGE_REMOVED,
303 Intent.ACTION_PACKAGE_RESTARTED -> {
304 intent.data?.encodedSchemeSpecificPart?.let { removeAllForPackage(it) }
305 }
306 }
307 }
308 }
309
310 init {
311 dumpManager.registerDumpable(TAG, this)
312
313 // Initialize the internal processing pipeline. The listeners at the front of the pipeline
314 // are set as internal listeners so that they receive events. From there, events are
315 // propagated through the pipeline. The end of the pipeline is currently mediaDataFilter,
316 // so it is responsible for dispatching events to external listeners. To achieve this,
317 // external listeners that are registered with [MediaDataManager.addListener] are actually
318 // registered as listeners to mediaDataFilter.
319 addInternalListener(mediaTimeoutListener)
320 addInternalListener(mediaResumeListener)
321 addInternalListener(mediaSessionBasedFilter)
322 mediaSessionBasedFilter.addListener(mediaDeviceManager)
323 mediaSessionBasedFilter.addListener(mediaDataCombineLatest)
324 mediaDeviceManager.addListener(mediaDataCombineLatest)
325 mediaDataCombineLatest.addListener(mediaDataFilter)
326
327 // Set up links back into the pipeline for listeners that need to send events upstream.
timedOutnull328 mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean ->
329 setTimedOut(key, timedOut)
330 }
statenull331 mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
332 updateState(key, state)
333 }
keynull334 mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
335 mediaResumeListener.setManager(this)
336 mediaDataFilter.mediaDataManager = this
337
338 val suspendFilter = IntentFilter(Intent.ACTION_PACKAGES_SUSPENDED)
339 broadcastDispatcher.registerReceiver(appChangeReceiver, suspendFilter, null, UserHandle.ALL)
340
341 val uninstallFilter =
<lambda>null342 IntentFilter().apply {
343 addAction(Intent.ACTION_PACKAGE_REMOVED)
344 addAction(Intent.ACTION_PACKAGE_RESTARTED)
345 addDataScheme("package")
346 }
347 // BroadcastDispatcher does not allow filters with data schemes
348 context.registerReceiver(appChangeReceiver, uninstallFilter)
349
350 // Register for Smartspace data updates.
351 smartspaceMediaDataProvider.registerListener(this)
352 smartspaceSession =
353 smartspaceManager.createSmartspaceSession(
354 SmartspaceConfig.Builder(context, SMARTSPACE_UI_SURFACE_LABEL).build()
355 )
<lambda>null356 smartspaceSession?.let {
357 it.addOnTargetsAvailableListener(
358 // Use a main uiExecutor thread listening to Smartspace updates instead of using
359 // the existing background executor.
360 // SmartspaceSession has scheduled routine updates which can be unpredictable on
361 // test simulators, using the backgroundExecutor makes it's hard to test the threads
362 // numbers.
363 uiExecutor,
364 SmartspaceSession.OnTargetsAvailableListener { targets ->
365 smartspaceMediaDataProvider.onTargetsAvailable(targets)
366 }
367 )
368 }
<lambda>null369 smartspaceSession?.let { it.requestSmartspaceUpdate() }
370 tunerService.addTunable(
371 object : TunerService.Tunable {
onTuningChangednull372 override fun onTuningChanged(key: String?, newValue: String?) {
373 allowMediaRecommendations = allowMediaRecommendations(context)
374 if (!allowMediaRecommendations) {
375 dismissSmartspaceRecommendation(
376 key = smartspaceMediaData.targetId,
377 delay = 0L
378 )
379 }
380 }
381 },
382 Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION
383 )
384 }
385
destroynull386 fun destroy() {
387 smartspaceMediaDataProvider.unregisterListener(this)
388 smartspaceSession?.close()
389 smartspaceSession = null
390 context.unregisterReceiver(appChangeReceiver)
391 }
392
onNotificationAddednull393 fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
394 if (useQsMediaPlayer && isMediaNotification(sbn)) {
395 var isNewlyActiveEntry = false
396 Assert.isMainThread()
397 val oldKey = findExistingEntry(key, sbn.packageName)
398 if (oldKey == null) {
399 val instanceId = logger.getNewInstanceId()
400 val temp = LOADING.copy(packageName = sbn.packageName, instanceId = instanceId)
401 mediaEntries.put(key, temp)
402 isNewlyActiveEntry = true
403 } else if (oldKey != key) {
404 // Resume -> active conversion; move to new key
405 val oldData = mediaEntries.remove(oldKey)!!
406 isNewlyActiveEntry = true
407 mediaEntries.put(key, oldData)
408 }
409 loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
410 } else {
411 onNotificationRemoved(key)
412 }
413 }
414
removeAllForPackagenull415 private fun removeAllForPackage(packageName: String) {
416 Assert.isMainThread()
417 val toRemove = mediaEntries.filter { it.value.packageName == packageName }
418 toRemove.forEach { removeEntry(it.key) }
419 }
420
setResumeActionnull421 fun setResumeAction(key: String, action: Runnable?) {
422 mediaEntries.get(key)?.let {
423 it.resumeAction = action
424 it.hasCheckedForResume = true
425 }
426 }
427
addResumptionControlsnull428 fun addResumptionControls(
429 userId: Int,
430 desc: MediaDescription,
431 action: Runnable,
432 token: MediaSession.Token,
433 appName: String,
434 appIntent: PendingIntent,
435 packageName: String
436 ) {
437 // Resume controls don't have a notification key, so store by package name instead
438 if (!mediaEntries.containsKey(packageName)) {
439 val instanceId = logger.getNewInstanceId()
440 val appUid =
441 try {
442 context.packageManager.getApplicationInfo(packageName, 0)?.uid!!
443 } catch (e: PackageManager.NameNotFoundException) {
444 Log.w(TAG, "Could not get app UID for $packageName", e)
445 Process.INVALID_UID
446 }
447
448 val resumeData =
449 LOADING.copy(
450 packageName = packageName,
451 resumeAction = action,
452 hasCheckedForResume = true,
453 instanceId = instanceId,
454 appUid = appUid
455 )
456 mediaEntries.put(packageName, resumeData)
457 logSingleVsMultipleMediaAdded(appUid, packageName, instanceId)
458 logger.logResumeMediaAdded(appUid, packageName, instanceId)
459 }
460 backgroundExecutor.execute {
461 loadMediaDataInBgForResumption(
462 userId,
463 desc,
464 action,
465 token,
466 appName,
467 appIntent,
468 packageName
469 )
470 }
471 }
472
473 /**
474 * Check if there is an existing entry that matches the key or package name. Returns the key
475 * that matches, or null if not found.
476 */
findExistingEntrynull477 private fun findExistingEntry(key: String, packageName: String): String? {
478 if (mediaEntries.containsKey(key)) {
479 return key
480 }
481 // Check if we already had a resume player
482 if (mediaEntries.containsKey(packageName)) {
483 return packageName
484 }
485 return null
486 }
487
loadMediaDatanull488 private fun loadMediaData(
489 key: String,
490 sbn: StatusBarNotification,
491 oldKey: String?,
492 isNewlyActiveEntry: Boolean = false,
493 ) {
494 backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
495 }
496
497 /** Add a listener for changes in this class */
addListenernull498 fun addListener(listener: Listener) {
499 // mediaDataFilter is the current end of the internal pipeline. Register external
500 // listeners as listeners to it.
501 mediaDataFilter.addListener(listener)
502 }
503
504 /** Remove a listener for changes in this class */
removeListenernull505 fun removeListener(listener: Listener) {
506 // Since mediaDataFilter is the current end of the internal pipelie, external listeners
507 // have been registered to it. So, they need to be removed from it too.
508 mediaDataFilter.removeListener(listener)
509 }
510
511 /** Add a listener for internal events. */
addInternalListenernull512 private fun addInternalListener(listener: Listener) = internalListeners.add(listener)
513
514 /**
515 * Notify internal listeners of media loaded event.
516 *
517 * External listeners registered with [addListener] will be notified after the event propagates
518 * through the internal listener pipeline.
519 */
520 private fun notifyMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
521 internalListeners.forEach { it.onMediaDataLoaded(key, oldKey, info) }
522 }
523
524 /**
525 * Notify internal listeners of Smartspace media loaded event.
526 *
527 * External listeners registered with [addListener] will be notified after the event propagates
528 * through the internal listener pipeline.
529 */
notifySmartspaceMediaDataLoadednull530 private fun notifySmartspaceMediaDataLoaded(key: String, info: SmartspaceMediaData) {
531 internalListeners.forEach { it.onSmartspaceMediaDataLoaded(key, info) }
532 }
533
534 /**
535 * Notify internal listeners of media removed event.
536 *
537 * External listeners registered with [addListener] will be notified after the event propagates
538 * through the internal listener pipeline.
539 */
notifyMediaDataRemovednull540 private fun notifyMediaDataRemoved(key: String) {
541 internalListeners.forEach { it.onMediaDataRemoved(key) }
542 }
543
544 /**
545 * Notify internal listeners of Smartspace media removed event.
546 *
547 * External listeners registered with [addListener] will be notified after the event propagates
548 * through the internal listener pipeline.
549 *
550 * @param immediately indicates should apply the UI changes immediately, otherwise wait until
551 * the next refresh-round before UI becomes visible. Should only be true if the update is
552 * initiated by user's interaction.
553 */
notifySmartspaceMediaDataRemovednull554 private fun notifySmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
555 internalListeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
556 }
557
558 /**
559 * Called whenever the player has been paused or stopped for a while, or swiped from QQS. This
560 * will make the player not active anymore, hiding it from QQS and Keyguard.
561 *
562 * @see MediaData.active
563 */
setTimedOutnull564 internal fun setTimedOut(key: String, timedOut: Boolean, forceUpdate: Boolean = false) {
565 mediaEntries[key]?.let {
566 if (timedOut && !forceUpdate) {
567 // Only log this event when media expires on its own
568 logger.logMediaTimeout(it.appUid, it.packageName, it.instanceId)
569 }
570 if (it.active == !timedOut && !forceUpdate) {
571 if (it.resumption) {
572 if (DEBUG) Log.d(TAG, "timing out resume player $key")
573 dismissMediaData(key, 0L /* delay */)
574 }
575 return
576 }
577 it.active = !timedOut
578 if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
579 onMediaDataLoaded(key, key, it)
580 }
581
582 if (key == smartspaceMediaData.targetId) {
583 if (DEBUG) Log.d(TAG, "smartspace card expired")
584 dismissSmartspaceRecommendation(key, delay = 0L)
585 }
586 }
587
588 /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
updateStatenull589 private fun updateState(key: String, state: PlaybackState) {
590 mediaEntries.get(key)?.let {
591 val token = it.token
592 if (token == null) {
593 if (DEBUG) Log.d(TAG, "State updated, but token was null")
594 return
595 }
596 val actions =
597 createActionsFromState(
598 it.packageName,
599 mediaControllerFactory.create(it.token),
600 UserHandle(it.userId)
601 )
602
603 // Control buttons
604 // If flag is enabled and controller has a PlaybackState,
605 // create actions from session info
606 // otherwise, no need to update semantic actions.
607 val data =
608 if (actions != null) {
609 it.copy(semanticActions = actions, isPlaying = isPlayingState(state.state))
610 } else {
611 it.copy(isPlaying = isPlayingState(state.state))
612 }
613 if (DEBUG) Log.d(TAG, "State updated outside of notification")
614 onMediaDataLoaded(key, key, data)
615 }
616 }
617
removeEntrynull618 private fun removeEntry(key: String, logEvent: Boolean = true) {
619 mediaEntries.remove(key)?.let {
620 if (logEvent) {
621 logger.logMediaRemoved(it.appUid, it.packageName, it.instanceId)
622 }
623 }
624 notifyMediaDataRemoved(key)
625 }
626
627 /** Dismiss a media entry. Returns false if the key was not found. */
dismissMediaDatanull628 fun dismissMediaData(key: String, delay: Long): Boolean {
629 val existed = mediaEntries[key] != null
630 backgroundExecutor.execute {
631 mediaEntries[key]?.let { mediaData ->
632 if (mediaData.isLocalSession()) {
633 mediaData.token?.let {
634 val mediaController = mediaControllerFactory.create(it)
635 mediaController.transportControls.stop()
636 }
637 }
638 }
639 }
640 foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
641 return existed
642 }
643
644 /**
645 * Called whenever the recommendation has been expired or removed by the user. This will remove
646 * the recommendation card entirely from the carousel.
647 */
dismissSmartspaceRecommendationnull648 fun dismissSmartspaceRecommendation(key: String, delay: Long) {
649 if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
650 // If this doesn't match, or we've already invalidated the data, no action needed
651 return
652 }
653
654 if (DEBUG) Log.d(TAG, "Dismissing Smartspace media target")
655 if (smartspaceMediaData.isActive) {
656 smartspaceMediaData =
657 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
658 targetId = smartspaceMediaData.targetId,
659 instanceId = smartspaceMediaData.instanceId
660 )
661 }
662 foregroundExecutor.executeDelayed(
663 { notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = true) },
664 delay
665 )
666 }
667
668 /** Called when the recommendation card should no longer be visible in QQS or lockscreen */
setRecommendationInactivenull669 fun setRecommendationInactive(key: String) {
670 if (!mediaFlags.isPersistentSsCardEnabled()) {
671 Log.e(TAG, "Only persistent recommendation can be inactive!")
672 return
673 }
674 if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive")
675
676 if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
677 // If this doesn't match, or we've already invalidated the data, no action needed
678 return
679 }
680
681 smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
682 notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
683 }
684
loadMediaDataInBgForResumptionnull685 private fun loadMediaDataInBgForResumption(
686 userId: Int,
687 desc: MediaDescription,
688 resumeAction: Runnable,
689 token: MediaSession.Token,
690 appName: String,
691 appIntent: PendingIntent,
692 packageName: String
693 ) {
694 if (desc.title.isNullOrBlank()) {
695 Log.e(TAG, "Description incomplete")
696 // Delete the placeholder entry
697 mediaEntries.remove(packageName)
698 return
699 }
700
701 if (DEBUG) {
702 Log.d(TAG, "adding track for $userId from browser: $desc")
703 }
704
705 val currentEntry = mediaEntries.get(packageName)
706 val appUid = currentEntry?.appUid ?: Process.INVALID_UID
707
708 // Album art
709 var artworkBitmap = desc.iconBitmap
710 if (artworkBitmap == null && desc.iconUri != null) {
711 artworkBitmap = loadBitmapFromUriForUser(desc.iconUri!!, userId, appUid, packageName)
712 }
713 val artworkIcon =
714 if (artworkBitmap != null) {
715 Icon.createWithBitmap(artworkBitmap)
716 } else {
717 null
718 }
719
720 val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
721 val isExplicit =
722 desc.extras?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
723 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
724
725 val progress =
726 if (mediaFlags.isResumeProgressEnabled()) {
727 MediaDataUtils.getDescriptionProgress(desc.extras)
728 } else null
729
730 val mediaAction = getResumeMediaAction(resumeAction)
731 val lastActive = systemClock.elapsedRealtime()
732 foregroundExecutor.execute {
733 onMediaDataLoaded(
734 packageName,
735 null,
736 MediaData(
737 userId,
738 true,
739 appName,
740 null,
741 desc.subtitle,
742 desc.title,
743 artworkIcon,
744 listOf(mediaAction),
745 listOf(0),
746 MediaButton(playOrPause = mediaAction),
747 packageName,
748 token,
749 appIntent,
750 device = null,
751 active = false,
752 resumeAction = resumeAction,
753 resumption = true,
754 notificationKey = packageName,
755 hasCheckedForResume = true,
756 lastActive = lastActive,
757 instanceId = instanceId,
758 appUid = appUid,
759 isExplicit = isExplicit,
760 resumeProgress = progress,
761 )
762 )
763 }
764 }
765
loadMediaDataInBgnull766 fun loadMediaDataInBg(
767 key: String,
768 sbn: StatusBarNotification,
769 oldKey: String?,
770 isNewlyActiveEntry: Boolean = false,
771 ) {
772 val token =
773 sbn.notification.extras.getParcelable(
774 Notification.EXTRA_MEDIA_SESSION,
775 MediaSession.Token::class.java
776 )
777 if (token == null) {
778 return
779 }
780 val mediaController = mediaControllerFactory.create(token)
781 val metadata = mediaController.metadata
782 val notif: Notification = sbn.notification
783
784 val appInfo =
785 notif.extras.getParcelable(
786 Notification.EXTRA_BUILDER_APPLICATION_INFO,
787 ApplicationInfo::class.java
788 )
789 ?: getAppInfoFromPackage(sbn.packageName)
790
791 // App name
792 val appName = getAppName(sbn, appInfo)
793
794 // Song name
795 var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
796 if (song.isNullOrBlank()) {
797 song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
798 }
799 if (song.isNullOrBlank()) {
800 song = HybridGroupManager.resolveTitle(notif)
801 }
802 if (song.isNullOrBlank()) {
803 // For apps that don't include a title, log and add a placeholder
804 song = context.getString(R.string.controls_media_empty_title, appName)
805 try {
806 statusBarManager.logBlankMediaTitle(sbn.packageName, sbn.user.identifier)
807 } catch (e: RuntimeException) {
808 Log.e(TAG, "Error reporting blank media title for package ${sbn.packageName}")
809 }
810 }
811
812 // Album art
813 var artworkBitmap = metadata?.let { loadBitmapFromUri(it) }
814 if (artworkBitmap == null) {
815 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ART)
816 }
817 if (artworkBitmap == null) {
818 artworkBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
819 }
820 val artWorkIcon =
821 if (artworkBitmap == null) {
822 notif.getLargeIcon()
823 } else {
824 Icon.createWithBitmap(artworkBitmap)
825 }
826
827 // App Icon
828 val smallIcon = sbn.notification.smallIcon
829
830 // Explicit Indicator
831 var isExplicit = false
832 val mediaMetadataCompat = MediaMetadataCompat.fromMediaMetadata(metadata)
833 isExplicit =
834 mediaMetadataCompat?.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT) ==
835 MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
836
837 // Artist name
838 var artist: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
839 if (artist.isNullOrBlank()) {
840 artist = HybridGroupManager.resolveText(notif)
841 }
842
843 // Device name (used for remote cast notifications)
844 var device: MediaDeviceData? = null
845 if (isRemoteCastNotification(sbn)) {
846 val extras = sbn.notification.extras
847 val deviceName = extras.getCharSequence(Notification.EXTRA_MEDIA_REMOTE_DEVICE, null)
848 val deviceIcon = extras.getInt(Notification.EXTRA_MEDIA_REMOTE_ICON, -1)
849 val deviceIntent =
850 extras.getParcelable(
851 Notification.EXTRA_MEDIA_REMOTE_INTENT,
852 PendingIntent::class.java
853 )
854 Log.d(TAG, "$key is RCN for $deviceName")
855
856 if (deviceName != null && deviceIcon > -1) {
857 // Name and icon must be present, but intent may be null
858 val enabled = deviceIntent != null && deviceIntent.isActivity
859 val deviceDrawable =
860 Icon.createWithResource(sbn.packageName, deviceIcon)
861 .loadDrawable(sbn.getPackageContext(context))
862 device =
863 MediaDeviceData(
864 enabled,
865 deviceDrawable,
866 deviceName,
867 deviceIntent,
868 showBroadcastButton = false
869 )
870 }
871 }
872
873 // Control buttons
874 // If flag is enabled and controller has a PlaybackState, create actions from session info
875 // Otherwise, use the notification actions
876 var actionIcons: List<MediaAction> = emptyList()
877 var actionsToShowCollapsed: List<Int> = emptyList()
878 val semanticActions = createActionsFromState(sbn.packageName, mediaController, sbn.user)
879 if (semanticActions == null) {
880 val actions = createActionsFromNotification(sbn)
881 actionIcons = actions.first
882 actionsToShowCollapsed = actions.second
883 }
884
885 val playbackLocation =
886 if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
887 else if (
888 mediaController.playbackInfo?.playbackType ==
889 MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL
890 )
891 MediaData.PLAYBACK_LOCAL
892 else MediaData.PLAYBACK_CAST_LOCAL
893 val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
894
895 val currentEntry = mediaEntries.get(key)
896 val instanceId = currentEntry?.instanceId ?: logger.getNewInstanceId()
897 val appUid = appInfo?.uid ?: Process.INVALID_UID
898
899 if (isNewlyActiveEntry) {
900 logSingleVsMultipleMediaAdded(appUid, sbn.packageName, instanceId)
901 logger.logActiveMediaAdded(appUid, sbn.packageName, instanceId, playbackLocation)
902 } else if (playbackLocation != currentEntry?.playbackLocation) {
903 logger.logPlaybackLocationChange(appUid, sbn.packageName, instanceId, playbackLocation)
904 }
905
906 val lastActive = systemClock.elapsedRealtime()
907 foregroundExecutor.execute {
908 val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
909 val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
910 val active = mediaEntries[key]?.active ?: true
911 onMediaDataLoaded(
912 key,
913 oldKey,
914 MediaData(
915 sbn.normalizedUserId,
916 true,
917 appName,
918 smallIcon,
919 artist,
920 song,
921 artWorkIcon,
922 actionIcons,
923 actionsToShowCollapsed,
924 semanticActions,
925 sbn.packageName,
926 token,
927 notif.contentIntent,
928 device,
929 active,
930 resumeAction = resumeAction,
931 playbackLocation = playbackLocation,
932 notificationKey = key,
933 hasCheckedForResume = hasCheckedForResume,
934 isPlaying = isPlaying,
935 isClearable = !sbn.isOngoing,
936 lastActive = lastActive,
937 instanceId = instanceId,
938 appUid = appUid,
939 isExplicit = isExplicit,
940 )
941 )
942 }
943 }
944
logSingleVsMultipleMediaAddednull945 private fun logSingleVsMultipleMediaAdded(
946 appUid: Int,
947 packageName: String,
948 instanceId: InstanceId
949 ) {
950 if (mediaEntries.size == 1) {
951 logger.logSingleMediaPlayerInCarousel(appUid, packageName, instanceId)
952 } else if (mediaEntries.size == 2) {
953 // Since this method is only called when there is a new media session added.
954 // logging needed once there is more than one media session in carousel.
955 logger.logMultipleMediaPlayersInCarousel(appUid, packageName, instanceId)
956 }
957 }
958
getAppInfoFromPackagenull959 private fun getAppInfoFromPackage(packageName: String): ApplicationInfo? {
960 try {
961 return context.packageManager.getApplicationInfo(packageName, 0)
962 } catch (e: PackageManager.NameNotFoundException) {
963 Log.w(TAG, "Could not get app info for $packageName", e)
964 }
965 return null
966 }
967
getAppNamenull968 private fun getAppName(sbn: StatusBarNotification, appInfo: ApplicationInfo?): String {
969 val name = sbn.notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
970 if (name != null) {
971 return name
972 }
973
974 return if (appInfo != null) {
975 context.packageManager.getApplicationLabel(appInfo).toString()
976 } else {
977 sbn.packageName
978 }
979 }
980
981 /** Generate action buttons based on notification actions */
createActionsFromNotificationnull982 private fun createActionsFromNotification(
983 sbn: StatusBarNotification
984 ): Pair<List<MediaAction>, List<Int>> {
985 val notif = sbn.notification
986 val actionIcons: MutableList<MediaAction> = ArrayList()
987 val actions = notif.actions
988 var actionsToShowCollapsed =
989 notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList()
990 ?: mutableListOf()
991 if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
992 Log.e(
993 TAG,
994 "Too many compact actions for ${sbn.key}," +
995 "limiting to first $MAX_COMPACT_ACTIONS"
996 )
997 actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
998 }
999
1000 if (actions != null) {
1001 for ((index, action) in actions.withIndex()) {
1002 if (index == MAX_NOTIFICATION_ACTIONS) {
1003 Log.w(
1004 TAG,
1005 "Too many notification actions for ${sbn.key}," +
1006 " limiting to first $MAX_NOTIFICATION_ACTIONS"
1007 )
1008 break
1009 }
1010 if (action.getIcon() == null) {
1011 if (DEBUG) Log.i(TAG, "No icon for action $index ${action.title}")
1012 actionsToShowCollapsed.remove(index)
1013 continue
1014 }
1015 val runnable =
1016 if (action.actionIntent != null) {
1017 Runnable {
1018 if (action.actionIntent.isActivity) {
1019 activityStarter.startPendingIntentDismissingKeyguard(
1020 action.actionIntent
1021 )
1022 } else if (action.isAuthenticationRequired()) {
1023 activityStarter.dismissKeyguardThenExecute(
1024 {
1025 var result = sendPendingIntent(action.actionIntent)
1026 result
1027 },
1028 {},
1029 true
1030 )
1031 } else {
1032 sendPendingIntent(action.actionIntent)
1033 }
1034 }
1035 } else {
1036 null
1037 }
1038 val mediaActionIcon =
1039 if (action.getIcon()?.getType() == Icon.TYPE_RESOURCE) {
1040 Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId())
1041 } else {
1042 action.getIcon()
1043 }
1044 .setTint(themeText)
1045 .loadDrawable(context)
1046 val mediaAction = MediaAction(mediaActionIcon, runnable, action.title, null)
1047 actionIcons.add(mediaAction)
1048 }
1049 }
1050 return Pair(actionIcons, actionsToShowCollapsed)
1051 }
1052
1053 /**
1054 * Generates action button info for this media session based on the PlaybackState
1055 *
1056 * @param packageName Package name for the media app
1057 * @param controller MediaController for the current session
1058 * @return a Pair consisting of a list of media actions, and a list of ints representing which
1059 *
1060 * ```
1061 * of those actions should be shown in the compact player
1062 * ```
1063 */
createActionsFromStatenull1064 private fun createActionsFromState(
1065 packageName: String,
1066 controller: MediaController,
1067 user: UserHandle
1068 ): MediaButton? {
1069 val state = controller.playbackState
1070 if (state == null || !mediaFlags.areMediaSessionActionsEnabled(packageName, user)) {
1071 return null
1072 }
1073
1074 // First, check for standard actions
1075 val playOrPause =
1076 if (isConnectingState(state.state)) {
1077 // Spinner needs to be animating to render anything. Start it here.
1078 val drawable =
1079 context.getDrawable(com.android.internal.R.drawable.progress_small_material)
1080 (drawable as Animatable).start()
1081 MediaAction(
1082 drawable,
1083 null, // no action to perform when clicked
1084 context.getString(R.string.controls_media_button_connecting),
1085 context.getDrawable(R.drawable.ic_media_connecting_container),
1086 // Specify a rebind id to prevent the spinner from restarting on later binds.
1087 com.android.internal.R.drawable.progress_small_material
1088 )
1089 } else if (isPlayingState(state.state)) {
1090 getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE)
1091 } else {
1092 getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY)
1093 }
1094 val prevButton =
1095 getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS)
1096 val nextButton =
1097 getStandardAction(controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT)
1098
1099 // Then, create a way to build any custom actions that will be needed
1100 val customActions =
1101 state.customActions
1102 .asSequence()
1103 .filterNotNull()
1104 .map { getCustomAction(state, packageName, controller, it) }
1105 .iterator()
1106 fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
1107
1108 // Finally, assign the remaining button slots: play/pause A B C D
1109 // A = previous, else custom action (if not reserved)
1110 // B = next, else custom action (if not reserved)
1111 // C and D are always custom actions
1112 val reservePrev =
1113 controller.extras?.getBoolean(
1114 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV
1115 ) == true
1116 val reserveNext =
1117 controller.extras?.getBoolean(
1118 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT
1119 ) == true
1120
1121 val prevOrCustom =
1122 if (prevButton != null) {
1123 prevButton
1124 } else if (!reservePrev) {
1125 nextCustomAction()
1126 } else {
1127 null
1128 }
1129
1130 val nextOrCustom =
1131 if (nextButton != null) {
1132 nextButton
1133 } else if (!reserveNext) {
1134 nextCustomAction()
1135 } else {
1136 null
1137 }
1138
1139 return MediaButton(
1140 playOrPause,
1141 nextOrCustom,
1142 prevOrCustom,
1143 nextCustomAction(),
1144 nextCustomAction(),
1145 reserveNext,
1146 reservePrev
1147 )
1148 }
1149
1150 /**
1151 * Create a [MediaAction] for a given action and media session
1152 *
1153 * @param controller MediaController for the session
1154 * @param stateActions The actions included with the session's [PlaybackState]
1155 * @param action A [PlaybackState.Actions] value representing what action to generate. One of:
1156 * ```
1157 * [PlaybackState.ACTION_PLAY]
1158 * [PlaybackState.ACTION_PAUSE]
1159 * [PlaybackState.ACTION_SKIP_TO_PREVIOUS]
1160 * [PlaybackState.ACTION_SKIP_TO_NEXT]
1161 * @return
1162 * ```
1163 *
1164 * A [MediaAction] with correct values set, or null if the state doesn't support it
1165 */
getStandardActionnull1166 private fun getStandardAction(
1167 controller: MediaController,
1168 stateActions: Long,
1169 @PlaybackState.Actions action: Long
1170 ): MediaAction? {
1171 if (!includesAction(stateActions, action)) {
1172 return null
1173 }
1174
1175 return when (action) {
1176 PlaybackState.ACTION_PLAY -> {
1177 MediaAction(
1178 context.getDrawable(R.drawable.ic_media_play),
1179 { controller.transportControls.play() },
1180 context.getString(R.string.controls_media_button_play),
1181 context.getDrawable(R.drawable.ic_media_play_container)
1182 )
1183 }
1184 PlaybackState.ACTION_PAUSE -> {
1185 MediaAction(
1186 context.getDrawable(R.drawable.ic_media_pause),
1187 { controller.transportControls.pause() },
1188 context.getString(R.string.controls_media_button_pause),
1189 context.getDrawable(R.drawable.ic_media_pause_container)
1190 )
1191 }
1192 PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
1193 MediaAction(
1194 context.getDrawable(R.drawable.ic_media_prev),
1195 { controller.transportControls.skipToPrevious() },
1196 context.getString(R.string.controls_media_button_prev),
1197 null
1198 )
1199 }
1200 PlaybackState.ACTION_SKIP_TO_NEXT -> {
1201 MediaAction(
1202 context.getDrawable(R.drawable.ic_media_next),
1203 { controller.transportControls.skipToNext() },
1204 context.getString(R.string.controls_media_button_next),
1205 null
1206 )
1207 }
1208 else -> null
1209 }
1210 }
1211
1212 /** Check whether the actions from a [PlaybackState] include a specific action */
includesActionnull1213 private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean {
1214 if (
1215 (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) &&
1216 (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L)
1217 ) {
1218 return true
1219 }
1220 return (stateActions and action != 0L)
1221 }
1222
1223 /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */
getCustomActionnull1224 private fun getCustomAction(
1225 state: PlaybackState,
1226 packageName: String,
1227 controller: MediaController,
1228 customAction: PlaybackState.CustomAction
1229 ): MediaAction {
1230 return MediaAction(
1231 Icon.createWithResource(packageName, customAction.icon).loadDrawable(context),
1232 { controller.transportControls.sendCustomAction(customAction, customAction.extras) },
1233 customAction.name,
1234 null
1235 )
1236 }
1237
1238 /** Load a bitmap from the various Art metadata URIs */
loadBitmapFromUrinull1239 private fun loadBitmapFromUri(metadata: MediaMetadata): Bitmap? {
1240 for (uri in ART_URIS) {
1241 val uriString = metadata.getString(uri)
1242 if (!TextUtils.isEmpty(uriString)) {
1243 val albumArt = loadBitmapFromUri(Uri.parse(uriString))
1244 if (albumArt != null) {
1245 if (DEBUG) Log.d(TAG, "loaded art from $uri")
1246 return albumArt
1247 }
1248 }
1249 }
1250 return null
1251 }
1252
sendPendingIntentnull1253 private fun sendPendingIntent(intent: PendingIntent): Boolean {
1254 return try {
1255 val options = BroadcastOptions.makeBasic()
1256 options.setInteractive(true)
1257 options.setPendingIntentBackgroundActivityStartMode(
1258 BroadcastOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
1259 )
1260 intent.send(options.toBundle())
1261 true
1262 } catch (e: PendingIntent.CanceledException) {
1263 Log.d(TAG, "Intent canceled", e)
1264 false
1265 }
1266 }
1267
1268 /** Returns a bitmap if the user can access the given URI, else null */
loadBitmapFromUriForUsernull1269 private fun loadBitmapFromUriForUser(
1270 uri: Uri,
1271 userId: Int,
1272 appUid: Int,
1273 packageName: String,
1274 ): Bitmap? {
1275 try {
1276 val ugm = UriGrantsManager.getService()
1277 ugm.checkGrantUriPermission_ignoreNonSystem(
1278 appUid,
1279 packageName,
1280 ContentProvider.getUriWithoutUserId(uri),
1281 Intent.FLAG_GRANT_READ_URI_PERMISSION,
1282 ContentProvider.getUserIdFromUri(uri, userId)
1283 )
1284 return loadBitmapFromUri(uri)
1285 } catch (e: SecurityException) {
1286 Log.e(TAG, "Failed to get URI permission: $e")
1287 }
1288 return null
1289 }
1290
1291 /**
1292 * Load a bitmap from a URI
1293 *
1294 * @param uri the uri to load
1295 * @return bitmap, or null if couldn't be loaded
1296 */
loadBitmapFromUrinull1297 private fun loadBitmapFromUri(uri: Uri): Bitmap? {
1298 // ImageDecoder requires a scheme of the following types
1299 if (uri.scheme == null) {
1300 return null
1301 }
1302
1303 if (
1304 !uri.scheme.equals(ContentResolver.SCHEME_CONTENT) &&
1305 !uri.scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE) &&
1306 !uri.scheme.equals(ContentResolver.SCHEME_FILE)
1307 ) {
1308 return null
1309 }
1310
1311 val source = ImageDecoder.createSource(context.contentResolver, uri)
1312 return try {
1313 ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
1314 val width = info.size.width
1315 val height = info.size.height
1316 val scale =
1317 MediaDataUtils.getScaleFactor(
1318 APair(width, height),
1319 APair(artworkWidth, artworkHeight)
1320 )
1321
1322 // Downscale if needed
1323 if (scale != 0f && scale < 1) {
1324 decoder.setTargetSize((scale * width).toInt(), (scale * height).toInt())
1325 }
1326 decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
1327 }
1328 } catch (e: IOException) {
1329 Log.e(TAG, "Unable to load bitmap", e)
1330 null
1331 } catch (e: RuntimeException) {
1332 Log.e(TAG, "Unable to load bitmap", e)
1333 null
1334 }
1335 }
1336
getResumeMediaActionnull1337 private fun getResumeMediaAction(action: Runnable): MediaAction {
1338 return MediaAction(
1339 Icon.createWithResource(context, R.drawable.ic_media_play)
1340 .setTint(themeText)
1341 .loadDrawable(context),
1342 action,
1343 context.getString(R.string.controls_media_resume),
1344 context.getDrawable(R.drawable.ic_media_play_container)
1345 )
1346 }
1347
onMediaDataLoadednull1348 fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) =
1349 traceSection("MediaDataManager#onMediaDataLoaded") {
1350 Assert.isMainThread()
1351 if (mediaEntries.containsKey(key)) {
1352 // Otherwise this was removed already
1353 mediaEntries.put(key, data)
1354 notifyMediaDataLoaded(key, oldKey, data)
1355 }
1356 }
1357
onSmartspaceTargetsUpdatednull1358 override fun onSmartspaceTargetsUpdated(targets: List<Parcelable>) {
1359 if (!allowMediaRecommendations) {
1360 if (DEBUG) Log.d(TAG, "Smartspace recommendation is disabled in Settings.")
1361 return
1362 }
1363
1364 val mediaTargets = targets.filterIsInstance<SmartspaceTarget>()
1365 when (mediaTargets.size) {
1366 0 -> {
1367 if (!smartspaceMediaData.isActive) {
1368 return
1369 }
1370 if (DEBUG) {
1371 Log.d(TAG, "Set Smartspace media to be inactive for the data update")
1372 }
1373 if (mediaFlags.isPersistentSsCardEnabled()) {
1374 // Smartspace uses this signal to hide the card (e.g. when it expires or user
1375 // disconnects headphones), so treat as setting inactive when flag is on
1376 smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
1377 notifySmartspaceMediaDataLoaded(
1378 smartspaceMediaData.targetId,
1379 smartspaceMediaData,
1380 )
1381 } else {
1382 smartspaceMediaData =
1383 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
1384 targetId = smartspaceMediaData.targetId,
1385 instanceId = smartspaceMediaData.instanceId,
1386 )
1387 notifySmartspaceMediaDataRemoved(
1388 smartspaceMediaData.targetId,
1389 immediately = false,
1390 )
1391 }
1392 }
1393 1 -> {
1394 val newMediaTarget = mediaTargets.get(0)
1395 if (smartspaceMediaData.targetId == newMediaTarget.smartspaceTargetId) {
1396 // The same Smartspace updates can be received. Skip the duplicate updates.
1397 return
1398 }
1399 if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.")
1400 smartspaceMediaData = toSmartspaceMediaData(newMediaTarget)
1401 notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
1402 }
1403 else -> {
1404 // There should NOT be more than 1 Smartspace media update. When it happens, it
1405 // indicates a bad state or an error. Reset the status accordingly.
1406 Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...")
1407 notifySmartspaceMediaDataRemoved(
1408 smartspaceMediaData.targetId,
1409 immediately = false,
1410 )
1411 smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
1412 }
1413 }
1414 }
1415
onNotificationRemovednull1416 fun onNotificationRemoved(key: String) {
1417 Assert.isMainThread()
1418 val removed = mediaEntries.remove(key) ?: return
1419 if (keyguardUpdateMonitor.isUserInLockdown(removed.userId)) {
1420 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1421 } else if (isAbleToResume(removed)) {
1422 convertToResumePlayer(key, removed)
1423 } else if (mediaFlags.isRetainingPlayersEnabled()) {
1424 handlePossibleRemoval(key, removed, notificationRemoved = true)
1425 } else {
1426 notifyMediaDataRemoved(key)
1427 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1428 }
1429 }
1430
onSessionDestroyednull1431 private fun onSessionDestroyed(key: String) {
1432 if (!mediaFlags.isRetainingPlayersEnabled()) return
1433
1434 if (DEBUG) Log.d(TAG, "session destroyed for $key")
1435 val entry = mediaEntries.remove(key) ?: return
1436 // Clear token since the session is no longer valid
1437 val updated = entry.copy(token = null)
1438 handlePossibleRemoval(key, updated)
1439 }
1440
isAbleToResumenull1441 private fun isAbleToResume(data: MediaData): Boolean {
1442 val isEligibleForResume =
1443 data.isLocalSession() ||
1444 (mediaFlags.isRemoteResumeAllowed() &&
1445 data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE)
1446 return useMediaResumption && data.resumeAction != null && isEligibleForResume
1447 }
1448
1449 /**
1450 * Convert to resume state if the player is no longer valid and active, then notify listeners
1451 * that the data was updated. Does not convert to resume state if the player is still valid, or
1452 * if it was removed before becoming inactive. (Assumes that [removed] was removed from
1453 * [mediaEntries] before this function was called)
1454 */
handlePossibleRemovalnull1455 private fun handlePossibleRemoval(
1456 key: String,
1457 removed: MediaData,
1458 notificationRemoved: Boolean = false
1459 ) {
1460 val hasSession = removed.token != null
1461 if (hasSession && removed.semanticActions != null) {
1462 // The app was using session actions, and the session is still valid: keep player
1463 if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
1464 mediaEntries.put(key, removed)
1465 notifyMediaDataLoaded(key, key, removed)
1466 } else if (!notificationRemoved && removed.semanticActions == null) {
1467 // The app was using notification actions, and notif wasn't removed yet: keep player
1468 if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
1469 mediaEntries.put(key, removed)
1470 notifyMediaDataLoaded(key, key, removed)
1471 } else if (removed.active && !isAbleToResume(removed)) {
1472 // This player was still active - it didn't last long enough to time out,
1473 // and its app doesn't normally support resume: remove
1474 if (DEBUG) Log.d(TAG, "Removing still-active player $key")
1475 notifyMediaDataRemoved(key)
1476 logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
1477 } else {
1478 // Convert to resume
1479 if (DEBUG) {
1480 Log.d(
1481 TAG,
1482 "Notification ($notificationRemoved) and/or session " +
1483 "($hasSession) gone for inactive player $key"
1484 )
1485 }
1486 convertToResumePlayer(key, removed)
1487 }
1488 }
1489
1490 /** Set the given [MediaData] as a resume state player and notify listeners */
convertToResumePlayernull1491 private fun convertToResumePlayer(key: String, data: MediaData) {
1492 if (DEBUG) Log.d(TAG, "Converting $key to resume")
1493 // Resumption controls must have a title.
1494 if (data.song.isNullOrBlank()) {
1495 Log.e(TAG, "Description incomplete")
1496 notifyMediaDataRemoved(key)
1497 logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
1498 return
1499 }
1500 // Move to resume key (aka package name) if that key doesn't already exist.
1501 val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
1502 val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
1503 val launcherIntent =
1504 context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
1505 PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
1506 }
1507 val updated =
1508 data.copy(
1509 token = null,
1510 actions = actions,
1511 semanticActions = MediaButton(playOrPause = resumeAction),
1512 actionsToShowInCompact = listOf(0),
1513 active = false,
1514 resumption = true,
1515 isPlaying = false,
1516 isClearable = true,
1517 clickIntent = launcherIntent,
1518 )
1519 val pkg = data.packageName
1520 val migrate = mediaEntries.put(pkg, updated) == null
1521 // Notify listeners of "new" controls when migrating or removed and update when not
1522 Log.d(TAG, "migrating? $migrate from $key -> $pkg")
1523 if (migrate) {
1524 notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
1525 } else {
1526 // Since packageName is used for the key of the resumption controls, it is
1527 // possible that another notification has already been reused for the resumption
1528 // controls of this package. In this case, rather than renaming this player as
1529 // packageName, just remove it and then send a update to the existing resumption
1530 // controls.
1531 notifyMediaDataRemoved(key)
1532 notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
1533 }
1534 logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
1535
1536 // Limit total number of resume controls
1537 val resumeEntries = mediaEntries.filter { (key, data) -> data.resumption }
1538 val numResume = resumeEntries.size
1539 if (numResume > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
1540 resumeEntries
1541 .toList()
1542 .sortedBy { (key, data) -> data.lastActive }
1543 .subList(0, numResume - ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS)
1544 .forEach { (key, data) ->
1545 Log.d(TAG, "Removing excess control $key")
1546 mediaEntries.remove(key)
1547 notifyMediaDataRemoved(key)
1548 logger.logMediaRemoved(data.appUid, data.packageName, data.instanceId)
1549 }
1550 }
1551 }
1552
setMediaResumptionEnablednull1553 fun setMediaResumptionEnabled(isEnabled: Boolean) {
1554 if (useMediaResumption == isEnabled) {
1555 return
1556 }
1557
1558 useMediaResumption = isEnabled
1559
1560 if (!useMediaResumption) {
1561 // Remove any existing resume controls
1562 val filtered = mediaEntries.filter { !it.value.active }
1563 filtered.forEach {
1564 mediaEntries.remove(it.key)
1565 notifyMediaDataRemoved(it.key)
1566 logger.logMediaRemoved(it.value.appUid, it.value.packageName, it.value.instanceId)
1567 }
1568 }
1569 }
1570
1571 /** Invoked when the user has dismissed the media carousel */
onSwipeToDismissnull1572 fun onSwipeToDismiss() = mediaDataFilter.onSwipeToDismiss()
1573
1574 /** Are there any media notifications active, including the recommendations? */
1575 fun hasActiveMediaOrRecommendation() = mediaDataFilter.hasActiveMediaOrRecommendation()
1576
1577 /**
1578 * Are there any media entries we should display, including the recommendations?
1579 * - If resumption is enabled, this will include inactive players
1580 * - If resumption is disabled, we only want to show active players
1581 */
1582 fun hasAnyMediaOrRecommendation() = mediaDataFilter.hasAnyMediaOrRecommendation()
1583
1584 /** Are there any resume media notifications active, excluding the recommendations? */
1585 fun hasActiveMedia() = mediaDataFilter.hasActiveMedia()
1586
1587 /**
1588 * Are there any resume media notifications active, excluding the recommendations?
1589 * - If resumption is enabled, this will include inactive players
1590 * - If resumption is disabled, we only want to show active players
1591 */
1592 fun hasAnyMedia() = mediaDataFilter.hasAnyMedia()
1593
1594 interface Listener {
1595
1596 /**
1597 * Called whenever there's new MediaData Loaded for the consumption in views.
1598 *
1599 * oldKey is provided to check whether the view has changed keys, which can happen when a
1600 * player has gone from resume state (key is package name) to active state (key is
1601 * notification key) or vice versa.
1602 *
1603 * @param immediately indicates should apply the UI changes immediately, otherwise wait
1604 * until the next refresh-round before UI becomes visible. True by default to take in
1605 * place immediately.
1606 * @param receivedSmartspaceCardLatency is the latency between headphone connects and sysUI
1607 * displays Smartspace media targets. Will be 0 if the data is not activated by Smartspace
1608 * signal.
1609 * @param isSsReactivated indicates resume media card is reactivated by Smartspace
1610 * recommendation signal
1611 */
1612 fun onMediaDataLoaded(
1613 key: String,
1614 oldKey: String?,
1615 data: MediaData,
1616 immediately: Boolean = true,
1617 receivedSmartspaceCardLatency: Int = 0,
1618 isSsReactivated: Boolean = false
1619 ) {}
1620
1621 /**
1622 * Called whenever there's new Smartspace media data loaded.
1623 *
1624 * @param shouldPrioritize indicates the sorting priority of the Smartspace card. If true,
1625 * it will be prioritized as the first card. Otherwise, it will show up as the last card
1626 * as default.
1627 */
1628 fun onSmartspaceMediaDataLoaded(
1629 key: String,
1630 data: SmartspaceMediaData,
1631 shouldPrioritize: Boolean = false
1632 ) {}
1633
1634 /** Called whenever a previously existing Media notification was removed. */
1635 fun onMediaDataRemoved(key: String) {}
1636
1637 /**
1638 * Called whenever a previously existing Smartspace media data was removed.
1639 *
1640 * @param immediately indicates should apply the UI changes immediately, otherwise wait
1641 * until the next refresh-round before UI becomes visible. True by default to take in
1642 * place immediately.
1643 */
1644 fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true) {}
1645 }
1646
1647 /**
1648 * Converts the pass-in SmartspaceTarget to SmartspaceMediaData
1649 *
1650 * @return An empty SmartspaceMediaData with the valid target Id is returned if the
1651 * SmartspaceTarget's data is invalid.
1652 */
toSmartspaceMediaDatanull1653 private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData {
1654 val baseAction: SmartspaceAction? = target.baseAction
1655 val dismissIntent =
1656 baseAction?.extras?.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY) as Intent?
1657
1658 val isActive =
1659 when {
1660 !mediaFlags.isPersistentSsCardEnabled() -> true
1661 baseAction == null -> true
1662 else -> {
1663 val triggerSource = baseAction.extras?.getString(EXTRA_KEY_TRIGGER_SOURCE)
1664 triggerSource != EXTRA_VALUE_TRIGGER_PERIODIC
1665 }
1666 }
1667
1668 packageName(target)?.let {
1669 return SmartspaceMediaData(
1670 targetId = target.smartspaceTargetId,
1671 isActive = isActive,
1672 packageName = it,
1673 cardAction = target.baseAction,
1674 recommendations = target.iconGrid,
1675 dismissIntent = dismissIntent,
1676 headphoneConnectionTimeMillis = target.creationTimeMillis,
1677 instanceId = logger.getNewInstanceId(),
1678 expiryTimeMs = target.expiryTimeMillis,
1679 )
1680 }
1681 return EMPTY_SMARTSPACE_MEDIA_DATA.copy(
1682 targetId = target.smartspaceTargetId,
1683 isActive = isActive,
1684 dismissIntent = dismissIntent,
1685 headphoneConnectionTimeMillis = target.creationTimeMillis,
1686 instanceId = logger.getNewInstanceId(),
1687 expiryTimeMs = target.expiryTimeMillis,
1688 )
1689 }
1690
packageNamenull1691 private fun packageName(target: SmartspaceTarget): String? {
1692 val recommendationList = target.iconGrid
1693 if (recommendationList == null || recommendationList.isEmpty()) {
1694 Log.w(TAG, "Empty or null media recommendation list.")
1695 return null
1696 }
1697 for (recommendation in recommendationList) {
1698 val extras = recommendation.extras
1699 extras?.let {
1700 it.getString(EXTRAS_MEDIA_SOURCE_PACKAGE_NAME)?.let { packageName ->
1701 return packageName
1702 }
1703 }
1704 }
1705 Log.w(TAG, "No valid package name is provided.")
1706 return null
1707 }
1708
dumpnull1709 override fun dump(pw: PrintWriter, args: Array<out String>) {
1710 pw.apply {
1711 println("internalListeners: $internalListeners")
1712 println("externalListeners: ${mediaDataFilter.listeners}")
1713 println("mediaEntries: $mediaEntries")
1714 println("useMediaResumption: $useMediaResumption")
1715 println("allowMediaRecommendations: $allowMediaRecommendations")
1716 }
1717 }
1718 }
1719