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

Commit ad87df88 authored by Anton Potapov's avatar Anton Potapov Committed by Android (Google) Code Review
Browse files

Merge changes from topic "volume_panel_cast_improvements" into main

* changes:
  Media output domain layer tests
  Rework cast slider.
parents aae2cb64 2765782f
Loading
Loading
Loading
Loading
+23 −1
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
package com.android.settingslib.media.session

import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.MediaSessionManager
import android.os.UserHandle
import androidx.concurrent.futures.DirectExecutor
@@ -28,7 +29,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch

/** [Flow] for [MediaSessionManager.OnActiveSessionsChangedListener]. */
val MediaSessionManager.activeMediaChanges: Flow<Collection<MediaController>?>
val MediaSessionManager.activeMediaChanges: Flow<List<MediaController>?>
    get() =
        callbackFlow {
                val listener =
@@ -42,3 +43,24 @@ val MediaSessionManager.activeMediaChanges: Flow<Collection<MediaController>?>
                awaitClose { removeOnActiveSessionsChangedListener(listener) }
            }
            .buffer(capacity = Channel.CONFLATED)

/** [Flow] for [MediaSessionManager.RemoteSessionCallback]. */
val MediaSessionManager.remoteSessionChanges: Flow<MediaSession.Token?>
    get() =
        callbackFlow {
                val callback =
                    object : MediaSessionManager.RemoteSessionCallback {
                        override fun onVolumeChanged(sessionToken: MediaSession.Token, flags: Int) {
                            launch { send(sessionToken) }
                        }

                        override fun onDefaultRemoteSessionChanged(
                            sessionToken: MediaSession.Token?
                        ) {
                            launch { send(sessionToken) }
                        }
                    }
                registerRemoteSessionCallback(DirectExecutor.INSTANCE, callback)
                awaitClose { unregisterRemoteSessionCallback(callback) }
            }
            .buffer(capacity = Channel.CONFLATED)
+0 −52
Original line number Diff line number Diff line
@@ -15,14 +15,10 @@
 */
package com.android.settingslib.volume.data.repository

import android.media.MediaRouter2Manager
import android.media.RoutingSessionInfo
import com.android.settingslib.media.LocalMediaManager
import com.android.settingslib.media.MediaDevice
import com.android.settingslib.volume.data.model.RoutingSession
import com.android.settingslib.volume.shared.AudioManagerEventsReceiver
import com.android.settingslib.volume.shared.model.AudioManagerEvent
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -30,35 +26,23 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext

/** Repository providing data about connected media devices. */
interface LocalMediaRepository {

    /** Available devices list */
    val mediaDevices: StateFlow<Collection<MediaDevice>>

    /** Currently connected media device */
    val currentConnectedDevice: StateFlow<MediaDevice?>

    val remoteRoutingSessions: StateFlow<Collection<RoutingSession>>

    suspend fun adjustSessionVolume(sessionId: String?, volume: Int)
}

class LocalMediaRepositoryImpl(
    audioManagerEventsReceiver: AudioManagerEventsReceiver,
    private val localMediaManager: LocalMediaManager,
    private val mediaRouter2Manager: MediaRouter2Manager,
    coroutineScope: CoroutineScope,
    private val backgroundContext: CoroutineContext,
) : LocalMediaRepository {

    private val devicesChanges =
@@ -94,18 +78,6 @@ class LocalMediaRepositoryImpl(
            }
            .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0)

    override val mediaDevices: StateFlow<Collection<MediaDevice>> =
        mediaDevicesUpdates
            .mapNotNull {
                if (it is DevicesUpdate.DeviceListUpdate) {
                    it.newDevices ?: emptyList()
                } else {
                    null
                }
            }
            .flowOn(backgroundContext)
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())

    override val currentConnectedDevice: StateFlow<MediaDevice?> =
        merge(devicesChanges, mediaDevicesUpdates)
            .map { localMediaManager.currentConnectedDevice }
@@ -116,30 +88,6 @@ class LocalMediaRepositoryImpl(
                localMediaManager.currentConnectedDevice
            )

    override val remoteRoutingSessions: StateFlow<Collection<RoutingSession>> =
        merge(devicesChanges, mediaDevicesUpdates)
            .onStart { emit(Unit) }
            .map { localMediaManager.remoteRoutingSessions.map(::toRoutingSession) }
            .flowOn(backgroundContext)
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())

    override suspend fun adjustSessionVolume(sessionId: String?, volume: Int) {
        withContext(backgroundContext) {
            if (sessionId == null) {
                localMediaManager.adjustSessionVolume(volume)
            } else {
                localMediaManager.adjustSessionVolume(sessionId, volume)
            }
        }
    }

    private fun toRoutingSession(info: RoutingSessionInfo): RoutingSession =
        RoutingSession(
            info,
            isMediaOutputDisabled = mediaRouter2Manager.getTransferableRoutes(info).isEmpty(),
            isVolumeSeekBarEnabled = localMediaManager.shouldEnableVolumeSeekBar(info)
        )

    private sealed interface DevicesUpdate {

        data class DeviceListUpdate(val newDevices: List<MediaDevice>?) : DevicesUpdate
+24 −50
Original line number Diff line number Diff line
@@ -27,18 +27,26 @@ import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn

/** Provides controllers for currently active device media sessions. */
interface MediaControllerRepository {

    /** Current [MediaController]. Null is emitted when there is no active [MediaController]. */
    val activeLocalMediaController: StateFlow<MediaController?>
    /**
     * Get a list of controllers for all ongoing sessions. The controllers will be provided in
     * priority order with the most important controller at index 0.
     *
     * This requires the [android.Manifest.permission.MEDIA_CONTENT_CONTROL] permission be held by
     * the calling app.
     */
    val activeSessions: StateFlow<List<MediaController>>
}

class MediaControllerRepositoryImpl(
@@ -49,51 +57,17 @@ class MediaControllerRepositoryImpl(
    backgroundContext: CoroutineContext,
) : MediaControllerRepository {

    private val devicesChanges =
        audioManagerEventsReceiver.events.filterIsInstance(
            AudioManagerEvent.StreamDevicesChanged::class
    override val activeSessions: StateFlow<List<MediaController>> =
        merge(
                mediaSessionManager.activeMediaChanges.filterNotNull(),
                localBluetoothManager?.headsetAudioModeChanges?.map {
                    mediaSessionManager.getActiveSessions(null)
                } ?: emptyFlow(),
                audioManagerEventsReceiver.events
                    .filterIsInstance(AudioManagerEvent.StreamDevicesChanged::class)
                    .map { mediaSessionManager.getActiveSessions(null) },
            )

    override val activeLocalMediaController: StateFlow<MediaController?> =
        combine(
                mediaSessionManager.activeMediaChanges.onStart {
                    emit(mediaSessionManager.getActiveSessions(null))
                },
                localBluetoothManager?.headsetAudioModeChanges?.onStart { emit(Unit) }
                    ?: flowOf(null),
                devicesChanges.onStart { emit(AudioManagerEvent.StreamDevicesChanged) },
            ) { controllers, _, _ ->
                controllers?.let(::findLocalMediaController)
            }
            .onStart { emit(mediaSessionManager.getActiveSessions(null)) }
            .flowOn(backgroundContext)
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null)

    private fun findLocalMediaController(
        controllers: Collection<MediaController>,
    ): MediaController? {
        var localController: MediaController? = null
        val remoteMediaSessionLists: MutableList<String> = ArrayList()
        for (controller in controllers) {
            val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
            when (playbackInfo.playbackType) {
                MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE -> {
                    if (localController?.packageName.equals(controller.packageName)) {
                        localController = null
                    }
                    if (!remoteMediaSessionLists.contains(controller.packageName)) {
                        remoteMediaSessionLists.add(controller.packageName)
                    }
                }
                MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL -> {
                    if (
                        localController == null &&
                            !remoteMediaSessionLists.contains(controller.packageName)
                    ) {
                        localController = controller
                    }
                }
            }
        }
        return localController
    }
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())
}
+0 −57
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package com.android.settingslib.volume.domain.interactor

import com.android.settingslib.media.MediaDevice
import com.android.settingslib.volume.data.repository.LocalMediaRepository
import com.android.settingslib.volume.domain.model.RoutingSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

class LocalMediaInteractor(
    private val repository: LocalMediaRepository,
    coroutineScope: CoroutineScope,
) {

    /** Available devices list */
    val mediaDevices: StateFlow<Collection<MediaDevice>>
        get() = repository.mediaDevices

    /** Currently connected media device */
    val currentConnectedDevice: StateFlow<MediaDevice?>
        get() = repository.currentConnectedDevice

    val remoteRoutingSessions: StateFlow<List<RoutingSession>> =
        repository.remoteRoutingSessions
            .map { sessions ->
                sessions.map {
                    RoutingSession(
                        routingSessionInfo = it.routingSessionInfo,
                        isMediaOutputDisabled = it.isMediaOutputDisabled,
                        isVolumeSeekBarEnabled =
                            it.isVolumeSeekBarEnabled && it.routingSessionInfo.volumeMax > 0
                    )
                }
            }
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList())

    suspend fun adjustSessionVolume(sessionId: String?, volume: Int) =
        repository.adjustSessionVolume(sessionId, volume)
}
+0 −103
Original line number Diff line number Diff line
@@ -15,17 +15,12 @@
 */
package com.android.settingslib.volume.data.repository

import android.media.MediaRoute2Info
import android.media.MediaRouter2Manager
import android.media.RoutingSessionInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.media.LocalMediaManager
import com.android.settingslib.media.MediaDevice
import com.android.settingslib.volume.data.model.RoutingSession
import com.android.settingslib.volume.shared.FakeAudioManagerEventsReceiver
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.TestScope
@@ -37,15 +32,10 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.any
import org.mockito.Mockito.anyInt
import org.mockito.Mockito.anyString
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@SmallTest
class LocalMediaRepositoryImplTest {
@@ -53,7 +43,6 @@ class LocalMediaRepositoryImplTest {
    @Mock private lateinit var localMediaManager: LocalMediaManager
    @Mock private lateinit var mediaDevice1: MediaDevice
    @Mock private lateinit var mediaDevice2: MediaDevice
    @Mock private lateinit var mediaRouter2Manager: MediaRouter2Manager

    @Captor
    private lateinit var deviceCallbackCaptor: ArgumentCaptor<LocalMediaManager.DeviceCallback>
@@ -71,28 +60,10 @@ class LocalMediaRepositoryImplTest {
            LocalMediaRepositoryImpl(
                eventsReceiver,
                localMediaManager,
                mediaRouter2Manager,
                testScope.backgroundScope,
                testScope.testScheduler,
            )
    }

    @Test
    fun mediaDevices_areUpdated() {
        testScope.runTest {
            var mediaDevices: Collection<MediaDevice>? = null
            underTest.mediaDevices.onEach { mediaDevices = it }.launchIn(backgroundScope)
            runCurrent()
            verify(localMediaManager).registerCallback(deviceCallbackCaptor.capture())
            deviceCallbackCaptor.value.onDeviceListUpdate(listOf(mediaDevice1, mediaDevice2))
            runCurrent()

            assertThat(mediaDevices).hasSize(2)
            assertThat(mediaDevices).contains(mediaDevice1)
            assertThat(mediaDevices).contains(mediaDevice2)
        }
    }

    @Test
    fun deviceListUpdated_currentConnectedDeviceUpdated() {
        testScope.runTest {
@@ -110,78 +81,4 @@ class LocalMediaRepositoryImplTest {
            assertThat(currentConnectedDevice).isEqualTo(mediaDevice1)
        }
    }

    @Test
    fun kek() {
        testScope.runTest {
            `when`(localMediaManager.remoteRoutingSessions)
                .thenReturn(
                    listOf(
                        testRoutingSessionInfo1,
                        testRoutingSessionInfo2,
                        testRoutingSessionInfo3,
                    )
                )
            `when`(localMediaManager.shouldEnableVolumeSeekBar(any())).then {
                (it.arguments[0] as RoutingSessionInfo) == testRoutingSessionInfo1
            }
            `when`(mediaRouter2Manager.getTransferableRoutes(any<RoutingSessionInfo>())).then {
                if ((it.arguments[0] as RoutingSessionInfo) == testRoutingSessionInfo2) {
                    return@then listOf(mock(MediaRoute2Info::class.java))
                }
                emptyList<MediaRoute2Info>()
            }
            var remoteRoutingSessions: Collection<RoutingSession>? = null
            underTest.remoteRoutingSessions
                .onEach { remoteRoutingSessions = it }
                .launchIn(backgroundScope)

            runCurrent()

            assertThat(remoteRoutingSessions)
                .containsExactlyElementsIn(
                    listOf(
                        RoutingSession(
                            routingSessionInfo = testRoutingSessionInfo1,
                            isVolumeSeekBarEnabled = true,
                            isMediaOutputDisabled = true,
                        ),
                        RoutingSession(
                            routingSessionInfo = testRoutingSessionInfo2,
                            isVolumeSeekBarEnabled = false,
                            isMediaOutputDisabled = false,
                        ),
                        RoutingSession(
                            routingSessionInfo = testRoutingSessionInfo3,
                            isVolumeSeekBarEnabled = false,
                            isMediaOutputDisabled = true,
                        )
                    )
                )
        }
    }

    @Test
    fun adjustSessionVolume_adjusts() {
        testScope.runTest {
            var volume = 0
            `when`(localMediaManager.adjustSessionVolume(anyString(), anyInt())).then {
                volume = it.arguments[1] as Int
                Unit
            }

            underTest.adjustSessionVolume("test_session", 10)

            assertThat(volume).isEqualTo(10)
        }
    }

    private companion object {
        val testRoutingSessionInfo1 =
            RoutingSessionInfo.Builder("id_1", "test.pkg.1").addSelectedRoute("route_1").build()
        val testRoutingSessionInfo2 =
            RoutingSessionInfo.Builder("id_2", "test.pkg.2").addSelectedRoute("route_2").build()
        val testRoutingSessionInfo3 =
            RoutingSessionInfo.Builder("id_3", "test.pkg.3").addSelectedRoute("route_3").build()
    }
}
Loading