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