diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt index 9132b3508b..993b0aea64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt @@ -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 = 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 = 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt index 78fb8dd049..4b8d93f317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt index af0e58e51b..c7b2ccae16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index 094c9c3190..2fb237e8fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -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) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt new file mode 100644 index 0000000000..118ed5e73d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt @@ -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 { + 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) + } +}