From 58c17584291434c655ae5d98a608bcf700350276 Mon Sep 17 00:00:00 2001 From: Beth Thibodeau Date: Wed, 25 Aug 2021 18:28:35 -0400 Subject: [PATCH] Time out resume controls 1. Schedule a 3 day timeout when resume controls are first added, or converted from active controls 2. Store the last played time for the app, and do not create resume controls on boot if that time is greater than the 3 day timeout Fixes: 186247527 Test: atest com.android.systemui.media Test: manual (adb shell setprop debug.sysui.media_timeout_resume 10000) Change-Id: I6e579153a35ef0d56d8d6205739a58b8cc74d061 --- .../systemui/media/MediaDataManager.kt | 16 ++- .../systemui/media/MediaResumeListener.kt | 48 ++++++-- .../systemui/media/MediaTimeoutListener.kt | 46 +++++-- .../systemui/media/MediaDataManagerTest.kt | 21 +++- .../systemui/media/MediaResumeListenerTest.kt | 113 +++++++++++++++++- .../media/MediaTimeoutListenerTest.kt | 98 ++++++++++++++- 6 files changed, 310 insertions(+), 32 deletions(-) diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt index 0a28b47923dac..ba99f5df653ca 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt @@ -212,8 +212,8 @@ class MediaDataManager( mediaDataCombineLatest.addListener(mediaDataFilter) // Set up links back into the pipeline for listeners that need to send events upstream. - mediaTimeoutListener.timeoutCallback = { token: String, timedOut: Boolean -> - setTimedOut(token, timedOut) } + mediaTimeoutListener.timeoutCallback = { key: String, timedOut: Boolean -> + setTimedOut(key, timedOut) } mediaResumeListener.setManager(this) mediaDataFilter.mediaDataManager = this @@ -414,14 +414,18 @@ class MediaDataManager( * This will make the player not active anymore, hiding it from QQS and Keyguard. * @see MediaData.active */ - internal fun setTimedOut(token: String, timedOut: Boolean, forceUpdate: Boolean = false) { - mediaEntries[token]?.let { + internal fun setTimedOut(key: String, timedOut: Boolean, forceUpdate: Boolean = false) { + mediaEntries[key]?.let { if (it.active == !timedOut && !forceUpdate) { + if (it.resumption) { + if (DEBUG) Log.d(TAG, "timing out resume player $key") + dismissMediaData(key, 0L /* delay */) + } return } it.active = !timedOut - if (DEBUG) Log.d(TAG, "Updating $token timedOut: $timedOut") - onMediaDataLoaded(token, token, it) + if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut") + onMediaDataLoaded(key, key, it) } } diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaResumeListener.kt b/packages/SystemUI/src/com/android/systemui/media/MediaResumeListener.kt index ab568c8c5a855..608c784f5d395 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaResumeListener.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaResumeListener.kt @@ -35,6 +35,7 @@ import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dump.DumpManager import com.android.systemui.tuner.TunerService import com.android.systemui.util.Utils +import com.android.systemui.util.time.SystemClock import java.io.FileDescriptor import java.io.PrintWriter import java.util.concurrent.ConcurrentLinkedQueue @@ -53,11 +54,13 @@ class MediaResumeListener @Inject constructor( @Background private val backgroundExecutor: Executor, private val tunerService: TunerService, private val mediaBrowserFactory: ResumeMediaBrowserFactory, - dumpManager: DumpManager + dumpManager: DumpManager, + private val systemClock: SystemClock ) : MediaDataManager.Listener, Dumpable { private var useMediaResumption: Boolean = Utils.useMediaResumption(context) - private val resumeComponents: ConcurrentLinkedQueue = ConcurrentLinkedQueue() + private val resumeComponents: ConcurrentLinkedQueue> = + ConcurrentLinkedQueue() private lateinit var mediaDataManager: MediaDataManager @@ -131,14 +134,32 @@ class MediaResumeListener @Inject constructor( val listString = prefs.getString(MEDIA_PREFERENCE_KEY + currentUserId, null) val components = listString?.split(ResumeMediaBrowser.DELIMITER.toRegex()) ?.dropLastWhile { it.isEmpty() } + var needsUpdate = false components?.forEach { val info = it.split("/") val packageName = info[0] val className = info[1] val component = ComponentName(packageName, className) - resumeComponents.add(component) + + val lastPlayed = if (info.size == 3) { + try { + info[2].toLong() + } catch (e: NumberFormatException) { + needsUpdate = true + systemClock.currentTimeMillis() + } + } else { + needsUpdate = true + systemClock.currentTimeMillis() + } + resumeComponents.add(component to lastPlayed) } Log.d(TAG, "loaded resume components ${resumeComponents.toArray().contentToString()}") + + if (needsUpdate) { + // Save any missing times that we had to fill in + writeSharedPrefs() + } } /** @@ -149,9 +170,12 @@ class MediaResumeListener @Inject constructor( return } + val now = systemClock.currentTimeMillis() resumeComponents.forEach { - val browser = mediaBrowserFactory.create(mediaBrowserCallback, it) - browser.findRecentMedia() + if (now.minus(it.second) <= RESUME_MEDIA_TIMEOUT) { + val browser = mediaBrowserFactory.create(mediaBrowserCallback, it.first) + browser.findRecentMedia() + } } } @@ -234,18 +258,24 @@ class MediaResumeListener @Inject constructor( */ private fun updateResumptionList(componentName: ComponentName) { // Remove if exists - resumeComponents.remove(componentName) + resumeComponents.remove(resumeComponents.find { it.first.equals(componentName) }) // Insert at front of queue - resumeComponents.add(componentName) + val currentTime = systemClock.currentTimeMillis() + resumeComponents.add(componentName to currentTime) // Remove old components if over the limit if (resumeComponents.size > ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) { resumeComponents.remove() } - // Save changes + writeSharedPrefs() + } + + private fun writeSharedPrefs() { val sb = StringBuilder() resumeComponents.forEach { - sb.append(it.flattenToString()) + sb.append(it.first.flattenToString()) + sb.append("/") + sb.append(it.second) sb.append(ResumeMediaBrowser.DELIMITER) } val prefs = context.getSharedPreferences(MEDIA_PREFERENCES, Context.MODE_PRIVATE) diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt b/packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt index 9a3919326cbd7..6f047712c954b 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaTimeoutListener.kt @@ -20,6 +20,7 @@ import android.media.session.MediaController import android.media.session.PlaybackState import android.os.SystemProperties import android.util.Log +import com.android.internal.annotations.VisibleForTesting import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState @@ -29,9 +30,15 @@ import javax.inject.Inject private const val DEBUG = true private const val TAG = "MediaTimeout" -private val PAUSED_MEDIA_TIMEOUT = SystemProperties + +@VisibleForTesting +val PAUSED_MEDIA_TIMEOUT = SystemProperties .getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10)) +@VisibleForTesting +val RESUME_MEDIA_TIMEOUT = SystemProperties + .getLong("debug.sysui.media_timeout_resume", TimeUnit.DAYS.toMillis(3)) + /** * Controller responsible for keeping track of playback states and expiring inactive streams. */ @@ -45,8 +52,9 @@ class MediaTimeoutListener @Inject constructor( /** * Callback representing that a media object is now expired: - * @param token Media session unique identifier - * @param pauseTimeout True when expired for {@code PAUSED_MEDIA_TIMEOUT} + * @param key Media control unique identifier + * @param timedOut True when expired for {@code PAUSED_MEDIA_TIMEOUT} for active media, + * or {@code RESUME_MEDIA_TIMEOUT} for resume media */ lateinit var timeoutCallback: (String, Boolean) -> Unit @@ -122,6 +130,7 @@ class MediaTimeoutListener @Inject constructor( var timedOut = false var playing: Boolean? = null + var resumption: Boolean? = null var destroyed = false var mediaData: MediaData = data @@ -159,12 +168,19 @@ class MediaTimeoutListener @Inject constructor( } override fun onSessionDestroyed() { - // If the session is destroyed, the controller is no longer valid, and we will need to - // recreate it if this key is updated later if (DEBUG) { Log.d(TAG, "Session destroyed for $key") } - destroy() + + if (resumption == true) { + // Some apps create a session when MBS is queried. We should unregister the + // controller since it will no longer be valid, but don't cancel the timeout + mediaController?.unregisterCallback(this) + } else { + // For active controls, if the session is destroyed, clean up everything since we + // will need to recreate it if this key is updated later + destroy() + } } private fun processState(state: PlaybackState?, dispatchEvents: Boolean) { @@ -173,20 +189,28 @@ class MediaTimeoutListener @Inject constructor( } val isPlaying = state != null && isPlayingState(state.state) - if (playing == isPlaying && playing != null) { + val resumptionChanged = resumption != mediaData.resumption + if (playing == isPlaying && playing != null && !resumptionChanged) { return } playing = isPlaying + resumption = mediaData.resumption if (!isPlaying) { if (DEBUG) { - Log.v(TAG, "schedule timeout for $key") + Log.v(TAG, "schedule timeout for $key playing $isPlaying, $resumption") } - if (cancellation != null) { + if (cancellation != null && !resumptionChanged) { + // if the media changed resume state, we'll need to adjust the timeout length if (DEBUG) Log.d(TAG, "cancellation already exists, continuing.") return } - expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state") + expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state, $resumption") + val timeout = if (mediaData.resumption) { + RESUME_MEDIA_TIMEOUT + } else { + PAUSED_MEDIA_TIMEOUT + } cancellation = mainExecutor.executeDelayed({ cancellation = null if (DEBUG) { @@ -195,7 +219,7 @@ class MediaTimeoutListener @Inject constructor( timedOut = true // this event is async, so it's safe even when `dispatchEvents` is false timeoutCallback(key, timedOut) - }, PAUSED_MEDIA_TIMEOUT) + }, timeout) } else { expireMediaTimeout(key, "playback started - $state, $key") timedOut = false diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt index ba6dfd3c453a7..5c3108c331476 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt @@ -163,7 +163,7 @@ class MediaDataManagerTest : SysuiTestCase() { } @Test - fun testSetTimedOut_deactivatesMedia() { + fun testSetTimedOut_active_deactivatesMedia() { val data = MediaData(userId = USER_ID, initialized = true, backgroundColor = 0, app = null, appIcon = null, artist = null, song = null, artwork = null, actions = emptyList(), actionsToShowInCompact = emptyList(), packageName = "INVALID", token = null, @@ -175,6 +175,25 @@ class MediaDataManagerTest : SysuiTestCase() { assertThat(data.active).isFalse() } + @Test + fun testSetTimedOut_resume_dismissesMedia() { + // WHEN resume controls are present, and time out + val desc = MediaDescription.Builder().run { + setTitle(SESSION_TITLE) + build() + } + mediaDataManager.addResumptionControls(USER_ID, desc, Runnable {}, session.sessionToken, + APP_NAME, pendingIntent, PACKAGE_NAME) + backgroundExecutor.runAllReady() + foregroundExecutor.runAllReady() + mediaDataManager.setTimedOut(PACKAGE_NAME, timedOut = true) + + // THEN it is removed and listeners are informed + foregroundExecutor.advanceClockToLast() + foregroundExecutor.runAllReady() + verify(listener).onMediaDataRemoved(PACKAGE_NAME) + } + @Test fun testLoadsMetadataOnBackground() { mediaDataManager.onNotificationAdded(KEY, mediaNotification) diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaResumeListenerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaResumeListenerTest.kt index 150f4545bd43d..359746bca0ff1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaResumeListenerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaResumeListenerTest.kt @@ -91,10 +91,12 @@ class MediaResumeListenerTest : SysuiTestCase() { @Captor lateinit var callbackCaptor: ArgumentCaptor @Captor lateinit var actionCaptor: ArgumentCaptor + @Captor lateinit var componentCaptor: ArgumentCaptor private lateinit var executor: FakeExecutor private lateinit var data: MediaData private lateinit var resumeListener: MediaResumeListener + private val clock = FakeSystemClock() private var originalQsSetting = Settings.Global.getInt(context.contentResolver, Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1) @@ -122,9 +124,9 @@ class MediaResumeListenerTest : SysuiTestCase() { whenever(mockContext.packageManager).thenReturn(context.packageManager) whenever(mockContext.contentResolver).thenReturn(context.contentResolver) - executor = FakeExecutor(FakeSystemClock()) + executor = FakeExecutor(clock) resumeListener = MediaResumeListener(mockContext, broadcastDispatcher, executor, - tunerService, resumeBrowserFactory, dumpManager) + tunerService, resumeBrowserFactory, dumpManager, clock) resumeListener.setManager(mediaDataManager) mediaDataManager.addListener(resumeListener) @@ -163,7 +165,7 @@ class MediaResumeListenerTest : SysuiTestCase() { // When listener is created, we do NOT register a user change listener val listener = MediaResumeListener(context, broadcastDispatcher, executor, tunerService, - resumeBrowserFactory, dumpManager) + resumeBrowserFactory, dumpManager, clock) listener.setManager(mediaDataManager) verify(broadcastDispatcher, never()).registerReceiver(eq(listener.userChangeReceiver), any(), any(), any()) @@ -328,4 +330,109 @@ class MediaResumeListenerTest : SysuiTestCase() { // Then we call restart verify(resumeBrowser).restart() } + + @Test + fun testOnUserUnlock_missingTime_saves() { + val currentTime = clock.currentTimeMillis() + + // When resume components without a last played time are loaded + testOnUserUnlock_loadsTracks() + + // Then we save an update with the current time + verify(sharedPrefsEditor).putString(any(), (capture(componentCaptor))) + componentCaptor.value.split(ResumeMediaBrowser.DELIMITER.toRegex()) + ?.dropLastWhile { it.isEmpty() }.forEach { + val result = it.split("/") + assertThat(result.size).isEqualTo(3) + assertThat(result[2].toLong()).isEqualTo(currentTime) + } + verify(sharedPrefsEditor, times(1)).apply() + } + + @Test + fun testLoadComponents_recentlyPlayed_adds() { + // Set up browser to return successfully + val description = MediaDescription.Builder().setTitle(TITLE).build() + val component = ComponentName(PACKAGE_NAME, CLASS_NAME) + whenever(resumeBrowser.token).thenReturn(token) + whenever(resumeBrowser.appIntent).thenReturn(pendingIntent) + whenever(resumeBrowser.findRecentMedia()).thenAnswer { + callbackCaptor.value.addTrack(description, component, resumeBrowser) + } + + // Set up shared preferences to have a component with a recent lastplayed time + val lastPlayed = clock.currentTimeMillis() + val componentsString = "$PACKAGE_NAME/$CLASS_NAME/$lastPlayed:" + whenever(sharedPrefs.getString(any(), any())).thenReturn(componentsString) + val resumeListener = MediaResumeListener(mockContext, broadcastDispatcher, executor, + tunerService, resumeBrowserFactory, dumpManager, clock) + resumeListener.setManager(mediaDataManager) + mediaDataManager.addListener(resumeListener) + + // When we load a component that was played recently + val intent = Intent(Intent.ACTION_USER_UNLOCKED) + resumeListener.userChangeReceiver.onReceive(mockContext, intent) + + // We add its resume controls + verify(resumeBrowser, times(1)).findRecentMedia() + verify(mediaDataManager, times(1)).addResumptionControls(anyInt(), + any(), any(), any(), any(), any(), eq(PACKAGE_NAME)) + } + + @Test + fun testLoadComponents_old_ignores() { + // Set up shared preferences to have a component with an old lastplayed time + val lastPlayed = clock.currentTimeMillis() - RESUME_MEDIA_TIMEOUT - 100 + val componentsString = "$PACKAGE_NAME/$CLASS_NAME/$lastPlayed:" + whenever(sharedPrefs.getString(any(), any())).thenReturn(componentsString) + val resumeListener = MediaResumeListener(mockContext, broadcastDispatcher, executor, + tunerService, resumeBrowserFactory, dumpManager, clock) + resumeListener.setManager(mediaDataManager) + mediaDataManager.addListener(resumeListener) + + // When we load a component that is not recent + val intent = Intent(Intent.ACTION_USER_UNLOCKED) + resumeListener.userChangeReceiver.onReceive(mockContext, intent) + + // We do not try to add resume controls + verify(resumeBrowser, times(0)).findRecentMedia() + verify(mediaDataManager, times(0)).addResumptionControls(anyInt(), + any(), any(), any(), any(), any(), any()) + } + + @Test + fun testOnLoad_hasService_updatesLastPlayed() { + // Set up browser to return successfully + val description = MediaDescription.Builder().setTitle(TITLE).build() + val component = ComponentName(PACKAGE_NAME, CLASS_NAME) + whenever(resumeBrowser.token).thenReturn(token) + whenever(resumeBrowser.appIntent).thenReturn(pendingIntent) + whenever(resumeBrowser.findRecentMedia()).thenAnswer { + callbackCaptor.value.addTrack(description, component, resumeBrowser) + } + + // Set up shared preferences to have a component with a lastplayed time + val currentTime = clock.currentTimeMillis() + val lastPlayed = currentTime - 1000 + val componentsString = "$PACKAGE_NAME/$CLASS_NAME/$lastPlayed:" + whenever(sharedPrefs.getString(any(), any())).thenReturn(componentsString) + val resumeListener = MediaResumeListener(mockContext, broadcastDispatcher, executor, + tunerService, resumeBrowserFactory, dumpManager, clock) + resumeListener.setManager(mediaDataManager) + mediaDataManager.addListener(resumeListener) + + // When media data is loaded that has not been checked yet, and does have a MBS + val dataCopy = data.copy(resumeAction = null, hasCheckedForResume = false) + resumeListener.onMediaDataLoaded(KEY, null, dataCopy) + + // Then we store the new lastPlayed time + verify(sharedPrefsEditor).putString(any(), (capture(componentCaptor))) + componentCaptor.value.split(ResumeMediaBrowser.DELIMITER.toRegex()) + ?.dropLastWhile { it.isEmpty() }.forEach { + val result = it.split("/") + assertThat(result.size).isEqualTo(3) + assertThat(result[2].toLong()).isEqualTo(currentTime) + } + verify(sharedPrefsEditor, times(1)).apply() + } } \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt index 0a573cd6020c5..de2235dd1c420 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt @@ -71,6 +71,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { private lateinit var playbackBuilder: PlaybackState.Builder private lateinit var session: MediaSession private lateinit var mediaData: MediaData + private lateinit var resumeData: MediaData private lateinit var mediaTimeoutListener: MediaTimeoutListener @Before @@ -97,6 +98,10 @@ class MediaTimeoutListenerTest : SysuiTestCase() { mediaData = MediaData(USER_ID, true, 0, PACKAGE, null, null, SESSION_TITLE, null, emptyList(), emptyList(), PACKAGE, session.sessionToken, clickIntent = null, device = null, active = true, resumeAction = null) + + resumeData = MediaData(USER_ID, true, 0, PACKAGE, null, null, SESSION_TITLE, null, + emptyList(), emptyList(), PACKAGE, null, clickIntent = null, + device = null, active = false, resumeAction = null, resumption = true) } @Test @@ -120,6 +125,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) assertThat(executor.numPending()).isEqualTo(1) verify(timeoutCallback, never()).invoke(anyString(), anyBoolean()) + assertThat(executor.advanceClockToNext()).isEqualTo(PAUSED_MEDIA_TIMEOUT) } @Test @@ -188,6 +194,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { mediaCallbackCaptor.value.onPlaybackStateChanged(PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0L, 0f).build()) assertThat(executor.numPending()).isEqualTo(1) + assertThat(executor.advanceClockToNext()).isEqualTo(PAUSED_MEDIA_TIMEOUT) } @Test @@ -229,7 +236,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { } @Test - fun testOnSessionDestroyed_clearsTimeout() { + fun testOnSessionDestroyed_active_clearsTimeout() { // GIVEN media that is paused val mediaPaused = mediaData.copy(isPlaying = false) mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaPaused) @@ -247,7 +254,7 @@ class MediaTimeoutListenerTest : SysuiTestCase() { @Test fun testSessionDestroyed_thenRestarts_resetsTimeout() { // Assuming we have previously destroyed the session - testOnSessionDestroyed_clearsTimeout() + testOnSessionDestroyed_active_clearsTimeout() // WHEN we get an update with media playing val playingState = mock(android.media.session.PlaybackState::class.java) @@ -264,4 +271,91 @@ class MediaTimeoutListenerTest : SysuiTestCase() { } verify(timeoutCallback).invoke(eq(KEY), eq(false)) } + + @Test + fun testOnSessionDestroyed_resume_continuesTimeout() { + // GIVEN resume media with session info + val resumeWithSession = resumeData.copy(token = session.sessionToken) + mediaTimeoutListener.onMediaDataLoaded(PACKAGE, null, resumeWithSession) + verify(mediaController).registerCallback(capture(mediaCallbackCaptor)) + assertThat(executor.numPending()).isEqualTo(1) + + // WHEN the session is destroyed + mediaCallbackCaptor.value.onSessionDestroyed() + + // THEN the controller is unregistered, but the timeout is still scheduled + verify(mediaController).unregisterCallback(anyObject()) + assertThat(executor.numPending()).isEqualTo(1) + } + + @Test + fun testOnMediaDataLoaded_activeToResume_registersTimeout() { + // WHEN a regular media is loaded + mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) + + // AND it turns into a resume control + mediaTimeoutListener.onMediaDataLoaded(PACKAGE, KEY, resumeData) + + // THEN we register a timeout + assertThat(executor.numPending()).isEqualTo(1) + verify(timeoutCallback, never()).invoke(anyString(), anyBoolean()) + assertThat(executor.advanceClockToNext()).isEqualTo(RESUME_MEDIA_TIMEOUT) + } + + @Test + fun testOnMediaDataLoaded_pausedToResume_updatesTimeout() { + // WHEN regular media is paused + val pausedState = PlaybackState.Builder() + .setState(PlaybackState.STATE_PAUSED, 0L, 0f) + .build() + `when`(mediaController.playbackState).thenReturn(pausedState) + mediaTimeoutListener.onMediaDataLoaded(KEY, null, mediaData) + assertThat(executor.numPending()).isEqualTo(1) + + // AND it turns into a resume control + mediaTimeoutListener.onMediaDataLoaded(PACKAGE, KEY, resumeData) + + // THEN we update the timeout length + assertThat(executor.numPending()).isEqualTo(1) + verify(timeoutCallback, never()).invoke(anyString(), anyBoolean()) + assertThat(executor.advanceClockToNext()).isEqualTo(RESUME_MEDIA_TIMEOUT) + } + + @Test + fun testOnMediaDataLoaded_resumption_registersTimeout() { + // WHEN a resume media is loaded + mediaTimeoutListener.onMediaDataLoaded(PACKAGE, null, resumeData) + + // THEN we register a timeout + assertThat(executor.numPending()).isEqualTo(1) + verify(timeoutCallback, never()).invoke(anyString(), anyBoolean()) + assertThat(executor.advanceClockToNext()).isEqualTo(RESUME_MEDIA_TIMEOUT) + } + + @Test + fun testOnMediaDataLoaded_resumeToActive_updatesTimeout() { + // WHEN we have a resume control + mediaTimeoutListener.onMediaDataLoaded(PACKAGE, null, resumeData) + + // AND that media is resumed + val playingState = PlaybackState.Builder() + .setState(PlaybackState.STATE_PAUSED, 0L, 0f) + .build() + `when`(mediaController.playbackState).thenReturn(playingState) + mediaTimeoutListener.onMediaDataLoaded(KEY, PACKAGE, mediaData) + + // THEN the timeout length is changed to a regular media control + assertThat(executor.advanceClockToNext()).isEqualTo(PAUSED_MEDIA_TIMEOUT) + } + + @Test + fun testOnMediaDataRemoved_resume_timeoutCancelled() { + // WHEN we have a resume control + testOnMediaDataLoaded_resumption_registersTimeout() + // AND the media is removed + mediaTimeoutListener.onMediaDataRemoved(PACKAGE) + + // THEN the timeout runnable is cancelled + assertThat(executor.numPending()).isEqualTo(0) + } }