diff options
7 files changed, 647 insertions, 28 deletions
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/BluetoothEventManagerExt.kt b/packages/SettingsLib/src/com/android/settingslib/bluetooth/BluetoothEventManagerExt.kt new file mode 100644 index 000000000000..2eaa804152e4 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/BluetoothEventManagerExt.kt @@ -0,0 +1,39 @@ +/* + * 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 + +/** [Flow] for [BluetoothCallback] device profile connection state change events */ +val BluetoothEventManager.onProfileConnectionStateChanged: Flow<ProfileConnectionState> + get() = callbackFlow { + val callback = + object : BluetoothCallback { + override fun onProfileConnectionStateChanged( + cachedDevice: CachedBluetoothDevice, + @BluetoothCallback.ConnectionState state: Int, + bluetoothProfile: Int + ) { + launch { send(ProfileConnectionState(cachedDevice, state, bluetoothProfile)) } + } + } + registerCallback(callback) + awaitClose { unregisterCallback(callback) } + } diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/LocalBluetoothLeBroadcastAssistantCallbackExt.kt b/packages/SettingsLib/src/com/android/settingslib/bluetooth/LocalBluetoothLeBroadcastAssistantCallbackExt.kt new file mode 100644 index 000000000000..91a99aed6db5 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/LocalBluetoothLeBroadcastAssistantCallbackExt.kt @@ -0,0 +1,85 @@ +/* + * 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 android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothLeBroadcastAssistant +import android.bluetooth.BluetoothLeBroadcastMetadata +import android.bluetooth.BluetoothLeBroadcastReceiveState +import com.android.internal.util.ConcurrentUtils +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch + +/** [Flow] for [BluetoothLeBroadcastAssistant.Callback] source connected/removed events */ +val LocalBluetoothLeBroadcastAssistant.onSourceConnectedOrRemoved: Flow<Unit> + get() = callbackFlow { + val callback = + object : BluetoothLeBroadcastAssistant.Callback { + override fun onReceiveStateChanged( + sink: BluetoothDevice, + sourceId: Int, + state: BluetoothLeBroadcastReceiveState + ) { + if (BluetoothUtils.isConnected(state)) { + launch { send(Unit) } + } + } + + override fun onSourceRemoved(sink: BluetoothDevice, sourceId: Int, reason: Int) { + launch { send(Unit) } + } + + override fun onSearchStarted(reason: Int) {} + + override fun onSearchStartFailed(reason: Int) {} + + override fun onSearchStopped(reason: Int) {} + + override fun onSearchStopFailed(reason: Int) {} + + override fun onSourceFound(source: BluetoothLeBroadcastMetadata) {} + + override fun onSourceAdded(sink: BluetoothDevice, sourceId: Int, reason: Int) {} + + override fun onSourceAddFailed( + sink: BluetoothDevice, + source: BluetoothLeBroadcastMetadata, + reason: Int + ) {} + + override fun onSourceModified(sink: BluetoothDevice, sourceId: Int, reason: Int) {} + + override fun onSourceModifyFailed( + sink: BluetoothDevice, + sourceId: Int, + reason: Int + ) {} + + override fun onSourceRemoveFailed( + sink: BluetoothDevice, + sourceId: Int, + reason: Int + ) {} + } + registerServiceCallBack( + ConcurrentUtils.DIRECT_EXECUTOR, + callback, + ) + awaitClose { unregisterServiceCallBack(callback) } + } diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/ProfileConnectionState.kt b/packages/SettingsLib/src/com/android/settingslib/bluetooth/ProfileConnectionState.kt new file mode 100644 index 000000000000..45aaa66e7f95 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/ProfileConnectionState.kt @@ -0,0 +1,23 @@ +/* + * 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 + +data class ProfileConnectionState( + val cachedDevice: CachedBluetoothDevice, + @BluetoothCallback.ConnectionState val state: Int, + val bluetoothProfile: Int, +)
\ No newline at end of file diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioSharingRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioSharingRepository.kt index 9dbf23eba7a5..eb33a7a10524 100644 --- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioSharingRepository.kt +++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/AudioSharingRepository.kt @@ -16,33 +16,84 @@ package com.android.settingslib.volume.data.repository +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothCsipSetCoordinator +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothLeBroadcast import android.bluetooth.BluetoothLeBroadcastMetadata +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothVolumeControl +import android.content.ContentResolver +import android.content.Context +import android.database.ContentObserver +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.LocalBluetoothManager +import com.android.settingslib.bluetooth.onProfileConnectionStateChanged +import com.android.settingslib.bluetooth.onSourceConnectedOrRemoved import com.android.settingslib.flags.Flags +import com.android.settingslib.volume.data.repository.AudioSharingRepository.Companion.AUDIO_SHARING_VOLUME_MAX +import com.android.settingslib.volume.data.repository.AudioSharingRepository.Companion.AUDIO_SHARING_VOLUME_MIN import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi 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.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +typealias GroupIdToVolumes = Map<Int, Int> /** Provides audio sharing functionality. */ interface AudioSharingRepository { /** Whether the device is in audio sharing. */ val inAudioSharing: Flow<Boolean> + + /** The secondary headset groupId in audio sharing. */ + val secondaryGroupId: StateFlow<Int> + + /** The headset groupId to volume map during audio sharing. */ + val volumeMap: StateFlow<GroupIdToVolumes> + + /** Set the volume of secondary headset during audio sharing. */ + suspend fun setSecondaryVolume( + @IntRange(from = AUDIO_SHARING_VOLUME_MIN.toLong(), to = AUDIO_SHARING_VOLUME_MAX.toLong()) + volume: Int + ) + + companion object { + const val AUDIO_SHARING_VOLUME_MIN = 0 + const val AUDIO_SHARING_VOLUME_MAX = 255 + } } +@OptIn(ExperimentalCoroutinesApi::class) class AudioSharingRepositoryImpl( - private val localBluetoothManager: LocalBluetoothManager?, - backgroundCoroutineContext: CoroutineContext, + private val context: Context, + private val contentResolver: ContentResolver, + private val btManager: LocalBluetoothManager?, + private val coroutineScope: CoroutineScope, + private val backgroundCoroutineContext: CoroutineContext, ) : AudioSharingRepository { override val inAudioSharing: Flow<Boolean> = if (Flags.enableLeAudioSharing()) { - localBluetoothManager?.profileManager?.leAudioBroadcastProfile?.let { leBroadcast -> + btManager?.profileManager?.leAudioBroadcastProfile?.let { leBroadcast -> callbackFlow { val listener = object : BluetoothLeBroadcast.Callback { @@ -92,9 +143,117 @@ class AudioSharingRepositoryImpl( flowOf(false) } + private val primaryChange: Flow<Unit> = callbackFlow { + val callback = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + launch { send(Unit) } + } + } + contentResolver.registerContentObserver( + Settings.Secure.getUriFor(BluetoothUtils.getPrimaryGroupIdUriForBroadcast()), + false, + callback) + awaitClose { contentResolver.unregisterContentObserver(callback) } + } + + override val secondaryGroupId: StateFlow<Int> = + if (Flags.volumeDialogAudioSharingFix()) { + merge( + btManager + ?.profileManager + ?.leAudioBroadcastAssistantProfile + ?.onSourceConnectedOrRemoved + ?.map { getSecondaryGroupId() } ?: emptyFlow(), + btManager + ?.eventManager + ?.onProfileConnectionStateChanged + ?.filter { profileConnection -> + profileConnection.state == BluetoothAdapter.STATE_DISCONNECTED && + profileConnection.bluetoothProfile == + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + } + ?.map { getSecondaryGroupId() } ?: emptyFlow(), + primaryChange.map { getSecondaryGroupId() }) + .onStart { emit(getSecondaryGroupId()) } + .distinctUntilChanged() + .flowOn(backgroundCoroutineContext) + } else { + emptyFlow() + } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), getSecondaryGroupId()) + + override val volumeMap: StateFlow<GroupIdToVolumes> = + if (Flags.volumeDialogAudioSharingFix()) { + btManager?.profileManager?.volumeControlProfile?.let { volumeControl -> + inAudioSharing.flatMapLatest { isSharing -> + if (isSharing) { + callbackFlow { + val callback = + object : BluetoothVolumeControl.Callback { + override fun onDeviceVolumeChanged( + device: BluetoothDevice, + @IntRange( + from = AUDIO_SHARING_VOLUME_MIN.toLong(), + to = AUDIO_SHARING_VOLUME_MAX.toLong()) + volume: Int + ) { + launch { send(Pair(device, volume)) } + } + } + // Once registered, we will receive the initial volume of all + // connected BT devices on VolumeControlProfile via callbacks + volumeControl.registerCallback( + ConcurrentUtils.DIRECT_EXECUTOR, callback) + awaitClose { volumeControl.unregisterCallback(callback) } + } + .runningFold(emptyMap<Int, Int>()) { acc, value -> + val groupId = + BluetoothUtils.getGroupId( + btManager.cachedDeviceManager?.findDevice(value.first)) + if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) { + acc + Pair(groupId, value.second) + } else { + acc + } + } + .distinctUntilChanged() + .flowOn(backgroundCoroutineContext) + } else { + emptyFlow() + } + } + } ?: emptyFlow() + } else { + emptyFlow() + } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyMap()) + + override suspend fun setSecondaryVolume( + @IntRange(from = AUDIO_SHARING_VOLUME_MIN.toLong(), to = AUDIO_SHARING_VOLUME_MAX.toLong()) + volume: Int + ) { + withContext(backgroundCoroutineContext) { + if (Flags.volumeDialogAudioSharingFix()) { + btManager?.profileManager?.volumeControlProfile?.let { + // Find secondary headset and set volume. + val cachedDevice = + BluetoothUtils.getSecondaryDeviceForBroadcast(context, btManager) + if (cachedDevice != null) { + it.setDeviceVolume(cachedDevice.device, volume, /* isGroupOp= */ true) + } + } + } + } + } + private fun isBroadcasting(): Boolean { return Flags.enableLeAudioSharing() && - (localBluetoothManager?.profileManager?.leAudioBroadcastProfile?.isEnabled(null) - ?: false) + (btManager?.profileManager?.leAudioBroadcastProfile?.isEnabled(null) ?: false) + } + + private fun getSecondaryGroupId(): Int { + return BluetoothUtils.getGroupId( + BluetoothUtils.getSecondaryDeviceForBroadcast(context, btManager)) } } diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/AudioSharingRepositoryTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/AudioSharingRepositoryTest.kt index 1c80ef4fe4d4..000664dd1552 100644 --- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/AudioSharingRepositoryTest.kt +++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/AudioSharingRepositoryTest.kt @@ -16,15 +16,33 @@ package com.android.settingslib.volume.data.repository +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothLeBroadcast +import android.bluetooth.BluetoothLeBroadcastAssistant +import android.bluetooth.BluetoothLeBroadcastReceiveState +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothVolumeControl +import android.content.ContentResolver +import android.content.Context +import android.database.ContentObserver import android.platform.test.annotations.DisableFlags import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule +import android.provider.Settings +import androidx.test.core.app.ApplicationProvider 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.BluetoothUtils +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant import com.android.settingslib.bluetooth.LocalBluetoothManager import com.android.settingslib.bluetooth.LocalBluetoothProfileManager +import com.android.settingslib.bluetooth.VolumeControlProfile import com.android.settingslib.flags.Flags import com.google.common.truth.Truth import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -39,6 +57,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.eq import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.never @@ -52,27 +73,76 @@ import org.mockito.junit.MockitoRule @RunWith(AndroidJUnit4::class) class AudioSharingRepositoryTest { @get:Rule val mockito: MockitoRule = MockitoJUnit.rule() + @get:Rule val setFlagsRule: SetFlagsRule = SetFlagsRule() - @Mock private lateinit var localBluetoothManager: LocalBluetoothManager - @Mock private lateinit var localBluetoothProfileManager: LocalBluetoothProfileManager - @Mock private lateinit var localBluetoothLeBroadcast: LocalBluetoothLeBroadcast + @Mock private lateinit var btManager: LocalBluetoothManager + + @Mock private lateinit var profileManager: LocalBluetoothProfileManager + + @Mock private lateinit var broadcast: LocalBluetoothLeBroadcast + + @Mock private lateinit var assistant: LocalBluetoothLeBroadcastAssistant + + @Mock private lateinit var volumeControl: VolumeControlProfile + + @Mock private lateinit var eventManager: BluetoothEventManager + + @Mock private lateinit var deviceManager: CachedBluetoothDeviceManager + + @Mock private lateinit var device1: BluetoothDevice + + @Mock private lateinit var device2: BluetoothDevice + + @Mock private lateinit var cachedDevice1: CachedBluetoothDevice + + @Mock private lateinit var cachedDevice2: CachedBluetoothDevice + + @Mock private lateinit var receiveState: BluetoothLeBroadcastReceiveState + + @Mock private lateinit var contentResolver: ContentResolver @Captor - private lateinit var leBroadcastCallbackCaptor: ArgumentCaptor<BluetoothLeBroadcast.Callback> - private val testScope = TestScope() + private lateinit var broadcastCallbackCaptor: ArgumentCaptor<BluetoothLeBroadcast.Callback> + + @Captor + private lateinit var assistantCallbackCaptor: + ArgumentCaptor<BluetoothLeBroadcastAssistant.Callback> + + @Captor private lateinit var btCallbackCaptor: ArgumentCaptor<BluetoothCallback> + + @Captor private lateinit var contentObserverCaptor: ArgumentCaptor<ContentObserver> + @Captor + private lateinit var volumeCallbackCaptor: ArgumentCaptor<BluetoothVolumeControl.Callback> + + private val testScope = TestScope() + private val context: Context = ApplicationProvider.getApplicationContext() private lateinit var underTest: AudioSharingRepository @Before fun setup() { - `when`(localBluetoothManager.profileManager).thenReturn(localBluetoothProfileManager) - `when`(localBluetoothProfileManager.leAudioBroadcastProfile) - .thenReturn(localBluetoothLeBroadcast) - `when`(localBluetoothLeBroadcast.isEnabled(null)).thenReturn(true) + `when`(btManager.profileManager).thenReturn(profileManager) + `when`(profileManager.leAudioBroadcastProfile).thenReturn(broadcast) + `when`(profileManager.leAudioBroadcastAssistantProfile).thenReturn(assistant) + `when`(profileManager.volumeControlProfile).thenReturn(volumeControl) + `when`(btManager.eventManager).thenReturn(eventManager) + `when`(btManager.cachedDeviceManager).thenReturn(deviceManager) + `when`(broadcast.isEnabled(null)).thenReturn(true) + `when`(cachedDevice1.groupId).thenReturn(TEST_GROUP_ID1) + `when`(cachedDevice1.device).thenReturn(device1) + `when`(deviceManager.findDevice(device1)).thenReturn(cachedDevice1) + `when`(cachedDevice2.groupId).thenReturn(TEST_GROUP_ID2) + `when`(cachedDevice2.device).thenReturn(device2) + `when`(deviceManager.findDevice(device2)).thenReturn(cachedDevice2) + `when`(receiveState.bisSyncState).thenReturn(arrayListOf(TEST_RECEIVE_STATE_CONTENT)) + `when`(assistant.getAllSources(any())).thenReturn(listOf(receiveState)) underTest = AudioSharingRepositoryImpl( - localBluetoothManager, + context, + contentResolver, + btManager, + testScope.backgroundScope, testScope.testScheduler, ) } @@ -84,9 +154,9 @@ class AudioSharingRepositoryTest { val states = mutableListOf<Boolean?>() underTest.inAudioSharing.onEach { states.add(it) }.launchIn(backgroundScope) runCurrent() - triggerAudioSharingStateChange(false) + triggerAudioSharingStateChange(TriggerType.BROADCAST_STOP, broadcastStopped) runCurrent() - triggerAudioSharingStateChange(true) + triggerAudioSharingStateChange(TriggerType.BROADCAST_START, broadcastStarted) runCurrent() Truth.assertThat(states).containsExactly(true, false, true) @@ -102,19 +172,229 @@ class AudioSharingRepositoryTest { runCurrent() Truth.assertThat(states).containsExactly(false) - verify(localBluetoothLeBroadcast, never()).registerServiceCallBack(any(), any()) - verify(localBluetoothLeBroadcast, never()).isEnabled(any()) + verify(broadcast, never()).registerServiceCallBack(any(), any()) + verify(broadcast, never()).isEnabled(any()) + } + } + + @Test + @EnableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun secondaryGroupIdChange_emitValues() { + testScope.runTest { + val groupIds = mutableListOf<Int?>() + underTest.secondaryGroupId.onEach { groupIds.add(it) }.launchIn(backgroundScope) + runCurrent() + triggerSourceAdded() + runCurrent() + triggerContentObserverChange() + runCurrent() + triggerSourceRemoved() + runCurrent() + triggerSourceAdded() + runCurrent() + triggerProfileConnectionChange( + BluetoothAdapter.STATE_CONNECTING, BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) + runCurrent() + triggerProfileConnectionChange( + BluetoothAdapter.STATE_DISCONNECTED, BluetoothProfile.LE_AUDIO) + runCurrent() + triggerProfileConnectionChange( + BluetoothAdapter.STATE_DISCONNECTED, BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) + runCurrent() + + Truth.assertThat(groupIds) + .containsExactly( + TEST_GROUP_ID_INVALID, + TEST_GROUP_ID2, + TEST_GROUP_ID1, + TEST_GROUP_ID_INVALID, + TEST_GROUP_ID2, + TEST_GROUP_ID_INVALID) } } - private fun triggerAudioSharingStateChange(inAudioSharing: Boolean) { - verify(localBluetoothLeBroadcast) - .registerServiceCallBack(any(), leBroadcastCallbackCaptor.capture()) - `when`(localBluetoothLeBroadcast.isEnabled(null)).thenReturn(inAudioSharing) - if (inAudioSharing) { - leBroadcastCallbackCaptor.value.onBroadcastStarted(0, 0) - } else { - leBroadcastCallbackCaptor.value.onBroadcastStopped(0, 0) + @Test + @DisableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun secondaryGroupIdChange_audioSharingFlagOff_returnFalse() { + testScope.runTest { + val groupIds = mutableListOf<Int?>() + underTest.secondaryGroupId.onEach { groupIds.add(it) }.launchIn(backgroundScope) + runCurrent() + + Truth.assertThat(groupIds).containsExactly(TEST_GROUP_ID_INVALID) + verify(assistant, never()).registerServiceCallBack(any(), any()) + verify(eventManager, never()).registerCallback(any()) + } + } + + @Test + @EnableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun volumeMapChange_emitValues() { + testScope.runTest { + val volumeMaps = mutableListOf<GroupIdToVolumes?>() + underTest.volumeMap.onEach { volumeMaps.add(it) }.launchIn(backgroundScope) + runCurrent() + triggerVolumeMapChange(Pair(device1, TEST_VOLUME1)) + runCurrent() + triggerVolumeMapChange(Pair(device1, TEST_VOLUME2)) + runCurrent() + triggerAudioSharingStateChange(TriggerType.BROADCAST_STOP, broadcastStopped) + runCurrent() + verify(volumeControl).unregisterCallback(any()) + runCurrent() + + Truth.assertThat(volumeMaps) + .containsExactly( + emptyMap<Int, Int>(), + mapOf(TEST_GROUP_ID1 to TEST_VOLUME1), + mapOf(TEST_GROUP_ID1 to TEST_VOLUME2)) + } + } + + @Test + @DisableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun volumeMapChange_audioSharingFlagOff_returnFalse() { + testScope.runTest { + val volumeMaps = mutableListOf<GroupIdToVolumes?>() + underTest.volumeMap.onEach { volumeMaps.add(it) }.launchIn(backgroundScope) + runCurrent() + + Truth.assertThat(volumeMaps).isEmpty() + verify(broadcast, never()).registerServiceCallBack(any(), any()) + verify(volumeControl, never()).registerCallback(any(), any()) + } + } + + @Test + @EnableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun setSecondaryVolume_setValue() { + testScope.runTest { + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID2) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2)) + underTest.setSecondaryVolume(TEST_VOLUME1) + + runCurrent() + verify(volumeControl).setDeviceVolume(device1, TEST_VOLUME1, true) + } + } + + @Test + @DisableFlags(Flags.FLAG_VOLUME_DIALOG_AUDIO_SHARING_FIX) + fun setSecondaryVolume_audioSharingFlagOff_doNothing() { + testScope.runTest { + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID2) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2)) + underTest.setSecondaryVolume(TEST_VOLUME1) + + runCurrent() + verify(volumeControl, never()).setDeviceVolume(any(), anyInt(), anyBoolean()) + } + } + + private fun triggerAudioSharingStateChange( + type: TriggerType, + broadcastAction: BluetoothLeBroadcast.Callback.() -> Unit + ) { + verify(broadcast).registerServiceCallBack(any(), broadcastCallbackCaptor.capture()) + when (type) { + TriggerType.BROADCAST_START -> { + `when`(broadcast.isEnabled(null)).thenReturn(true) + broadcastCallbackCaptor.value.broadcastAction() + } + TriggerType.BROADCAST_STOP -> { + `when`(broadcast.isEnabled(null)).thenReturn(false) + broadcastCallbackCaptor.value.broadcastAction() + } + } + } + + private fun triggerSourceAdded() { + verify(assistant).registerServiceCallBack(any(), assistantCallbackCaptor.capture()) + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID1) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2)) + assistantCallbackCaptor.value.sourceAdded(device1, receiveState) + } + + private fun triggerSourceRemoved() { + verify(assistant).registerServiceCallBack(any(), assistantCallbackCaptor.capture()) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1)) + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID1) + assistantCallbackCaptor.value.sourceRemoved(device2) + } + + private fun triggerProfileConnectionChange(state: Int, profile: Int) { + verify(eventManager).registerCallback(btCallbackCaptor.capture()) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1)) + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID1) + btCallbackCaptor.value.onProfileConnectionStateChanged(cachedDevice2, state, profile) + } + + private fun triggerContentObserverChange() { + verify(contentResolver) + .registerContentObserver( + eq(Settings.Secure.getUriFor(BluetoothUtils.getPrimaryGroupIdUriForBroadcast())), + eq(false), + contentObserverCaptor.capture()) + `when`(assistant.allConnectedDevices).thenReturn(listOf(device1, device2)) + Settings.Secure.putInt( + context.contentResolver, + BluetoothUtils.getPrimaryGroupIdUriForBroadcast(), + TEST_GROUP_ID2) + contentObserverCaptor.value.primaryChanged() + } + + private fun triggerVolumeMapChange(change: Pair<BluetoothDevice, Int>) { + verify(volumeControl).registerCallback(any(), volumeCallbackCaptor.capture()) + volumeCallbackCaptor.value.onDeviceVolumeChanged(change.first, change.second) + } + + private enum class TriggerType { + BROADCAST_START, + BROADCAST_STOP + } + + private companion object { + const val TEST_GROUP_ID_INVALID = -1 + const val TEST_GROUP_ID1 = 1 + const val TEST_GROUP_ID2 = 2 + const val TEST_SOURCE_ID = 1 + const val TEST_BROADCAST_ID = 1 + const val TEST_REASON = 1 + const val TEST_RECEIVE_STATE_CONTENT = 1L + const val TEST_VOLUME1 = 10 + const val TEST_VOLUME2 = 20 + + val broadcastStarted: BluetoothLeBroadcast.Callback.() -> Unit = { + onBroadcastStarted(TEST_REASON, TEST_BROADCAST_ID) + } + val broadcastStopped: BluetoothLeBroadcast.Callback.() -> Unit = { + onBroadcastStopped(TEST_REASON, TEST_BROADCAST_ID) } + val sourceAdded: + BluetoothLeBroadcastAssistant.Callback.( + sink: BluetoothDevice, state: BluetoothLeBroadcastReceiveState) -> Unit = + { sink, state -> + onReceiveStateChanged(sink, TEST_SOURCE_ID, state) + } + val sourceRemoved: BluetoothLeBroadcastAssistant.Callback.(sink: BluetoothDevice) -> Unit = + { sink -> + onSourceRemoved(sink, TEST_SOURCE_ID, TEST_REASON) + } + val primaryChanged: ContentObserver.() -> Unit = { onChange(false) } } } diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/AudioModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/AudioModule.kt index de8b9b1e7e3c..eb2f71a1cd7d 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dagger/AudioModule.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/AudioModule.kt @@ -73,10 +73,19 @@ interface AudioModule { @Provides @SysUISingleton fun provideAudioSharingRepository( + @Application context: Context, + contentResolver: ContentResolver, localBluetoothManager: LocalBluetoothManager?, + @Application coroutineScope: CoroutineScope, @Background coroutineContext: CoroutineContext, ): AudioSharingRepository = - AudioSharingRepositoryImpl(localBluetoothManager, coroutineContext) + AudioSharingRepositoryImpl( + context, + contentResolver, + localBluetoothManager, + coroutineScope, + coroutineContext + ) @Provides @SysUISingleton diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioSharingRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioSharingRepository.kt index 327e1b5df2ac..d391750a2612 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioSharingRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioSharingRepository.kt @@ -16,16 +16,40 @@ package com.android.systemui.volume.data.repository +import androidx.annotation.IntRange import com.android.settingslib.volume.data.repository.AudioSharingRepository +import com.android.settingslib.volume.data.repository.AudioSharingRepository.Companion.AUDIO_SHARING_VOLUME_MAX +import com.android.settingslib.volume.data.repository.AudioSharingRepository.Companion.AUDIO_SHARING_VOLUME_MIN +import com.android.settingslib.volume.data.repository.GroupIdToVolumes import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow class FakeAudioSharingRepository : AudioSharingRepository { private val mutableInAudioSharing: MutableStateFlow<Boolean> = MutableStateFlow(false) + private val mutableSecondaryGroupId: MutableStateFlow<Int> = + MutableStateFlow(TEST_GROUP_ID_INVALID) + private val mutableVolumeMap: MutableStateFlow<GroupIdToVolumes> = MutableStateFlow(emptyMap()) override val inAudioSharing: Flow<Boolean> = mutableInAudioSharing + override val secondaryGroupId: StateFlow<Int> = mutableSecondaryGroupId + override val volumeMap: StateFlow<GroupIdToVolumes> = mutableVolumeMap + + override suspend fun setSecondaryVolume(volume: Int) {} fun setInAudioSharing(state: Boolean) { mutableInAudioSharing.value = state } + + fun setSecondaryGroupId(groupId: Int) { + mutableSecondaryGroupId.value = groupId + } + + fun setVolumeMap(volumeMap: GroupIdToVolumes) { + mutableVolumeMap.value = volumeMap + } + + private companion object { + const val TEST_GROUP_ID_INVALID = -1 + } } |