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

Commit 1c150318 authored by Beth Thibodeau's avatar Beth Thibodeau Committed by Automerger Merge Worker
Browse files

Merge "Retain media controls longer" into tm-qpr-dev am: d2909c6c am: ba6f9d24

parents 16427c59 ba6f9d24
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -354,6 +354,9 @@ object Flags {
    val MEDIA_TAP_TO_TRANSFER_DISMISS_GESTURE =
        unreleasedFlag(912, "media_ttt_dismiss_gesture", teamfood = true)

    // TODO(b/266157412): Tracking Bug
    val MEDIA_RETAIN_SESSIONS = unreleasedFlag(913, "media_retain_sessions")

    // 1000 - dock
    val SIMULATE_DOCK_THROUGH_CHARGING = releasedFlag(1000, "simulate_dock_through_charging")

+94 −32
Original line number Diff line number Diff line
@@ -304,6 +304,7 @@ class MediaDataManager(
        mediaTimeoutListener.stateCallback = { key: String, state: PlaybackState ->
            updateState(key, state)
        }
        mediaTimeoutListener.sessionCallback = { key: String -> onSessionDestroyed(key) }
        mediaResumeListener.setManager(this)
        mediaDataFilter.mediaDataManager = this

@@ -1292,27 +1293,94 @@ class MediaDataManager(

    fun onNotificationRemoved(key: String) {
        Assert.isMainThread()
        val removed = mediaEntries.remove(key)
        if (useMediaResumption && removed?.resumeAction != null && removed.isLocalSession()) {
            Log.d(TAG, "Not removing $key because resumable")
        val removed = mediaEntries.remove(key) ?: return

        if (useMediaResumption && removed.resumeAction != null && removed.isLocalSession()) {
            convertToResumePlayer(removed)
        } else if (mediaFlags.isRetainingPlayersEnabled()) {
            handlePossibleRemoval(removed, notificationRemoved = true)
        } else {
            notifyMediaDataRemoved(key)
            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
        }
    }

    private fun onSessionDestroyed(key: String) {
        if (!mediaFlags.isRetainingPlayersEnabled()) return

        if (DEBUG) Log.d(TAG, "session destroyed for $key")
        val entry = mediaEntries.remove(key) ?: return
        // Clear token since the session is no longer valid
        val updated = entry.copy(token = null)
        handlePossibleRemoval(updated)
    }

    /**
     * Convert to resume state if the player is no longer valid and active, then notify listeners
     * that the data was updated. Does not convert to resume state if the player is still valid, or
     * if it was removed before becoming inactive. (Assumes that [removed] was removed from
     * [mediaEntries] before this function was called)
     */
    private fun handlePossibleRemoval(removed: MediaData, notificationRemoved: Boolean = false) {
        val key = removed.notificationKey!!
        val hasSession = removed.token != null
        if (hasSession && removed.semanticActions != null) {
            // The app was using session actions, and the session is still valid: keep player
            if (DEBUG) Log.d(TAG, "Notification removed but using session actions $key")
            mediaEntries.put(key, removed)
            notifyMediaDataLoaded(key, key, removed)
        } else if (!notificationRemoved && removed.semanticActions == null) {
            // The app was using notification actions, and notif wasn't removed yet: keep player
            if (DEBUG) Log.d(TAG, "Session destroyed but using notification actions $key")
            mediaEntries.put(key, removed)
            notifyMediaDataLoaded(key, key, removed)
        } else if (removed.active) {
            // This player was still active - it didn't last long enough to time out: remove
            if (DEBUG) Log.d(TAG, "Removing still-active player $key")
            notifyMediaDataRemoved(key)
            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
        } else {
            // Convert to resume
            if (DEBUG) {
                Log.d(
                    TAG,
                    "Notification ($notificationRemoved) and/or session " +
                        "($hasSession) gone for inactive player $key"
                )
            }
            convertToResumePlayer(removed)
        }
    }

    /** Set the given [MediaData] as a resume state player and notify listeners */
    private fun convertToResumePlayer(data: MediaData) {
        val key = data.notificationKey!!
        if (DEBUG) Log.d(TAG, "Converting $key to resume")
        // Move to resume key (aka package name) if that key doesn't already exist.
            val resumeAction = getResumeMediaAction(removed.resumeAction!!)
        val resumeAction = data.resumeAction?.let { getResumeMediaAction(it) }
        val actions = resumeAction?.let { listOf(resumeAction) } ?: emptyList()
        val launcherIntent =
            context.packageManager.getLaunchIntentForPackage(data.packageName)?.let {
                PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE)
            }
        val updated =
                removed.copy(
            data.copy(
                token = null,
                    actions = listOf(resumeAction),
                actions = actions,
                semanticActions = MediaButton(playOrPause = resumeAction),
                actionsToShowInCompact = listOf(0),
                active = false,
                resumption = true,
                isPlaying = false,
                    isClearable = true
                isClearable = true,
                clickIntent = launcherIntent,
            )
            val pkg = removed.packageName
        val pkg = data.packageName
        val migrate = mediaEntries.put(pkg, updated) == null
        // Notify listeners of "new" controls when migrating or removed and update when not
        Log.d(TAG, "migrating? $migrate from $key -> $pkg")
        if (migrate) {
                notifyMediaDataLoaded(pkg, key, updated)
            notifyMediaDataLoaded(key = pkg, oldKey = key, info = updated)
        } else {
            // Since packageName is used for the key of the resumption controls, it is
            // possible that another notification has already been reused for the resumption
@@ -1320,15 +1388,9 @@ class MediaDataManager(
            // packageName, just remove it and then send a update to the existing resumption
            // controls.
            notifyMediaDataRemoved(key)
                notifyMediaDataLoaded(pkg, pkg, updated)
            notifyMediaDataLoaded(key = pkg, oldKey = pkg, info = updated)
        }
        logger.logActiveConvertedToResume(updated.appUid, pkg, updated.instanceId)
            return
        }
        if (removed != null) {
            notifyMediaDataRemoved(key)
            logger.logMediaRemoved(removed.appUid, removed.packageName, removed.instanceId)
        }
    }

    fun setMediaResumptionEnabled(isEnabled: Boolean) {
+7 −0
Original line number Diff line number Diff line
@@ -71,6 +71,12 @@ constructor(
     */
    lateinit var stateCallback: (String, PlaybackState) -> Unit

    /**
     * Callback representing that the [MediaSession] for an active control has been destroyed
     * @param key Media control unique identifier
     */
    lateinit var sessionCallback: (String) -> Unit

    init {
        statusBarStateController.addCallback(
            object : StatusBarStateController.StateListener {
@@ -211,6 +217,7 @@ constructor(
            } 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
                sessionCallback.invoke(key)
                destroy()
            }
        }
+6 −0
Original line number Diff line number Diff line
@@ -45,4 +45,10 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) {

    /** Check whether we show explicit indicator on UMO */
    fun isExplicitIndicatorEnabled() = featureFlags.isEnabled(Flags.MEDIA_EXPLICIT_INDICATOR)

    /**
     * If true, keep active media controls for the lifetime of the MediaSession, regardless of
     * whether the underlying notification was dismissed
     */
    fun isRetainingPlayersEnabled() = featureFlags.isEnabled(Flags.MEDIA_RETAIN_SESSIONS)
}
+206 −16
Original line number Diff line number Diff line
@@ -134,7 +134,8 @@ class MediaDataManagerTest : SysuiTestCase() {
    private val clock = FakeSystemClock()
    @Mock private lateinit var tunerService: TunerService
    @Captor lateinit var tunableCaptor: ArgumentCaptor<TunerService.Tunable>
    @Captor lateinit var callbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
    @Captor lateinit var stateCallbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
    @Captor lateinit var sessionCallbackCaptor: ArgumentCaptor<(String) -> Unit>
    @Captor lateinit var smartSpaceConfigBuilderCaptor: ArgumentCaptor<SmartspaceConfig>

    private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
@@ -184,6 +185,8 @@ class MediaDataManagerTest : SysuiTestCase() {
            )
        verify(tunerService)
            .addTunable(capture(tunableCaptor), eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
        verify(mediaTimeoutListener).stateCallback = capture(stateCallbackCaptor)
        verify(mediaTimeoutListener).sessionCallback = capture(sessionCallbackCaptor)
        session = MediaSession(context, "MediaDataManagerTestSession")
        mediaNotification =
            SbnBuilder().run {
@@ -230,6 +233,7 @@ class MediaDataManagerTest : SysuiTestCase() {
        whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(1234L)
        whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(false)
        whenever(mediaFlags.isExplicitIndicatorEnabled()).thenReturn(true)
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(false)
        whenever(logger.getNewInstanceId()).thenReturn(instanceIdSequence.newInstanceId())
    }

@@ -547,6 +551,7 @@ class MediaDataManagerTest : SysuiTestCase() {
        mediaDataManager.onNotificationAdded(KEY_2, mediaNotification)
        assertThat(backgroundExecutor.runAllReady()).isEqualTo(2)
        assertThat(foregroundExecutor.runAllReady()).isEqualTo(2)

        verify(listener)
            .onMediaDataLoaded(
                eq(KEY),
@@ -558,9 +563,21 @@ class MediaDataManagerTest : SysuiTestCase() {
            )
        val data = mediaDataCaptor.value
        assertThat(data.resumption).isFalse()
        val resumableData = data.copy(resumeAction = Runnable {})
        mediaDataManager.onMediaDataLoaded(KEY, null, resumableData)
        mediaDataManager.onMediaDataLoaded(KEY_2, null, resumableData)

        verify(listener)
            .onMediaDataLoaded(
                eq(KEY_2),
                eq(null),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        val data2 = mediaDataCaptor.value
        assertThat(data2.resumption).isFalse()

        mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
        mediaDataManager.onMediaDataLoaded(KEY_2, null, data2.copy(resumeAction = Runnable {}))
        reset(listener)
        // WHEN the first is removed
        mediaDataManager.onNotificationRemoved(KEY)
@@ -1310,11 +1327,10 @@ class MediaDataManagerTest : SysuiTestCase() {
    fun testPlaybackStateChange_keyExists_callsListener() {
        // Notification has been added
        addNotificationAndLoad()
        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)

        // Callback gets an updated state
        val state = PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0L, 1f).build()
        callbackCaptor.value.invoke(KEY, state)
        stateCallbackCaptor.value.invoke(KEY, state)

        // Listener is notified of updated state
        verify(listener)
@@ -1332,11 +1348,10 @@ class MediaDataManagerTest : SysuiTestCase() {
    @Test
    fun testPlaybackStateChange_keyDoesNotExist_doesNothing() {
        val state = PlaybackState.Builder().build()
        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)

        // No media added with this key

        callbackCaptor.value.invoke(KEY, state)
        stateCallbackCaptor.value.invoke(KEY, state)
        verify(listener, never())
            .onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }
@@ -1352,10 +1367,9 @@ class MediaDataManagerTest : SysuiTestCase() {

        // And then get a state update
        val state = PlaybackState.Builder().build()
        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)

        // Then no changes are made
        callbackCaptor.value.invoke(KEY, state)
        stateCallbackCaptor.value.invoke(KEY, state)
        verify(listener, never())
            .onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }
@@ -1367,8 +1381,7 @@ class MediaDataManagerTest : SysuiTestCase() {
        whenever(controller.playbackState).thenReturn(state)

        addNotificationAndLoad()
        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
        callbackCaptor.value.invoke(KEY, state)
        stateCallbackCaptor.value.invoke(KEY, state)

        verify(listener)
            .onMediaDataLoaded(
@@ -1410,8 +1423,7 @@ class MediaDataManagerTest : SysuiTestCase() {
        backgroundExecutor.runAllReady()
        foregroundExecutor.runAllReady()

        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
        callbackCaptor.value.invoke(PACKAGE_NAME, state)
        stateCallbackCaptor.value.invoke(PACKAGE_NAME, state)

        verify(listener)
            .onMediaDataLoaded(
@@ -1436,8 +1448,7 @@ class MediaDataManagerTest : SysuiTestCase() {
                .build()

        addNotificationAndLoad()
        verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
        callbackCaptor.value.invoke(KEY, state)
        stateCallbackCaptor.value.invoke(KEY, state)

        verify(listener)
            .onMediaDataLoaded(
@@ -1485,6 +1496,177 @@ class MediaDataManagerTest : SysuiTestCase() {
        assertThat(mediaDataCaptor.value.isClearable).isFalse()
    }

    @Test
    fun testRetain_notifPlayer_notifRemoved_setToResume() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)

        // When a media control based on notification is added, times out, and then removed
        addNotificationAndLoad()
        mediaDataManager.setTimedOut(KEY, timedOut = true)
        assertThat(mediaDataCaptor.value.active).isFalse()
        mediaDataManager.onNotificationRemoved(KEY)

        // It is converted to a resume player
        verify(listener)
            .onMediaDataLoaded(
                eq(PACKAGE_NAME),
                eq(KEY),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        assertThat(mediaDataCaptor.value.resumption).isTrue()
        assertThat(mediaDataCaptor.value.active).isFalse()
        verify(logger)
            .logActiveConvertedToResume(
                anyInt(),
                eq(PACKAGE_NAME),
                eq(mediaDataCaptor.value.instanceId)
            )
    }

    @Test
    fun testRetain_notifPlayer_sessionDestroyed_doesNotChange() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)

        // When a media control based on notification is added and times out
        addNotificationAndLoad()
        mediaDataManager.setTimedOut(KEY, timedOut = true)
        assertThat(mediaDataCaptor.value.active).isFalse()

        // and then the session is destroyed
        sessionCallbackCaptor.value.invoke(KEY)

        // It remains as a regular player
        verify(listener, never()).onMediaDataRemoved(eq(KEY))
        verify(listener, never())
            .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }

    @Test
    fun testRetain_notifPlayer_removeWhileActive_fullyRemoved() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)

        // When a media control based on notification is added and then removed, without timing out
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        assertThat(data.active).isTrue()
        mediaDataManager.onNotificationRemoved(KEY)

        // It is fully removed
        verify(listener).onMediaDataRemoved(eq(KEY))
        verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
        verify(listener, never())
            .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }

    @Test
    fun testRetain_canResume_removeWhileActive_setToResume() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)

        // When a media control that supports resumption is added
        addNotificationAndLoad()
        val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
        mediaDataManager.onMediaDataLoaded(KEY, null, dataResumable)

        // And then removed while still active
        mediaDataManager.onNotificationRemoved(KEY)

        // It is converted to a resume player
        verify(listener)
            .onMediaDataLoaded(
                eq(PACKAGE_NAME),
                eq(KEY),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        assertThat(mediaDataCaptor.value.resumption).isTrue()
        assertThat(mediaDataCaptor.value.active).isFalse()
        verify(logger)
            .logActiveConvertedToResume(
                anyInt(),
                eq(PACKAGE_NAME),
                eq(mediaDataCaptor.value.instanceId)
            )
    }

    @Test
    fun testRetain_sessionPlayer_notifRemoved_doesNotChange() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
        whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
        addPlaybackStateAction()

        // When a media control with PlaybackState actions is added, times out,
        // and then the notification is removed
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        assertThat(data.active).isTrue()
        mediaDataManager.setTimedOut(KEY, timedOut = true)
        mediaDataManager.onNotificationRemoved(KEY)

        // It remains as a regular player
        verify(listener, never()).onMediaDataRemoved(eq(KEY))
        verify(listener, never())
            .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }

    @Test
    fun testRetain_sessionPlayer_sessionDestroyed_setToResume() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
        whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
        addPlaybackStateAction()

        // When a media control with PlaybackState actions is added, times out,
        // and then the session is destroyed
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        assertThat(data.active).isTrue()
        mediaDataManager.setTimedOut(KEY, timedOut = true)
        sessionCallbackCaptor.value.invoke(KEY)

        // It is converted to a resume player
        verify(listener)
            .onMediaDataLoaded(
                eq(PACKAGE_NAME),
                eq(KEY),
                capture(mediaDataCaptor),
                eq(true),
                eq(0),
                eq(false)
            )
        assertThat(mediaDataCaptor.value.resumption).isTrue()
        assertThat(mediaDataCaptor.value.active).isFalse()
        verify(logger)
            .logActiveConvertedToResume(
                anyInt(),
                eq(PACKAGE_NAME),
                eq(mediaDataCaptor.value.instanceId)
            )
    }

    @Test
    fun testRetain_sessionPlayer_destroyedWhileActive_fullyRemoved() {
        whenever(mediaFlags.isRetainingPlayersEnabled()).thenReturn(true)
        whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
        addPlaybackStateAction()

        // When a media control using session actions is added, and then the session is destroyed
        // without timing out first
        addNotificationAndLoad()
        val data = mediaDataCaptor.value
        assertThat(data.active).isTrue()
        sessionCallbackCaptor.value.invoke(KEY)

        // It is fully removed
        verify(listener).onMediaDataRemoved(eq(KEY))
        verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
        verify(listener, never())
            .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
    }

    /** Helper function to add a media notification and capture the resulting MediaData */
    private fun addNotificationAndLoad() {
        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
@@ -1500,4 +1682,12 @@ class MediaDataManagerTest : SysuiTestCase() {
                eq(false)
            )
    }

    /** Helper function to set up a PlaybackState with action */
    private fun addPlaybackStateAction() {
        val stateActions = PlaybackState.ACTION_PLAY_PAUSE
        val stateBuilder = PlaybackState.Builder().setActions(stateActions)
        stateBuilder.setState(PlaybackState.STATE_PAUSED, 0, 1.0f)
        whenever(controller.playbackState).thenReturn(stateBuilder.build())
    }
}
Loading