Fix issue with initial audio output setting.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Alex Hart
2026-02-12 09:18:22 -04:00
parent 2771b31aab
commit a4469a4285
5 changed files with 256 additions and 14 deletions

View File

@@ -0,0 +1,201 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.app.Application
import android.media.AudioDeviceInfo
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.every
import io.mockk.mockk
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class, sdk = [31])
class WebRtcAudioPicker31Test {
companion object {
@JvmStatic
@BeforeClass
fun setUpClass() {
Log.initialize(SystemOutLogger())
}
}
@get:Rule
val appDependencies = MockAppDependenciesRule()
private lateinit var audioManagerCompat: AudioManagerCompat
private lateinit var outputState: ToggleButtonOutputState
private var lastSelectedDevice: WebRtcAudioDevice? = null
private var lastUpdatedAudioOutput: WebRtcAudioOutput? = null
private var pickerHidden: Boolean = false
private val listener = OnAudioOutputChangedListener { device -> lastSelectedDevice = device }
private val stateUpdater = object : AudioStateUpdater {
override fun updateAudioOutputState(audioOutput: WebRtcAudioOutput) {
lastUpdatedAudioOutput = audioOutput
}
override fun hidePicker() {
pickerHidden = true
}
}
@Before
fun setUp() {
audioManagerCompat = AppDependencies.androidCallAudioManager
outputState = ToggleButtonOutputState()
lastSelectedDevice = null
lastUpdatedAudioOutput = null
pickerHidden = false
}
private fun createDevice(type: Int, id: Int): AudioDeviceInfo {
return mockk<AudioDeviceInfo> {
every { getType() } returns type
every { getId() } returns id
}
}
@Test
fun `cycleToNextDevice cycles from earpiece to speaker when communicationDevice is set`() {
val earpiece = createDevice(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, 1)
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(earpiece, speaker)
every { audioManagerCompat.communicationDevice } returns earpiece
outputState.isEarpieceAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.HANDSET)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
assertThat(lastSelectedDevice?.webRtcAudioOutput).isEqualTo(WebRtcAudioOutput.SPEAKER)
assertThat(lastSelectedDevice?.deviceId).isEqualTo(2)
}
@Test
fun `cycleToNextDevice cycles from speaker to earpiece when communicationDevice is set`() {
val earpiece = createDevice(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, 1)
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(earpiece, speaker)
every { audioManagerCompat.communicationDevice } returns speaker
outputState.isEarpieceAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.SPEAKER)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
assertThat(lastSelectedDevice?.webRtcAudioOutput).isEqualTo(WebRtcAudioOutput.HANDSET)
assertThat(lastSelectedDevice?.deviceId).isEqualTo(1)
}
@Test
fun `cycleToNextDevice falls back to type lookup when communicationDevice is null`() {
val earpiece = createDevice(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, 1)
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(earpiece, speaker)
every { audioManagerCompat.communicationDevice } returns null
outputState.isEarpieceAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.HANDSET)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
// peekNext() from HANDSET should be SPEAKER
assertThat(lastSelectedDevice?.webRtcAudioOutput).isEqualTo(WebRtcAudioOutput.SPEAKER)
assertThat(lastSelectedDevice?.deviceId).isEqualTo(2)
}
@Test
fun `cycleToNextDevice falls back to first device when target type not found`() {
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(speaker)
every { audioManagerCompat.communicationDevice } returns null
// outputState only has SPEAKER, peekNext() cycles to SPEAKER
// but simulate a mismatch: current is SPEAKER, next is SPEAKER
// Let's set up a case where the target type doesn't match any device
outputState.isEarpieceAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.SPEAKER)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
// peekNext() from SPEAKER → HANDSET, but only speaker device exists
// falls back to first device (speaker)
assertThat(lastSelectedDevice?.webRtcAudioOutput).isEqualTo(WebRtcAudioOutput.SPEAKER)
assertThat(lastSelectedDevice?.deviceId).isEqualTo(2)
}
@Test
fun `cycleToNextDevice does nothing when no devices available`() {
every { audioManagerCompat.availableCommunicationDevices } returns emptyList()
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
assertThat(lastSelectedDevice).isNull()
assertThat(lastUpdatedAudioOutput).isNull()
}
@Test
fun `cycleToNextDevice updates state via onAudioDeviceSelected`() {
val earpiece = createDevice(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, 1)
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(earpiece, speaker)
every { audioManagerCompat.communicationDevice } returns earpiece
outputState.isEarpieceAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.HANDSET)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
assertThat(lastUpdatedAudioOutput).isEqualTo(WebRtcAudioOutput.SPEAKER)
}
@Test
fun `cycleToNextDevice wraps around with three devices`() {
val earpiece = createDevice(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, 1)
val speaker = createDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 2)
val bluetooth = createDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, 3)
every { audioManagerCompat.availableCommunicationDevices } returns listOf(earpiece, speaker, bluetooth)
every { audioManagerCompat.communicationDevice } returns bluetooth
outputState.isEarpieceAvailable = true
outputState.isBluetoothHeadsetAvailable = true
outputState.setCurrentOutput(WebRtcAudioOutput.BLUETOOTH_HEADSET)
val picker = WebRtcAudioPicker31(listener, outputState, stateUpdater)
picker.cycleToNextDevice()
// bluetooth is last, wraps around to earpiece
assertThat(lastSelectedDevice?.webRtcAudioOutput).isEqualTo(WebRtcAudioOutput.HANDSET)
assertThat(lastSelectedDevice?.deviceId).isEqualTo(1)
}
}