Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 58c17584 authored by Beth Thibodeau's avatar Beth Thibodeau
Browse files

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
parent 0e40c2c9
Loading
Loading
Loading
Loading
+10 −6
Original line number Diff line number Diff line
@@ -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)
        }
    }

+39 −9
Original line number Diff line number Diff line
@@ -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<ComponentName> = ConcurrentLinkedQueue()
    private val resumeComponents: ConcurrentLinkedQueue<Pair<ComponentName, Long>> =
            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,11 +170,14 @@ class MediaResumeListener @Inject constructor(
            return
        }

        val now = systemClock.currentTimeMillis()
        resumeComponents.forEach {
            val browser = mediaBrowserFactory.create(mediaBrowserCallback, it)
            if (now.minus(it.second) <= RESUME_MEDIA_TIMEOUT) {
                val browser = mediaBrowserFactory.create(mediaBrowserCallback, it.first)
                browser.findRecentMedia()
            }
        }
    }

    override fun onMediaDataLoaded(
        key: String,
@@ -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)
+35 −11
Original line number Diff line number Diff line
@@ -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,13 +168,20 @@ 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")
            }

            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) {
            if (DEBUG) {
@@ -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
+20 −1
Original line number Diff line number Diff line
@@ -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)
+110 −3
Original line number Diff line number Diff line
@@ -91,10 +91,12 @@ class MediaResumeListenerTest : SysuiTestCase() {

    @Captor lateinit var callbackCaptor: ArgumentCaptor<ResumeMediaBrowser.Callback>
    @Captor lateinit var actionCaptor: ArgumentCaptor<Runnable>
    @Captor lateinit var componentCaptor: ArgumentCaptor<String>

    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
Loading