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) + } }