1 /*
2  * Copyright (C) 2022 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.ui
18 
19 import android.app.PendingIntent
20 import android.content.Context
21 import android.content.Intent
22 import android.content.res.ColorStateList
23 import android.content.res.Configuration
24 import android.database.ContentObserver
25 import android.provider.Settings
26 import android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS
27 import android.util.Log
28 import android.util.MathUtils
29 import android.view.LayoutInflater
30 import android.view.View
31 import android.view.ViewGroup
32 import android.view.animation.PathInterpolator
33 import android.widget.LinearLayout
34 import androidx.annotation.VisibleForTesting
35 import androidx.lifecycle.Lifecycle
36 import androidx.lifecycle.repeatOnLifecycle
37 import com.android.internal.logging.InstanceId
38 import com.android.keyguard.KeyguardUpdateMonitor
39 import com.android.keyguard.KeyguardUpdateMonitorCallback
40 import com.android.systemui.Dumpable
41 import com.android.systemui.R
42 import com.android.systemui.classifier.FalsingCollector
43 import com.android.systemui.dagger.SysUISingleton
44 import com.android.systemui.dagger.qualifiers.Main
45 import com.android.systemui.dump.DumpManager
46 import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
47 import com.android.systemui.keyguard.shared.model.TransitionState
48 import com.android.systemui.lifecycle.repeatWhenAttached
49 import com.android.systemui.media.controls.models.player.MediaData
50 import com.android.systemui.media.controls.models.player.MediaViewHolder
51 import com.android.systemui.media.controls.models.recommendation.RecommendationViewHolder
52 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
53 import com.android.systemui.media.controls.pipeline.MediaDataManager
54 import com.android.systemui.media.controls.ui.MediaControlPanel.SMARTSPACE_CARD_DISMISS_EVENT
55 import com.android.systemui.media.controls.util.MediaFlags
56 import com.android.systemui.media.controls.util.MediaUiEventLogger
57 import com.android.systemui.media.controls.util.SmallHash
58 import com.android.systemui.plugins.ActivityStarter
59 import com.android.systemui.plugins.FalsingManager
60 import com.android.systemui.qs.PageIndicator
61 import com.android.systemui.shared.system.SysUiStatsLog
62 import com.android.systemui.statusbar.notification.collection.provider.OnReorderingAllowedListener
63 import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider
64 import com.android.systemui.statusbar.policy.ConfigurationController
65 import com.android.systemui.util.Utils
66 import com.android.systemui.util.animation.UniqueObjectHostView
67 import com.android.systemui.util.animation.requiresRemeasuring
68 import com.android.systemui.util.concurrency.DelayableExecutor
69 import com.android.systemui.util.settings.GlobalSettings
70 import com.android.systemui.util.time.SystemClock
71 import com.android.systemui.util.traceSection
72 import java.io.PrintWriter
73 import java.util.Locale
74 import java.util.TreeMap
75 import javax.inject.Inject
76 import javax.inject.Provider
77 import kotlinx.coroutines.CoroutineScope
78 import kotlinx.coroutines.Job
79 import kotlinx.coroutines.flow.filter
80 import kotlinx.coroutines.launch
81 
82 private const val TAG = "MediaCarouselController"
83 private val settingsIntent = Intent().setAction(ACTION_MEDIA_CONTROLS_SETTINGS)
84 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
85 
86 /**
87  * Class that is responsible for keeping the view carousel up to date. This also handles changes in
88  * state and applies them to the media carousel like the expansion.
89  */
90 @SysUISingleton
91 class MediaCarouselController
92 @Inject
93 constructor(
94     private val context: Context,
95     private val mediaControlPanelFactory: Provider<MediaControlPanel>,
96     private val visualStabilityProvider: VisualStabilityProvider,
97     private val mediaHostStatesManager: MediaHostStatesManager,
98     private val activityStarter: ActivityStarter,
99     private val systemClock: SystemClock,
100     @Main executor: DelayableExecutor,
101     private val mediaManager: MediaDataManager,
102     configurationController: ConfigurationController,
103     falsingCollector: FalsingCollector,
104     falsingManager: FalsingManager,
105     dumpManager: DumpManager,
106     private val logger: MediaUiEventLogger,
107     private val debugLogger: MediaCarouselControllerLogger,
108     private val mediaFlags: MediaFlags,
109     private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
110     private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
111     private val globalSettings: GlobalSettings,
112 ) : Dumpable {
113     /** The current width of the carousel */
114     var currentCarouselWidth: Int = 0
115         private set
116 
117     /** The current height of the carousel */
118     private var currentCarouselHeight: Int = 0
119 
120     /** Are we currently showing only active players */
121     private var currentlyShowingOnlyActive: Boolean = false
122 
123     /** Is the player currently visible (at the end of the transformation */
124     private var playersVisible: Boolean = false
125     /**
126      * The desired location where we'll be at the end of the transformation. Usually this matches
127      * the end location, except when we're still waiting on a state update call.
128      */
129     @MediaLocation private var desiredLocation: Int = -1
130 
131     /**
132      * The ending location of the view where it ends when all animations and transitions have
133      * finished
134      */
135     @MediaLocation @VisibleForTesting var currentEndLocation: Int = -1
136 
137     /**
138      * The ending location of the view where it ends when all animations and transitions have
139      * finished
140      */
141     @MediaLocation private var currentStartLocation: Int = -1
142 
143     /** The progress of the transition or 1.0 if there is no transition happening */
144     private var currentTransitionProgress: Float = 1.0f
145 
146     /** The measured width of the carousel */
147     private var carouselMeasureWidth: Int = 0
148 
149     /** The measured height of the carousel */
150     private var carouselMeasureHeight: Int = 0
151     private var desiredHostState: MediaHostState? = null
152     @VisibleForTesting var mediaCarousel: MediaScrollView
153     val mediaCarouselScrollHandler: MediaCarouselScrollHandler
154     val mediaFrame: ViewGroup
155     @VisibleForTesting
156     lateinit var settingsButton: View
157         private set
158     private val mediaContent: ViewGroup
159     @VisibleForTesting var pageIndicator: PageIndicator
160     private val visualStabilityCallback: OnReorderingAllowedListener
161     private var needsReordering: Boolean = false
162     private var keysNeedRemoval = mutableSetOf<String>()
163     var shouldScrollToKey: Boolean = false
164     private var isRtl: Boolean = false
165         set(value) {
166             if (value != field) {
167                 field = value
168                 mediaFrame.layoutDirection =
169                     if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR
170                 mediaCarouselScrollHandler.scrollToStart()
171             }
172         }
173 
174     private var carouselLocale: Locale? = null
175 
176     private val animationScaleObserver: ContentObserver =
177         object : ContentObserver(null) {
178             override fun onChange(selfChange: Boolean) {
179                 MediaPlayerData.players().forEach { it.updateAnimatorDurationScale() }
180             }
181         }
182 
183     /** Whether the media card currently has the "expanded" layout */
184     @VisibleForTesting
185     var currentlyExpanded = true
186         set(value) {
187             if (field != value) {
188                 field = value
189                 updateSeekbarListening(mediaCarouselScrollHandler.visibleToUser)
190             }
191         }
192 
193     companion object {
194         val TRANSFORM_BEZIER = PathInterpolator(0.68F, 0F, 0F, 1F)
195 
196         fun calculateAlpha(
197             squishinessFraction: Float,
198             startPosition: Float,
199             endPosition: Float
200         ): Float {
201             val transformFraction =
202                 MathUtils.constrain(
203                     (squishinessFraction - startPosition) / (endPosition - startPosition),
204                     0F,
205                     1F
206                 )
207             return TRANSFORM_BEZIER.getInterpolation(transformFraction)
208         }
209     }
210 
211     private val configListener =
212         object : ConfigurationController.ConfigurationListener {
213 
214             override fun onDensityOrFontScaleChanged() {
215                 // System font changes should only happen when UMO is offscreen or a flicker may
216                 // occur
217                 updatePlayers(recreateMedia = true)
218                 inflateSettingsButton()
219             }
220 
221             override fun onThemeChanged() {
222                 updatePlayers(recreateMedia = false)
223                 inflateSettingsButton()
224             }
225 
226             override fun onConfigChanged(newConfig: Configuration?) {
227                 if (newConfig == null) return
228                 isRtl = newConfig.layoutDirection == View.LAYOUT_DIRECTION_RTL
229             }
230 
231             override fun onUiModeChanged() {
232                 updatePlayers(recreateMedia = false)
233                 inflateSettingsButton()
234             }
235 
236             override fun onLocaleListChanged() {
237                 // Update players only if system primary language changes.
238                 if (carouselLocale != context.resources.configuration.locales.get(0)) {
239                     carouselLocale = context.resources.configuration.locales.get(0)
240                     updatePlayers(recreateMedia = true)
241                     inflateSettingsButton()
242                 }
243             }
244         }
245 
246     private val keyguardUpdateMonitorCallback =
247         object : KeyguardUpdateMonitorCallback() {
248             override fun onStrongAuthStateChanged(userId: Int) {
249                 if (keyguardUpdateMonitor.isUserInLockdown(userId)) {
250                     debugLogger.logCarouselHidden()
251                     hideMediaCarousel()
252                 } else if (keyguardUpdateMonitor.isUserUnlocked(userId)) {
253                     debugLogger.logCarouselVisible()
254                     showMediaCarousel()
255                 }
256             }
257         }
258 
259     /**
260      * Update MediaCarouselScrollHandler.visibleToUser to reflect media card container visibility.
261      * It will be called when the container is out of view.
262      */
263     lateinit var updateUserVisibility: () -> Unit
264     lateinit var updateHostVisibility: () -> Unit
265 
266     private val isReorderingAllowed: Boolean
267         get() = visualStabilityProvider.isReorderingAllowed
268 
269     init {
270         dumpManager.registerDumpable(TAG, this)
271         mediaFrame = inflateMediaCarousel()
272         mediaCarousel = mediaFrame.requireViewById(R.id.media_carousel_scroller)
273         pageIndicator = mediaFrame.requireViewById(R.id.media_page_indicator)
274         mediaCarouselScrollHandler =
275             MediaCarouselScrollHandler(
276                 mediaCarousel,
277                 pageIndicator,
278                 executor,
279                 this::onSwipeToDismiss,
280                 this::updatePageIndicatorLocation,
281                 this::updateSeekbarListening,
282                 this::closeGuts,
283                 falsingCollector,
284                 falsingManager,
285                 this::logSmartspaceImpression,
286                 logger
287             )
288         carouselLocale = context.resources.configuration.locales.get(0)
289         isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
290         inflateSettingsButton()
291         mediaContent = mediaCarousel.requireViewById(R.id.media_carousel)
292         configurationController.addCallback(configListener)
293         visualStabilityCallback = OnReorderingAllowedListener {
294             if (needsReordering) {
295                 needsReordering = false
296                 reorderAllPlayers(previousVisiblePlayerKey = null)
297             }
298 
299             keysNeedRemoval.forEach { removePlayer(it) }
300             if (keysNeedRemoval.size > 0) {
301                 // Carousel visibility may need to be updated after late removals
302                 updateHostVisibility()
303             }
304             keysNeedRemoval.clear()
305 
306             // Update user visibility so that no extra impression will be logged when
307             // activeMediaIndex resets to 0
308             if (this::updateUserVisibility.isInitialized) {
309                 updateUserVisibility()
310             }
311 
312             // Let's reset our scroll position
313             mediaCarouselScrollHandler.scrollToStart()
314         }
315         visualStabilityProvider.addPersistentReorderingAllowedListener(visualStabilityCallback)
316         mediaManager.addListener(
317             object : MediaDataManager.Listener {
318                 override fun onMediaDataLoaded(
319                     key: String,
320                     oldKey: String?,
321                     data: MediaData,
322                     immediately: Boolean,
323                     receivedSmartspaceCardLatency: Int,
324                     isSsReactivated: Boolean
325                 ) {
326                     debugLogger.logMediaLoaded(key, data.active)
327                     if (addOrUpdatePlayer(key, oldKey, data, isSsReactivated)) {
328                         // Log card received if a new resumable media card is added
329                         MediaPlayerData.getMediaPlayer(key)?.let {
330                             /* ktlint-disable max-line-length */
331                             logSmartspaceCardReported(
332                                 759, // SMARTSPACE_CARD_RECEIVED
333                                 it.mSmartspaceId,
334                                 it.mUid,
335                                 surfaces =
336                                     intArrayOf(
337                                         SysUiStatsLog
338                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
339                                         SysUiStatsLog
340                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN,
341                                         SysUiStatsLog
342                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY
343                                     ),
344                                 rank = MediaPlayerData.getMediaPlayerIndex(key)
345                             )
346                             /* ktlint-disable max-line-length */
347                         }
348                         if (
349                             mediaCarouselScrollHandler.visibleToUser &&
350                                 mediaCarouselScrollHandler.visibleMediaIndex ==
351                                     MediaPlayerData.getMediaPlayerIndex(key)
352                         ) {
353                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
354                         }
355                     } else if (receivedSmartspaceCardLatency != 0) {
356                         // Log resume card received if resumable media card is reactivated and
357                         // resume card is ranked first
358                         MediaPlayerData.players().forEachIndexed { index, it ->
359                             if (it.recommendationViewHolder == null) {
360                                 it.mSmartspaceId =
361                                     SmallHash.hash(
362                                         it.mUid + systemClock.currentTimeMillis().toInt()
363                                     )
364                                 it.mIsImpressed = false
365                                 /* ktlint-disable max-line-length */
366                                 logSmartspaceCardReported(
367                                     759, // SMARTSPACE_CARD_RECEIVED
368                                     it.mSmartspaceId,
369                                     it.mUid,
370                                     surfaces =
371                                         intArrayOf(
372                                             SysUiStatsLog
373                                                 .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
374                                             SysUiStatsLog
375                                                 .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN,
376                                             SysUiStatsLog
377                                                 .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY
378                                         ),
379                                     rank = index,
380                                     receivedLatencyMillis = receivedSmartspaceCardLatency
381                                 )
382                                 /* ktlint-disable max-line-length */
383                             }
384                         }
385                         // If media container area already visible to the user, log impression for
386                         // reactivated card.
387                         if (
388                             mediaCarouselScrollHandler.visibleToUser &&
389                                 !mediaCarouselScrollHandler.qsExpanded
390                         ) {
391                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
392                         }
393                     }
394 
395                     val canRemove = data.isPlaying?.let { !it } ?: data.isClearable && !data.active
396                     if (canRemove && !Utils.useMediaResumption(context)) {
397                         // This view isn't playing, let's remove this! This happens e.g. when
398                         // dismissing/timing out a view. We still have the data around because
399                         // resumption could be on, but we should save the resources and release
400                         // this.
401                         if (isReorderingAllowed) {
402                             onMediaDataRemoved(key)
403                         } else {
404                             keysNeedRemoval.add(key)
405                         }
406                     } else {
407                         keysNeedRemoval.remove(key)
408                     }
409                 }
410 
411                 override fun onSmartspaceMediaDataLoaded(
412                     key: String,
413                     data: SmartspaceMediaData,
414                     shouldPrioritize: Boolean
415                 ) {
416                     debugLogger.logRecommendationLoaded(key, data.isActive)
417                     // Log the case where the hidden media carousel with the existed inactive resume
418                     // media is shown by the Smartspace signal.
419                     if (data.isActive) {
420                         val hasActivatedExistedResumeMedia =
421                             !mediaManager.hasActiveMedia() &&
422                                 mediaManager.hasAnyMedia() &&
423                                 shouldPrioritize
424                         if (hasActivatedExistedResumeMedia) {
425                             // Log resume card received if resumable media card is reactivated and
426                             // recommendation card is valid and ranked first
427                             MediaPlayerData.players().forEachIndexed { index, it ->
428                                 if (it.recommendationViewHolder == null) {
429                                     it.mSmartspaceId =
430                                         SmallHash.hash(
431                                             it.mUid + systemClock.currentTimeMillis().toInt()
432                                         )
433                                     it.mIsImpressed = false
434                                     /* ktlint-disable max-line-length */
435                                     logSmartspaceCardReported(
436                                         759, // SMARTSPACE_CARD_RECEIVED
437                                         it.mSmartspaceId,
438                                         it.mUid,
439                                         surfaces =
440                                             intArrayOf(
441                                                 SysUiStatsLog
442                                                     .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
443                                                 SysUiStatsLog
444                                                     .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN,
445                                                 SysUiStatsLog
446                                                     .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY
447                                             ),
448                                         rank = index,
449                                         receivedLatencyMillis =
450                                             (systemClock.currentTimeMillis() -
451                                                     data.headphoneConnectionTimeMillis)
452                                                 .toInt()
453                                     )
454                                     /* ktlint-disable max-line-length */
455                                 }
456                             }
457                         }
458                         addSmartspaceMediaRecommendations(key, data, shouldPrioritize)
459                         MediaPlayerData.getMediaPlayer(key)?.let {
460                             /* ktlint-disable max-line-length */
461                             logSmartspaceCardReported(
462                                 759, // SMARTSPACE_CARD_RECEIVED
463                                 it.mSmartspaceId,
464                                 it.mUid,
465                                 surfaces =
466                                     intArrayOf(
467                                         SysUiStatsLog
468                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE,
469                                         SysUiStatsLog
470                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN,
471                                         SysUiStatsLog
472                                             .SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY
473                                     ),
474                                 rank = MediaPlayerData.getMediaPlayerIndex(key),
475                                 receivedLatencyMillis =
476                                     (systemClock.currentTimeMillis() -
477                                             data.headphoneConnectionTimeMillis)
478                                         .toInt()
479                             )
480                             /* ktlint-disable max-line-length */
481                         }
482                         if (
483                             mediaCarouselScrollHandler.visibleToUser &&
484                                 mediaCarouselScrollHandler.visibleMediaIndex ==
485                                     MediaPlayerData.getMediaPlayerIndex(key)
486                         ) {
487                             logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded)
488                         }
489                     } else {
490                         if (!mediaFlags.isPersistentSsCardEnabled()) {
491                             // Handle update to inactive as a removal
492                             onSmartspaceMediaDataRemoved(data.targetId, immediately = true)
493                         } else {
494                             addSmartspaceMediaRecommendations(key, data, shouldPrioritize)
495                         }
496                     }
497                 }
498 
499                 override fun onMediaDataRemoved(key: String) {
500                     debugLogger.logMediaRemoved(key)
501                     removePlayer(key)
502                 }
503 
504                 override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
505                     debugLogger.logRecommendationRemoved(key, immediately)
506                     if (immediately || isReorderingAllowed) {
507                         removePlayer(key)
508                         if (!immediately) {
509                             // Although it wasn't requested, we were able to process the removal
510                             // immediately since reordering is allowed. So, notify hosts to update
511                             if (this@MediaCarouselController::updateHostVisibility.isInitialized) {
512                                 updateHostVisibility()
513                             }
514                         }
515                     } else {
516                         keysNeedRemoval.add(key)
517                     }
518                 }
519             }
520         )
521         mediaFrame.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
522             // The pageIndicator is not laid out yet when we get the current state update,
523             // Lets make sure we have the right dimensions
524             updatePageIndicatorLocation()
525         }
526         mediaHostStatesManager.addCallback(
527             object : MediaHostStatesManager.Callback {
528                 override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
529                     updateUserVisibility()
530                     if (location == desiredLocation) {
531                         onDesiredLocationChanged(desiredLocation, mediaHostState, animate = false)
532                     }
533                 }
534             }
535         )
536         keyguardUpdateMonitor.registerCallback(keyguardUpdateMonitorCallback)
537         mediaCarousel.repeatWhenAttached {
538             repeatOnLifecycle(Lifecycle.State.STARTED) {
539                 // A backup to show media carousel (if available) once the keyguard is gone.
540                 listenForAnyStateToGoneKeyguardTransition(this)
541             }
542         }
543 
544         // Notifies all active players about animation scale changes.
545         globalSettings.registerContentObserver(
546             Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
547             animationScaleObserver
548         )
549     }
550 
551     private fun inflateSettingsButton() {
552         val settings =
553             LayoutInflater.from(context)
554                 .inflate(R.layout.media_carousel_settings_button, mediaFrame, false) as View
555         if (this::settingsButton.isInitialized) {
556             mediaFrame.removeView(settingsButton)
557         }
558         settingsButton = settings
559         mediaFrame.addView(settingsButton)
560         mediaCarouselScrollHandler.onSettingsButtonUpdated(settings)
561         settingsButton.setOnClickListener {
562             logger.logCarouselSettings()
563             activityStarter.startActivity(settingsIntent, true /* dismissShade */)
564         }
565     }
566 
567     private fun inflateMediaCarousel(): ViewGroup {
568         val mediaCarousel =
569             LayoutInflater.from(context)
570                 .inflate(R.layout.media_carousel, UniqueObjectHostView(context), false) as ViewGroup
571         // Because this is inflated when not attached to the true view hierarchy, it resolves some
572         // potential issues to force that the layout direction is defined by the locale
573         // (rather than inherited from the parent, which would resolve to LTR when unattached).
574         mediaCarousel.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
575         return mediaCarousel
576     }
577 
578     private fun hideMediaCarousel() {
579         mediaCarousel.visibility = View.GONE
580     }
581 
582     private fun showMediaCarousel() {
583         mediaCarousel.visibility = View.VISIBLE
584     }
585 
586     @VisibleForTesting
587     internal fun listenForAnyStateToGoneKeyguardTransition(scope: CoroutineScope): Job {
588         return scope.launch {
589             keyguardTransitionInteractor.anyStateToGoneTransition
590                 .filter { it.transitionState == TransitionState.FINISHED }
591                 .collect { showMediaCarousel() }
592         }
593     }
594 
595     private fun reorderAllPlayers(
596         previousVisiblePlayerKey: MediaPlayerData.MediaSortKey?,
597         key: String? = null
598     ) {
599         mediaContent.removeAllViews()
600         for (mediaPlayer in MediaPlayerData.players()) {
601             mediaPlayer.mediaViewHolder?.let { mediaContent.addView(it.player) }
602                 ?: mediaPlayer.recommendationViewHolder?.let {
603                     mediaContent.addView(it.recommendations)
604                 }
605         }
606         mediaCarouselScrollHandler.onPlayersChanged()
607         MediaPlayerData.updateVisibleMediaPlayers()
608         // Automatically scroll to the active player if needed
609         if (shouldScrollToKey) {
610             shouldScrollToKey = false
611             val mediaIndex = key?.let { MediaPlayerData.getMediaPlayerIndex(it) } ?: -1
612             if (mediaIndex != -1) {
613                 previousVisiblePlayerKey?.let {
614                     val previousVisibleIndex =
615                         MediaPlayerData.playerKeys().indexOfFirst { key -> it == key }
616                     mediaCarouselScrollHandler.scrollToPlayer(previousVisibleIndex, mediaIndex)
617                 }
618                     ?: mediaCarouselScrollHandler.scrollToPlayer(destIndex = mediaIndex)
619             }
620         }
621         // Check postcondition: mediaContent should have the same number of children as there
622         // are
623         // elements in mediaPlayers.
624         if (MediaPlayerData.players().size != mediaContent.childCount) {
625             Log.e(
626                 TAG,
627                 "Size of players list and number of views in carousel are out of sync. " +
628                     "Players size is ${MediaPlayerData.players().size}. " +
629                     "View count is ${mediaContent.childCount}."
630             )
631         }
632     }
633 
634     // Returns true if new player is added
635     private fun addOrUpdatePlayer(
636         key: String,
637         oldKey: String?,
638         data: MediaData,
639         isSsReactivated: Boolean
640     ): Boolean =
641         traceSection("MediaCarouselController#addOrUpdatePlayer") {
642             MediaPlayerData.moveIfExists(oldKey, key)
643             val existingPlayer = MediaPlayerData.getMediaPlayer(key)
644             val curVisibleMediaKey =
645                 MediaPlayerData.visiblePlayerKeys()
646                     .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
647             if (existingPlayer == null) {
648                 val newPlayer = mediaControlPanelFactory.get()
649                 newPlayer.attachPlayer(
650                     MediaViewHolder.create(LayoutInflater.from(context), mediaContent)
651                 )
652                 newPlayer.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
653                 val lp =
654                     LinearLayout.LayoutParams(
655                         ViewGroup.LayoutParams.MATCH_PARENT,
656                         ViewGroup.LayoutParams.WRAP_CONTENT
657                     )
658                 newPlayer.mediaViewHolder?.player?.setLayoutParams(lp)
659                 newPlayer.bindPlayer(data, key)
660                 newPlayer.setListening(
661                     mediaCarouselScrollHandler.visibleToUser && currentlyExpanded
662                 )
663                 MediaPlayerData.addMediaPlayer(
664                     key,
665                     data,
666                     newPlayer,
667                     systemClock,
668                     isSsReactivated,
669                     debugLogger
670                 )
671                 updatePlayerToState(newPlayer, noAnimation = true)
672                 // Media data added from a recommendation card should starts playing.
673                 if (
674                     (shouldScrollToKey && data.isPlaying == true) ||
675                         (!shouldScrollToKey && data.active)
676                 ) {
677                     reorderAllPlayers(curVisibleMediaKey, key)
678                 } else {
679                     needsReordering = true
680                 }
681             } else {
682                 existingPlayer.bindPlayer(data, key)
683                 MediaPlayerData.addMediaPlayer(
684                     key,
685                     data,
686                     existingPlayer,
687                     systemClock,
688                     isSsReactivated,
689                     debugLogger
690                 )
691                 val packageName = MediaPlayerData.smartspaceMediaData?.packageName ?: String()
692                 // In case of recommendations hits.
693                 // Check the playing status of media player and the package name.
694                 // To make sure we scroll to the right app's media player.
695                 if (
696                     isReorderingAllowed ||
697                         shouldScrollToKey &&
698                             data.isPlaying == true &&
699                             packageName == data.packageName
700                 ) {
701                     reorderAllPlayers(curVisibleMediaKey, key)
702                 } else {
703                     needsReordering = true
704                 }
705             }
706             updatePageIndicator()
707             mediaCarouselScrollHandler.onPlayersChanged()
708             mediaFrame.requiresRemeasuring = true
709             return existingPlayer == null
710         }
711 
712     private fun addSmartspaceMediaRecommendations(
713         key: String,
714         data: SmartspaceMediaData,
715         shouldPrioritize: Boolean
716     ) =
717         traceSection("MediaCarouselController#addSmartspaceMediaRecommendations") {
718             if (DEBUG) Log.d(TAG, "Updating smartspace target in carousel")
719             MediaPlayerData.getMediaPlayer(key)?.let {
720                 if (mediaFlags.isPersistentSsCardEnabled()) {
721                     // The card exists, but could have changed active state, so update for sorting
722                     MediaPlayerData.addMediaRecommendation(
723                         key,
724                         data,
725                         it,
726                         shouldPrioritize,
727                         systemClock,
728                         debugLogger,
729                         update = true,
730                     )
731                 }
732                 Log.w(TAG, "Skip adding smartspace target in carousel")
733                 return
734             }
735 
736             val existingSmartspaceMediaKey = MediaPlayerData.smartspaceMediaKey()
737             existingSmartspaceMediaKey?.let {
738                 val removedPlayer =
739                     removePlayer(existingSmartspaceMediaKey, dismissMediaData = false)
740                 removedPlayer?.run {
741                     debugLogger.logPotentialMemoryLeak(existingSmartspaceMediaKey)
742                 }
743             }
744 
745             val newRecs = mediaControlPanelFactory.get()
746             newRecs.attachRecommendation(
747                 RecommendationViewHolder.create(LayoutInflater.from(context), mediaContent)
748             )
749             newRecs.mediaViewController.sizeChangedListener = this::updateCarouselDimensions
750             val lp =
751                 LinearLayout.LayoutParams(
752                     ViewGroup.LayoutParams.MATCH_PARENT,
753                     ViewGroup.LayoutParams.WRAP_CONTENT
754                 )
755             newRecs.recommendationViewHolder?.recommendations?.setLayoutParams(lp)
756             newRecs.bindRecommendation(data)
757             val curVisibleMediaKey =
758                 MediaPlayerData.visiblePlayerKeys()
759                     .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
760             MediaPlayerData.addMediaRecommendation(
761                 key,
762                 data,
763                 newRecs,
764                 shouldPrioritize,
765                 systemClock,
766                 debugLogger,
767             )
768             updatePlayerToState(newRecs, noAnimation = true)
769             reorderAllPlayers(curVisibleMediaKey)
770             updatePageIndicator()
771             mediaFrame.requiresRemeasuring = true
772             // Check postcondition: mediaContent should have the same number of children as there
773             // are
774             // elements in mediaPlayers.
775             if (MediaPlayerData.players().size != mediaContent.childCount) {
776                 Log.e(
777                     TAG,
778                     "Size of players list and number of views in carousel are out of sync. " +
779                         "Players size is ${MediaPlayerData.players().size}. " +
780                         "View count is ${mediaContent.childCount}."
781                 )
782             }
783         }
784 
785     fun removePlayer(
786         key: String,
787         dismissMediaData: Boolean = true,
788         dismissRecommendation: Boolean = true
789     ): MediaControlPanel? {
790         if (key == MediaPlayerData.smartspaceMediaKey()) {
791             MediaPlayerData.smartspaceMediaData?.let {
792                 logger.logRecommendationRemoved(it.packageName, it.instanceId)
793             }
794         }
795         val removed =
796             MediaPlayerData.removeMediaPlayer(key, dismissMediaData || dismissRecommendation)
797         return removed?.apply {
798             mediaCarouselScrollHandler.onPrePlayerRemoved(removed)
799             mediaContent.removeView(removed.mediaViewHolder?.player)
800             mediaContent.removeView(removed.recommendationViewHolder?.recommendations)
801             removed.onDestroy()
802             mediaCarouselScrollHandler.onPlayersChanged()
803             updatePageIndicator()
804 
805             if (dismissMediaData) {
806                 // Inform the media manager of a potentially late dismissal
807                 mediaManager.dismissMediaData(key, delay = 0L)
808             }
809             if (dismissRecommendation) {
810                 // Inform the media manager of a potentially late dismissal
811                 mediaManager.dismissSmartspaceRecommendation(key, delay = 0L)
812             }
813         }
814     }
815 
816     private fun updatePlayers(recreateMedia: Boolean) {
817         pageIndicator.tintList =
818             ColorStateList.valueOf(context.getColor(R.color.media_paging_indicator))
819         val previousVisibleKey =
820             MediaPlayerData.visiblePlayerKeys()
821                 .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
822 
823         MediaPlayerData.mediaData().forEach { (key, data, isSsMediaRec) ->
824             if (isSsMediaRec) {
825                 val smartspaceMediaData = MediaPlayerData.smartspaceMediaData
826                 removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
827                 smartspaceMediaData?.let {
828                     addSmartspaceMediaRecommendations(
829                         it.targetId,
830                         it,
831                         MediaPlayerData.shouldPrioritizeSs
832                     )
833                 }
834             } else {
835                 val isSsReactivated = MediaPlayerData.isSsReactivated(key)
836                 if (recreateMedia) {
837                     removePlayer(key, dismissMediaData = false, dismissRecommendation = false)
838                 }
839                 addOrUpdatePlayer(
840                     key = key,
841                     oldKey = null,
842                     data = data,
843                     isSsReactivated = isSsReactivated
844                 )
845             }
846             if (recreateMedia) {
847                 reorderAllPlayers(previousVisibleKey)
848             }
849         }
850     }
851 
852     private fun updatePageIndicator() {
853         val numPages = mediaContent.getChildCount()
854         pageIndicator.setNumPages(numPages)
855         if (numPages == 1) {
856             pageIndicator.setLocation(0f)
857         }
858         updatePageIndicatorAlpha()
859     }
860 
861     /**
862      * Set a new interpolated state for all players. This is a state that is usually controlled by a
863      * finger movement where the user drags from one state to the next.
864      *
865      * @param startLocation the start location of our state or -1 if this is directly set
866      * @param endLocation the ending location of our state.
867      * @param progress the progress of the transition between startLocation and endlocation. If
868      *
869      * ```
870      *                 this is not a guided transformation, this will be 1.0f
871      * @param immediately
872      * ```
873      *
874      * should this state be applied immediately, canceling all animations?
875      */
876     fun setCurrentState(
877         @MediaLocation startLocation: Int,
878         @MediaLocation endLocation: Int,
879         progress: Float,
880         immediately: Boolean
881     ) {
882         if (
883             startLocation != currentStartLocation ||
884                 endLocation != currentEndLocation ||
885                 progress != currentTransitionProgress ||
886                 immediately
887         ) {
888             currentStartLocation = startLocation
889             currentEndLocation = endLocation
890             currentTransitionProgress = progress
891             for (mediaPlayer in MediaPlayerData.players()) {
892                 updatePlayerToState(mediaPlayer, immediately)
893             }
894             maybeResetSettingsCog()
895             updatePageIndicatorAlpha()
896         }
897     }
898 
899     @VisibleForTesting
900     fun updatePageIndicatorAlpha() {
901         val hostStates = mediaHostStatesManager.mediaHostStates
902         val endIsVisible = hostStates[currentEndLocation]?.visible ?: false
903         val startIsVisible = hostStates[currentStartLocation]?.visible ?: false
904         val startAlpha = if (startIsVisible) 1.0f else 0.0f
905         // when squishing in split shade, only use endState, which keeps changing
906         // to provide squishFraction
907         val squishFraction = hostStates[currentEndLocation]?.squishFraction ?: 1.0F
908         val endAlpha =
909             (if (endIsVisible) 1.0f else 0.0f) *
910                 calculateAlpha(
911                     squishFraction,
912                     (pageIndicator.translationY + pageIndicator.height) /
913                         mediaCarousel.measuredHeight,
914                     1F
915                 )
916         var alpha = 1.0f
917         if (!endIsVisible || !startIsVisible) {
918             var progress = currentTransitionProgress
919             if (!endIsVisible) {
920                 progress = 1.0f - progress
921             }
922             // Let's fade in quickly at the end where the view is visible
923             progress =
924                 MathUtils.constrain(MathUtils.map(0.95f, 1.0f, 0.0f, 1.0f, progress), 0.0f, 1.0f)
925             alpha = MathUtils.lerp(startAlpha, endAlpha, progress)
926         }
927         pageIndicator.alpha = alpha
928     }
929 
930     private fun updatePageIndicatorLocation() {
931         // Update the location of the page indicator, carousel clipping
932         val translationX =
933             if (isRtl) {
934                 (pageIndicator.width - currentCarouselWidth) / 2.0f
935             } else {
936                 (currentCarouselWidth - pageIndicator.width) / 2.0f
937             }
938         pageIndicator.translationX = translationX + mediaCarouselScrollHandler.contentTranslation
939         val layoutParams = pageIndicator.layoutParams as ViewGroup.MarginLayoutParams
940         pageIndicator.translationY =
941             (mediaCarousel.measuredHeight - pageIndicator.height - layoutParams.bottomMargin)
942                 .toFloat()
943     }
944 
945     /** Update listening to seekbar. */
946     private fun updateSeekbarListening(visibleToUser: Boolean) {
947         for (player in MediaPlayerData.players()) {
948             player.setListening(visibleToUser && currentlyExpanded)
949         }
950     }
951 
952     /** Update the dimension of this carousel. */
953     private fun updateCarouselDimensions() {
954         var width = 0
955         var height = 0
956         for (mediaPlayer in MediaPlayerData.players()) {
957             val controller = mediaPlayer.mediaViewController
958             // When transitioning the view to gone, the view gets smaller, but the translation
959             // Doesn't, let's add the translation
960             width = Math.max(width, controller.currentWidth + controller.translationX.toInt())
961             height = Math.max(height, controller.currentHeight + controller.translationY.toInt())
962         }
963         if (width != currentCarouselWidth || height != currentCarouselHeight) {
964             currentCarouselWidth = width
965             currentCarouselHeight = height
966             mediaCarouselScrollHandler.setCarouselBounds(
967                 currentCarouselWidth,
968                 currentCarouselHeight
969             )
970             updatePageIndicatorLocation()
971             updatePageIndicatorAlpha()
972         }
973     }
974 
975     private fun maybeResetSettingsCog() {
976         val hostStates = mediaHostStatesManager.mediaHostStates
977         val endShowsActive = hostStates[currentEndLocation]?.showsOnlyActiveMedia ?: true
978         val startShowsActive =
979             hostStates[currentStartLocation]?.showsOnlyActiveMedia ?: endShowsActive
980         if (
981             currentlyShowingOnlyActive != endShowsActive ||
982                 ((currentTransitionProgress != 1.0f && currentTransitionProgress != 0.0f) &&
983                     startShowsActive != endShowsActive)
984         ) {
985             // Whenever we're transitioning from between differing states or the endstate differs
986             // we reset the translation
987             currentlyShowingOnlyActive = endShowsActive
988             mediaCarouselScrollHandler.resetTranslation(animate = true)
989         }
990     }
991 
992     private fun updatePlayerToState(mediaPlayer: MediaControlPanel, noAnimation: Boolean) {
993         mediaPlayer.mediaViewController.setCurrentState(
994             startLocation = currentStartLocation,
995             endLocation = currentEndLocation,
996             transitionProgress = currentTransitionProgress,
997             applyImmediately = noAnimation
998         )
999     }
1000 
1001     /**
1002      * The desired location of this view has changed. We should remeasure the view to match the new
1003      * bounds and kick off bounds animations if necessary. If an animation is happening, an
1004      * animation is kicked of externally, which sets a new current state until we reach the
1005      * targetState.
1006      *
1007      * @param desiredLocation the location we're going to
1008      * @param desiredHostState the target state we're transitioning to
1009      * @param animate should this be animated
1010      */
1011     fun onDesiredLocationChanged(
1012         desiredLocation: Int,
1013         desiredHostState: MediaHostState?,
1014         animate: Boolean,
1015         duration: Long = 200,
1016         startDelay: Long = 0
1017     ) =
1018         traceSection("MediaCarouselController#onDesiredLocationChanged") {
1019             desiredHostState?.let {
1020                 if (this.desiredLocation != desiredLocation) {
1021                     // Only log an event when location changes
1022                     logger.logCarouselPosition(desiredLocation)
1023                 }
1024 
1025                 // This is a hosting view, let's remeasure our players
1026                 this.desiredLocation = desiredLocation
1027                 this.desiredHostState = it
1028                 currentlyExpanded = it.expansion > 0
1029 
1030                 val shouldCloseGuts =
1031                     !currentlyExpanded &&
1032                         !mediaManager.hasActiveMediaOrRecommendation() &&
1033                         desiredHostState.showsOnlyActiveMedia
1034 
1035                 for (mediaPlayer in MediaPlayerData.players()) {
1036                     if (animate) {
1037                         mediaPlayer.mediaViewController.animatePendingStateChange(
1038                             duration = duration,
1039                             delay = startDelay
1040                         )
1041                     }
1042                     if (shouldCloseGuts && mediaPlayer.mediaViewController.isGutsVisible) {
1043                         mediaPlayer.closeGuts(!animate)
1044                     }
1045 
1046                     mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation)
1047                 }
1048                 mediaCarouselScrollHandler.showsSettingsButton = !it.showsOnlyActiveMedia
1049                 mediaCarouselScrollHandler.falsingProtectionNeeded = it.falsingProtectionNeeded
1050                 val nowVisible = it.visible
1051                 if (nowVisible != playersVisible) {
1052                     playersVisible = nowVisible
1053                     if (nowVisible) {
1054                         mediaCarouselScrollHandler.resetTranslation()
1055                     }
1056                 }
1057                 updateCarouselSize()
1058             }
1059         }
1060 
1061     fun closeGuts(immediate: Boolean = true) {
1062         MediaPlayerData.players().forEach { it.closeGuts(immediate) }
1063     }
1064 
1065     /** Update the size of the carousel, remeasuring it if necessary. */
1066     private fun updateCarouselSize() {
1067         val width = desiredHostState?.measurementInput?.width ?: 0
1068         val height = desiredHostState?.measurementInput?.height ?: 0
1069         if (
1070             width != carouselMeasureWidth && width != 0 ||
1071                 height != carouselMeasureHeight && height != 0
1072         ) {
1073             carouselMeasureWidth = width
1074             carouselMeasureHeight = height
1075             val playerWidthPlusPadding =
1076                 carouselMeasureWidth +
1077                     context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
1078             // Let's remeasure the carousel
1079             val widthSpec = desiredHostState?.measurementInput?.widthMeasureSpec ?: 0
1080             val heightSpec = desiredHostState?.measurementInput?.heightMeasureSpec ?: 0
1081             mediaCarousel.measure(widthSpec, heightSpec)
1082             mediaCarousel.layout(0, 0, width, mediaCarousel.measuredHeight)
1083             // Update the padding after layout; view widths are used in RTL to calculate scrollX
1084             mediaCarouselScrollHandler.playerWidthPlusPadding = playerWidthPlusPadding
1085         }
1086     }
1087 
1088     /** Log the user impression for media card at visibleMediaIndex. */
1089     fun logSmartspaceImpression(qsExpanded: Boolean) {
1090         val visibleMediaIndex = mediaCarouselScrollHandler.visibleMediaIndex
1091         if (MediaPlayerData.players().size > visibleMediaIndex) {
1092             val mediaControlPanel = MediaPlayerData.getMediaControlPanel(visibleMediaIndex)
1093             val hasActiveMediaOrRecommendationCard =
1094                 MediaPlayerData.hasActiveMediaOrRecommendationCard()
1095             if (!hasActiveMediaOrRecommendationCard && !qsExpanded) {
1096                 // Skip logging if on LS or QQS, and there is no active media card
1097                 return
1098             }
1099             mediaControlPanel?.let {
1100                 logSmartspaceCardReported(
1101                     800, // SMARTSPACE_CARD_SEEN
1102                     it.mSmartspaceId,
1103                     it.mUid,
1104                     intArrayOf(it.surfaceForSmartspaceLogging)
1105                 )
1106                 it.mIsImpressed = true
1107             }
1108         }
1109     }
1110 
1111     @JvmOverloads
1112     /**
1113      * Log Smartspace events
1114      *
1115      * @param eventId UI event id (e.g. 800 for SMARTSPACE_CARD_SEEN)
1116      * @param instanceId id to uniquely identify a card, e.g. each headphone generates a new
1117      *   instanceId
1118      * @param uid uid for the application that media comes from
1119      * @param surfaces list of display surfaces the media card is on (e.g. lockscreen, shade) when
1120      *   the event happened
1121      * @param interactedSubcardRank the rank for interacted media item for recommendation card, -1
1122      *   for tapping on card but not on any media item, 0 for first media item, 1 for second, etc.
1123      * @param interactedSubcardCardinality how many media items were shown to the user when there is
1124      *   user interaction
1125      * @param rank the rank for media card in the media carousel, starting from 0
1126      * @param receivedLatencyMillis latency in milliseconds for card received events. E.g. latency
1127      *   between headphone connection to sysUI displays media recommendation card
1128      * @param isSwipeToDismiss whether is to log swipe-to-dismiss event
1129      */
1130     fun logSmartspaceCardReported(
1131         eventId: Int,
1132         instanceId: Int,
1133         uid: Int,
1134         surfaces: IntArray,
1135         interactedSubcardRank: Int = 0,
1136         interactedSubcardCardinality: Int = 0,
1137         rank: Int = mediaCarouselScrollHandler.visibleMediaIndex,
1138         receivedLatencyMillis: Int = 0,
1139         isSwipeToDismiss: Boolean = false
1140     ) {
1141         if (MediaPlayerData.players().size <= rank) {
1142             return
1143         }
1144 
1145         val mediaControlKey = MediaPlayerData.visiblePlayerKeys().elementAt(rank)
1146         // Only log media resume card when Smartspace data is available
1147         if (
1148             !mediaControlKey.isSsMediaRec &&
1149                 !mediaManager.smartspaceMediaData.isActive &&
1150                 MediaPlayerData.smartspaceMediaData == null
1151         ) {
1152             return
1153         }
1154 
1155         val cardinality = mediaContent.getChildCount()
1156         surfaces.forEach { surface ->
1157             /* ktlint-disable max-line-length */
1158             SysUiStatsLog.write(
1159                 SysUiStatsLog.SMARTSPACE_CARD_REPORTED,
1160                 eventId,
1161                 instanceId,
1162                 // Deprecated, replaced with AiAi feature type so we don't need to create logging
1163                 // card type for each new feature.
1164                 SysUiStatsLog.SMART_SPACE_CARD_REPORTED__CARD_TYPE__UNKNOWN_CARD,
1165                 surface,
1166                 // Use -1 as rank value to indicate user swipe to dismiss the card
1167                 if (isSwipeToDismiss) -1 else rank,
1168                 cardinality,
1169                 if (mediaControlKey.isSsMediaRec) 15 // MEDIA_RECOMMENDATION
1170                 else if (mediaControlKey.isSsReactivated) 43 // MEDIA_RESUME_SS_ACTIVATED
1171                 else 31, // MEDIA_RESUME
1172                 uid,
1173                 interactedSubcardRank,
1174                 interactedSubcardCardinality,
1175                 receivedLatencyMillis,
1176                 null, // Media cards cannot have subcards.
1177                 null // Media cards don't have dimensions today.
1178             )
1179             /* ktlint-disable max-line-length */
1180             if (DEBUG) {
1181                 Log.d(
1182                     TAG,
1183                     "Log Smartspace card event id: $eventId instance id: $instanceId" +
1184                         " surface: $surface rank: $rank cardinality: $cardinality " +
1185                         "isRecommendationCard: ${mediaControlKey.isSsMediaRec} " +
1186                         "isSsReactivated: ${mediaControlKey.isSsReactivated}" +
1187                         "uid: $uid " +
1188                         "interactedSubcardRank: $interactedSubcardRank " +
1189                         "interactedSubcardCardinality: $interactedSubcardCardinality " +
1190                         "received_latency_millis: $receivedLatencyMillis"
1191                 )
1192             }
1193         }
1194     }
1195 
1196     private fun onSwipeToDismiss() {
1197         MediaPlayerData.players().forEachIndexed { index, it ->
1198             if (it.mIsImpressed) {
1199                 logSmartspaceCardReported(
1200                     SMARTSPACE_CARD_DISMISS_EVENT,
1201                     it.mSmartspaceId,
1202                     it.mUid,
1203                     intArrayOf(it.surfaceForSmartspaceLogging),
1204                     rank = index,
1205                     isSwipeToDismiss = true
1206                 )
1207                 // Reset card impressed state when swipe to dismissed
1208                 it.mIsImpressed = false
1209             }
1210         }
1211         logger.logSwipeDismiss()
1212         mediaManager.onSwipeToDismiss()
1213     }
1214 
1215     fun getCurrentVisibleMediaContentIntent(): PendingIntent? {
1216         return MediaPlayerData.playerKeys()
1217             .elementAtOrNull(mediaCarouselScrollHandler.visibleMediaIndex)
1218             ?.data
1219             ?.clickIntent
1220     }
1221 
1222     override fun dump(pw: PrintWriter, args: Array<out String>) {
1223         pw.apply {
1224             println("keysNeedRemoval: $keysNeedRemoval")
1225             println("dataKeys: ${MediaPlayerData.dataKeys()}")
1226             println("orderedPlayerSortKeys: ${MediaPlayerData.playerKeys()}")
1227             println("visiblePlayerSortKeys: ${MediaPlayerData.visiblePlayerKeys()}")
1228             println("smartspaceMediaData: ${MediaPlayerData.smartspaceMediaData}")
1229             println("shouldPrioritizeSs: ${MediaPlayerData.shouldPrioritizeSs}")
1230             println("current size: $currentCarouselWidth x $currentCarouselHeight")
1231             println("location: $desiredLocation")
1232             println(
1233                 "state: ${desiredHostState?.expansion}, " +
1234                     "only active ${desiredHostState?.showsOnlyActiveMedia}"
1235             )
1236         }
1237     }
1238 }
1239 
1240 @VisibleForTesting
1241 internal object MediaPlayerData {
1242     private val EMPTY =
1243         MediaData(
1244             userId = -1,
1245             initialized = false,
1246             app = null,
1247             appIcon = null,
1248             artist = null,
1249             song = null,
1250             artwork = null,
1251             actions = emptyList(),
1252             actionsToShowInCompact = emptyList(),
1253             packageName = "INVALID",
1254             token = null,
1255             clickIntent = null,
1256             device = null,
1257             active = true,
1258             resumeAction = null,
1259             instanceId = InstanceId.fakeInstanceId(-1),
1260             appUid = -1
1261         )
1262     // Whether should prioritize Smartspace card.
1263     internal var shouldPrioritizeSs: Boolean = false
1264         private set
1265     internal var smartspaceMediaData: SmartspaceMediaData? = null
1266         private set
1267 
1268     data class MediaSortKey(
1269         val isSsMediaRec: Boolean, // Whether the item represents a Smartspace media recommendation.
1270         val data: MediaData,
1271         val key: String,
1272         val updateTime: Long = 0,
1273         val isSsReactivated: Boolean = false
1274     )
1275 
1276     private val comparator =
1277         compareByDescending<MediaSortKey> {
1278                 it.data.isPlaying == true && it.data.playbackLocation == MediaData.PLAYBACK_LOCAL
1279             }
1280             .thenByDescending {
1281                 it.data.isPlaying == true &&
1282                     it.data.playbackLocation == MediaData.PLAYBACK_CAST_LOCAL
1283             }
1284             .thenByDescending { it.data.active }
1285             .thenByDescending { shouldPrioritizeSs == it.isSsMediaRec }
1286             .thenByDescending { !it.data.resumption }
1287             .thenByDescending { it.data.playbackLocation != MediaData.PLAYBACK_CAST_REMOTE }
1288             .thenByDescending { it.data.lastActive }
1289             .thenByDescending { it.updateTime }
1290             .thenByDescending { it.data.notificationKey }
1291 
1292     private val mediaPlayers = TreeMap<MediaSortKey, MediaControlPanel>(comparator)
1293     private val mediaData: MutableMap<String, MediaSortKey> = mutableMapOf()
1294     // A map that tracks order of visible media players before they get reordered.
1295     private val visibleMediaPlayers = LinkedHashMap<String, MediaSortKey>()
1296 
1297     fun addMediaPlayer(
1298         key: String,
1299         data: MediaData,
1300         player: MediaControlPanel,
1301         clock: SystemClock,
1302         isSsReactivated: Boolean,
1303         debugLogger: MediaCarouselControllerLogger? = null
1304     ) {
1305         val removedPlayer = removeMediaPlayer(key)
1306         if (removedPlayer != null && removedPlayer != player) {
1307             debugLogger?.logPotentialMemoryLeak(key)
1308         }
1309         val sortKey =
1310             MediaSortKey(
1311                 isSsMediaRec = false,
1312                 data,
1313                 key,
1314                 clock.currentTimeMillis(),
1315                 isSsReactivated = isSsReactivated
1316             )
1317         mediaData.put(key, sortKey)
1318         mediaPlayers.put(sortKey, player)
1319         visibleMediaPlayers.put(key, sortKey)
1320     }
1321 
1322     fun addMediaRecommendation(
1323         key: String,
1324         data: SmartspaceMediaData,
1325         player: MediaControlPanel,
1326         shouldPrioritize: Boolean,
1327         clock: SystemClock,
1328         debugLogger: MediaCarouselControllerLogger? = null,
1329         update: Boolean = false
1330     ) {
1331         shouldPrioritizeSs = shouldPrioritize
1332         val removedPlayer = removeMediaPlayer(key)
1333         if (!update && removedPlayer != null && removedPlayer != player) {
1334             debugLogger?.logPotentialMemoryLeak(key)
1335         }
1336         val sortKey =
1337             MediaSortKey(
1338                 isSsMediaRec = true,
1339                 EMPTY.copy(active = data.isActive, isPlaying = false),
1340                 key,
1341                 clock.currentTimeMillis(),
1342                 isSsReactivated = true
1343             )
1344         mediaData.put(key, sortKey)
1345         mediaPlayers.put(sortKey, player)
1346         visibleMediaPlayers.put(key, sortKey)
1347         smartspaceMediaData = data
1348     }
1349 
1350     fun moveIfExists(
1351         oldKey: String?,
1352         newKey: String,
1353         debugLogger: MediaCarouselControllerLogger? = null
1354     ) {
1355         if (oldKey == null || oldKey == newKey) {
1356             return
1357         }
1358 
1359         mediaData.remove(oldKey)?.let {
1360             // MediaPlayer should not be visible
1361             // no need to set isDismissed flag.
1362             val removedPlayer = removeMediaPlayer(newKey)
1363             removedPlayer?.run { debugLogger?.logPotentialMemoryLeak(newKey) }
1364             mediaData.put(newKey, it)
1365         }
1366     }
1367 
1368     fun getMediaControlPanel(visibleIndex: Int): MediaControlPanel? {
1369         return mediaPlayers.get(visiblePlayerKeys().elementAt(visibleIndex))
1370     }
1371 
1372     fun getMediaPlayer(key: String): MediaControlPanel? {
1373         return mediaData.get(key)?.let { mediaPlayers.get(it) }
1374     }
1375 
1376     fun getMediaPlayerIndex(key: String): Int {
1377         val sortKey = mediaData.get(key)
1378         mediaPlayers.entries.forEachIndexed { index, e ->
1379             if (e.key == sortKey) {
1380                 return index
1381             }
1382         }
1383         return -1
1384     }
1385 
1386     /**
1387      * Removes media player given the key.
1388      *
1389      * @param isDismissed determines whether the media player is removed from the carousel.
1390      */
1391     fun removeMediaPlayer(key: String, isDismissed: Boolean = false) =
1392         mediaData.remove(key)?.let {
1393             if (it.isSsMediaRec) {
1394                 smartspaceMediaData = null
1395             }
1396             if (isDismissed) {
1397                 visibleMediaPlayers.remove(key)
1398             }
1399             mediaPlayers.remove(it)
1400         }
1401 
1402     fun mediaData() =
1403         mediaData.entries.map { e -> Triple(e.key, e.value.data, e.value.isSsMediaRec) }
1404 
1405     fun dataKeys() = mediaData.keys
1406 
1407     fun players() = mediaPlayers.values
1408 
1409     fun playerKeys() = mediaPlayers.keys
1410 
1411     fun visiblePlayerKeys() = visibleMediaPlayers.values
1412 
1413     /** Returns the index of the first non-timeout media. */
1414     fun firstActiveMediaIndex(): Int {
1415         mediaPlayers.entries.forEachIndexed { index, e ->
1416             if (!e.key.isSsMediaRec && e.key.data.active) {
1417                 return index
1418             }
1419         }
1420         return -1
1421     }
1422 
1423     /** Returns the existing Smartspace target id. */
1424     fun smartspaceMediaKey(): String? {
1425         mediaData.entries.forEach { e ->
1426             if (e.value.isSsMediaRec) {
1427                 return e.key
1428             }
1429         }
1430         return null
1431     }
1432 
1433     @VisibleForTesting
1434     fun clear() {
1435         mediaData.clear()
1436         mediaPlayers.clear()
1437         visibleMediaPlayers.clear()
1438     }
1439 
1440     /* Returns true if there is active media player card or recommendation card */
1441     fun hasActiveMediaOrRecommendationCard(): Boolean {
1442         if (smartspaceMediaData != null && smartspaceMediaData?.isActive!!) {
1443             return true
1444         }
1445         if (firstActiveMediaIndex() != -1) {
1446             return true
1447         }
1448         return false
1449     }
1450 
1451     fun isSsReactivated(key: String): Boolean = mediaData.get(key)?.isSsReactivated ?: false
1452 
1453     /**
1454      * This method is called when media players are reordered. To make sure we have the new version
1455      * of the order of media players visible to user.
1456      */
1457     fun updateVisibleMediaPlayers() {
1458         visibleMediaPlayers.clear()
1459         playerKeys().forEach { visibleMediaPlayers.put(it.key, it) }
1460     }
1461 }
1462