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

Commit 1be83fe2 authored by Haijie Hong's avatar Haijie Hong
Browse files

Show two volume slider when audio sharing

1. Show an extra volume slider when audio sharing
2. Modify media slider icon and text when audio sharing

BUG: 336183611
Test: atest AudioSharingInteractorTest
Flag: com.android.settingslib.flags.enable_le_audio_sharing
Change-Id: I2fcfb8973a15f8c0e986de39a55e7862990e446f
parent 4b1b1739
Loading
Loading
Loading
Loading
+54 −11
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import android.provider.Settings
import androidx.annotation.IntRange
import com.android.internal.util.ConcurrentUtils
import com.android.settingslib.bluetooth.BluetoothUtils
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.bluetooth.onBroadcastStartedOrStopped
import com.android.settingslib.bluetooth.onProfileConnectionStateChanged
@@ -71,6 +72,12 @@ interface AudioSharingRepository {
    /** The secondary headset groupId in audio sharing. */
    val secondaryGroupId: StateFlow<Int>

    /** Primary audio sharing device. */
    val primaryDevice: StateFlow<CachedBluetoothDevice?>

    /** Secondary audio sharing device. */
    val secondaryDevice: StateFlow<CachedBluetoothDevice?>

    /** The headset groupId to volume map during audio sharing. */
    val volumeMap: StateFlow<GroupIdToVolumes>

@@ -144,12 +151,31 @@ class AudioSharingRepositoryImpl(
            )

    override val secondaryGroupId: StateFlow<Int> =
        merge(
        secondaryDevice
            .map { BluetoothUtils.getGroupId(it) }
            .onEach { logger.onSecondaryGroupIdChanged(it) }
            .flowOn(backgroundCoroutineContext)
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(),
                BluetoothCsipSetCoordinator.GROUP_ID_INVALID
            )

    override val primaryDevice: StateFlow<CachedBluetoothDevice?>
        get() = primaryGroupId.map { getCachedDeviceFromGroupId(it) }
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(),
                null
            )

    override val secondaryDevice: StateFlow<CachedBluetoothDevice?>
        get() = merge(
            isAudioSharingProfilesReady.flatMapLatest { ready ->
                if (ready) {
                    btManager.profileManager.leAudioBroadcastAssistantProfile
                        .onSourceConnectedOrRemoved
                        .map { getSecondaryGroupId() }
                        .map { getSecondaryDevice() }
                } else {
                    emptyFlow()
                }
@@ -160,15 +186,14 @@ class AudioSharingRepositoryImpl(
                            profileConnection.bluetoothProfile ==
                            BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT
                }
                .map { getSecondaryGroupId() },
            primaryGroupId.map { getSecondaryGroupId() })
            .onStart { emit(getSecondaryGroupId()) }
            .onEach { logger.onSecondaryGroupIdChanged(it) }
                .map { getSecondaryDevice() },
            primaryGroupId.map { getSecondaryDevice() })
            .onStart { emit(getSecondaryDevice()) }
            .flowOn(backgroundCoroutineContext)
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(),
                BluetoothCsipSetCoordinator.GROUP_ID_INVALID
                null
            )

    override val volumeMap: StateFlow<GroupIdToVolumes> =
@@ -257,10 +282,24 @@ class AudioSharingRepositoryImpl(
    private fun isBroadcasting(): Boolean =
        btManager.profileManager.leAudioBroadcastProfile?.isEnabled(null) ?: false

    private fun getSecondaryGroupId(): Int =
        BluetoothUtils.getGroupId(
    private fun getSecondaryDevice(): CachedBluetoothDevice? =
        BluetoothUtils.getSecondaryDeviceForBroadcast(contentResolver, btManager)
        )

    private suspend fun getCachedDeviceFromGroupId(groupId: Int): CachedBluetoothDevice? =
        withContext(backgroundCoroutineContext) {
            btManager
                .profileManager
                ?.leAudioBroadcastAssistantProfile
                ?.allConnectedDevices
                ?.firstNotNullOfOrNull { device ->
                    val cachedDevice = btManager.cachedDeviceManager.findDevice(device)
                    if (BluetoothUtils.getGroupId(cachedDevice) == groupId) {
                        cachedDevice
                    } else {
                        null
                    }
                }
        }
}

class AudioSharingRepositoryEmptyImpl : AudioSharingRepository {
@@ -269,6 +308,10 @@ class AudioSharingRepositoryEmptyImpl : AudioSharingRepository {
        MutableStateFlow(BluetoothCsipSetCoordinator.GROUP_ID_INVALID)
    override val secondaryGroupId: StateFlow<Int> =
        MutableStateFlow(BluetoothCsipSetCoordinator.GROUP_ID_INVALID)
    override val primaryDevice: StateFlow<CachedBluetoothDevice?>
        get() = MutableStateFlow(null)
    override val secondaryDevice: StateFlow<CachedBluetoothDevice?>
        get() = MutableStateFlow(null)
    override val volumeMap: StateFlow<GroupIdToVolumes> = MutableStateFlow(emptyMap())

    override suspend fun audioSharingAvailable(): Boolean = false
+42 −6
Original line number Diff line number Diff line
@@ -208,6 +208,21 @@ class AudioSharingRepositoryTest {
        }
    }

    @Test
    fun primaryDeviceChange_emitValues() {
        testScope.runTest {
            `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2))

            val devices = mutableListOf<CachedBluetoothDevice?>()
            underTest.primaryDevice.onEach { devices.add(it) }.launchIn(backgroundScope)
            runCurrent()
            triggerContentObserverChange()
            runCurrent()

            Truth.assertThat(devices).containsExactly(null, cachedDevice2)
        }
    }

    @Test
    fun secondaryGroupIdChange_profileNotReady_assistantCallbackNotRegistered() {
        testScope.runTest {
@@ -268,6 +283,29 @@ class AudioSharingRepositoryTest {
        }
    }

    @Test
    fun secondaryDeviceChange_emitValues() {
        testScope.runTest {
            `when`(broadcast.isProfileReady).thenReturn(true)
            `when`(assistant.isProfileReady).thenReturn(true)
            `when`(volumeControl.isProfileReady).thenReturn(true)
            val devices = mutableListOf<CachedBluetoothDevice?>()
            underTest.secondaryDevice.onEach { devices.add(it) }.launchIn(backgroundScope)
            runCurrent()
            triggerSourceAdded()
            runCurrent()
            triggerContentObserverChange()
            runCurrent()

            Truth.assertThat(devices)
                .containsExactly(
                    null,
                    cachedDevice2,
                    cachedDevice1,
                )
        }
    }

    @Test
    fun volumeMapChange_profileReady_emitValues() {
        testScope.runTest {
@@ -363,7 +401,7 @@ class AudioSharingRepositoryTest {
            TEST_GROUP_ID1
        )
        `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2))
        assistantCallbackCaptor.value.sourceAdded(device1, receiveState)
        assistantCallbackCaptor.value.sourceAdded(device1)
    }

    private fun triggerSourceRemoved() {
@@ -432,11 +470,9 @@ class AudioSharingRepositoryTest {
            onBroadcastStopped(TEST_REASON, TEST_BROADCAST_ID)
        }
        val sourceAdded:
                BluetoothLeBroadcastAssistant.Callback.(
                    sink: BluetoothDevice, state: BluetoothLeBroadcastReceiveState
                ) -> Unit =
            { sink, state ->
                onReceiveStateChanged(sink, TEST_SOURCE_ID, state)
                BluetoothLeBroadcastAssistant.Callback.(sink: BluetoothDevice) -> Unit =
            { sink ->
                onSourceAdded(sink, TEST_SOURCE_ID, TEST_REASON)
            }
        val sourceRemoved: BluetoothLeBroadcastAssistant.Callback.(sink: BluetoothDevice) -> Unit =
            { sink ->
+49 −0
Original line number Diff line number Diff line
@@ -16,11 +16,16 @@

package com.android.systemui.volume.domain.interactor

import android.bluetooth.BluetoothDevice
import android.media.AudioManager.STREAM_MUSIC
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant
import com.android.settingslib.volume.shared.model.AudioStream
import com.android.systemui.SysuiTestCase
import com.android.systemui.bluetooth.cachedBluetoothDeviceManager
import com.android.systemui.bluetooth.localBluetoothProfileManager
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.testKosmos
@@ -32,6 +37,8 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@@ -39,10 +46,23 @@ import org.junit.runner.RunWith
class AudioSharingInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    lateinit var underTest: AudioSharingInteractor
    private val bluetoothDevice: BluetoothDevice = mock {}
    private val cachedDevice: CachedBluetoothDevice = mock {
        on { groupId }.thenReturn(TEST_GROUP_ID)
        on { device }.thenReturn(bluetoothDevice)
    }

    @Before
    fun setUp() {
        with(kosmos) {
            whenever(cachedBluetoothDeviceManager.findDevice(bluetoothDevice))
                .thenReturn(cachedDevice)
            val broadcastAssistantProfile: LocalBluetoothLeBroadcastAssistant = mock {
                on { allConnectedDevices }.thenReturn(listOf(bluetoothDevice))
            }
            whenever(localBluetoothProfileManager.leAudioBroadcastAssistantProfile)
                .thenReturn(broadcastAssistantProfile)

            with(audioSharingRepository) { setVolumeMap(mapOf(TEST_GROUP_ID to TEST_VOLUME)) }
            underTest = audioSharingInteractor
        }
@@ -89,6 +109,35 @@ class AudioSharingInteractorTest : SysuiTestCase() {
        }
    }

    @Test
    fun getPrimaryDevice() {
        with(kosmos) {
            testScope.runTest {
                with(audioSharingRepository) { setPrimaryDevice(cachedDevice) }
                underTest.handlePrimaryGroupChange()

                val primaryDevice by collectLastValue(underTest.primaryDevice)
                runCurrent()

                Truth.assertThat(primaryDevice).isEqualTo(cachedDevice)
            }
        }
    }

    @Test
    fun getSecondaryDevice() {
        with(kosmos) {
            testScope.runTest {
                with(audioSharingRepository) { setSecondaryDevice(cachedDevice) }

                val secondaryDevice by collectLastValue(underTest.secondaryDevice)
                runCurrent()

                Truth.assertThat(secondaryDevice).isEqualTo(cachedDevice)
            }
        }
    }

    @Test
    fun handlePrimaryGroupChange_setStreamVolume() {
        with(kosmos) {
+91 −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.systemui.volume.panel.component.volume.slider.ui.viewmodel

import android.bluetooth.BluetoothDevice
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.uiEventLogger
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.slider.sliderHapticsViewModelFactory
import com.android.systemui.kosmos.testScope
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.android.systemui.volume.data.repository.audioSharingRepository
import com.android.systemui.volume.domain.interactor.audioSharingInteractor
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.kotlin.mock

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class AudioSharingStreamSliderViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private lateinit var stream: AudioSharingStreamSliderViewModel

    @Before
    fun setUp() {
        stream = audioSharingStreamSliderViewModel()
    }

    private fun audioSharingStreamSliderViewModel(): AudioSharingStreamSliderViewModel {
        return AudioSharingStreamSliderViewModel(
            testScope.backgroundScope,
            context,
            kosmos.audioSharingInteractor,
            kosmos.uiEventLogger,
            kosmos.sliderHapticsViewModelFactory,
        )
    }

    @Test
    fun slider_media_inAudioSharing() =
        with(kosmos) {
            testScope.runTest {
                val audioSharingSlider by collectLastValue(stream.slider)

                val bluetoothDevice: BluetoothDevice = mock {}
                val cachedDevice: CachedBluetoothDevice = mock {
                    on { groupId }.thenReturn(123)
                    on { device }.thenReturn(bluetoothDevice)
                    on { name }.thenReturn("my headset 2")
                }
                audioSharingRepository.setSecondaryDevice(cachedDevice)

                audioSharingRepository.setInAudioSharing(true)
                audioSharingRepository.setSecondaryGroupId(123)

                runCurrent()

                assertThat(audioSharingSlider!!.label).isEqualTo("my headset 2")
                assertThat(audioSharingSlider!!.icon)
                    .isEqualTo(Icon.Resource(R.drawable.ic_volume_media_bt, null))
            }
        }
}
+29 −0
Original line number Diff line number Diff line
@@ -23,19 +23,27 @@ import android.platform.test.annotations.EnableFlags
import android.service.notification.ZenPolicy
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.notification.modes.TestModeBuilder
import com.android.settingslib.volume.shared.model.AudioStream
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.res.R
import com.android.systemui.statusbar.policy.data.repository.fakeZenModeRepository
import com.android.systemui.testKosmos
import com.android.systemui.volume.data.repository.audioSharingRepository
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock

@SmallTest
@RunWith(AndroidJUnit4::class)
@@ -146,4 +154,25 @@ class AudioStreamSliderViewModelTest : SysuiTestCase() {
            assertThat(notificationSlider!!.disabledMessage)
                .isEqualTo("Unavailable because ring is muted")
        }

    @Test
    @EnableFlags(com.android.systemui.Flags.FLAG_SHOW_AUDIO_SHARING_SLIDER_IN_VOLUME_PANEL)
    fun slider_media_inAudioSharing() =
        kosmos.runTest {
            val mediaSlider by
                collectLastValue(audioStreamSliderViewModel(AudioManager.STREAM_MUSIC).slider)

            val cachedDevice: CachedBluetoothDevice = mock {
                on { groupId }.thenReturn(123)
                on { name }.thenReturn("my headset 1")
            }

            audioSharingRepository.setInAudioSharing(true)
            audioSharingRepository.setPrimaryDevice(cachedDevice)
            runCurrent()

            assertThat(mediaSlider!!.label).isEqualTo("my headset 1")
            assertThat(mediaSlider!!.icon)
                .isEqualTo(Icon.Resource(R.drawable.ic_volume_media_bt, null))
        }
}
Loading