Merge "Time out resume controls" into sc-qpr1-dev am: 6ff8a0f692

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/15677510

Change-Id: I759d5787674a428ab85c5088df5a5637e0403f32
This commit is contained in:
Beth Thibodeau
2021-09-09 16:54:48 +00:00
committed by Automerger Merge Worker
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)
}
}