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

Commit c2f0a9c7 authored by Michael Mikhail's avatar Michael Mikhail
Browse files

Add media controller state logic

Flag: com.android.systemui.media_controls_in_compose
Bug: 397989775
Test: atest SystemUiRoboTests:MediaRepositoryTest
Test: Build.
Change-Id: I3cf9579d4ef7ac29abb88e8292e06956d2d0d9d9
parent c582dc6a
Loading
Loading
Loading
Loading
+21 −67
Original line number Diff line number Diff line
@@ -17,8 +17,8 @@
package com.android.systemui.media.remedia.data.repository

import android.content.packageManager
import android.media.session.MediaController
import android.media.session.MediaSession
import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.InstanceId
@@ -26,10 +26,8 @@ import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.remedia.data.model.MediaDataModel
import com.android.systemui.media.remedia.shared.model.MediaColorScheme
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
@@ -37,7 +35,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyString
@@ -46,6 +43,7 @@ import org.mockito.kotlin.whenever
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
@UiThreadTest
class MediaRepositoryTest : SysuiTestCase() {

    private val drawable = context.getDrawable(R.drawable.ic_music_note)!!
@@ -59,11 +57,6 @@ class MediaRepositoryTest : SysuiTestCase() {

    private val underTest: MediaRepositoryImpl = kosmos.mediaRepository

    @Before
    fun setUp() {
        underTest.activateIn(testScope)
    }

    @Test
    fun addCurrentUserMediaEntry_activeThenInactivate() =
        testScope.runTest {
@@ -158,14 +151,8 @@ class MediaRepositoryTest : SysuiTestCase() {
            assertThat(underTest.currentMedia.size).isEqualTo(2)
            assertThat(underTest.currentMedia)
                .containsExactly(
                    playingData.toDataModel(
                        underTest.currentMedia[0].controller,
                        underTest.currentMedia[0].colorScheme,
                    ),
                    remoteData.toDataModel(
                        underTest.currentMedia[1].controller,
                        underTest.currentMedia[1].colorScheme,
                    ),
                    playingData.toDataModel(underTest.currentMedia[0]),
                    remoteData.toDataModel(underTest.currentMedia[1]),
                )
                .inOrder()
        }
@@ -184,14 +171,8 @@ class MediaRepositoryTest : SysuiTestCase() {
            assertThat(underTest.currentMedia.size).isEqualTo(2)
            assertThat(underTest.currentMedia)
                .containsExactly(
                    playingData1.toDataModel(
                        underTest.currentMedia[0].controller,
                        underTest.currentMedia[0].colorScheme,
                    ),
                    playingData2.toDataModel(
                        underTest.currentMedia[1].controller,
                        underTest.currentMedia[1].colorScheme,
                    ),
                    playingData1.toDataModel(underTest.currentMedia[0]),
                    playingData2.toDataModel(underTest.currentMedia[1]),
                )
                .inOrder()

@@ -204,14 +185,8 @@ class MediaRepositoryTest : SysuiTestCase() {
            assertThat(underTest.currentMedia.size).isEqualTo(2)
            assertThat(underTest.currentMedia)
                .containsExactly(
                    playingData1.toDataModel(
                        underTest.currentMedia[0].controller,
                        underTest.currentMedia[0].colorScheme,
                    ),
                    playingData2.toDataModel(
                        underTest.currentMedia[1].controller,
                        underTest.currentMedia[1].colorScheme,
                    ),
                    playingData1.toDataModel(underTest.currentMedia[0]),
                    playingData2.toDataModel(underTest.currentMedia[1]),
                )
                .inOrder()

@@ -221,14 +196,8 @@ class MediaRepositoryTest : SysuiTestCase() {
            assertThat(underTest.currentMedia.size).isEqualTo(2)
            assertThat(underTest.currentMedia)
                .containsExactly(
                    playingData2.toDataModel(
                        underTest.currentMedia[0].controller,
                        underTest.currentMedia[0].colorScheme,
                    ),
                    playingData1.toDataModel(
                        underTest.currentMedia[1].controller,
                        underTest.currentMedia[1].colorScheme,
                    ),
                    playingData2.toDataModel(underTest.currentMedia[0]),
                    playingData1.toDataModel(underTest.currentMedia[1]),
                )
                .inOrder()
        }
@@ -263,26 +232,11 @@ class MediaRepositoryTest : SysuiTestCase() {
            assertThat(underTest.currentMedia.size).isEqualTo(5)
            assertThat(underTest.currentMedia)
                .containsExactly(
                    playingAndLocalData.toDataModel(
                        underTest.currentMedia[0].controller,
                        underTest.currentMedia[0].colorScheme,
                    ),
                    playingAndRemoteData.toDataModel(
                        underTest.currentMedia[1].controller,
                        underTest.currentMedia[1].colorScheme,
                    ),
                    stoppedAndRemoteData.toDataModel(
                        underTest.currentMedia[2].controller,
                        underTest.currentMedia[2].colorScheme,
                    ),
                    stoppedAndLocalData.toDataModel(
                        underTest.currentMedia[3].controller,
                        underTest.currentMedia[3].colorScheme,
                    ),
                    canResumeData.toDataModel(
                        underTest.currentMedia[4].controller,
                        underTest.currentMedia[4].colorScheme,
                    ),
                    playingAndLocalData.toDataModel(underTest.currentMedia[0]),
                    playingAndRemoteData.toDataModel(underTest.currentMedia[1]),
                    stoppedAndRemoteData.toDataModel(underTest.currentMedia[2]),
                    stoppedAndLocalData.toDataModel(underTest.currentMedia[3]),
                    canResumeData.toDataModel(underTest.currentMedia[4]),
                )
                .inOrder()
        }
@@ -310,10 +264,7 @@ class MediaRepositoryTest : SysuiTestCase() {
        )
    }

    private fun MediaData.toDataModel(
        mediaController: MediaController,
        colorScheme: MediaColorScheme?,
    ): MediaDataModel {
    private fun MediaData.toDataModel(mediaModel: MediaDataModel): MediaDataModel {
        return MediaDataModel(
            instanceId = instanceId,
            appUid = appUid,
@@ -323,12 +274,15 @@ class MediaRepositoryTest : SysuiTestCase() {
            background = null,
            title = song.toString(),
            subtitle = artist.toString(),
            colorScheme = colorScheme,
            colorScheme = mediaModel.colorScheme,
            notificationActions = actions,
            playbackStateActions = semanticActions,
            outputDevice = device,
            clickIntent = clickIntent,
            controller = mediaController,
            state = mediaModel.state,
            durationMs = mediaModel.durationMs,
            positionMs = mediaModel.positionMs,
            canBeScrubbed = mediaModel.canBeScrubbed,
            canBeDismissed = isClearable,
            isActive = active,
            isResume = resumption,
+5 −2
Original line number Diff line number Diff line
@@ -17,13 +17,13 @@
package com.android.systemui.media.remedia.data.model

import android.app.PendingIntent
import android.media.session.MediaController
import com.android.internal.logging.InstanceId
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.media.controls.shared.model.MediaButton
import com.android.systemui.media.controls.shared.model.MediaDeviceData
import com.android.systemui.media.controls.shared.model.MediaNotificationAction
import com.android.systemui.media.remedia.shared.model.MediaColorScheme
import com.android.systemui.media.remedia.shared.model.MediaSessionState

/** Data model representing a media data. */
data class MediaDataModel(
@@ -50,7 +50,10 @@ data class MediaDataModel(
    val outputDevice: MediaDeviceData?,
    /** Action to perform when the media player is tapped. */
    val clickIntent: PendingIntent?,
    val controller: MediaController,
    val state: MediaSessionState,
    val durationMs: Long,
    val positionMs: Long,
    val canBeScrubbed: Boolean,
    val canBeDismissed: Boolean,
    /**
     * An active player represents a current media session that has not timed out or been swiped
+194 −35
Original line number Diff line number Diff line
@@ -20,9 +20,12 @@ import android.app.WallpaperColors
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.Color
import com.android.internal.logging.InstanceId
import com.android.systemui.common.shared.model.ContentDescription
@@ -30,12 +33,12 @@ import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.lifecycle.Activatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.media.NotificationMediaManager
import com.android.systemui.media.controls.data.model.MediaSortKeyModel
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.remedia.data.model.MediaDataModel
import com.android.systemui.media.remedia.shared.model.MediaColorScheme
import com.android.systemui.media.remedia.shared.model.MediaSessionState
import com.android.systemui.monet.ColorScheme
import com.android.systemui.monet.Style
import com.android.systemui.res.R
@@ -44,12 +47,14 @@ import java.util.TreeMap
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/** A repository that holds the state of current media on the device. */
interface MediaRepository : Activatable {
interface MediaRepository {
    /** Current sorted media sessions. */
    val currentMedia: List<MediaDataModel>

@@ -70,14 +75,16 @@ constructor(
    private val systemClock: SystemClock,
) : MediaRepository, MediaPipelineRepository() {

    private val hydrator = Hydrator(traceName = "MediaRepository.hydrator")
    private val mutableCurrentMedia: MutableStateFlow<List<MediaDataModel>> =
        MutableStateFlow(mutableListOf())
    override val currentMedia by
        hydrator.hydratedStateOf(traceName = "currentMedia", source = mutableCurrentMedia)
    override val currentMedia: SnapshotStateList<MediaDataModel> = mutableStateListOf()

    private var sortedMedia = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator)

    // To store active controllers and their callbacks
    private val activeControllers = mutableMapOf<InstanceId, MediaController>()
    private val mediaCallbacks = mutableMapOf<InstanceId, MediaController.Callback>()
    // To store active polling jobs
    private val positionPollers = mutableMapOf<InstanceId, Job>()

    override fun addCurrentUserMediaEntry(data: MediaData): Boolean {
        return super.addCurrentUserMediaEntry(data).also { addToSortedMedia(data) }
    }
@@ -101,19 +108,19 @@ constructor(
    }

    override fun seek(sessionKey: InstanceId, to: Long) {
        mutableCurrentMedia.value
            .first { sessionKey == it.instanceId }
            .controller
            .transportControls
            .seekTo(to)
        activeControllers[sessionKey]?.let { controller ->
            controller.transportControls.seekTo(to)
            currentMedia
                .find { it.instanceId == sessionKey }
                ?.let { latestModel ->
                    updateMediaModelInState(latestModel) { it.copy(positionMs = to) }
                }
        }

    override fun reorderMedia() {
        mutableCurrentMedia.value = sortedMedia.values.toList()
    }

    override suspend fun activate(): Nothing {
        hydrator.activate()
    override fun reorderMedia() {
        currentMedia.clear()
        currentMedia.addAll(sortedMedia.values.toList())
    }

    private fun addToSortedMedia(data: MediaData) {
@@ -139,12 +146,23 @@ constructor(
                    )

                applicationScope.launch {
                    val mediaModel = toDataModel(currentModel)
                    val controller =
                        if (
                            currentModel != null &&
                                activeControllers[currentModel.instanceId]?.sessionToken == token
                        ) {
                            activeControllers[currentModel.instanceId]
                        } else {
                            // Clear controller state if changed for the same media session.
                            currentModel?.instanceId?.let { clearControllerState(it) }
                            token?.let { MediaController(applicationContext, it) }
                        }
                    val mediaModel = toDataModel(controller)
                    sortedMap[sortKey] = mediaModel
                    controller?.let { setupController(mediaModel, it) }

                    var isNewToCurrentMedia = true
                    val currentList =
                        mutableListOf<MediaDataModel>().apply { addAll(mutableCurrentMedia.value) }
                    val currentList = mutableListOf<MediaDataModel>().apply { addAll(currentMedia) }
                    currentList.forEachIndexed { index, mediaDataModel ->
                        if (mediaDataModel.instanceId == data.instanceId) {
                            // When loading an update for an existing media control.
@@ -155,10 +173,11 @@ constructor(
                            }
                        }
                    }
                    currentMedia.clear()
                    if (isNewToCurrentMedia && active) {
                        mutableCurrentMedia.value = sortedMap.values.toList()
                        currentMedia.addAll(sortedMap.values.toList())
                    } else {
                        mutableCurrentMedia.value = currentList
                        currentMedia.addAll(currentList)
                    }

                    sortedMedia = sortedMap
@@ -168,20 +187,21 @@ constructor(
    }

    private fun removeFromSortedMedia(data: MediaData) {
        mutableCurrentMedia.value =
            mutableCurrentMedia.value.filter { model -> data.instanceId != model.instanceId }
        currentMedia.removeIf { model -> data.instanceId == model.instanceId }
        sortedMedia =
            TreeMap(sortedMedia.filter { (keyModel, _) -> keyModel.instanceId != data.instanceId })
        clearControllerState(data.instanceId)
    }

    private suspend fun MediaData.toDataModel(currentModel: MediaDataModel?): MediaDataModel {
    private suspend fun MediaData.toDataModel(controller: MediaController?): MediaDataModel {
        return withContext(backgroundDispatcher) {
            val controller =
                if (currentModel != null && currentModel.controller.sessionToken == token) {
                    currentModel.controller
                } else {
                    MediaController(applicationContext, token!!)
                }
            val metadata = controller?.metadata
            val currentPlaybackState = controller?.playbackState

            val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L
            val position = currentPlaybackState?.position ?: 0L
            val state = currentPlaybackState?.state ?: PlaybackState.STATE_NONE

            val icon = appIcon?.loadDrawable(applicationContext)
            val background = artwork?.loadDrawable(applicationContext)
            MediaDataModel(
@@ -200,7 +220,16 @@ constructor(
                playbackStateActions = semanticActions,
                outputDevice = device,
                clickIntent = clickIntent,
                controller = controller,
                state =
                    when {
                        NotificationMediaManager.isPlayingState(state) -> MediaSessionState.Playing
                        NotificationMediaManager.isConnectingState(state) ->
                            MediaSessionState.Buffering
                        else -> MediaSessionState.Paused
                    },
                durationMs = duration,
                positionMs = position,
                canBeScrubbed = state != PlaybackState.STATE_NONE && duration > 0L,
                canBeDismissed = isClearable,
                isActive = active,
                isResume = resumption,
@@ -287,7 +316,137 @@ constructor(
        }
    }

    private fun setupController(dataModel: MediaDataModel, controller: MediaController) {
        activeControllers[dataModel.instanceId] = controller
        val callback =
            object : MediaController.Callback() {
                override fun onPlaybackStateChanged(state: PlaybackState?) {
                    if (state == null || PlaybackState.STATE_NONE.equals(state)) {
                        clearControllerState(dataModel.instanceId)
                    } else {
                        updatePollingState(dataModel.instanceId, state)
                    }
                }

                override fun onMetadataChanged(metadata: MediaMetadata?) {
                    val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L
                    updateMediaModelInState(dataModel) { model ->
                        val canBeScrubbed =
                            controller.playbackState?.state != PlaybackState.STATE_NONE &&
                                duration > 0L
                        model.copy(canBeScrubbed = canBeScrubbed, durationMs = duration)
                    }
                }

                override fun onSessionDestroyed() {
                    clearControllerState(dataModel.instanceId)
                }
            }
        controller.registerCallback(callback)
        mediaCallbacks[dataModel.instanceId] = callback

        // Initial polling setup.
        controller.playbackState?.let {
            updatePollingState(dataModel.instanceId, it, requireUpdate = false)
        }
    }

    private fun updatePollingState(
        instanceId: InstanceId,
        playbackState: PlaybackState,
        requireUpdate: Boolean = true,
    ) {
        val controller = activeControllers[instanceId] ?: return
        val isInMotion = NotificationMediaManager.isPlayingState(playbackState.state)

        if (isInMotion) {
            if (positionPollers[instanceId]?.isActive != true) {
                // Cancel previous if any.
                positionPollers[instanceId]?.cancel()
                positionPollers[instanceId] =
                    applicationScope.launch(backgroundDispatcher) {
                        while (isActive) {
                            val currentController = activeControllers[instanceId]
                            val latestPlaybackState = currentController?.playbackState
                            checkPlaybackPosition(instanceId, latestPlaybackState)
                            delay(POSITION_UPDATE_INTERVAL_MILLIS)
                        }
                        positionPollers.remove(instanceId)
                    }
            }
        } else if (requireUpdate) {
            positionPollers[instanceId]?.cancel()
            positionPollers.remove(instanceId)
            checkPlaybackPosition(instanceId, controller.playbackState)
        }
    }

    private fun PlaybackState.computeActualPosition(mediaDurationMs: Long): Long {
        var currentPosition = position
        if (NotificationMediaManager.isPlayingState(state)) {
            val currentTime = systemClock.elapsedRealtime()
            if (lastPositionUpdateTime > 0) {
                var estimatedPosition =
                    (playbackSpeed * (currentTime - lastPositionUpdateTime)).toLong() + position
                if (mediaDurationMs in 0..<estimatedPosition) {
                    estimatedPosition = mediaDurationMs
                } else if (estimatedPosition < 0) {
                    estimatedPosition = 0
                }
                currentPosition = estimatedPosition
            }
        }
        return currentPosition
    }

    private fun checkPlaybackPosition(instanceId: InstanceId, playbackState: PlaybackState?) {
        currentMedia
            .find { it.instanceId == instanceId }
            ?.let { latestModel ->
                val newPosition = playbackState?.computeActualPosition(latestModel.durationMs)
                updateMediaModelInState(latestModel) {
                    if (newPosition != null && newPosition <= latestModel.durationMs) {
                        it.copy(positionMs = newPosition)
                    } else {
                        it
                    }
                }
            }
    }

    private fun clearControllerState(instanceId: InstanceId) {
        positionPollers[instanceId]?.cancel()
        positionPollers.remove(instanceId)
        mediaCallbacks[instanceId]?.let { activeControllers[instanceId]?.unregisterCallback(it) }
        activeControllers.remove(instanceId)
        mediaCallbacks.remove(instanceId)
    }

    private fun updateMediaModelInState(
        oldModel: MediaDataModel,
        updateBlock: (MediaDataModel) -> MediaDataModel,
    ) {
        val newModel = updateBlock(oldModel)
        if (oldModel != newModel) {
            sortedMedia.keys
                .find { it.instanceId == newModel.instanceId }
                ?.let {
                    val sortedMap = TreeMap<MediaSortKeyModel, MediaDataModel>(comparator)
                    sortedMap.putAll(
                        sortedMedia.filter { (keyModel, _) ->
                            keyModel.instanceId != newModel.instanceId
                        }
                    )
                    sortedMap[it] = newModel
                    sortedMedia = sortedMap
                }

            currentMedia[currentMedia.indexOf(oldModel)] = newModel
        }
    }

    companion object {
        private const val TAG = "MediaRepository"
        private const val POSITION_UPDATE_INTERVAL_MILLIS = 500L
    }
}
+5 −10
Original line number Diff line number Diff line
@@ -27,7 +27,6 @@ import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor
import com.android.systemui.media.controls.domain.pipeline.getNotificationActions
import com.android.systemui.media.controls.shared.model.MediaAction
@@ -70,7 +69,7 @@ constructor(
    val repository: MediaRepository,
    val mediaDataProcessor: MediaDataProcessor,
    private val activityStarter: ActivityStarter,
) : MediaInteractor, ExclusiveActivatable() {
) : MediaInteractor {

    override val sessions: List<MediaSessionModel>
        get() = repository.currentMedia.map { toMediaSessionModel(it) }
@@ -123,16 +122,16 @@ constructor(
                get() = dataModel.canBeDismissed

            override val canBeScrubbed: Boolean
                get() = TODO("Not yet implemented")
                get() = dataModel.canBeScrubbed

            override val state: MediaSessionState
                get() = TODO("Not yet implemented")
                get() = dataModel.state

            override val positionMs: Long
                get() = TODO("Not yet implemented")
                get() = dataModel.positionMs

            override val durationMs: Long
                get() = TODO("Not yet implemented")
                get() = dataModel.durationMs

            override val outputDevice: MediaOutputDeviceModel
                get() =
@@ -198,10 +197,6 @@ constructor(
        }
    }

    override suspend fun onActivated(): Nothing {
        repository.activate()
    }

    private fun MediaAction.getMediaActionModel(): MediaActionModel {
        return icon?.let { drawable ->
            MediaActionModel.Action(