diff options
15 files changed, 855 insertions, 120 deletions
diff --git a/packages/SettingsLib/src/com/android/settingslib/media/data/repository/SpatializerRepository.kt b/packages/SettingsLib/src/com/android/settingslib/media/data/repository/SpatializerRepository.kt index 2a4658bc69a1..a5c63be3c987 100644 --- a/packages/SettingsLib/src/com/android/settingslib/media/data/repository/SpatializerRepository.kt +++ b/packages/SettingsLib/src/com/android/settingslib/media/data/repository/SpatializerRepository.kt @@ -18,33 +18,71 @@ package com.android.settingslib.media.data.repository import android.media.AudioDeviceAttributes import android.media.Spatializer +import androidx.concurrent.futures.DirectExecutor import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext interface SpatializerRepository { + /** Returns true when head tracking is enabled and false the otherwise. */ + val isHeadTrackingAvailable: StateFlow<Boolean> + /** * Returns true when Spatial audio feature is supported for the [audioDeviceAttributes] and * false the otherwise. */ - suspend fun isAvailableForDevice(audioDeviceAttributes: AudioDeviceAttributes): Boolean + suspend fun isSpatialAudioAvailableForDevice( + audioDeviceAttributes: AudioDeviceAttributes + ): Boolean /** Returns a list [AudioDeviceAttributes] that are compatible with spatial audio. */ - suspend fun getCompatibleDevices(): Collection<AudioDeviceAttributes> + suspend fun getSpatialAudioCompatibleDevices(): Collection<AudioDeviceAttributes> + + /** Adds a [audioDeviceAttributes] to [getSpatialAudioCompatibleDevices] list. */ + suspend fun addSpatialAudioCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) + + /** Removes a [audioDeviceAttributes] from [getSpatialAudioCompatibleDevices] list. */ + suspend fun removeSpatialAudioCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) - /** Adds a [audioDeviceAttributes] to [getCompatibleDevices] list. */ - suspend fun addCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) + /** Checks if the head tracking is enabled for the [audioDeviceAttributes]. */ + suspend fun isHeadTrackingEnabled(audioDeviceAttributes: AudioDeviceAttributes): Boolean - /** Removes a [audioDeviceAttributes] to [getCompatibleDevices] list. */ - suspend fun removeCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) + /** Sets head tracking [isEnabled] for the [audioDeviceAttributes]. */ + suspend fun setHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes, + isEnabled: Boolean, + ) } class SpatializerRepositoryImpl( private val spatializer: Spatializer, + coroutineScope: CoroutineScope, private val backgroundContext: CoroutineContext, ) : SpatializerRepository { - override suspend fun isAvailableForDevice( + override val isHeadTrackingAvailable: StateFlow<Boolean> = + callbackFlow { + val listener = + Spatializer.OnHeadTrackerAvailableListener { _, available -> + launch { send(available) } + } + spatializer.addOnHeadTrackerAvailableListener(DirectExecutor.INSTANCE, listener) + awaitClose { spatializer.removeOnHeadTrackerAvailableListener(listener) } + } + .onStart { emit(spatializer.isHeadTrackerAvailable) } + .flowOn(backgroundContext) + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), false) + + override suspend fun isSpatialAudioAvailableForDevice( audioDeviceAttributes: AudioDeviceAttributes ): Boolean { return withContext(backgroundContext) { @@ -52,18 +90,36 @@ class SpatializerRepositoryImpl( } } - override suspend fun getCompatibleDevices(): Collection<AudioDeviceAttributes> = + override suspend fun getSpatialAudioCompatibleDevices(): Collection<AudioDeviceAttributes> = withContext(backgroundContext) { spatializer.compatibleAudioDevices } - override suspend fun addCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) { + override suspend fun addSpatialAudioCompatibleDevice( + audioDeviceAttributes: AudioDeviceAttributes + ) { withContext(backgroundContext) { spatializer.addCompatibleAudioDevice(audioDeviceAttributes) } } - override suspend fun removeCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) { + override suspend fun removeSpatialAudioCompatibleDevice( + audioDeviceAttributes: AudioDeviceAttributes + ) { withContext(backgroundContext) { spatializer.removeCompatibleAudioDevice(audioDeviceAttributes) } } + + override suspend fun isHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes + ): Boolean = + withContext(backgroundContext) { spatializer.isHeadTrackerEnabled(audioDeviceAttributes) } + + override suspend fun setHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes, + isEnabled: Boolean, + ) { + withContext(backgroundContext) { + spatializer.setHeadTrackerEnabled(isEnabled, audioDeviceAttributes) + } + } } diff --git a/packages/SettingsLib/src/com/android/settingslib/media/domain/interactor/SpatializerInteractor.kt b/packages/SettingsLib/src/com/android/settingslib/media/domain/interactor/SpatializerInteractor.kt index c3cc340d9cd8..0347403cb385 100644 --- a/packages/SettingsLib/src/com/android/settingslib/media/domain/interactor/SpatializerInteractor.kt +++ b/packages/SettingsLib/src/com/android/settingslib/media/domain/interactor/SpatializerInteractor.kt @@ -18,22 +18,40 @@ package com.android.settingslib.media.domain.interactor import android.media.AudioDeviceAttributes import com.android.settingslib.media.data.repository.SpatializerRepository +import kotlinx.coroutines.flow.StateFlow class SpatializerInteractor(private val repository: SpatializerRepository) { - suspend fun isAvailable(audioDeviceAttributes: AudioDeviceAttributes): Boolean = - repository.isAvailableForDevice(audioDeviceAttributes) + /** Checks if head tracking is available. */ + val isHeadTrackingAvailable: StateFlow<Boolean> + get() = repository.isHeadTrackingAvailable + + suspend fun isSpatialAudioAvailable(audioDeviceAttributes: AudioDeviceAttributes): Boolean = + repository.isSpatialAudioAvailableForDevice(audioDeviceAttributes) /** Checks if spatial audio is enabled for the [audioDeviceAttributes]. */ - suspend fun isEnabled(audioDeviceAttributes: AudioDeviceAttributes): Boolean = - repository.getCompatibleDevices().contains(audioDeviceAttributes) + suspend fun isSpatialAudioEnabled(audioDeviceAttributes: AudioDeviceAttributes): Boolean = + repository.getSpatialAudioCompatibleDevices().contains(audioDeviceAttributes) - /** Enblaes or disables spatial audio for [audioDeviceAttributes]. */ - suspend fun setEnabled(audioDeviceAttributes: AudioDeviceAttributes, isEnabled: Boolean) { + /** Enables or disables spatial audio for [audioDeviceAttributes]. */ + suspend fun setSpatialAudioEnabled( + audioDeviceAttributes: AudioDeviceAttributes, + isEnabled: Boolean + ) { if (isEnabled) { - repository.addCompatibleDevice(audioDeviceAttributes) + repository.addSpatialAudioCompatibleDevice(audioDeviceAttributes) } else { - repository.removeCompatibleDevice(audioDeviceAttributes) + repository.removeSpatialAudioCompatibleDevice(audioDeviceAttributes) } } + + /** Checks if head tracking is enabled for the [audioDeviceAttributes]. */ + suspend fun isHeadTrackingEnabled(audioDeviceAttributes: AudioDeviceAttributes): Boolean = + repository.isHeadTrackingEnabled(audioDeviceAttributes) + + /** Enables or disables head tracking for the [audioDeviceAttributes]. */ + suspend fun setHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes, + isEnabled: Boolean, + ) = repository.setHeadTrackingEnabled(audioDeviceAttributes, isEnabled) } diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/FakeSpatializerRepository.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/FakeSpatializerRepository.kt deleted file mode 100644 index 3f52f2494dfc..000000000000 --- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/FakeSpatializerRepository.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.media.domain.interactor - -import android.media.AudioDeviceAttributes -import com.android.settingslib.media.data.repository.SpatializerRepository - -class FakeSpatializerRepository : SpatializerRepository { - - private val availabilityByDevice: MutableMap<AudioDeviceAttributes, Boolean> = mutableMapOf() - private val compatibleDevices: MutableList<AudioDeviceAttributes> = mutableListOf() - - override suspend fun isAvailableForDevice( - audioDeviceAttributes: AudioDeviceAttributes - ): Boolean = availabilityByDevice.getOrDefault(audioDeviceAttributes, false) - - override suspend fun getCompatibleDevices(): Collection<AudioDeviceAttributes> = - compatibleDevices - - override suspend fun addCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) { - compatibleDevices.add(audioDeviceAttributes) - } - - override suspend fun removeCompatibleDevice(audioDeviceAttributes: AudioDeviceAttributes) { - compatibleDevices.remove(audioDeviceAttributes) - } - - fun setIsAvailable(audioDeviceAttributes: AudioDeviceAttributes, isAvailable: Boolean) { - availabilityByDevice[audioDeviceAttributes] = isAvailable - } -} diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/SpatializerInteractorTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/SpatializerInteractorTest.kt deleted file mode 100644 index a44baeb174bf..000000000000 --- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/media/domain/interactor/SpatializerInteractorTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.media.domain.interactor - -import android.media.AudioDeviceAttributes -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith - -@SmallTest -@RunWith(AndroidJUnit4::class) -class SpatializerInteractorTest { - - private val testScope = TestScope() - private val underTest = SpatializerInteractor(FakeSpatializerRepository()) - - @Test - fun setEnabledFalse_isEnabled_false() { - testScope.runTest { - underTest.setEnabled(deviceAttributes, false) - - assertThat(underTest.isEnabled(deviceAttributes)).isFalse() - } - } - - @Test - fun setEnabledTrue_isEnabled_true() { - testScope.runTest { - underTest.setEnabled(deviceAttributes, true) - - assertThat(underTest.isEnabled(deviceAttributes)).isTrue() - } - } - - private companion object { - val deviceAttributes = AudioDeviceAttributes(0, 0, "test_device") - } -} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/domain/interactor/SpatializerInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/domain/interactor/SpatializerInteractorTest.kt new file mode 100644 index 000000000000..a932dd6d106d --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/domain/interactor/SpatializerInteractorTest.kt @@ -0,0 +1,92 @@ +/* + * 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.media.domain.interactor + +import android.media.AudioDeviceAttributes +import android.media.AudioDeviceInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.testScope +import com.android.systemui.media.spatializerRepository +import com.android.systemui.testKosmos +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SpatializerInteractorTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private val underTest = SpatializerInteractor(kosmos.spatializerRepository) + + @Test + fun setSpatialAudioEnabledFalse_isEnabled_false() { + with(kosmos) { + testScope.runTest { + underTest.setSpatialAudioEnabled(deviceAttributes, false) + + assertThat(underTest.isSpatialAudioEnabled(deviceAttributes)).isFalse() + } + } + } + + @Test + fun setSpatialAudioEnabledTrue_isEnabled_true() { + with(kosmos) { + testScope.runTest { + underTest.setSpatialAudioEnabled(deviceAttributes, true) + + assertThat(underTest.isSpatialAudioEnabled(deviceAttributes)).isTrue() + } + } + } + + @Test + fun setHeadTrackingEnabledFalse_isEnabled_false() { + with(kosmos) { + testScope.runTest { + underTest.setHeadTrackingEnabled(deviceAttributes, false) + + assertThat(underTest.isHeadTrackingEnabled(deviceAttributes)).isFalse() + } + } + } + + @Test + fun setHeadTrackingEnabledTrue_isEnabled_true() { + with(kosmos) { + testScope.runTest { + underTest.setHeadTrackingEnabled(deviceAttributes, true) + + assertThat(underTest.isHeadTrackingEnabled(deviceAttributes)).isTrue() + } + } + } + + private companion object { + val deviceAttributes = + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_HEADSET, + "test_address", + ) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/SpatialAudioComponentKosmos.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/SpatialAudioComponentKosmos.kt new file mode 100644 index 000000000000..737b7f3e0af0 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/SpatialAudioComponentKosmos.kt @@ -0,0 +1,32 @@ +/* + * 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.spatial + +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.android.systemui.media.spatializerInteractor +import com.android.systemui.volume.mediaOutputInteractor +import com.android.systemui.volume.panel.component.spatial.domain.interactor.SpatialAudioComponentInteractor + +val Kosmos.spatialAudioComponentInteractor by + Kosmos.Fixture { + SpatialAudioComponentInteractor( + mediaOutputInteractor, + spatializerInteractor, + testScope.backgroundScope + ) + } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt new file mode 100644 index 000000000000..36be90ecbf7e --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteriaTest.kt @@ -0,0 +1,138 @@ +/* + * 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.spatial.domain + +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.testing.TestableLooper.RunWithLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.media.BluetoothMediaDevice +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.kosmos.testScope +import com.android.systemui.media.spatializerRepository +import com.android.systemui.testKosmos +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.volume.localMediaRepository +import com.android.systemui.volume.mediaController +import com.android.systemui.volume.mediaControllerRepository +import com.android.systemui.volume.panel.component.spatial.spatialAudioComponentInteractor +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +@RunWithLooper(setAsMainLooper = true) +class SpatialAudioAvailabilityCriteriaTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private val cachedBluetoothDevice: CachedBluetoothDevice = mock { + whenever(address).thenReturn("test_address") + } + private val bluetoothMediaDevice: BluetoothMediaDevice = mock { + whenever(cachedDevice).thenReturn(cachedBluetoothDevice) + } + + private lateinit var underTest: SpatialAudioAvailabilityCriteria + + @Before + fun setup() { + with(kosmos) { + mediaControllerRepository.setActiveLocalMediaController( + mediaController.apply { + whenever(packageName).thenReturn("test.pkg") + whenever(sessionToken).thenReturn(MediaSession.Token(0, mock {})) + whenever(playbackState).thenReturn(PlaybackState.Builder().build()) + } + ) + + underTest = SpatialAudioAvailabilityCriteria(spatialAudioComponentInteractor) + } + } + + @Test + fun noSpatialAudio_noHeadTracking_unavailable() { + with(kosmos) { + testScope.runTest { + localMediaRepository.updateCurrentConnectedDevice(bluetoothMediaDevice) + spatializerRepository.setIsHeadTrackingAvailable(false) + spatializerRepository.defaultSpatialAudioAvailable = false + + val isAvailable by collectLastValue(underTest.isAvailable()) + runCurrent() + + assertThat(isAvailable).isFalse() + } + } + } + + @Test + fun spatialAudio_noHeadTracking_available() { + with(kosmos) { + testScope.runTest { + localMediaRepository.updateCurrentConnectedDevice(bluetoothMediaDevice) + spatializerRepository.setIsHeadTrackingAvailable(false) + spatializerRepository.defaultSpatialAudioAvailable = true + + val isAvailable by collectLastValue(underTest.isAvailable()) + runCurrent() + + assertThat(isAvailable).isTrue() + } + } + } + + @Test + fun spatialAudio_headTracking_available() { + with(kosmos) { + testScope.runTest { + localMediaRepository.updateCurrentConnectedDevice(bluetoothMediaDevice) + spatializerRepository.setIsHeadTrackingAvailable(true) + spatializerRepository.defaultSpatialAudioAvailable = true + + val isAvailable by collectLastValue(underTest.isAvailable()) + runCurrent() + + assertThat(isAvailable).isTrue() + } + } + } + + @Test + fun spatialAudio_headTracking_noDevice_unavailable() { + with(kosmos) { + testScope.runTest { + spatializerRepository.setIsHeadTrackingAvailable(true) + spatializerRepository.defaultSpatialAudioAvailable = true + + val isAvailable by collectLastValue(underTest.isAvailable()) + runCurrent() + + assertThat(isAvailable).isFalse() + } + } + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt new file mode 100644 index 000000000000..eb6f0b2e32b3 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractorTest.kt @@ -0,0 +1,119 @@ +/* + * 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.spatial.domain.interactor + +import android.media.AudioDeviceAttributes +import android.media.AudioDeviceInfo +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.testing.TestableLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.media.BluetoothMediaDevice +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectValues +import com.android.systemui.kosmos.testScope +import com.android.systemui.media.spatializerInteractor +import com.android.systemui.media.spatializerRepository +import com.android.systemui.testKosmos +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.volume.localMediaRepository +import com.android.systemui.volume.mediaController +import com.android.systemui.volume.mediaControllerRepository +import com.android.systemui.volume.mediaOutputInteractor +import com.android.systemui.volume.panel.component.spatial.domain.model.SpatialAudioEnabledModel +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +class SpatialAudioComponentInteractorTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private lateinit var underTest: SpatialAudioComponentInteractor + + @Before + fun setup() { + with(kosmos) { + val cachedBluetoothDevice: CachedBluetoothDevice = mock { + whenever(address).thenReturn("test_address") + } + localMediaRepository.updateCurrentConnectedDevice( + mock<BluetoothMediaDevice> { + whenever(name).thenReturn("test_device") + whenever(cachedDevice).thenReturn(cachedBluetoothDevice) + } + ) + + whenever(mediaController.packageName).thenReturn("test.pkg") + whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {})) + whenever(mediaController.playbackState).thenReturn(PlaybackState.Builder().build()) + + mediaControllerRepository.setActiveLocalMediaController(mediaController) + + spatializerRepository.setIsSpatialAudioAvailable( + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_HEADSET, + "test_address" + ), + true + ) + spatializerRepository.setIsHeadTrackingAvailable(true) + + underTest = + SpatialAudioComponentInteractor( + mediaOutputInteractor, + spatializerInteractor, + testScope.backgroundScope, + ) + } + } + + @Test + fun setEnabled_changesIsEnabled() { + with(kosmos) { + testScope.runTest { + val values by collectValues(underTest.isEnabled) + + underTest.setEnabled(SpatialAudioEnabledModel.Disabled) + runCurrent() + underTest.setEnabled(SpatialAudioEnabledModel.HeadTrackingEnabled) + runCurrent() + underTest.setEnabled(SpatialAudioEnabledModel.SpatialAudioEnabled) + runCurrent() + + assertThat(values) + .containsExactly( + SpatialAudioEnabledModel.Disabled, + SpatialAudioEnabledModel.HeadTrackingEnabled, + SpatialAudioEnabledModel.SpatialAudioEnabled, + ) + .inOrder() + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/SpatializerModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/SpatializerModule.kt index 18a9161ac0e3..593b90aa3c68 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dagger/SpatializerModule.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/SpatializerModule.kt @@ -21,15 +21,19 @@ import android.media.Spatializer import com.android.settingslib.media.data.repository.SpatializerRepository import com.android.settingslib.media.data.repository.SpatializerRepositoryImpl import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import dagger.Module import dagger.Provides import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope /** Spatializer module. */ @Module interface SpatializerModule { + companion object { + @Provides fun provideSpatializer( audioManager: AudioManager, @@ -38,8 +42,9 @@ interface SpatializerModule { @Provides fun provdieSpatializerRepository( spatializer: Spatializer, + @Application scope: CoroutineScope, @Background backgroundContext: CoroutineContext, - ): SpatializerRepository = SpatializerRepositoryImpl(spatializer, backgroundContext) + ): SpatializerRepository = SpatializerRepositoryImpl(spatializer, scope, backgroundContext) @Provides fun provideSpatializerInetractor(repository: SpatializerRepository): SpatializerInteractor = diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteria.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteria.kt new file mode 100644 index 000000000000..71bce5e470f4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/SpatialAudioAvailabilityCriteria.kt @@ -0,0 +1,35 @@ +/* + * 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.spatial.domain + +import com.android.systemui.volume.panel.component.spatial.domain.interactor.SpatialAudioComponentInteractor +import com.android.systemui.volume.panel.component.spatial.domain.model.SpatialAudioAvailabilityModel +import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope +import com.android.systemui.volume.panel.domain.ComponentAvailabilityCriteria +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@VolumePanelScope +class SpatialAudioAvailabilityCriteria +@Inject +constructor(private val interactor: SpatialAudioComponentInteractor) : + ComponentAvailabilityCriteria { + + override fun isAvailable(): Flow<Boolean> = + interactor.isAvailable.map { it is SpatialAudioAvailabilityModel.SpatialAudio } +} diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractor.kt new file mode 100644 index 000000000000..4358611694b2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/interactor/SpatialAudioComponentInteractor.kt @@ -0,0 +1,170 @@ +/* + * 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.spatial.domain.interactor + +import android.media.AudioDeviceAttributes +import android.media.AudioDeviceInfo +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.media.BluetoothMediaDevice +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor +import com.android.systemui.volume.panel.component.spatial.domain.model.SpatialAudioAvailabilityModel +import com.android.systemui.volume.panel.component.spatial.domain.model.SpatialAudioEnabledModel +import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Provides an ability to access and update spatial audio and head tracking state. + * + * Head tracking is a sub-feature of spatial audio. This means that it requires spatial audio to be + * available for it to be available. And spatial audio to be enabled for it to be enabled. + */ +@VolumePanelScope +class SpatialAudioComponentInteractor +@Inject +constructor( + mediaOutputInteractor: MediaOutputInteractor, + private val spatializerInteractor: SpatializerInteractor, + @VolumePanelScope private val coroutineScope: CoroutineScope, +) { + + private val changes = MutableSharedFlow<Unit>() + private val currentAudioDeviceAttributes: StateFlow<AudioDeviceAttributes?> = + mediaOutputInteractor.currentConnectedDevice + .map { mediaDevice -> + mediaDevice ?: return@map null + val btDevice: CachedBluetoothDevice = + (mediaDevice as? BluetoothMediaDevice)?.cachedDevice ?: return@map null + btDevice.getAudioDeviceAttributes() + } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null) + + /** + * Returns spatial audio availability model. It can be: + * - unavailable + * - only spatial audio is available + * - spatial audio and head tracking are available + */ + val isAvailable: StateFlow<SpatialAudioAvailabilityModel> = + combine( + currentAudioDeviceAttributes, + changes.onStart { emit(Unit) }, + spatializerInteractor.isHeadTrackingAvailable, + ) { attributes, _, isHeadTrackingAvailable -> + attributes ?: return@combine SpatialAudioAvailabilityModel.Unavailable + if (isHeadTrackingAvailable) { + return@combine SpatialAudioAvailabilityModel.HeadTracking + } + if (spatializerInteractor.isSpatialAudioAvailable(attributes)) { + return@combine SpatialAudioAvailabilityModel.SpatialAudio + } + SpatialAudioAvailabilityModel.Unavailable + } + .stateIn( + coroutineScope, + SharingStarted.Eagerly, + SpatialAudioAvailabilityModel.Unavailable, + ) + + /** + * Returns spatial audio enabled/disabled model. It can be + * - disabled + * - only spatial audio is enabled + * - spatial audio and head tracking are enabled + */ + val isEnabled: StateFlow<SpatialAudioEnabledModel> = + combine( + changes.onStart { emit(Unit) }, + currentAudioDeviceAttributes, + isAvailable, + ) { _, attributes, isAvailable -> + if (isAvailable is SpatialAudioAvailabilityModel.Unavailable) { + return@combine SpatialAudioEnabledModel.Disabled + } + attributes ?: return@combine SpatialAudioEnabledModel.Disabled + if (spatializerInteractor.isHeadTrackingEnabled(attributes)) { + return@combine SpatialAudioEnabledModel.HeadTrackingEnabled + } + if (spatializerInteractor.isSpatialAudioEnabled(attributes)) { + return@combine SpatialAudioEnabledModel.SpatialAudioEnabled + } + SpatialAudioEnabledModel.Disabled + } + .stateIn( + coroutineScope, + SharingStarted.Eagerly, + SpatialAudioEnabledModel.Disabled, + ) + + /** + * Sets current [isEnabled] to a specific [SpatialAudioEnabledModel]. It + * - disables both spatial audio and head tracking + * - enables only spatial audio + * - enables both spatial audio and head tracking + */ + suspend fun setEnabled(model: SpatialAudioEnabledModel) { + val attributes = currentAudioDeviceAttributes.value ?: return + spatializerInteractor.setSpatialAudioEnabled( + attributes, + model is SpatialAudioEnabledModel.SpatialAudioEnabled, + ) + spatializerInteractor.setHeadTrackingEnabled( + attributes, + model is SpatialAudioEnabledModel.HeadTrackingEnabled, + ) + changes.emit(Unit) + } + + private suspend fun CachedBluetoothDevice.getAudioDeviceAttributes(): AudioDeviceAttributes? { + return listOf( + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_HEADSET, + address + ), + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + address + ), + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLE_BROADCAST, + address + ), + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + address + ), + AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_HEARING_AID, + address + ) + ) + .firstOrNull { spatializerInteractor.isSpatialAudioAvailable(it) } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioAvailabilityModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioAvailabilityModel.kt new file mode 100644 index 000000000000..cf1454618367 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioAvailabilityModel.kt @@ -0,0 +1,32 @@ +/* + * 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.spatial.domain.model + +/** Models spatial audio and head tracking availability. */ +interface SpatialAudioAvailabilityModel { + + /** Spatial audio is unavailable. */ + data object Unavailable : SpatialAudioAvailabilityModel + + /** Spatial audio is available. */ + interface SpatialAudio : SpatialAudioAvailabilityModel { + companion object : SpatialAudio + } + + /** Head tracking is available. This also means that [SpatialAudio] is available. */ + data object HeadTracking : SpatialAudio +} diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioEnabledModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioEnabledModel.kt new file mode 100644 index 000000000000..4e65f60aa0e1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/spatial/domain/model/SpatialAudioEnabledModel.kt @@ -0,0 +1,32 @@ +/* + * 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.spatial.domain.model + +/** Models spatial audio and head tracking enabled/disabled state. */ +interface SpatialAudioEnabledModel { + + /** Spatial audio is disabled. */ + data object Disabled : SpatialAudioEnabledModel + + /** Spatial audio is enabled. */ + interface SpatialAudioEnabled : SpatialAudioEnabledModel { + companion object : SpatialAudioEnabled + } + + /** Head tracking is enabled. This also means that [SpatialAudioEnabled]. */ + data object HeadTrackingEnabled : SpatialAudioEnabled +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/SpatializerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/SpatializerKosmos.kt new file mode 100644 index 000000000000..7001ea8b6427 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/SpatializerKosmos.kt @@ -0,0 +1,24 @@ +/* + * 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.media + +import com.android.settingslib.media.domain.interactor.SpatializerInteractor +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.media.data.repository.FakeSpatializerRepository + +val Kosmos.spatializerRepository by Kosmos.Fixture { FakeSpatializerRepository() } +val Kosmos.spatializerInteractor by Kosmos.Fixture { SpatializerInteractor(spatializerRepository) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/data/repository/FakeSpatializerRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/data/repository/FakeSpatializerRepository.kt new file mode 100644 index 000000000000..0183b97090dc --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/data/repository/FakeSpatializerRepository.kt @@ -0,0 +1,83 @@ +/* + * 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.media.data.repository + +import android.media.AudioDeviceAttributes +import com.android.settingslib.media.data.repository.SpatializerRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeSpatializerRepository : SpatializerRepository { + + var defaultSpatialAudioAvailable: Boolean = false + + private val spatialAudioAvailabilityByDevice: MutableMap<AudioDeviceAttributes, Boolean> = + mutableMapOf() + private val spatialAudioCompatibleDevices: MutableList<AudioDeviceAttributes> = mutableListOf() + + private val mutableHeadTrackingAvailable = MutableStateFlow(false) + private val headTrackingEnabledByDevice = mutableMapOf<AudioDeviceAttributes, Boolean>() + + override val isHeadTrackingAvailable: StateFlow<Boolean> = + mutableHeadTrackingAvailable.asStateFlow() + + override suspend fun isSpatialAudioAvailableForDevice( + audioDeviceAttributes: AudioDeviceAttributes + ): Boolean = + spatialAudioAvailabilityByDevice.getOrDefault( + audioDeviceAttributes, + defaultSpatialAudioAvailable + ) + + override suspend fun getSpatialAudioCompatibleDevices(): Collection<AudioDeviceAttributes> = + spatialAudioCompatibleDevices + + override suspend fun addSpatialAudioCompatibleDevice( + audioDeviceAttributes: AudioDeviceAttributes + ) { + spatialAudioCompatibleDevices.add(audioDeviceAttributes) + } + + override suspend fun removeSpatialAudioCompatibleDevice( + audioDeviceAttributes: AudioDeviceAttributes + ) { + spatialAudioCompatibleDevices.remove(audioDeviceAttributes) + } + + override suspend fun isHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes + ): Boolean = headTrackingEnabledByDevice.getOrDefault(audioDeviceAttributes, false) + + override suspend fun setHeadTrackingEnabled( + audioDeviceAttributes: AudioDeviceAttributes, + isEnabled: Boolean + ) { + headTrackingEnabledByDevice[audioDeviceAttributes] = isEnabled + } + + fun setIsSpatialAudioAvailable( + audioDeviceAttributes: AudioDeviceAttributes, + isAvailable: Boolean, + ) { + spatialAudioAvailabilityByDevice[audioDeviceAttributes] = isAvailable + } + + fun setIsHeadTrackingAvailable(isAvailable: Boolean) { + mutableHeadTrackingAvailable.value = isAvailable + } +} |