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

Commit ca9c9beb authored by Michael Mikhail's avatar Michael Mikhail Committed by Android (Google) Code Review
Browse files

Merge "Add media controller state logic" into main

parents 3b7d7bbe c2f0a9c7
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(