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

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

Merge "Add MediaOutput repository" into main

parents b778873d 08ae98f2
Loading
Loading
Loading
Loading
+37 −0
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.bluetooth

import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch

/** Returns a [Flow] that emits a [Unit] whenever the headset audio mode changes. */
val LocalBluetoothManager.headsetAudioModeChanges: Flow<Unit>
    get() {
        return callbackFlow {
            val callback =
                object : BluetoothCallback {
                    override fun onAudioModeChanged() {
                        launch { send(Unit) }
                    }
                }

            eventManager.registerCallback(callback)
            awaitClose { eventManager.unregisterCallback(callback) }
        }
    }
+104 −0
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.data.repository

import com.android.settingslib.media.LocalMediaManager
import com.android.settingslib.media.MediaDevice
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn

/** 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?>
}

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

    private val deviceUpdates: Flow<DevicesUpdate> = callbackFlow {
        val callback =
            object : LocalMediaManager.DeviceCallback {
                override fun onDeviceListUpdate(newDevices: List<MediaDevice>?) {
                    trySend(DevicesUpdate.DeviceListUpdate(newDevices ?: emptyList()))
                }

                override fun onSelectedDeviceStateChanged(
                    device: MediaDevice?,
                    state: Int,
                ) {
                    trySend(DevicesUpdate.SelectedDeviceStateChanged)
                }

                override fun onDeviceAttributesChanged() {
                    trySend(DevicesUpdate.DeviceAttributesChanged)
                }
            }
        localMediaManager.registerCallback(callback)
        localMediaManager.startScan()

        awaitClose {
            localMediaManager.stopScan()
            localMediaManager.unregisterCallback(callback)
        }
    }

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

    override val currentConnectedDevice: StateFlow<MediaDevice?> =
        deviceUpdates
            .map { localMediaManager.currentConnectedDevice }
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(),
                localMediaManager.currentConnectedDevice
            )

    private sealed interface DevicesUpdate {

        data class DeviceListUpdate(val newDevices: List<MediaDevice>?) : DevicesUpdate

        data object SelectedDeviceStateChanged : DevicesUpdate

        data object DeviceAttributesChanged : DevicesUpdate
    }
}
+124 −0
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.data.repository

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.bluetooth.headsetAudioModeChanges
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

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

    /** Current [MediaController]. Null is emitted when there is no active [MediaController]. */
    val activeMediaController: StateFlow<MediaController?>
}

class MediaControllerRepositoryImpl(
    private val context: Context,
    private val mediaSessionManager: MediaSessionManager,
    localBluetoothManager: LocalBluetoothManager?,
    coroutineScope: CoroutineScope,
    backgroundContext: CoroutineContext,
) : MediaControllerRepository {

    private val devicesChanges: Flow<Unit> =
        callbackFlow {
                val receiver =
                    object : BroadcastReceiver() {
                        override fun onReceive(context: Context?, intent: Intent?) {
                            if (AudioManager.STREAM_DEVICES_CHANGED_ACTION == intent?.action) {
                                launch { send(Unit) }
                            }
                        }
                    }
                context.registerReceiver(
                    receiver,
                    IntentFilter(AudioManager.STREAM_DEVICES_CHANGED_ACTION)
                )

                awaitClose { context.unregisterReceiver(receiver) }
            }
            .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0)

    override val activeMediaController: StateFlow<MediaController?> =
        combine(
                localBluetoothManager?.headsetAudioModeChanges?.onStart { emit(Unit) }
                    ?: emptyFlow(),
                devicesChanges.onStart { emit(Unit) },
            ) { _, _ ->
                getActiveLocalMediaController()
            }
            .flowOn(backgroundContext)
            .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null)

    private fun getActiveLocalMediaController(): MediaController? {
        var localController: MediaController? = null
        val remoteMediaSessionLists: MutableList<String> = ArrayList()
        for (controller in mediaSessionManager.getActiveSessions(null)) {
            val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
            val playbackState = controller.playbackState ?: continue
            if (inactivePlaybackStates.contains(playbackState.state)) {
                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
    }

    private companion object {
        val inactivePlaybackStates =
            setOf(PlaybackState.STATE_STOPPED, PlaybackState.STATE_NONE, PlaybackState.STATE_ERROR)
    }
}
+100 −0
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.data.repository

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.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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.ArgumentCaptor
import org.mockito.Captor
import org.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 {

    @Mock private lateinit var localMediaManager: LocalMediaManager
    @Mock private lateinit var mediaDevice1: MediaDevice
    @Mock private lateinit var mediaDevice2: MediaDevice

    @Captor
    private lateinit var deviceCallbackCaptor: ArgumentCaptor<LocalMediaManager.DeviceCallback>

    private val testScope = TestScope()

    private lateinit var underTest: LocalMediaRepository

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)

        underTest =
            LocalMediaRepositoryImpl(
                localMediaManager,
                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 {
            var currentConnectedDevice: MediaDevice? = null
            underTest.currentConnectedDevice
                .onEach { currentConnectedDevice = it }
                .launchIn(backgroundScope)
            runCurrent()

            `when`(localMediaManager.currentConnectedDevice).thenReturn(mediaDevice1)
            verify(localMediaManager).registerCallback(deviceCallbackCaptor.capture())
            deviceCallbackCaptor.value.onDeviceListUpdate(listOf(mediaDevice1, mediaDevice2))
            runCurrent()

            assertThat(currentConnectedDevice).isEqualTo(mediaDevice1)
        }
    }
}
+178 −0
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.data.repository

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.session.MediaController
import android.media.session.MediaController.PlaybackInfo
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.BluetoothCallback
import com.android.settingslib.bluetooth.BluetoothEventManager
import com.android.settingslib.bluetooth.LocalBluetoothManager
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
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.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.any
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@SmallTest
class MediaControllerRepositoryImplTest {

    @Captor private lateinit var receiverCaptor: ArgumentCaptor<BroadcastReceiver>
    @Captor private lateinit var callbackCaptor: ArgumentCaptor<BluetoothCallback>

    @Mock private lateinit var context: Context
    @Mock private lateinit var mediaSessionManager: MediaSessionManager
    @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
    @Mock private lateinit var eventManager: BluetoothEventManager

    @Mock private lateinit var stoppedMediaController: MediaController
    @Mock private lateinit var statelessMediaController: MediaController
    @Mock private lateinit var errorMediaController: MediaController
    @Mock private lateinit var remoteMediaController: MediaController
    @Mock private lateinit var localMediaController: MediaController

    @Mock private lateinit var remotePlaybackInfo: PlaybackInfo
    @Mock private lateinit var localPlaybackInfo: PlaybackInfo

    private val testScope = TestScope()

    private lateinit var underTest: MediaControllerRepository

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)

        `when`(localBluetoothManager.eventManager).thenReturn(eventManager)

        `when`(stoppedMediaController.playbackState).thenReturn(stateStopped)
        `when`(stoppedMediaController.packageName).thenReturn("test.pkg.stopped")
        `when`(statelessMediaController.playbackState).thenReturn(stateNone)
        `when`(statelessMediaController.packageName).thenReturn("test.pkg.stateless")
        `when`(errorMediaController.playbackState).thenReturn(stateError)
        `when`(errorMediaController.packageName).thenReturn("test.pkg.error")
        `when`(remoteMediaController.playbackState).thenReturn(statePlaying)
        `when`(remoteMediaController.playbackInfo).thenReturn(remotePlaybackInfo)
        `when`(remoteMediaController.packageName).thenReturn("test.pkg.remote")
        `when`(localMediaController.playbackState).thenReturn(statePlaying)
        `when`(localMediaController.playbackInfo).thenReturn(localPlaybackInfo)
        `when`(localMediaController.packageName).thenReturn("test.pkg.local")

        `when`(remotePlaybackInfo.playbackType).thenReturn(PlaybackInfo.PLAYBACK_TYPE_REMOTE)
        `when`(localPlaybackInfo.playbackType).thenReturn(PlaybackInfo.PLAYBACK_TYPE_LOCAL)

        underTest =
            MediaControllerRepositoryImpl(
                context,
                mediaSessionManager,
                localBluetoothManager,
                testScope.backgroundScope,
                testScope.testScheduler,
            )
    }

    @Test
    fun playingMediaDevicesAvailable_sessionIsActive() {
        testScope.runTest {
            `when`(mediaSessionManager.getActiveSessions(any()))
                .thenReturn(
                    listOf(
                        stoppedMediaController,
                        statelessMediaController,
                        errorMediaController,
                        remoteMediaController,
                        localMediaController
                    )
                )
            var mediaController: MediaController? = null
            underTest.activeMediaController
                .onEach { mediaController = it }
                .launchIn(backgroundScope)
            runCurrent()

            triggerDevicesChange()
            triggerOnAudioModeChanged()
            runCurrent()

            assertThat(mediaController).isSameInstanceAs(localMediaController)
        }
    }

    @Test
    fun noPlayingMediaDevicesAvailable_sessionIsInactive() {
        testScope.runTest {
            `when`(mediaSessionManager.getActiveSessions(any()))
                .thenReturn(
                    listOf(
                        stoppedMediaController,
                        statelessMediaController,
                        errorMediaController,
                    )
                )
            var mediaController: MediaController? = null
            underTest.activeMediaController
                .onEach { mediaController = it }
                .launchIn(backgroundScope)
            runCurrent()

            triggerDevicesChange()
            triggerOnAudioModeChanged()
            runCurrent()

            assertThat(mediaController).isNull()
        }
    }

    private fun triggerDevicesChange() {
        verify(context).registerReceiver(receiverCaptor.capture(), any())
        receiverCaptor.value.onReceive(context, Intent(AudioManager.STREAM_DEVICES_CHANGED_ACTION))
    }

    private fun triggerOnAudioModeChanged() {
        verify(eventManager).registerCallback(callbackCaptor.capture())
        callbackCaptor.value.onAudioModeChanged()
    }

    private companion object {
        val statePlaying =
            PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0, 0f).build()
        val stateError = PlaybackState.Builder().setState(PlaybackState.STATE_ERROR, 0, 0f).build()
        val stateStopped =
            PlaybackState.Builder().setState(PlaybackState.STATE_STOPPED, 0, 0f).build()
        val stateNone = PlaybackState.Builder().setState(PlaybackState.STATE_NONE, 0, 0f).build()
    }
}
Loading