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

@@ -7,6 +7,7 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
@@ -28,6 +29,15 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
companion object {
const val TAG = "WebRtcAudioPicker31"
private fun WebRtcAudioOutput.toSignalAudioDevice(): SignalAudioManager.AudioDevice {
return when (this) {
WebRtcAudioOutput.HANDSET -> SignalAudioManager.AudioDevice.EARPIECE
WebRtcAudioOutput.SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
WebRtcAudioOutput.BLUETOOTH_HEADSET -> SignalAudioManager.AudioDevice.BLUETOOTH
WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
}
}
}
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
@@ -60,20 +70,20 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
val am = AppDependencies.androidCallAudioManager
if (am.availableCommunicationDevices.isEmpty()) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
LaunchedEffect(Unit) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
stateUpdater.hidePicker()
}
return
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
val currentDeviceId = am.communicationDevice?.id ?: -1
if (devices.size < threshold) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
if (devices.isEmpty()) return
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index == -1) return
onAudioDeviceSelected(devices[(index + 1) % devices.size])
LaunchedEffect(Unit) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
cycleToNextDevice()
}
return
} else {
Log.d(TAG, "Found $devices devices, showing picker.")
@@ -124,6 +134,37 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
}
}
/**
* Cycles to the next audio device without showing a picker.
* Uses the system device list to resolve the actual device ID, falling back to
* type-based lookup from app-tracked state when the current communication device is unknown.
*/
fun cycleToNextDevice() {
val am = AppDependencies.androidCallAudioManager
val devices: List<AudioOutputOption> = am.availableCommunicationDevices
.map { AudioOutputOption("", AudioDeviceMapping.fromPlatformType(it.type), it.id) }
.distinctBy { it.deviceType.name }
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
if (devices.isEmpty()) {
Log.w(TAG, "cycleToNextDevice: no available communication devices")
return
}
val currentDeviceId = am.communicationDevice?.id ?: -1
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index != -1) {
onAudioDeviceSelected(devices[(index + 1) % devices.size])
} else {
val nextOutput = outputState.peekNext()
val targetDeviceType = nextOutput.toSignalAudioDevice()
val targetDevice = devices.firstOrNull { it.deviceType == targetDeviceType } ?: devices.first()
Log.d(TAG, "cycleToNextDevice: communicationDevice unknown, selecting ${targetDevice.deviceType} by type")
onAudioDeviceSelected(targetDevice)
}
}
private fun AudioOutputOption.toWebRtcAudioOutput(): WebRtcAudioOutput {
return when (this.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> WebRtcAudioOutput.WIRED_HEADSET

View File

@@ -185,11 +185,13 @@ class AudioOutputPickerController(
val isLegacy = Build.VERSION.SDK_INT < 31
if (!willDisplayPicker) {
if (isLegacy) {
LaunchedEffect(Unit) {
displaySheet = false
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
} else {
newApiController!!.Picker(threshold = SHOW_PICKER_THRESHOLD)
if (isLegacy) {
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
} else {
newApiController!!.cycleToNextDevice()
}
}
return
}

View File

@@ -205,7 +205,6 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener
Log.i(TAG, "startIncomingRinger: uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate currentMode: ${getModeName(androidAudioManager.mode)}")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
setDefaultAudioDevice(recipientId = null, newDefaultDevice = AudioDevice.SPEAKER_PHONE, clearUserEarpieceSelection = false)
incomingRinger.start(ringtoneUri, vibrate)
}

View File

@@ -429,7 +429,6 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
setDefaultAudioDevice(recipientId = null, newDefaultDevice = AudioDevice.SPEAKER_PHONE, clearUserEarpieceSelection = false)
incomingRinger.start(ringtoneUri, vibrate)
}

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