diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index ba06543635..4886f06512 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -314,45 +314,58 @@ class MicrophoneManager( val mediaManager: MediaManagerImpl, val preferSpeakerphone: Boolean, ) { + // Internal data + private val logger by taggedLogger("Media:MicrophoneManager") + private lateinit var audioHandler: AudioSwitchHandler + private var setupCompleted: Boolean = false internal var audioManager: AudioManager? = null + internal var priorStatus: DeviceStatus? = null - private val logger by taggedLogger("Media:MicrophoneManager") + // Exposed state + private val _status = MutableStateFlow(DeviceStatus.NotSelected) /** The status of the audio */ - private val _status = MutableStateFlow(DeviceStatus.NotSelected) val status: StateFlow = _status /** Represents whether the audio is enabled */ public val isEnabled: StateFlow = _status.mapState { it is DeviceStatus.Enabled } private val _selectedDevice = MutableStateFlow(null) + + /** Currently selected device */ val selectedDevice: StateFlow = _selectedDevice private val _devices = MutableStateFlow>(emptyList()) - val devices: StateFlow> = _devices - internal var priorStatus: DeviceStatus? = null + /** List of available devices. */ + val devices: StateFlow> = _devices + // API /** Enable the audio, the rtc engine will automatically inform the SFU */ fun enable(fromUser: Boolean = true) { - setup() - if (fromUser) { - _status.value = DeviceStatus.Enabled + enforceSetup { + if (fromUser) { + _status.value = DeviceStatus.Enabled + } + mediaManager.audioTrack.setEnabled(true) } - mediaManager.audioTrack.setEnabled(true) } fun pause(fromUser: Boolean = true) { - // pause the microphone, and when resuming switched back to the previous state - priorStatus = _status.value - disable(fromUser = fromUser) + enforceSetup { + // pause the microphone, and when resuming switched back to the previous state + priorStatus = _status.value + disable(fromUser = fromUser) + } } fun resume(fromUser: Boolean = true) { - priorStatus?.let { - if (it == DeviceStatus.Enabled) { - enable(fromUser = fromUser) + enforceSetup { + priorStatus?.let { + if (it == DeviceStatus.Enabled) { + enable(fromUser = fromUser) + } } } } @@ -360,20 +373,24 @@ class MicrophoneManager( /** Disable the audio track. Audio is still captured, but not send. * This allows for the "you are muted" toast to indicate you are talking while muted */ fun disable(fromUser: Boolean = true) { - if (fromUser) { - _status.value = DeviceStatus.Disabled + enforceSetup { + if (fromUser) { + _status.value = DeviceStatus.Disabled + } + mediaManager.audioTrack.setEnabled(false) } - mediaManager.audioTrack.setEnabled(false) } /** * Enable or disable the microphone */ fun setEnabled(enabled: Boolean, fromUser: Boolean = true) { - if (enabled) { - enable(fromUser = fromUser) - } else { - disable(fromUser = fromUser) + enforceSetup { + if (enabled) { + enable(fromUser = fromUser) + } else { + disable(fromUser = fromUser) + } } } @@ -381,10 +398,11 @@ class MicrophoneManager( * Select a specific device */ fun select(device: StreamAudioDevice?) { - logger.i { "selecting device $device" } - audioHandler.selectDevice(device?.toAudioDevice()) - - _selectedDevice.value = device + enforceSetup { + logger.i { "selecting device $device" } + ifAudioHandlerInitialized { it.selectDevice(device?.toAudioDevice()) } + _selectedDevice.value = device + } } /** @@ -395,11 +413,18 @@ class MicrophoneManager( return devices } - internal fun setup() { - if (setupCompleted) return + fun cleanup() { + ifAudioHandlerInitialized { it.stop() } + setupCompleted = false + } + // Internal logic + internal fun setup() { + if (setupCompleted) { + // Already setup, return + return + } audioManager = mediaManager.context.getSystemService() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { audioManager?.allowedCapturePolicy = AudioAttributes.ALLOW_CAPTURE_BY_ALL } @@ -412,16 +437,21 @@ class MicrophoneManager( } audioHandler.start() - setupCompleted = true } - fun cleanup() { - audioHandler.stop() - setupCompleted = false + private inline fun enforceSetup(actual: () -> T): T { + setup() + return actual.invoke() } - private var setupCompleted: Boolean = false + private fun ifAudioHandlerInitialized(then: (audioHandler: AudioSwitchHandler) -> Unit) { + if (this::audioHandler.isInitialized) { + then(this.audioHandler) + } else { + logger.e { "Audio handler not initialized. Ensure calling setup(), before using the handler." } + } + } } public sealed class CameraDirection { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt new file mode 100644 index 0000000000..5a9b4841d9 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.core + +import android.content.Context +import android.media.AudioManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MicrophoneManagerTest { + + @Test + fun `Ensure setup is called prior to any action onto the microphone manager`() = runTest { + // Given + val mediaManager = mockk(relaxed = true) + val actual = MicrophoneManager(mediaManager, false) + val context = mockk(relaxed = true) + val microphoneManager = spyk(actual) + every { mediaManager.context } returns context + every { context.getSystemService(any()) } returns mockk(relaxed = true) + + // When + microphoneManager.enable() // 1 + microphoneManager.select(null) // 2 + microphoneManager.resume() // 3, 4, Resume calls enable internally, thus two invocations + microphoneManager.disable() // 5 + microphoneManager.pause() // 6 + microphoneManager.setEnabled(true) // 7, 8, calls enable internally + microphoneManager.setEnabled(false) // 9, 10, calls disable internally + + // Then + verify(exactly = 10) { + // Setup will be called exactly 10 times + microphoneManager.setup() + } + verify(exactly = 1) { + // Even thou setup was invoked 10 times, actual initialization happened once + // because context.getSystemService was called once only. + context.getSystemService(any()) + } + } + + @Test + fun `Don't crash when accessing audioHandler prior to setup`() { + // Given + val mediaManager = mockk(relaxed = true) + val actual = MicrophoneManager(mediaManager, false) + val context = mockk(relaxed = true) + val microphoneManager = spyk(actual) + every { mediaManager.context } returns context + every { context.getSystemService(any()) } returns mockk(relaxed = true) + + // When + microphoneManager.cleanup() + + // Then + verify(exactly = 0) { + // Setup was not called, but still there is no exception + microphoneManager.setup() + } + } + + @Test + fun `Ensure setup if ever the manager was cleaned`() { + // Given + val mediaManager = mockk(relaxed = true) + val actual = MicrophoneManager(mediaManager, false) + val context = mockk(relaxed = true) + val microphoneManager = spyk(actual) + every { mediaManager.context } returns context + every { context.getSystemService(any()) } returns mockk(relaxed = true) + + // When + microphoneManager.setup() + microphoneManager.cleanup() // Clean and then invoke again + microphoneManager.resume() // Should call setup again + + // Then + verify(exactly = 2) { + // Setup was called twice + microphoneManager.setup() + } + verifyOrder { + microphoneManager.setup() // Manual call + microphoneManager.cleanup() // Manual call + microphoneManager.resume() // Manual call + microphoneManager.setup() // Automatic as part of enforce setup strategy of resume() + } + } + + @Test + fun `Resume will call enable only if prior status was DeviceStatus#enabled`() { + // Given + val mediaManager = mockk(relaxed = true) + val actual = MicrophoneManager(mediaManager, false) + val context = mockk(relaxed = true) + val microphoneManager = spyk(actual) + every { mediaManager.context } returns context + every { context.getSystemService(any()) } returns mockk(relaxed = true) + + // When + microphoneManager.setup() + microphoneManager.priorStatus = DeviceStatus.Enabled + microphoneManager.resume() // Should call setup again + + // Then + verify(exactly = 1) { + // Setup was called twice + microphoneManager.enable() + } + } +}