From 243ed5881985e5fc5b4ee13cbcdad2a6878f69a9 Mon Sep 17 00:00:00 2001 From: Beth Thibodeau Date: Sun, 29 Jan 2023 17:51:15 -0600 Subject: [PATCH] Allow inactive recommendation card and set timeout When the flag is enabled, the recommendation card can be inactive, rather than being removed immediately once it is swiped away. If the smartspace update is marked as "PERIODIC_TRIGGER", it will not make the carousel active. The card can still be removed manually with the long press menu. In addition recommendation cards will now be removed automatically when its expiry time is reached rather than waiting for an empty update. Smartspace updates not marked as periodic will have the same behavior as before (make carousel active, showing recent media first if it exists) Test: atest com.android.systemui.media.controls Test: manual with test build + smartspace flags Bug: 264690420 Change-Id: I78d365a2df8b7654041bd425745343232cd19bc4 --- .../src/com/android/systemui/flags/Flags.kt | 3 + .../recommendation/SmartspaceMediaData.kt | 13 +- .../controls/pipeline/MediaDataFilter.kt | 47 ++++-- .../controls/pipeline/MediaDataManager.kt | 83 +++++++--- .../controls/pipeline/MediaTimeoutListener.kt | 89 ++++++++++- .../controls/pipeline/MediaTimeoutLogger.kt | 13 +- .../controls/ui/MediaCarouselController.kt | 32 +++- .../ui/MediaCarouselControllerLogger.kt | 12 +- .../media/controls/util/MediaFlags.kt | 3 + .../recommendation/SmartspaceMediaDataTest.kt | 3 +- .../controls/pipeline/MediaDataFilterTest.kt | 99 +++++++++--- .../controls/pipeline/MediaDataManagerTest.kt | 146 +++++++++++++++++- .../pipeline/MediaTimeoutListenerTest.kt | 118 ++++++++++++-- .../ui/MediaCarouselControllerTest.kt | 37 +++++ 14 files changed, 610 insertions(+), 88 deletions(-) diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt index 9595bc421b5fb..661b202eb7c24 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt @@ -364,6 +364,9 @@ object Flags { // TODO(b/267007629): Tracking Bug val MEDIA_RESUME_PROGRESS = unreleasedFlag(915, "media_resume_progress") + // TODO(b/267166152) : Tracking Bug + val MEDIA_RETAIN_RECOMMENDATIONS = unreleasedFlag(916, "media_retain_recommendations") + // 1000 - dock val SIMULATE_DOCK_THROUGH_CHARGING = releasedFlag(1000, "simulate_dock_through_charging") diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaData.kt b/packages/SystemUI/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaData.kt index 1df42c641df64..dc7a4f18adbce 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaData.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaData.kt @@ -41,10 +41,12 @@ data class SmartspaceMediaData( val recommendations: List, /** Intent for the user's initiated dismissal. */ val dismissIntent: Intent?, - /** The timestamp in milliseconds that headphone is connected. */ + /** The timestamp in milliseconds that the card was generated */ val headphoneConnectionTimeMillis: Long, /** Instance ID for [MediaUiEventLogger] */ - val instanceId: InstanceId + val instanceId: InstanceId, + /** The timestamp in milliseconds indicating when the card should be removed */ + val expiryTimeMs: Long, ) { /** * Indicates if all the data is valid. @@ -86,5 +88,12 @@ data class SmartspaceMediaData( } } +/** Key for extras [SmartspaceMediaData.cardAction] indicating why the card was sent */ +const val EXTRA_KEY_TRIGGER_SOURCE = "MEDIA_RECOMMENDATION_TRIGGER_SOURCE" +/** Value for [EXTRA_KEY_TRIGGER_SOURCE] when the card is sent on headphone connection */ +const val EXTRA_VALUE_TRIGGER_HEADPHONE = "HEADPHONE_CONNECTION" +/** Value for key [EXTRA_KEY_TRIGGER_SOURCE] when the card is sent as a regular update */ +const val EXTRA_VALUE_TRIGGER_PERIODIC = "PERIODIC_TRIGGER" + const val NUM_REQUIRED_RECOMMENDATIONS = 3 private val TAG = SmartspaceMediaData::class.simpleName!! diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataFilter.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataFilter.kt index cf71d675865b0..27f7b97368070 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataFilter.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataFilter.kt @@ -24,6 +24,7 @@ import com.android.systemui.broadcast.BroadcastSender import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.media.controls.models.player.MediaData import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData +import com.android.systemui.media.controls.util.MediaFlags import com.android.systemui.media.controls.util.MediaUiEventLogger import com.android.systemui.settings.UserTracker import com.android.systemui.statusbar.NotificationLockscreenUserManager @@ -66,7 +67,8 @@ constructor( private val lockscreenUserManager: NotificationLockscreenUserManager, @Main private val executor: Executor, private val systemClock: SystemClock, - private val logger: MediaUiEventLogger + private val logger: MediaUiEventLogger, + private val mediaFlags: MediaFlags, ) : MediaDataManager.Listener { private val _listeners: MutableSet = mutableSetOf() internal val listeners: Set @@ -121,7 +123,9 @@ constructor( data: SmartspaceMediaData, shouldPrioritize: Boolean ) { - if (!data.isActive) { + // With persistent recommendation card, we could get a background update while inactive + // Otherwise, consider it an invalid update + if (!data.isActive && !mediaFlags.isPersistentSsCardEnabled()) { Log.d(TAG, "Inactive recommendation data. Skip triggering.") return } @@ -141,7 +145,7 @@ constructor( } } - val shouldReactivate = !hasActiveMedia() && hasAnyMedia() + val shouldReactivate = !hasActiveMedia() && hasAnyMedia() && data.isActive if (timeSinceActive < smartspaceMaxAgeMillis) { // It could happen there are existing active media resume cards, then we don't need to @@ -169,7 +173,7 @@ constructor( ) } } - } else { + } else if (data.isActive) { // Mark to prioritize Smartspace card if no recent media. shouldPrioritizeMutable = true } @@ -252,7 +256,7 @@ constructor( if (dismissIntent == null) { Log.w( TAG, - "Cannot create dismiss action click action: " + "extras missing dismiss_intent." + "Cannot create dismiss action click action: extras missing dismiss_intent." ) } else if ( dismissIntent.getComponent() != null && @@ -264,15 +268,21 @@ constructor( } else { broadcastSender.sendBroadcast(dismissIntent) } - smartspaceMediaData = - EMPTY_SMARTSPACE_MEDIA_DATA.copy( - targetId = smartspaceMediaData.targetId, - instanceId = smartspaceMediaData.instanceId + + if (mediaFlags.isPersistentSsCardEnabled()) { + smartspaceMediaData = smartspaceMediaData.copy(isActive = false) + mediaDataManager.setRecommendationInactive(smartspaceMediaData.targetId) + } else { + smartspaceMediaData = + EMPTY_SMARTSPACE_MEDIA_DATA.copy( + targetId = smartspaceMediaData.targetId, + instanceId = smartspaceMediaData.instanceId, + ) + mediaDataManager.dismissSmartspaceRecommendation( + smartspaceMediaData.targetId, + delay = 0L, ) - mediaDataManager.dismissSmartspaceRecommendation( - smartspaceMediaData.targetId, - delay = 0L - ) + } } } @@ -283,8 +293,15 @@ constructor( (smartspaceMediaData.isValid() || reactivatedKey != null)) /** Are there any media entries we should display? */ - fun hasAnyMediaOrRecommendation() = - userEntries.isNotEmpty() || (smartspaceMediaData.isActive && smartspaceMediaData.isValid()) + fun hasAnyMediaOrRecommendation(): Boolean { + val hasSmartspace = + if (mediaFlags.isPersistentSsCardEnabled()) { + smartspaceMediaData.isValid() + } else { + smartspaceMediaData.isActive && smartspaceMediaData.isValid() + } + return userEntries.isNotEmpty() || hasSmartspace + } /** Are there any media notifications active (excluding the recommendation)? */ fun hasActiveMedia() = userEntries.any { it.value.active } diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt index aba3e98cc7779..0a948034ca78a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt @@ -49,7 +49,6 @@ import android.support.v4.media.MediaMetadataCompat import android.text.TextUtils import android.util.Log import androidx.media.utils.MediaConstants -import com.android.internal.annotations.VisibleForTesting import com.android.internal.logging.InstanceId import com.android.systemui.Dumpable import com.android.systemui.R @@ -63,6 +62,8 @@ import com.android.systemui.media.controls.models.player.MediaButton import com.android.systemui.media.controls.models.player.MediaData import com.android.systemui.media.controls.models.player.MediaDeviceData import com.android.systemui.media.controls.models.player.MediaViewHolder +import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_SOURCE +import com.android.systemui.media.controls.models.recommendation.EXTRA_VALUE_TRIGGER_PERIODIC import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataProvider import com.android.systemui.media.controls.resume.MediaResumeListener @@ -119,7 +120,6 @@ private val LOADING = appUid = Process.INVALID_UID ) -@VisibleForTesting internal val EMPTY_SMARTSPACE_MEDIA_DATA = SmartspaceMediaData( targetId = "INVALID", @@ -129,7 +129,8 @@ internal val EMPTY_SMARTSPACE_MEDIA_DATA = recommendations = emptyList(), dismissIntent = null, headphoneConnectionTimeMillis = 0, - instanceId = InstanceId.fakeInstanceId(-1) + instanceId = InstanceId.fakeInstanceId(-1), + expiryTimeMs = 0, ) fun isMediaNotification(sbn: StatusBarNotification): Boolean { @@ -548,6 +549,11 @@ class MediaDataManager( if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut") onMediaDataLoaded(key, key, it) } + + if (key == smartspaceMediaData.targetId) { + if (DEBUG) Log.d(TAG, "smartspace card expired") + dismissSmartspaceRecommendation(key, delay = 0L) + } } /** Called when the player's [PlaybackState] has been updated with new actions and/or state */ @@ -605,8 +611,8 @@ class MediaDataManager( } /** - * Called whenever the recommendation has been expired, or swiped from QQS. This will make the - * recommendation view to not be shown anymore during this headphone connection session. + * Called whenever the recommendation has been expired or removed by the user. This will remove + * the recommendation card entirely from the carousel. */ fun dismissSmartspaceRecommendation(key: String, delay: Long) { if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) { @@ -628,6 +634,23 @@ class MediaDataManager( ) } + /** Called when the recommendation card should no longer be visible in QQS or lockscreen */ + fun setRecommendationInactive(key: String) { + if (!mediaFlags.isPersistentSsCardEnabled()) { + Log.e(TAG, "Only persistent recommendation can be inactive!") + return + } + if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive") + + if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) { + // If this doesn't match, or we've already invalidated the data, no action needed + return + } + + smartspaceMediaData = smartspaceMediaData.copy(isActive = false) + notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData) + } + private fun loadMediaDataInBgForResumption( userId: Int, desc: MediaDescription, @@ -1265,12 +1288,25 @@ class MediaDataManager( if (DEBUG) { Log.d(TAG, "Set Smartspace media to be inactive for the data update") } - smartspaceMediaData = - EMPTY_SMARTSPACE_MEDIA_DATA.copy( - targetId = smartspaceMediaData.targetId, - instanceId = smartspaceMediaData.instanceId + if (mediaFlags.isPersistentSsCardEnabled()) { + // Smartspace uses this signal to hide the card (e.g. when it expires or user + // disconnects headphones), so treat as setting inactive when flag is on + smartspaceMediaData = smartspaceMediaData.copy(isActive = false) + notifySmartspaceMediaDataLoaded( + smartspaceMediaData.targetId, + smartspaceMediaData, ) - notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = false) + } else { + smartspaceMediaData = + EMPTY_SMARTSPACE_MEDIA_DATA.copy( + targetId = smartspaceMediaData.targetId, + instanceId = smartspaceMediaData.instanceId, + ) + notifySmartspaceMediaDataRemoved( + smartspaceMediaData.targetId, + immediately = false, + ) + } } 1 -> { val newMediaTarget = mediaTargets.get(0) @@ -1279,7 +1315,7 @@ class MediaDataManager( return } if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.") - smartspaceMediaData = toSmartspaceMediaData(newMediaTarget, isActive = true) + smartspaceMediaData = toSmartspaceMediaData(newMediaTarget) notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData) } else -> { @@ -1288,7 +1324,7 @@ class MediaDataManager( Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...") notifySmartspaceMediaDataRemoved( smartspaceMediaData.targetId, - false /* immediately */ + immediately = false, ) smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA } @@ -1494,21 +1530,28 @@ class MediaDataManager( } /** - * Converts the pass-in SmartspaceTarget to SmartspaceMediaData with the pass-in active status. + * Converts the pass-in SmartspaceTarget to SmartspaceMediaData * * @return An empty SmartspaceMediaData with the valid target Id is returned if the * SmartspaceTarget's data is invalid. */ - private fun toSmartspaceMediaData( - target: SmartspaceTarget, - isActive: Boolean - ): SmartspaceMediaData { + private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData { var dismissIntent: Intent? = null if (target.baseAction != null && target.baseAction.extras != null) { dismissIntent = target.baseAction.extras.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY) as Intent? } + + val isActive = + when { + !mediaFlags.isPersistentSsCardEnabled() -> true + target.baseAction == null -> true + else -> + target.baseAction.extras.getString(EXTRA_KEY_TRIGGER_SOURCE) != + EXTRA_VALUE_TRIGGER_PERIODIC + } + packageName(target)?.let { return SmartspaceMediaData( targetId = target.smartspaceTargetId, @@ -1518,7 +1561,8 @@ class MediaDataManager( recommendations = target.iconGrid, dismissIntent = dismissIntent, headphoneConnectionTimeMillis = target.creationTimeMillis, - instanceId = logger.getNewInstanceId() + instanceId = logger.getNewInstanceId(), + expiryTimeMs = target.expiryTimeMillis, ) } return EMPTY_SMARTSPACE_MEDIA_DATA.copy( @@ -1526,7 +1570,8 @@ class MediaDataManager( isActive = isActive, dismissIntent = dismissIntent, headphoneConnectionTimeMillis = target.creationTimeMillis, - instanceId = logger.getNewInstanceId() + instanceId = logger.getNewInstanceId(), + expiryTimeMs = target.expiryTimeMillis, ) } diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt index a898b00790a96..aa46b14d11c18 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListener.kt @@ -23,7 +23,9 @@ import com.android.internal.annotations.VisibleForTesting import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.media.controls.models.player.MediaData +import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData import com.android.systemui.media.controls.util.MediaControllerFactory +import com.android.systemui.media.controls.util.MediaFlags import com.android.systemui.plugins.statusbar.StatusBarStateController import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState import com.android.systemui.statusbar.SysuiStatusBarStateController @@ -49,10 +51,12 @@ constructor( @Main private val mainExecutor: DelayableExecutor, private val logger: MediaTimeoutLogger, statusBarStateController: SysuiStatusBarStateController, - private val systemClock: SystemClock + private val systemClock: SystemClock, + private val mediaFlags: MediaFlags, ) : MediaDataManager.Listener { private val mediaListeners: MutableMap = mutableMapOf() + private val recommendationListeners: MutableMap = mutableMapOf() /** * Callback representing that a media object is now expired: @@ -93,6 +97,16 @@ constructor( listener.doTimeout() } } + + recommendationListeners.forEach { (key, listener) -> + if ( + listener.cancellation != null && + listener.expiration <= systemClock.currentTimeMillis() + ) { + logger.logTimeoutCancelled(key, "Timed out while dozing") + listener.doTimeout() + } + } } } } @@ -155,6 +169,30 @@ constructor( mediaListeners.remove(key)?.destroy() } + override fun onSmartspaceMediaDataLoaded( + key: String, + data: SmartspaceMediaData, + shouldPrioritize: Boolean + ) { + if (!mediaFlags.isPersistentSsCardEnabled()) return + + // First check if we already have a listener + recommendationListeners.get(key)?.let { + if (!it.destroyed) { + it.recommendationData = data + return + } + } + + // Otherwise, create a new one + recommendationListeners[key] = RecommendationListener(key, data) + } + + override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) { + if (!mediaFlags.isPersistentSsCardEnabled()) return + recommendationListeners.remove(key)?.destroy() + } + fun isTimedOut(key: String): Boolean { return mediaListeners[key]?.timedOut ?: false } @@ -335,4 +373,53 @@ constructor( } return true } + + /** Listens to changes in recommendation card data and schedules a timeout for its expiration */ + private inner class RecommendationListener(var key: String, data: SmartspaceMediaData) { + private var timedOut = false + var destroyed = false + var expiration = Long.MAX_VALUE + private set + var cancellation: Runnable? = null + private set + + var recommendationData: SmartspaceMediaData = data + set(value) { + destroyed = false + field = value + processUpdate() + } + + init { + recommendationData = data + } + + fun destroy() { + cancellation?.run() + cancellation = null + destroyed = true + } + + private fun processUpdate() { + if (recommendationData.expiryTimeMs != expiration) { + // The expiry time changed - cancel and reschedule + val timeout = + recommendationData.expiryTimeMs - + recommendationData.headphoneConnectionTimeMillis + logger.logRecommendationTimeoutScheduled(key, timeout) + cancellation?.run() + cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout) + expiration = recommendationData.expiryTimeMs + } + } + + fun doTimeout() { + cancellation?.run() + cancellation = null + logger.logTimeout(key) + timedOut = true + expiration = Long.MAX_VALUE + timeoutCallback(key, timedOut) + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutLogger.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutLogger.kt index 8f3f0548230fb..f731dc064355b 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaTimeoutLogger.kt @@ -107,6 +107,17 @@ constructor(@MediaTimeoutListenerLog private val buffer: LogBuffer) { str1 = key str2 = reason }, - { "media timeout cancelled for $str1, reason: $str2" } + { "timeout cancelled for $str1, reason: $str2" } + ) + + fun logRecommendationTimeoutScheduled(key: String, timeout: Long) = + buffer.log( + TAG, + LogLevel.VERBOSE, + { + str1 = key + long1 = timeout + }, + { "recommendation timeout scheduled for $str1 in $long1 ms" } ) } diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselController.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselController.kt index b2ad155227433..fac1d5eae7949 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselController.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselController.kt @@ -368,7 +368,7 @@ constructor( data: SmartspaceMediaData, shouldPrioritize: Boolean ) { - debugLogger.logRecommendationLoaded(key) + debugLogger.logRecommendationLoaded(key, data.isActive) // Log the case where the hidden media carousel with the existed inactive resume // media is shown by the Smartspace signal. if (data.isActive) { @@ -442,7 +442,12 @@ constructor( logSmartspaceImpression(mediaCarouselScrollHandler.qsExpanded) } } else { - onSmartspaceMediaDataRemoved(data.targetId, immediately = true) + if (!mediaFlags.isPersistentSsCardEnabled()) { + // Handle update to inactive as a removal + onSmartspaceMediaDataRemoved(data.targetId, immediately = true) + } else { + addSmartspaceMediaRecommendations(key, data, shouldPrioritize) + } } } @@ -633,7 +638,19 @@ constructor( ) = traceSection("MediaCarouselController#addSmartspaceMediaRecommendations") { if (DEBUG) Log.d(TAG, "Updating smartspace target in carousel") - if (MediaPlayerData.getMediaPlayer(key) != null) { + MediaPlayerData.getMediaPlayer(key)?.let { + if (mediaFlags.isPersistentSsCardEnabled()) { + // The card exists, but could have changed active state, so update for sorting + MediaPlayerData.addMediaRecommendation( + key, + data, + it, + shouldPrioritize, + systemClock, + debugLogger, + update = true, + ) + } Log.w(TAG, "Skip adding smartspace target in carousel") return } @@ -672,7 +689,7 @@ constructor( newRecs, shouldPrioritize, systemClock, - debugLogger + debugLogger, ) updatePlayerToState(newRecs, noAnimation = true) reorderAllPlayers(curVisibleMediaKey) @@ -1225,17 +1242,18 @@ internal object MediaPlayerData { player: MediaControlPanel, shouldPrioritize: Boolean, clock: SystemClock, - debugLogger: MediaCarouselControllerLogger? = null + debugLogger: MediaCarouselControllerLogger? = null, + update: Boolean = false ) { shouldPrioritizeSs = shouldPrioritize val removedPlayer = removeMediaPlayer(key) - if (removedPlayer != null && removedPlayer != player) { + if (!update && removedPlayer != null && removedPlayer != player) { debugLogger?.logPotentialMemoryLeak(key) } val sortKey = MediaSortKey( isSsMediaRec = true, - EMPTY.copy(isPlaying = false), + EMPTY.copy(active = data.isActive, isPlaying = false), key, clock.currentTimeMillis(), isSsReactivated = true diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselControllerLogger.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselControllerLogger.kt index eed1bd7439381..35bda15ece921 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselControllerLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaCarouselControllerLogger.kt @@ -48,8 +48,16 @@ constructor(@MediaCarouselControllerLog private val buffer: LogBuffer) { fun logMediaRemoved(key: String) = buffer.log(TAG, LogLevel.DEBUG, { str1 = key }, { "removing player $str1" }) - fun logRecommendationLoaded(key: String) = - buffer.log(TAG, LogLevel.DEBUG, { str1 = key }, { "add recommendation $str1" }) + fun logRecommendationLoaded(key: String, isActive: Boolean) = + buffer.log( + TAG, + LogLevel.DEBUG, + { + str1 = key + bool1 = isActive + }, + { "add recommendation $str1, active $bool1" } + ) fun logRecommendationRemoved(key: String, immediately: Boolean) = buffer.log( diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt index a689dc369b50e..c3fa76ec9433b 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt @@ -58,4 +58,7 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) { /** Check whether to get progress information for resume players */ fun isResumeProgressEnabled() = featureFlags.isEnabled(Flags.MEDIA_RESUME_PROGRESS) + + /** If true, do not automatically dismiss the recommendation card */ + fun isPersistentSsCardEnabled() = featureFlags.isEnabled(Flags.MEDIA_RETAIN_RECOMMENDATIONS) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaDataTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaDataTest.kt index 1d6e980bdb86b..670f11787ce46 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaDataTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/models/recommendation/SmartspaceMediaDataTest.kt @@ -113,5 +113,6 @@ private val DEFAULT_DATA = recommendations = emptyList(), dismissIntent = null, headphoneConnectionTimeMillis = 0, - instanceId = InstanceId.fakeInstanceId(-1) + instanceId = InstanceId.fakeInstanceId(-1), + expiryTimeMs = 0, ) diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataFilterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataFilterTest.kt index 9d33e6f84972e..eb6235ca8a6ab 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataFilterTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataFilterTest.kt @@ -27,11 +27,13 @@ import com.android.systemui.media.controls.MediaTestUtils import com.android.systemui.media.controls.models.player.MediaData import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData import com.android.systemui.media.controls.ui.MediaPlayerData +import com.android.systemui.media.controls.util.MediaFlags import com.android.systemui.media.controls.util.MediaUiEventLogger import com.android.systemui.settings.UserTracker import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.whenever import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import java.util.concurrent.Executor @@ -40,11 +42,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong import org.mockito.Mock import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations private const val KEY = "TEST_KEY" @@ -72,6 +74,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Mock private lateinit var smartspaceData: SmartspaceMediaData @Mock private lateinit var smartspaceMediaRecommendationItem: SmartspaceAction @Mock private lateinit var logger: MediaUiEventLogger + @Mock private lateinit var mediaFlags: MediaFlags private lateinit var mediaDataFilter: MediaDataFilter private lateinit var dataMain: MediaData @@ -82,6 +85,7 @@ class MediaDataFilterTest : SysuiTestCase() { fun setup() { MockitoAnnotations.initMocks(this) MediaPlayerData.clear() + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false) mediaDataFilter = MediaDataFilter( context, @@ -90,7 +94,8 @@ class MediaDataFilterTest : SysuiTestCase() { lockscreenUserManager, executor, clock, - logger + logger, + mediaFlags ) mediaDataFilter.mediaDataManager = mediaDataManager mediaDataFilter.addListener(listener) @@ -108,19 +113,20 @@ class MediaDataFilterTest : SysuiTestCase() { ) dataGuest = dataMain.copy(userId = USER_GUEST) - `when`(smartspaceData.targetId).thenReturn(SMARTSPACE_KEY) - `when`(smartspaceData.isActive).thenReturn(true) - `when`(smartspaceData.isValid()).thenReturn(true) - `when`(smartspaceData.packageName).thenReturn(SMARTSPACE_PACKAGE) - `when`(smartspaceData.recommendations).thenReturn(listOf(smartspaceMediaRecommendationItem)) - `when`(smartspaceData.headphoneConnectionTimeMillis) + whenever(smartspaceData.targetId).thenReturn(SMARTSPACE_KEY) + whenever(smartspaceData.isActive).thenReturn(true) + whenever(smartspaceData.isValid()).thenReturn(true) + whenever(smartspaceData.packageName).thenReturn(SMARTSPACE_PACKAGE) + whenever(smartspaceData.recommendations) + .thenReturn(listOf(smartspaceMediaRecommendationItem)) + whenever(smartspaceData.headphoneConnectionTimeMillis) .thenReturn(clock.currentTimeMillis() - 100) - `when`(smartspaceData.instanceId).thenReturn(SMARTSPACE_INSTANCE_ID) + whenever(smartspaceData.instanceId).thenReturn(SMARTSPACE_INSTANCE_ID) } private fun setUser(id: Int) { - `when`(lockscreenUserManager.isCurrentProfile(anyInt())).thenReturn(false) - `when`(lockscreenUserManager.isCurrentProfile(eq(id))).thenReturn(true) + whenever(lockscreenUserManager.isCurrentProfile(anyInt())).thenReturn(false) + whenever(lockscreenUserManager.isCurrentProfile(eq(id))).thenReturn(true) mediaDataFilter.handleUserSwitched(id) } @@ -277,7 +283,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun hasActiveMediaOrRecommendation_inactiveRecommendationSet_returnsFalse() { - `when`(smartspaceData.isActive).thenReturn(false) + whenever(smartspaceData.isActive).thenReturn(false) mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isFalse() @@ -285,7 +291,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun hasActiveMediaOrRecommendation_invalidRecommendationSet_returnsFalse() { - `when`(smartspaceData.isValid()).thenReturn(false) + whenever(smartspaceData.isValid()).thenReturn(false) mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isFalse() @@ -293,8 +299,8 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun hasActiveMediaOrRecommendation_activeAndValidRecommendationSet_returnsTrue() { - `when`(smartspaceData.isActive).thenReturn(true) - `when`(smartspaceData.isValid()).thenReturn(true) + whenever(smartspaceData.isActive).thenReturn(true) + whenever(smartspaceData.isValid()).thenReturn(true) mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isTrue() @@ -349,7 +355,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun testOnSmartspaceMediaDataLoaded_noMedia_inactiveRec_showsNothing() { - `when`(smartspaceData.isActive).thenReturn(false) + whenever(smartspaceData.isActive).thenReturn(false) mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) @@ -379,7 +385,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun testOnSmartspaceMediaDataLoaded_noRecentMedia_inactiveRec_showsNothing() { - `when`(smartspaceData.isActive).thenReturn(false) + whenever(smartspaceData.isActive).thenReturn(false) val dataOld = dataMain.copy(active = false, lastActive = clock.elapsedRealtime()) mediaDataFilter.onMediaDataLoaded(KEY, null, dataOld) @@ -395,7 +401,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_inactiveRec_showsNothing() { - `when`(smartspaceData.isActive).thenReturn(false) + whenever(smartspaceData.isActive).thenReturn(false) // WHEN we have media that was recently played, but not currently active val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime()) @@ -418,7 +424,7 @@ class MediaDataFilterTest : SysuiTestCase() { @Test fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_activeInvalidRec_usesMedia() { - `when`(smartspaceData.isValid()).thenReturn(false) + whenever(smartspaceData.isValid()).thenReturn(false) // WHEN we have media that was recently played, but not currently active val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime()) @@ -513,4 +519,59 @@ class MediaDataFilterTest : SysuiTestCase() { assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isFalse() assertThat(mediaDataFilter.hasActiveMedia()).isFalse() } + + @Test + fun testOnSmartspaceLoaded_persistentEnabled_isInactive_notifiesListeners() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + whenever(smartspaceData.isActive).thenReturn(false) + + mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + + verify(listener) + .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false)) + assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isFalse() + assertThat(mediaDataFilter.hasAnyMediaOrRecommendation()).isTrue() + } + + @Test + fun testOnSmartspaceLoaded_persistentEnabled_inactive_hasRecentMedia_staysInactive() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + whenever(smartspaceData.isActive).thenReturn(false) + + // If there is media that was recently played but inactive + val dataCurrent = dataMain.copy(active = false, lastActive = clock.elapsedRealtime()) + mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent) + verify(listener) + .onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent), eq(true), eq(0), eq(false)) + + // And an inactive recommendation is loaded + mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + + // Smartspace is loaded but the media stays inactive + verify(listener) + .onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData), eq(false)) + verify(listener, never()) + .onMediaDataLoaded(any(), any(), any(), anyBoolean(), anyInt(), anyBoolean()) + assertThat(mediaDataFilter.hasActiveMediaOrRecommendation()).isFalse() + assertThat(mediaDataFilter.hasAnyMediaOrRecommendation()).isTrue() + } + + @Test + fun testOnSwipeToDismiss_persistentEnabled_recommendationSetInactive() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + + val data = + EMPTY_SMARTSPACE_MEDIA_DATA.copy( + targetId = SMARTSPACE_KEY, + isActive = true, + packageName = SMARTSPACE_PACKAGE, + recommendations = listOf(smartspaceMediaRecommendationItem), + ) + mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, data) + mediaDataFilter.onSwipeToDismiss() + + verify(mediaDataManager).setRecommendationInactive(eq(SMARTSPACE_KEY)) + verify(mediaDataManager, never()) + .dismissSmartspaceRecommendation(eq(SMARTSPACE_KEY), anyLong()) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt index 53cc78f36f15b..44e2fbd8465f5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaDataManagerTest.kt @@ -46,6 +46,8 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.broadcast.BroadcastDispatcher import com.android.systemui.dump.DumpManager import com.android.systemui.media.controls.models.player.MediaData +import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_SOURCE +import com.android.systemui.media.controls.models.recommendation.EXTRA_VALUE_TRIGGER_PERIODIC import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataProvider import com.android.systemui.media.controls.resume.MediaResumeListener @@ -82,6 +84,8 @@ import org.mockito.junit.MockitoJUnit private const val KEY = "KEY" private const val KEY_2 = "KEY_2" private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID" +private const val SMARTSPACE_CREATION_TIME = 1234L +private const val SMARTSPACE_EXPIRY_TIME = 5678L private const val PACKAGE_NAME = "com.example.app" private const val SYSTEM_PACKAGE_NAME = "com.android.systemui" private const val APP_NAME = "SystemUI" @@ -230,10 +234,12 @@ class MediaDataManagerTest : SysuiTestCase() { whenever(mediaSmartspaceTarget.smartspaceTargetId).thenReturn(KEY_MEDIA_SMARTSPACE) whenever(mediaSmartspaceTarget.featureType).thenReturn(SmartspaceTarget.FEATURE_MEDIA) whenever(mediaSmartspaceTarget.iconGrid).thenReturn(validRecommendationList) - whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(1234L) + whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(SMARTSPACE_CREATION_TIME) + whenever(mediaSmartspaceTarget.expiryTimeMillis).thenReturn(SMARTSPACE_EXPIRY_TIME) whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(false) whenever(mediaFlags.isExplicitIndicatorEnabled()).thenReturn(true) whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(false) + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false) whenever(logger.getNewInstanceId()).thenReturn(instanceIdSequence.newInstanceId()) } @@ -847,8 +853,9 @@ class MediaDataManagerTest : SysuiTestCase() { cardAction = mediaSmartspaceBaseAction, recommendations = validRecommendationList, dismissIntent = DISMISS_INTENT, - headphoneConnectionTimeMillis = 1234L, - instanceId = InstanceId.fakeInstanceId(instanceId) + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, ) ), eq(false) @@ -870,8 +877,9 @@ class MediaDataManagerTest : SysuiTestCase() { targetId = KEY_MEDIA_SMARTSPACE, isActive = true, dismissIntent = DISMISS_INTENT, - headphoneConnectionTimeMillis = 1234L, - instanceId = InstanceId.fakeInstanceId(instanceId) + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, ) ), eq(false) @@ -901,8 +909,9 @@ class MediaDataManagerTest : SysuiTestCase() { targetId = KEY_MEDIA_SMARTSPACE, isActive = true, dismissIntent = null, - headphoneConnectionTimeMillis = 1234L, - instanceId = InstanceId.fakeInstanceId(instanceId) + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, ) ), eq(false) @@ -930,6 +939,129 @@ class MediaDataManagerTest : SysuiTestCase() { verifyNoMoreInteractions(logger) } + @Test + fun testOnSmartspaceMediaDataLoaded_persistentEnabled_headphoneTrigger_isActive() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget)) + val instanceId = instanceIdSequence.lastInstanceId + + verify(listener) + .onSmartspaceMediaDataLoaded( + eq(KEY_MEDIA_SMARTSPACE), + eq( + SmartspaceMediaData( + targetId = KEY_MEDIA_SMARTSPACE, + isActive = true, + packageName = PACKAGE_NAME, + cardAction = mediaSmartspaceBaseAction, + recommendations = validRecommendationList, + dismissIntent = DISMISS_INTENT, + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, + ) + ), + eq(false) + ) + } + + @Test + fun testOnSmartspaceMediaDataLoaded_persistentEnabled_periodicTrigger_notActive() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + val extras = + Bundle().apply { + putString("package_name", PACKAGE_NAME) + putParcelable("dismiss_intent", DISMISS_INTENT) + putString(EXTRA_KEY_TRIGGER_SOURCE, EXTRA_VALUE_TRIGGER_PERIODIC) + } + whenever(mediaSmartspaceBaseAction.extras).thenReturn(extras) + + smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget)) + val instanceId = instanceIdSequence.lastInstanceId + + verify(listener) + .onSmartspaceMediaDataLoaded( + eq(KEY_MEDIA_SMARTSPACE), + eq( + SmartspaceMediaData( + targetId = KEY_MEDIA_SMARTSPACE, + isActive = false, + packageName = PACKAGE_NAME, + cardAction = mediaSmartspaceBaseAction, + recommendations = validRecommendationList, + dismissIntent = DISMISS_INTENT, + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, + ) + ), + eq(false) + ) + } + + @Test + fun testOnSmartspaceMediaDataLoaded_persistentEnabled_noTargets_inactive() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + + smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget)) + val instanceId = instanceIdSequence.lastInstanceId + + smartspaceMediaDataProvider.onTargetsAvailable(listOf()) + uiExecutor.advanceClockToLast() + uiExecutor.runAllReady() + + verify(listener) + .onSmartspaceMediaDataLoaded( + eq(KEY_MEDIA_SMARTSPACE), + eq( + SmartspaceMediaData( + targetId = KEY_MEDIA_SMARTSPACE, + isActive = false, + packageName = PACKAGE_NAME, + cardAction = mediaSmartspaceBaseAction, + recommendations = validRecommendationList, + dismissIntent = DISMISS_INTENT, + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, + ) + ), + eq(false) + ) + verify(listener, never()).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(false)) + } + + @Test + fun testSetRecommendationInactive_notifiesListeners() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + + smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget)) + val instanceId = instanceIdSequence.lastInstanceId + + mediaDataManager.setRecommendationInactive(KEY_MEDIA_SMARTSPACE) + uiExecutor.advanceClockToLast() + uiExecutor.runAllReady() + + verify(listener) + .onSmartspaceMediaDataLoaded( + eq(KEY_MEDIA_SMARTSPACE), + eq( + SmartspaceMediaData( + targetId = KEY_MEDIA_SMARTSPACE, + isActive = false, + packageName = PACKAGE_NAME, + cardAction = mediaSmartspaceBaseAction, + recommendations = validRecommendationList, + dismissIntent = DISMISS_INTENT, + headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME, + instanceId = InstanceId.fakeInstanceId(instanceId), + expiryTimeMs = SMARTSPACE_EXPIRY_TIME, + ) + ), + eq(false) + ) + } + @Test fun testOnSmartspaceMediaDataLoaded_settingDisabled_doesNothing() { // WHEN media recommendation setting is off diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListenerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListenerTest.kt index 92bf84ce285c0..8baa06ac0141f 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListenerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/pipeline/MediaTimeoutListenerTest.kt @@ -25,13 +25,16 @@ import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.media.controls.MediaTestUtils import com.android.systemui.media.controls.models.player.MediaData +import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData import com.android.systemui.media.controls.util.MediaControllerFactory +import com.android.systemui.media.controls.util.MediaFlags import com.android.systemui.plugins.statusbar.StatusBarStateController import com.android.systemui.statusbar.SysuiStatusBarStateController import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.whenever import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import org.junit.Before @@ -48,7 +51,6 @@ import org.mockito.Mockito.clearInvocations import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnit private const val KEY = "KEY" @@ -56,6 +58,7 @@ private const val PACKAGE = "PKG" private const val SESSION_KEY = "SESSION_KEY" private const val SESSION_ARTIST = "SESSION_ARTIST" private const val SESSION_TITLE = "SESSION_TITLE" +private const val SMARTSPACE_KEY = "SMARTSPACE_KEY" private fun anyObject(): T { return Mockito.anyObject() @@ -85,10 +88,13 @@ class MediaTimeoutListenerTest : SysuiTestCase() { private lateinit var resumeData: MediaData private lateinit var mediaTimeoutListener: MediaTimeoutListener private var clock = FakeSystemClock() + @Mock private lateinit var mediaFlags: MediaFlags + @Mock private lateinit var smartspaceData: SmartspaceMediaData @Before fun setup() { - `when`(mediaControllerFactory.create(any())).thenReturn(mediaController) + whenever(mediaControllerFactory.create(any())).thenReturn(mediaController) + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false) executor = FakeExecutor(clock) mediaTimeoutListener = MediaTimeoutListener( @@ -96,7 +102,8 @@ class MediaTimeoutListenerTest : SysuiTestCase() { executor, logger, statusBarStateController, - clock + clock, + mediaFlags, ) mediaTimeoutListener.timeoutCallback = timeoutCallback mediaTimeoutListener.stateCallback = stateCallback @@ -133,9 +140,9 @@ class MediaTimeoutListenerTest : SysuiTestCase() { @Test fun testOnMediaDataLoaded_registersPlaybackListener() { val playingState = mock(android.media.session.PlaybackState::class.java) - `when`(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) + whenever(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) - `when`(mediaController.playbackState).thenReturn(playingState) + whenever(mediaController.playbackState).thenReturn(playingState) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) verify(logger).logPlaybackState(eq(KEY), eq(playingState)) @@ -188,8 +195,8 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // To playing val playingState = mock(android.media.session.PlaybackState::class.java) - `when`(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) - `when`(mediaController.playbackState).thenReturn(playingState) + whenever(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) + whenever(mediaController.playbackState).thenReturn(playingState) mediaTimeoutListener.onMediaDataLoaded(newKey, KEY, mediaData) verify(mediaController).unregisterCallback(anyObject()) verify(mediaController).registerCallback(anyObject()) @@ -208,8 +215,8 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // Migrate, still not playing val playingState = mock(android.media.session.PlaybackState::class.java) - `when`(playingState.state).thenReturn(PlaybackState.STATE_PAUSED) - `when`(mediaController.playbackState).thenReturn(playingState) + whenever(playingState.state).thenReturn(PlaybackState.STATE_PAUSED) + whenever(mediaController.playbackState).thenReturn(playingState) mediaTimeoutListener.onMediaDataLoaded(newKey, KEY, mediaData) // The number of queued timeout tasks remains the same. The timeout task isn't cancelled nor @@ -296,8 +303,8 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // WHEN we get an update with media playing val playingState = mock(android.media.session.PlaybackState::class.java) - `when`(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) - `when`(mediaController.playbackState).thenReturn(playingState) + whenever(playingState.state).thenReturn(PlaybackState.STATE_PLAYING) + whenever(mediaController.playbackState).thenReturn(playingState) val mediaPlaying = mediaData.copy(isPlaying = true) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaPlaying) @@ -347,7 +354,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // WHEN regular media is paused val pausedState = PlaybackState.Builder().setState(PlaybackState.STATE_PAUSED, 0L, 0f).build() - `when`(mediaController.playbackState).thenReturn(pausedState) + whenever(mediaController.playbackState).thenReturn(pausedState) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) assertThat(executor.numPending()).isEqualTo(1) @@ -379,7 +386,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { // AND that media is resumed val playingState = PlaybackState.Builder().setState(PlaybackState.STATE_PAUSED, 0L, 0f).build() - `when`(mediaController.playbackState).thenReturn(playingState) + whenever(mediaController.playbackState).thenReturn(playingState) mediaTimeoutListener.onMediaDataLoaded(KEY, PACKAGE, mediaData) // THEN the timeout length is changed to a regular media control @@ -593,8 +600,91 @@ class MediaTimeoutListenerTest : SysuiTestCase() { assertThat(executor.numPending()).isEqualTo(1) } + @Test + fun testSmartspaceDataLoaded_schedulesTimeout() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + val duration = 60_000 + val createTime = 1234L + val expireTime = createTime + duration + whenever(smartspaceData.headphoneConnectionTimeMillis).thenReturn(createTime) + whenever(smartspaceData.expiryTimeMs).thenReturn(expireTime) + + mediaTimeoutListener.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + assertThat(executor.numPending()).isEqualTo(1) + assertThat(executor.advanceClockToNext()).isEqualTo(duration) + } + + @Test + fun testSmartspaceMediaData_timesOut_invokesCallback() { + // Given a pending timeout + testSmartspaceDataLoaded_schedulesTimeout() + + executor.runAllReady() + verify(timeoutCallback).invoke(eq(SMARTSPACE_KEY), eq(true)) + } + + @Test + fun testSmartspaceDataLoaded_alreadyExists_updatesTimeout() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + val duration = 100 + val createTime = 1234L + val expireTime = createTime + duration + whenever(smartspaceData.headphoneConnectionTimeMillis).thenReturn(createTime) + whenever(smartspaceData.expiryTimeMs).thenReturn(expireTime) + + mediaTimeoutListener.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + assertThat(executor.numPending()).isEqualTo(1) + + val expiryLonger = expireTime + duration + whenever(smartspaceData.expiryTimeMs).thenReturn(expiryLonger) + mediaTimeoutListener.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + + assertThat(executor.numPending()).isEqualTo(1) + assertThat(executor.advanceClockToNext()).isEqualTo(duration * 2) + } + + @Test + fun testSmartspaceDataRemoved_cancelTimeout() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + + mediaTimeoutListener.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + assertThat(executor.numPending()).isEqualTo(1) + + mediaTimeoutListener.onSmartspaceMediaDataRemoved(SMARTSPACE_KEY) + assertThat(executor.numPending()).isEqualTo(0) + } + + @Test + fun testSmartspaceData_dozedPastTimeout_invokedOnWakeup() { + // Given a pending timeout + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + verify(statusBarStateController).addCallback(capture(dozingCallbackCaptor)) + val duration = 60_000 + val createTime = 1234L + val expireTime = createTime + duration + whenever(smartspaceData.headphoneConnectionTimeMillis).thenReturn(createTime) + whenever(smartspaceData.expiryTimeMs).thenReturn(expireTime) + + mediaTimeoutListener.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData) + assertThat(executor.numPending()).isEqualTo(1) + + // And we doze past the scheduled timeout + val time = clock.currentTimeMillis() + clock.setElapsedRealtime(time + duration * 2) + assertThat(executor.numPending()).isEqualTo(1) + + // Then when no longer dozing, the timeout runs immediately + dozingCallbackCaptor.value.onDozingChanged(false) + verify(timeoutCallback).invoke(eq(SMARTSPACE_KEY), eq(true)) + verify(logger).logTimeout(eq(SMARTSPACE_KEY)) + + // and cancel any later scheduled timeout + assertThat(executor.numPending()).isEqualTo(0) + } + private fun loadMediaDataWithPlaybackState(state: PlaybackState) { - `when`(mediaController.playbackState).thenReturn(state) + whenever(mediaController.playbackState).thenReturn(state) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaCarouselControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaCarouselControllerTest.kt index 5e5dc8b20c652..e201b6b67f916 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaCarouselControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaCarouselControllerTest.kt @@ -47,6 +47,7 @@ import com.android.systemui.util.mockito.eq import com.android.systemui.util.time.FakeSystemClock import javax.inject.Provider import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertFalse import junit.framework.Assert.assertTrue import org.junit.Before import org.junit.Ignore @@ -126,6 +127,7 @@ class MediaCarouselControllerTest : SysuiTestCase() { whenever(mediaControlPanelFactory.get()).thenReturn(panel) whenever(panel.mediaViewController).thenReturn(mediaViewController) whenever(mediaDataManager.smartspaceMediaData).thenReturn(smartspaceMediaData) + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(false) MediaPlayerData.clear() } @@ -703,4 +705,39 @@ class MediaCarouselControllerTest : SysuiTestCase() { mediaCarouselController.mediaCarouselScrollHandler.visibleMediaIndex ) } + + @Test + fun testRecommendation_persistentEnabled_newSmartspaceLoaded_updatesSort() { + testRecommendation_persistentEnabled_inactiveSmartspaceDataLoaded_isAdded() + + // When an update to existing smartspace data is loaded + listener.value.onSmartspaceMediaDataLoaded( + SMARTSPACE_KEY, + EMPTY_SMARTSPACE_MEDIA_DATA.copy(isActive = true), + true + ) + + // Then the carousel is updated + assertTrue(MediaPlayerData.playerKeys().elementAt(0).data.active) + assertTrue(MediaPlayerData.visiblePlayerKeys().elementAt(0).data.active) + } + + @Test + fun testRecommendation_persistentEnabled_inactiveSmartspaceDataLoaded_isAdded() { + whenever(mediaFlags.isPersistentSsCardEnabled()).thenReturn(true) + + // When inactive smartspace data is loaded + listener.value.onSmartspaceMediaDataLoaded( + SMARTSPACE_KEY, + EMPTY_SMARTSPACE_MEDIA_DATA, + false + ) + + // Then it is added to the carousel with correct state + assertTrue(MediaPlayerData.playerKeys().elementAt(0).isSsMediaRec) + assertFalse(MediaPlayerData.playerKeys().elementAt(0).data.active) + + assertTrue(MediaPlayerData.visiblePlayerKeys().elementAt(0).isSsMediaRec) + assertFalse(MediaPlayerData.visiblePlayerKeys().elementAt(0).data.active) + } }