Skip to content

Commit

Permalink
Microphone manager will no longer crash when accessing audio handler …
Browse files Browse the repository at this point in the history
…prior to setup (#867)

Make calls to audioHandler wrapped into a defensive check to ensure the property was initialized.
Enforce call of setup, prior to any use of the Microphone manager API.
  • Loading branch information
aleksandar-apostolov authored Oct 17, 2023
1 parent 0245ded commit d4919a4
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -314,77 +314,95 @@ 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>(DeviceStatus.NotSelected)

/** The status of the audio */
private val _status = MutableStateFlow<DeviceStatus>(DeviceStatus.NotSelected)
val status: StateFlow<DeviceStatus> = _status

/** Represents whether the audio is enabled */
public val isEnabled: StateFlow<Boolean> = _status.mapState { it is DeviceStatus.Enabled }

private val _selectedDevice = MutableStateFlow<StreamAudioDevice?>(null)

/** Currently selected device */
val selectedDevice: StateFlow<StreamAudioDevice?> = _selectedDevice

private val _devices = MutableStateFlow<List<StreamAudioDevice>>(emptyList())
val devices: StateFlow<List<StreamAudioDevice>> = _devices

internal var priorStatus: DeviceStatus? = null
/** List of available devices. */
val devices: StateFlow<List<StreamAudioDevice>> = _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)
}
}
}
}

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

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

/**
Expand All @@ -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
}
Expand All @@ -412,16 +437,21 @@ class MicrophoneManager(
}

audioHandler.start()

setupCompleted = true
}

fun cleanup() {
audioHandler.stop()
setupCompleted = false
private inline fun <T> 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MediaManagerImpl>(relaxed = true)
val actual = MicrophoneManager(mediaManager, false)
val context = mockk<Context>(relaxed = true)
val microphoneManager = spyk(actual)
every { mediaManager.context } returns context
every { context.getSystemService(any()) } returns mockk<AudioManager>(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<MediaManagerImpl>(relaxed = true)
val actual = MicrophoneManager(mediaManager, false)
val context = mockk<Context>(relaxed = true)
val microphoneManager = spyk(actual)
every { mediaManager.context } returns context
every { context.getSystemService(any()) } returns mockk<AudioManager>(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<MediaManagerImpl>(relaxed = true)
val actual = MicrophoneManager(mediaManager, false)
val context = mockk<Context>(relaxed = true)
val microphoneManager = spyk(actual)
every { mediaManager.context } returns context
every { context.getSystemService(any()) } returns mockk<AudioManager>(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<MediaManagerImpl>(relaxed = true)
val actual = MicrophoneManager(mediaManager, false)
val context = mockk<Context>(relaxed = true)
val microphoneManager = spyk(actual)
every { mediaManager.context } returns context
every { context.getSystemService(any()) } returns mockk<AudioManager>(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()
}
}
}

0 comments on commit d4919a4

Please sign in to comment.