mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-20 17:57:29 +00:00
Fix issue with initial audio output setting.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user