1 package com.android.systemui.media
2 
3 import android.content.Context
4 import android.content.Intent
5 import android.content.res.ColorStateList
6 import android.content.res.Configuration
7 import android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS
8 import android.util.Log
9 import android.util.MathUtils
10 import android.view.LayoutInflater
11 import android.view.View
12 import android.view.ViewGroup
13 import android.widget.LinearLayout
14 import androidx.annotation.VisibleForTesting
15 import com.android.systemui.Dumpable
16 import com.android.systemui.R
17 import com.android.systemui.classifier.FalsingCollector
18 import com.android.systemui.dagger.SysUISingleton
19 import com.android.systemui.dagger.qualifiers.Main
20 import com.android.systemui.dump.DumpManager
21 import com.android.systemui.plugins.ActivityStarter
22 import com.android.systemui.plugins.FalsingManager
23 import com.android.systemui.qs.PageIndicator
24 import com.android.systemui.shared.system.SysUiStatsLog
25 import com.android.systemui.statusbar.notification.collection.legacy.VisualStabilityManager
26 import com.android.systemui.statusbar.policy.ConfigurationController
27 import com.android.systemui.util.Utils
28 import com.android.systemui.util.animation.UniqueObjectHostView
29 import com.android.systemui.util.animation.requiresRemeasuring
30 import com.android.systemui.util.concurrency.DelayableExecutor
31 import com.android.systemui.util.time.SystemClock
32 import java.io.FileDescriptor
33 import java.io.PrintWriter
34 import java.util.TreeMap
35 import javax.inject.Inject
36 import javax.inject.Provider
37 
38 private const val TAG = "MediaCarouselController"
39 private val settingsIntent = Intent().setAction(ACTION_MEDIA_CONTROLS_SETTINGS)
40 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
41 
42 /**
43  * Class that is responsible for keeping the view carousel up to date.
44  * This also handles changes in state and applies them to the media carousel like the expansion.
45  */
46 @SysUISingleton
47 class MediaCarouselController @Inject constructor(
48     private val context: Context,
49     private val mediaControlPanelFactory: Provider<MediaControlPanel>,
50     private val visualStabilityManager: VisualStabilityManager,
51     private val mediaHostStatesManager: MediaHostStatesManager,
52     private val activityStarter: ActivityStarter,
53     private val systemClock: SystemClock,
54     @Main executor: DelayableExecutor,
55     private val mediaManager: MediaDataManager,
56     configurationController: ConfigurationController,
57     falsingCollector: FalsingCollector,
58     falsingManager: FalsingManager,
59     dumpManager: DumpManager
60 ) : Dumpable {
61     /**
62      * The current width of the carousel
63      */
64     private var currentCarouselWidth: Int = 0
65 
66     /**
67      * The current height of the carousel
68      */
69     private var currentCarouselHeight: Int = 0
70 
71     /**
72      * Are we currently showing only active players
73      */
74     private var currentlyShowingOnlyActive: Boolean = false
75 
76     /**
77      * Is the player currently visible (at the end of the transformation
78      */
79     private var playersVisible: Boolean = false
80     /**
81      * The desired location where we'll be at the end of the transformation. Usually this matches
82      * the end location, except when we're still waiting on a state update call.
83      */
84     @MediaLocation
85     private var desiredLocation: Int = -1
86 
87     /**
88      * The ending location of the view where it ends when all animations and transitions have
89      * finished
90      */
91     @MediaLocation
92     private var currentEndLocation: Int = -1
93 
94     /**
95      * The ending location of the view where it ends when all animations and transitions have
96      * finished
97      */
98     @MediaLocation
99     private var currentStartLocation: Int = -1
100 
101     /**
102      * The progress of the transition or 1.0 if there is no transition happening
103      */
104     private var currentTransitionProgress: Float = 1.0f
105 
106     /**
107      * The measured width of the carousel
108      */
109     private var carouselMeasureWidth: Int = 0
110 
111     /**
112      * The measured height of the carousel
113      */
114     private var carouselMeasureHeight: Int = 0
115     private var desiredHostState: MediaHostState? = null
116     private val mediaCarousel: MediaScrollView
117     val mediaCarouselScrollHandler: MediaCarouselScrollHandler
118     val mediaFrame: ViewGroup
119     private lateinit var settingsButton: View
120     private val mediaContent: ViewGroup
121     private val pageIndicator: PageIndicator
122     private val visualStabilityCallback: VisualStabilityManager.Callback
123     private var needsReordering: Boolean = false
124     private var keysNeedRemoval = mutableSetOf<String>()
125     private var bgColor = getBackgroundColor()
126     protected var shouldScrollToActivePlayer: Boolean = false
127     private var isRtl: Boolean = false
128         set(value) {
129             if (value != field) {
130                 field = value
131                 mediaFrame.layoutDirection =
132                         if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
133                 mediaCarouselScrollHandler.scrollToStart()
134             }
135         }
136     private var currentlyExpanded = true
137         set(value) {
138             if (field != value) {
139                 field = value
140                 for (player in MediaPlayerData.players()) {
141                     player.setListening(field)
142                 }
143             }
144         }
145     private val configListener = object : ConfigurationController.ConfigurationListener {
146         override fun onDensityOrFontScaleChanged() {
147             recreatePlayers()
148             inflateSettingsButton()
149         }
150 
151         override fun onThemeChanged() {
152             recreatePlayers()
153             inflateSettingsButton()
154         }
155 
156         override fun onConfigChanged(newConfig: Configuration?) {
157             if (newConfig == null) return
158             isRtl = newConfig.layoutDirection == View.LAYOUT_DIRECTION_RTL
159         }
160 
161         override fun onUiModeChanged() {
162             recreatePlayers()
163             inflateSettingsButton()
164         }
165     }
166 
167     /**
168      * Update MediaCarouselScrollHandler.visibleToUser to reflect media card container visibility.
169      * It will be called when the container is out of view.
170      */
171     lateinit var updateUserVisibility: () -> Unit
172 
173     init {
174         dumpManager.registerDumpable(TAG, this)
175         mediaFrame = inflateMediaCarousel()
176         mediaCarousel = mediaFrame.requireViewById(R.id.media_carousel_scroller)
177         pageIndicator = mediaFrame.requireViewById(R.id.media_page_indicator)
178         mediaCarouselScrollHandler = MediaCarouselScrollHandler(mediaCarousel, pageIndicator,
179                 executor, this::onSwipeToDismiss, this::updatePageIndicatorLocation,
180                 this::closeGuts, falsingCollector, falsingManager, this::logSmartspaceImpression)
181         isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
182         inflateSettingsButton()
183         mediaContent = mediaCarousel.requireViewById(R.id.media_carousel)
184         configurationController.addCallback(configListener)
185         // TODO (b/162832756): remove visual stability manager when migrating to new pipeline
186         visualStabilityCallback = VisualStabilityManager.Callback {
187             if (needsReordering) {
188                 needsReordering = false
189                 reorderAllPlayers(previousVisiblePlayerKey = null)
190             }
191 
192             keysNeedRemoval.forEach { removePlayer(it) }
193             keysNeedRemoval.clear()
194 
195             // Update user visibility so that no extra impression will be logged when
196             // activeMediaIndex resets to 0
197             if (this::updateUserVisibility.isInitialized) {
198                 updateUserVisibility()
199             }
200 
201             // Let's reset our scroll position
202             mediaCarouselScrollHandler.scrollToStart()
203         }
204         visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback,
205                 true /* persistent */)
206         mediaManager.addListener(object : MediaDataManager.Listener {
207             override fun onMediaDataLoaded(
208                 key: String,
209                 oldKey: String?,
210                 data: MediaData,
211                 immediately: Boolean,
212                 receivedSmartspaceCardLatency: Int
213             ) {
214                 if (addOrUpdatePlayer(key, oldKey, data)) {
215                     // Log card received if a new resumable media card is added
216                     MediaPlayerData.getMediaPlayer(key)?.let {
217                         /* ktlint-disable max-line-length */
218                         logSmartspaceCardReported(759, // SMARTSPACE_CARD_RECEIVED
219                                 it.mInstanceId,
220                                 it.mUid,
221                                 /* isRecommendationCard */ false,
222                                 intArrayOf(
223                                         SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
224                                         SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN),
225                                 rank = MediaPlayerData.getMediaPlayerIndex(key))
226                         /* ktlint-disable max-line-length */
227                     }
228                     if (mediaCarouselScrollHandler.visibleToUser &&
229                             mediaCarouselScrollHandler.visibleMediaIndex
230                             == MediaPlayerData.getMediaPlayerIndex(key)) {
231                         logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
232                     }
233                 } else if (receivedSmartspaceCardLatency != 0) {
234                     // Log resume card received if resumable media card is reactivated and
235                     // resume card is ranked first
236                     MediaPlayerData.players().forEachIndexed { index, it ->
237                         if (it.recommendationViewHolder == null) {
238                             it.mInstanceId = SmallHash.hash(it.mUid +
239                                     systemClock.currentTimeMillis().toInt())
240                             it.mIsImpressed = false
241                             /* ktlint-disable max-line-length */
242                             logSmartspaceCardReported(759, // SMARTSPACE_CARD_RECEIVED
243                                     it.mInstanceId,
244                                     it.mUid,
245                                     /* isRecommendationCard */ false,
246                                     intArrayOf(
247                                             SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
248                                             SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN),
249                                     rank = index,
250                                     receivedLatencyMillis = receivedSmartspaceCardLatency)
251                             /* ktlint-disable max-line-length */
252                         }
253                     }
254                     // If media container area already visible to the user, log impression for
255                     // reactivated card.
256                     if (mediaCarouselScrollHandler.visibleToUser &&
257                             !mediaCarouselScrollHandler.qsExpanded) {
258                         logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
259                     }
260                 }
261 
262                 val canRemove = data.isPlaying?.let { !it } ?: data.isClearable && !data.active
263                 if (canRemove && !Utils.useMediaResumption(context)) {
264                     // This view isn't playing, let's remove this! This happens e.g when
265                     // dismissing/timing out a view. We still have the data around because
266                     // resumption could be on, but we should save the resources and release this.
267                     if (visualStabilityManager.isReorderingAllowed) {
268                         onMediaDataRemoved(key)
269                     } else {
270                         keysNeedRemoval.add(key)
271                     }
272                 } else {
273                     keysNeedRemoval.remove(key)
274                 }
275             }
276 
277             override fun onSmartspaceMediaDataLoaded(
278                 key: String,
279                 data: SmartspaceMediaData,
280                 shouldPrioritize: Boolean,
281                 isSsReactivated: Boolean
282             ) {
283                 if (DEBUG) Log.d(TAG, "Loading Smartspace media update")
284                 if (data.isActive) {
285                     if (isSsReactivated && shouldPrioritize) {
286                         // Log resume card received if resumable media card is reactivated and
287                         // recommendation card is valid and ranked first
288                         MediaPlayerData.players().forEachIndexed { index, it ->
289                             if (it.recommendationViewHolder == null) {
290                                 it.mInstanceId = SmallHash.hash(it.mUid +
291                                         systemClock.currentTimeMillis().toInt())
292                                 it.mIsImpressed = false
293                                 /* ktlint-disable max-line-length */
294                                 logSmartspaceCardReported(759, // SMARTSPACE_CARD_RECEIVED
295                                         it.mInstanceId,
296                                         it.mUid,
297                                         /* isRecommendationCard */ false,
298                                         intArrayOf(
299                                                 SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
300                                                 SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN),
301                                         rank = index,
302                                         receivedLatencyMillis = (systemClock.currentTimeMillis() - data.headphoneConnectionTimeMillis).toInt())
303                                 /* ktlint-disable max-line-length */
304                             }
305                         }
306                     }
307                     addSmartspaceMediaRecommendations(key, data, shouldPrioritize)
308                     MediaPlayerData.getMediaPlayer(key)?.let {
309                         /* ktlint-disable max-line-length */
310                         logSmartspaceCardReported(759, // SMARTSPACE_CARD_RECEIVED
311                                 it.mInstanceId,
312                                 it.mUid,
313                                 /* isRecommendationCard */ true,
314                                 intArrayOf(
315                                         SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
316                                         SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN),
317                                 rank = MediaPlayerData.getMediaPlayerIndex(key),
318                                 receivedLatencyMillis = (systemClock.currentTimeMillis() - data.headphoneConnectionTimeMillis).toInt())
319                         /* ktlint-disable max-line-length */
320                     }
321                     if (mediaCarouselScrollHandler.visibleToUser &&
322                             mediaCarouselScrollHandler.visibleMediaIndex
323                             == MediaPlayerData.getMediaPlayerIndex(key)) {
324                         logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
325                     }
326                 } else {
327                     onSmartspaceMediaDataRemoved(data.targetId, immediately = true)
328                 }
329             }
330 
331             override fun onMediaDataRemoved(key: String) {
332                 removePlayer(key)
333             }
334 
335             override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
336                 if (DEBUG) Log.d(TAG, "My Smartspace media removal request is received")
337                 if (immediately || visualStabilityManager.isReorderingAllowed) {
338                     onMediaDataRemoved(key)
339                 } else {
340                     keysNeedRemoval.add(key)
341                 }
342             }
343         })
344         mediaFrame.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
345             // The pageIndicator is not laid out yet when we get the current state update,
346             // Lets make sure we have the right dimensions
347             updatePageIndicatorLocation()
348         }
349         mediaHostStatesManager.addCallback(object : MediaHostStatesManager.Callback {
350             override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
351                 if (location == desiredLocation) {
352                     onDesiredLocationChanged(desiredLocation, mediaHostState, animate = false)
353                 }
354             }
355         })
356     }
357 
358     private fun inflateSettingsButton() {
359         val settings = LayoutInflater.from(context).inflate(R.layout.media_carousel_settings_button,
360                 mediaFrame, false) as View
361         if (this::settingsButton.isInitialized) {
362             mediaFrame.removeView(settingsButton)
363         }
364         settingsButton = settings
365         mediaFrame.addView(settingsButton)
366         mediaCarouselScrollHandler.onSettingsButtonUpdated(settings)
367         settingsButton.setOnClickListener {
368             activityStarter.startActivity(settingsIntent, true /* dismissShade */)
369         }
370     }
371 
372     private fun inflateMediaCarousel(): ViewGroup {
373         val mediaCarousel = LayoutInflater.from(context).inflate(R.layout.media_carousel,
374                 UniqueObjectHostView(context), false) as ViewGroup
375         // Because this is inflated when not attached to the true view hierarchy, it resolves some
376         // potential issues to force that the layout direction is defined by the locale
377         // (rather than inherited from the parent, which would resolve to LTR when unattached).
378         mediaCarousel.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
379         return mediaCarousel
380     }
381 
382     private fun reorderAllPlayers(previousVisiblePlayerKey: MediaPlayerData.MediaSortKey?) {
383         mediaContent.removeAllViews()
384         for (mediaPlayer in MediaPlayerData.players()) {
385             mediaPlayer.playerViewHolder?.let {
386                 mediaContent.addView(it.player)
387             } ?: mediaPlayer.recommendationViewHolder?.let {
388                 mediaContent.addView(it.recommendations)
389             }
390         }
391         mediaCarouselScrollHandler.onPlayersChanged()
392 
393         // Automatically scroll to the active player if needed
394         if (shouldScrollToActivePlayer) {
395             shouldScrollToActivePlayer = false
396             val activeMediaIndex = MediaPlayerData.firstActiveMediaIndex()
397             if (activeMediaIndex != -1) {
398                 previousVisiblePlayerKey?.let {
399                     val previousVisibleIndex = MediaPlayerData.playerKeys()
400                             .indexOfFirst { key -> it == key }
401                     mediaCarouselScrollHandler
402                             .scrollToPlayer(previousVisibleIndex, activeMediaIndex)
403                 } ?: {
404                     mediaCarouselScrollHandler.scrollToPlayer(destIndex = activeMediaIndex)
405                 }
406             }
407         }
408     }
409 
410     // Returns true if new player is added
411     private fun addOrUpdatePlayer(key: String, oldKey: String?, data: MediaData): Boolean {
412         val dataCopy = data.copy(backgroundColor = bgColor)
413         MediaPlayerData.moveIfExists(oldKey, key)
414         val existingPlayer = MediaPlayerData.getMediaPlayer(key)
415         val curVisibleMediaKey = MediaPlayerData.playerKeys()
416                 .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
417         if (existingPlayer == null) {
418             var newPlayer = mediaControlPanelFactory.get()
419             newPlayer.attachPlayer(
420                     PlayerViewHolder.create(LayoutInflater.from(context), mediaContent))
421             newPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
422             val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
423                     ViewGroup.LayoutParams.WRAP_CONTENT)
424             newPlayer.playerViewHolder?.player?.setLayoutParams(lp)
425             newPlayer.bindPlayer(dataCopy, key)
426             newPlayer.setListening(currentlyExpanded)
427             MediaPlayerData.addMediaPlayer(key, dataCopy, newPlayer, systemClock)
428             updatePlayerToState(newPlayer, noAnimation = true)
429             reorderAllPlayers(curVisibleMediaKey)
430         } else {
431             existingPlayer.bindPlayer(dataCopy, key)
432             MediaPlayerData.addMediaPlayer(key, dataCopy, existingPlayer, systemClock)
433             if (visualStabilityManager.isReorderingAllowed || shouldScrollToActivePlayer) {
434                 reorderAllPlayers(curVisibleMediaKey)
435             } else {
436                 needsReordering = true
437             }
438         }
439         updatePageIndicator()
440         mediaCarouselScrollHandler.onPlayersChanged()
441         mediaFrame.requiresRemeasuring = true
442         // Check postcondition: mediaContent should have the same number of children as there are
443         // elements in mediaPlayers.
444         if (MediaPlayerData.players().size != mediaContent.childCount) {
445             Log.wtf(TAG, "Size of players list and number of views in carousel are out of sync")
446         }
447         return existingPlayer == null
448     }
449 
450     private fun addSmartspaceMediaRecommendations(
451         key: String,
452         data: SmartspaceMediaData,
453         shouldPrioritize: Boolean
454     ) {
455         if (DEBUG) Log.d(TAG, "Updating smartspace target in carousel")
456         if (MediaPlayerData.getMediaPlayer(key) != null) {
457             Log.w(TAG, "Skip adding smartspace target in carousel")
458             return
459         }
460 
461         val existingSmartspaceMediaKey = MediaPlayerData.smartspaceMediaKey()
462         existingSmartspaceMediaKey?.let {
463             MediaPlayerData.removeMediaPlayer(existingSmartspaceMediaKey)
464         }
465 
466         var newRecs = mediaControlPanelFactory.get()
467         newRecs.attachRecommendation(
468                 RecommendationViewHolder.create(LayoutInflater.from(context), mediaContent))
469         newRecs.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
470         val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
471                 ViewGroup.LayoutParams.WRAP_CONTENT)
472         newRecs.recommendationViewHolder?.recommendations?.setLayoutParams(lp)
473         newRecs.bindRecommendation(data.copy(backgroundColor = bgColor))
474         val curVisibleMediaKey = MediaPlayerData.playerKeys()
475                 .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
476         MediaPlayerData.addMediaRecommendation(key, data, newRecs, shouldPrioritize, systemClock)
477         updatePlayerToState(newRecs, noAnimation = true)
478         reorderAllPlayers(curVisibleMediaKey)
479         updatePageIndicator()
480         mediaFrame.requiresRemeasuring = true
481         // Check postcondition: mediaContent should have the same number of children as there are
482         // elements in mediaPlayers.
483         if (MediaPlayerData.players().size != mediaContent.childCount) {
484             Log.wtf(TAG, "Size of players list and number of views in carousel are out of sync")
485         }
486     }
487 
488     fun removePlayer(
489         key: String,
490         dismissMediaData: Boolean = true,
491         dismissRecommendation: Boolean = true
492     ) {
493         val removed = MediaPlayerData.removeMediaPlayer(key)
494         removed?.apply {
495             mediaCarouselScrollHandler.onPrePlayerRemoved(removed)
496             mediaContent.removeView(removed.playerViewHolder?.player)
497             mediaContent.removeView(removed.recommendationViewHolder?.recommendations)
498             removed.onDestroy()
499             mediaCarouselScrollHandler.onPlayersChanged()
500             updatePageIndicator()
501 
502             if (dismissMediaData) {
503                 // Inform the media manager of a potentially late dismissal
504                 mediaManager.dismissMediaData(key, delay = 0L)
505             }
506             if (dismissRecommendation) {
507                 // Inform the media manager of a potentially late dismissal
508                 mediaManager.dismissSmartspaceRecommendation(key, delay = 0L)
509             }
510         }
511     }
512 
513     private fun recreatePlayers() {
514         bgColor = getBackgroundColor()
515         pageIndicator.tintList = ColorStateList.valueOf(getForegroundColor())
516 
517         MediaPlayerData.mediaData().forEach { (key, data, isSsMediaRec) ->
518             if (isSsMediaRec) {
519                 val smartspaceMediaData = MediaPlayerData.smartspaceMediaData
520                 removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
521                 smartspaceMediaData?.let {
522                     addSmartspaceMediaRecommendations(
523                             it.targetId, it, MediaPlayerData.shouldPrioritizeSs)
524                 }
525             } else {
526                 removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
527                 addOrUpdatePlayer(key = key, oldKey = null, data = data)
528             }
529         }
530     }
531 
532     private fun getBackgroundColor(): Int {
533         return context.getColor(android.R.color.system_accent2_50)
534     }
535 
536     private fun getForegroundColor(): Int {
537         return context.getColor(android.R.color.system_accent2_900)
538     }
539 
540     private fun updatePageIndicator() {
541         val numPages = mediaContent.getChildCount()
542         pageIndicator.setNumPages(numPages)
543         if (numPages == 1) {
544             pageIndicator.setLocation(0f)
545         }
546         updatePageIndicatorAlpha()
547     }
548 
549     /**
550      * Set a new interpolated state for all players. This is a state that is usually controlled
551      * by a finger movement where the user drags from one state to the next.
552      *
553      * @param startLocation the start location of our state or -1 if this is directly set
554      * @param endLocation the ending location of our state.
555      * @param progress the progress of the transition between startLocation and endlocation. If
556      *                 this is not a guided transformation, this will be 1.0f
557      * @param immediately should this state be applied immediately, canceling all animations?
558      */
559     fun setCurrentState(
560         @MediaLocation startLocation: Int,
561         @MediaLocation endLocation: Int,
562         progress: Float,
563         immediately: Boolean
564     ) {
565         if (startLocation != currentStartLocation ||
566                 endLocation != currentEndLocation ||
567                 progress != currentTransitionProgress ||
568                 immediately
569         ) {
570             currentStartLocation = startLocation
571             currentEndLocation = endLocation
572             currentTransitionProgress = progress
573             for (mediaPlayer in MediaPlayerData.players()) {
574                 updatePlayerToState(mediaPlayer, immediately)
575             }
576             maybeResetSettingsCog()
577             updatePageIndicatorAlpha()
578         }
579     }
580 
581     private fun updatePageIndicatorAlpha() {
582         val hostStates = mediaHostStatesManager.mediaHostStates
583         val endIsVisible = hostStates[currentEndLocation]?.visible ?: false
584         val startIsVisible = hostStates[currentStartLocation]?.visible ?: false
585         val startAlpha = if (startIsVisible) 1.0f else 0.0f
586         val endAlpha = if (endIsVisible) 1.0f else 0.0f
587         var alpha = 1.0f
588         if (!endIsVisible || !startIsVisible) {
589             var progress = currentTransitionProgress
590             if (!endIsVisible) {
591                 progress = 1.0f - progress
592             }
593             // Let's fade in quickly at the end where the view is visible
594             progress = MathUtils.constrain(
595                     MathUtils.map(0.95f, 1.0f, 0.0f, 1.0f, progress),
596                     0.0f,
597                     1.0f)
598             alpha = MathUtils.lerp(startAlpha, endAlpha, progress)
599         }
600         pageIndicator.alpha = alpha
601     }
602 
603     private fun updatePageIndicatorLocation() {
604         // Update the location of the page indicator, carousel clipping
605         val translationX = if (isRtl) {
606             (pageIndicator.width - currentCarouselWidth) / 2.0f
607         } else {
608             (currentCarouselWidth - pageIndicator.width) / 2.0f
609         }
610         pageIndicator.translationX = translationX + mediaCarouselScrollHandler.contentTranslation
611         val layoutParams = pageIndicator.layoutParams as ViewGroup.MarginLayoutParams
612         pageIndicator.translationY = (currentCarouselHeight - pageIndicator.height -
613                 layoutParams.bottomMargin).toFloat()
614     }
615 
616     /**
617      * Update the dimension of this carousel.
618      */
619     private fun updateCarouselDimensions() {
620         var width = 0
621         var height = 0
622         for (mediaPlayer in MediaPlayerData.players()) {
623             val controller = mediaPlayer.mediaViewController
624             // When transitioning the view to gone, the view gets smaller, but the translation
625             // Doesn't, let's add the translation
626             width = Math.max(width, controller.currentWidth + controller.translationX.toInt())
627             height = Math.max(height, controller.currentHeight + controller.translationY.toInt())
628         }
629         if (width != currentCarouselWidth || height != currentCarouselHeight) {
630             currentCarouselWidth = width
631             currentCarouselHeight = height
632             mediaCarouselScrollHandler.setCarouselBounds(
633                     currentCarouselWidth, currentCarouselHeight)
634             updatePageIndicatorLocation()
635         }
636     }
637 
638     private fun maybeResetSettingsCog() {
639         val hostStates = mediaHostStatesManager.mediaHostStates
640         val endShowsActive = hostStates[currentEndLocation]?.showsOnlyActiveMedia
641                 ?: true
642         val startShowsActive = hostStates[currentStartLocation]?.showsOnlyActiveMedia
643                 ?: endShowsActive
644         if (currentlyShowingOnlyActive != endShowsActive ||
645                 ((currentTransitionProgress != 1.0f && currentTransitionProgress != 0.0f) &&
646                         startShowsActive != endShowsActive)) {
647             // Whenever we're transitioning from between differing states or the endstate differs
648             // we reset the translation
649             currentlyShowingOnlyActive = endShowsActive
650             mediaCarouselScrollHandler.resetTranslation(animate = true)
651         }
652     }
653 
654     private fun updatePlayerToState(mediaPlayer: MediaControlPanel, noAnimation: Boolean) {
655         mediaPlayer.mediaViewController.setCurrentState(
656                 startLocation = currentStartLocation,
657                 endLocation = currentEndLocation,
658                 transitionProgress = currentTransitionProgress,
659                 applyImmediately = noAnimation)
660     }
661 
662     /**
663      * The desired location of this view has changed. We should remeasure the view to match
664      * the new bounds and kick off bounds animations if necessary.
665      * If an animation is happening, an animation is kicked of externally, which sets a new
666      * current state until we reach the targetState.
667      *
668      * @param desiredLocation the location we're going to
669      * @param desiredHostState the target state we're transitioning to
670      * @param animate should this be animated
671      */
672     fun onDesiredLocationChanged(
673         desiredLocation: Int,
674         desiredHostState: MediaHostState?,
675         animate: Boolean,
676         duration: Long = 200,
677         startDelay: Long = 0
678     ) {
679         desiredHostState?.let {
680             // This is a hosting view, let's remeasure our players
681             this.desiredLocation = desiredLocation
682             this.desiredHostState = it
683             currentlyExpanded = it.expansion > 0
684 
685             val shouldCloseGuts = !currentlyExpanded && !mediaManager.hasActiveMedia() &&
686                     desiredHostState.showsOnlyActiveMedia
687 
688             for (mediaPlayer in MediaPlayerData.players()) {
689                 if (animate) {
690                     mediaPlayer.mediaViewController.animatePendingStateChange(
691                             duration = duration,
692                             delay = startDelay)
693                 }
694                 if (shouldCloseGuts && mediaPlayer.mediaViewController.isGutsVisible) {
695                     mediaPlayer.closeGuts(!animate)
696                 }
697 
698                 mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation)
699             }
700             mediaCarouselScrollHandler.showsSettingsButton = !it.showsOnlyActiveMedia
701             mediaCarouselScrollHandler.falsingProtectionNeeded = it.falsingProtectionNeeded
702             val nowVisible = it.visible
703             if (nowVisible != playersVisible) {
704                 playersVisible = nowVisible
705                 if (nowVisible) {
706                     mediaCarouselScrollHandler.resetTranslation()
707                 }
708             }
709             updateCarouselSize()
710         }
711     }
712 
713     fun closeGuts(immediate: Boolean = true) {
714         MediaPlayerData.players().forEach {
715             it.closeGuts(immediate)
716         }
717     }
718 
719     /**
720      * Update the size of the carousel, remeasuring it if necessary.
721      */
722     private fun updateCarouselSize() {
723         val width = desiredHostState?.measurementInput?.width ?: 0
724         val height = desiredHostState?.measurementInput?.height ?: 0
725         if (width != carouselMeasureWidth && width != 0 ||
726                 height != carouselMeasureHeight && height != 0) {
727             carouselMeasureWidth = width
728             carouselMeasureHeight = height
729             val playerWidthPlusPadding = carouselMeasureWidth +
730                     context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
731             // Let's remeasure the carousel
732             val widthSpec = desiredHostState?.measurementInput?.widthMeasureSpec ?: 0
733             val heightSpec = desiredHostState?.measurementInput?.heightMeasureSpec ?: 0
734             mediaCarousel.measure(widthSpec, heightSpec)
735             mediaCarousel.layout(0, 0, width, mediaCarousel.measuredHeight)
736             // Update the padding after layout; view widths are used in RTL to calculate scrollX
737             mediaCarouselScrollHandler.playerWidthPlusPadding = playerWidthPlusPadding
738         }
739     }
740 
741     /**
742      * Log the user impression for media card at visibleMediaIndex.
743      */
744     fun logSmartspaceImpression(qsExpanded: Boolean) {
745         val visibleMediaIndex = mediaCarouselScrollHandler.visibleMediaIndex
746         if (MediaPlayerData.players().size > visibleMediaIndex) {
747             val mediaControlPanel = MediaPlayerData.players().elementAt(visibleMediaIndex)
748             val hasActiveMediaOrRecommendationCard =
749                     MediaPlayerData.hasActiveMediaOrRecommendationCard()
750             val isRecommendationCard = mediaControlPanel.recommendationViewHolder != null
751             if (!hasActiveMediaOrRecommendationCard && !qsExpanded) {
752                 // Skip logging if on LS or QQS, and there is no active media card
753                 return
754             }
755             logSmartspaceCardReported(800, // SMARTSPACE_CARD_SEEN
756                     mediaControlPanel.mInstanceId,
757                     mediaControlPanel.mUid,
758                     isRecommendationCard,
759                     intArrayOf(mediaControlPanel.surfaceForSmartspaceLogging))
760             mediaControlPanel.mIsImpressed = true
761         }
762     }
763 
764     @JvmOverloads
765     /**
766      * Log Smartspace events
767      *
768      * @param eventId UI event id (e.g. 800 for SMARTSPACE_CARD_SEEN)
769      * @param instanceId id to uniquely identify a card, e.g. each headphone generates a new
770      * instanceId
771      * @param uid uid for the application that media comes from
772      * @param isRecommendationCard whether the card is media recommendation
773      * @param surfaces list of display surfaces the media card is on (e.g. lockscreen, shade) when
774      * the event happened
775      * @param interactedSubcardRank the rank for interacted media item for recommendation card, -1
776      * for tapping on card but not on any media item, 0 for first media item, 1 for second, etc.
777      * @param interactedSubcardCardinality how many media items were shown to the user when there
778      * is user interaction
779      * @param rank the rank for media card in the media carousel, starting from 0
780      * @param receivedLatencyMillis latency in milliseconds for card received events. E.g. latency
781      * between headphone connection to sysUI displays media recommendation card
782      *
783      */
784     fun logSmartspaceCardReported(
785         eventId: Int,
786         instanceId: Int,
787         uid: Int,
788         isRecommendationCard: Boolean,
789         surfaces: IntArray,
790         interactedSubcardRank: Int = 0,
791         interactedSubcardCardinality: Int = 0,
792         rank: Int = mediaCarouselScrollHandler.visibleMediaIndex,
793         receivedLatencyMillis: Int = 0
794     ) {
795         // Only log media resume card when Smartspace data is available
796         if (!isRecommendationCard &&
797                 !mediaManager.smartspaceMediaData.isActive &&
798                 MediaPlayerData.smartspaceMediaData == null) {
799             return
800         }
801 
802         val cardinality = mediaContent.getChildCount()
803         surfaces.forEach { surface ->
804             /* ktlint-disable max-line-length */
805             SysUiStatsLog.write(SysUiStatsLog.SMARTSPACE_CARD_REPORTED,
806                     eventId,
807                     instanceId,
808                     // Deprecated, replaced with AiAi feature type so we don't need to create logging
809                     // card type for each new feature.
810                     SysUiStatsLog.SMART_SPACE_CARD_REPORTED__CARD_TYPE__UNKNOWN_CARD,
811                     surface,
812                     rank,
813                     cardinality,
814                     if (isRecommendationCard)
815                         15 // MEDIA_RECOMMENDATION
816                     else
817                         31, // MEDIA_RESUME
818                     uid,
819                     interactedSubcardRank,
820                     interactedSubcardCardinality,
821                     receivedLatencyMillis
822             )
823             /* ktlint-disable max-line-length */
824             if (DEBUG) {
825                 Log.d(TAG, "Log Smartspace card event id: $eventId instance id: $instanceId" +
826                         " surface: $surface rank: $rank cardinality: $cardinality " +
827                         "isRecommendationCard: $isRecommendationCard uid: $uid " +
828                         "interactedSubcardRank: $interactedSubcardRank " +
829                         "interactedSubcardCardinality: $interactedSubcardCardinality " +
830                         "received_latency_millis: $receivedLatencyMillis")
831             }
832         }
833     }
834 
835     private fun onSwipeToDismiss() {
836         MediaPlayerData.players().forEachIndexed {
837             index, it ->
838             if (it.mIsImpressed) {
839                 logSmartspaceCardReported(761, // SMARTSPACE_CARD_DISMISS
840                         it.mInstanceId,
841                         it.mUid,
842                         it.recommendationViewHolder != null,
843                         intArrayOf(it.surfaceForSmartspaceLogging),
844                         // Use -1 as rank value to indicate user swipe to dismiss the card
845                         rank = -1)
846                 // Reset card impressed state when swipe to dismissed
847                 it.mIsImpressed = false
848             }
849         }
850         mediaManager.onSwipeToDismiss()
851     }
852 
853     override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
854         pw.apply {
855             println("keysNeedRemoval: $keysNeedRemoval")
856             println("playerKeys: ${MediaPlayerData.playerKeys()}")
857             println("smartspaceMediaData: ${MediaPlayerData.smartspaceMediaData}")
858             println("shouldPrioritizeSs: ${MediaPlayerData.shouldPrioritizeSs}")
859         }
860     }
861 }
862 
863 @VisibleForTesting
864 internal object MediaPlayerData {
865     private val EMPTY = MediaData(-1, false, 0, null, null, null, null, null,
866             emptyList(), emptyList(), "INVALID", null, null, null, true, null)
867     // Whether should prioritize Smartspace card.
868     internal var shouldPrioritizeSs: Boolean = false
869         private set
870     internal var smartspaceMediaData: SmartspaceMediaData? = null
871         private set
872 
873     data class MediaSortKey(
874             // Whether the item represents a Smartspace media recommendation.
875         val isSsMediaRec: Boolean,
876         val data: MediaData,
877         val updateTime: Long = 0
878     )
879 
880     private val comparator =
881             compareByDescending<MediaSortKey> { it.data.isPlaying == true &&
882                         it.data.playbackLocation == MediaData.PLAYBACK_LOCAL }
883                 .thenByDescending { it.data.isPlaying == true &&
884                         it.data.playbackLocation == MediaData.PLAYBACK_CAST_LOCAL }
885                 .thenByDescending { if (shouldPrioritizeSs) it.isSsMediaRec else !it.isSsMediaRec }
886                 .thenByDescending { !it.data.resumption }
887                 .thenByDescending { it.data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE }
888                 .thenByDescending { it.updateTime }
889                 .thenByDescending { it.data.notificationKey }
890 
891     private val mediaPlayers = TreeMap<MediaSortKey, MediaControlPanel>(comparator)
892     private val mediaData: MutableMap<String, MediaSortKey> = mutableMapOf()
893 
894     fun addMediaPlayer(key: String, data: MediaData, player: MediaControlPanel, clock: SystemClock) {
895         removeMediaPlayer(key)
896         val sortKey = MediaSortKey(isSsMediaRec = false, data, clock.currentTimeMillis())
897         mediaData.put(key, sortKey)
898         mediaPlayers.put(sortKey, player)
899     }
900 
901     fun addMediaRecommendation(
902         key: String,
903         data: SmartspaceMediaData,
904         player: MediaControlPanel,
905         shouldPrioritize: Boolean,
906         clock: SystemClock
907     ) {
908         shouldPrioritizeSs = shouldPrioritize
909         removeMediaPlayer(key)
910         val sortKey = MediaSortKey(/* isSsMediaRec= */ true,
911             EMPTY.copy(isPlaying = false), clock.currentTimeMillis())
912         mediaData.put(key, sortKey)
913         mediaPlayers.put(sortKey, player)
914         smartspaceMediaData = data
915     }
916 
917     fun moveIfExists(oldKey: String?, newKey: String) {
918         if (oldKey == null || oldKey == newKey) {
919             return
920         }
921 
922         mediaData.remove(oldKey)?.let {
923             removeMediaPlayer(newKey)
924             mediaData.put(newKey, it)
925         }
926     }
927 
928     fun getMediaPlayer(key: String): MediaControlPanel? {
929         return mediaData.get(key)?.let { mediaPlayers.get(it) }
930     }
931 
932     fun getMediaPlayerIndex(key: String): Int {
933         val sortKey = mediaData.get(key)
934         mediaPlayers.entries.forEachIndexed { index, e ->
935             if (e.key == sortKey) {
936                 return index
937             }
938         }
939         return -1
940     }
941 
942     fun removeMediaPlayer(key: String) = mediaData.remove(key)?.let {
943         if (it.isSsMediaRec) {
944             smartspaceMediaData = null
945         }
946         mediaPlayers.remove(it)
947     }
948 
949     fun mediaData() = mediaData.entries.map { e -> Triple(e.key, e.value.data, e.value.isSsMediaRec) }
950 
951     fun players() = mediaPlayers.values
952 
953     fun playerKeys() = mediaPlayers.keys
954 
955     /** Returns the index of the first non-timeout media. */
956     fun firstActiveMediaIndex(): Int {
957         mediaPlayers.entries.forEachIndexed { index, e ->
958             if (!e.key.isSsMediaRec && e.key.data.active) {
959                 return index
960             }
961         }
962         return -1
963     }
964 
965     /** Returns the existing Smartspace target id. */
966     fun smartspaceMediaKey(): String? {
967         mediaData.entries.forEach { e ->
968             if (e.value.isSsMediaRec) {
969                 return e.key
970             }
971         }
972         return null
973     }
974 
975     @VisibleForTesting
976     fun clear() {
977         mediaData.clear()
978         mediaPlayers.clear()
979     }
980 
981     /* Returns true if there is active media player card or recommendation card */
982     fun hasActiveMediaOrRecommendationCard(): Boolean {
983         if (smartspaceMediaData != null && smartspaceMediaData?.isActive!!) {
984             return true
985         }
986         if (firstActiveMediaIndex() != -1) {
987             return true
988         }
989         return false
990     }
991 }