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
This commit is contained in:
Beth Thibodeau
2021-08-25 18:28:35 -04:00
parent 0e40c2c994
commit 58c1758429
6 changed files with 310 additions and 32 deletions

View File

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

View File

@@ -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,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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}
}

View File

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