From 96273bb7244259ea0df0bcdfd8574da7c38c0395 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 22 Jan 2026 16:11:59 -0400 Subject: [PATCH] Disable call audio toggle while the switch is processing. --- .../components/webrtc/v2/CallAudioToggleButton.kt | 8 ++++++-- .../securesms/components/webrtc/v2/CallControls.kt | 8 ++++++-- .../securesms/components/webrtc/v2/WebRtcCallViewModel.kt | 8 ++++++-- .../org/thoughtcrime/securesms/events/WebRtcViewModel.kt | 1 + .../securesms/service/webrtc/ActiveCallManager.kt | 4 ++++ .../service/webrtc/DeviceAwareActionProcessor.java | 6 +++++- .../securesms/service/webrtc/SignalCallManager.java | 4 ++++ .../securesms/service/webrtc/WebRtcActionProcessor.java | 8 ++++++++ .../securesms/service/webrtc/state/LocalDeviceState.kt | 1 + .../service/webrtc/state/WebRtcServiceStateBuilder.java | 5 +++++ .../securesms/webrtc/audio/FullSignalAudioManagerApi31.kt | 2 ++ .../securesms/webrtc/audio/SignalAudioManager.kt | 6 +++++- 12 files changed, 53 insertions(+), 8 deletions(-) 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 0b92d02d6c..d2ffb460ee 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 @@ -55,7 +55,8 @@ fun CallAudioToggleButton( contentDescription: String, onSheetDisplayChanged: (Boolean) -> Unit, pickerController: AudioOutputPickerController, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { val buttonSize = dimensionResource(id = R.dimen.webrtc_button_size) @@ -79,9 +80,12 @@ fun CallAudioToggleButton( onClick = { pickerController.show() }, + enabled = enabled, colors = IconButtons.iconButtonColors( containerColor = containerColor, - contentColor = contentColor + contentColor = contentColor, + disabledContainerColor = containerColor.copy(alpha = 0.38f), + disabledContentColor = contentColor.copy(alpha = 0.38f) ), modifier = modifier.size(buttonSize) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index 77b6dbaa06..15e3354746 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -76,7 +76,8 @@ fun CallControls( CallAudioToggleButton( contentDescription = stringResource(id = R.string.WebRtcAudioOutputToggle__audio_output), onSheetDisplayChanged = callScreenSheetDisplayListener::onAudioDeviceSheetDisplayChanged, - pickerController = audioOutputPickerController + pickerController = audioOutputPickerController, + enabled = !callControlsState.isAudioOutputChangePending ) } @@ -202,6 +203,7 @@ data class CallControlsState( val skipHiddenState: Boolean = true, val displayAudioOutputToggle: Boolean = false, val audioOutput: WebRtcAudioOutput = WebRtcAudioOutput.HANDSET, + val isAudioOutputChangePending: Boolean = false, val displayVideoToggle: Boolean = false, val isVideoEnabled: Boolean = false, val displayMicToggle: Boolean = false, @@ -222,7 +224,8 @@ data class CallControlsState( fun fromViewModelData( callParticipantsState: CallParticipantsState, webRtcControls: WebRtcControls, - groupMemberCount: Int + groupMemberCount: Int, + isAudioDeviceChangePending: Boolean = false ): CallControlsState { return CallControlsState( isEarpieceAvailable = webRtcControls.isEarpieceAvailableForAudioToggle, @@ -231,6 +234,7 @@ data class CallControlsState( skipHiddenState = !(webRtcControls.isFadeOutEnabled || webRtcControls == WebRtcControls.PIP || webRtcControls.displayErrorControls()), displayAudioOutputToggle = webRtcControls.displayAudioToggle(), audioOutput = webRtcControls.audioOutput, + isAudioOutputChangePending = isAudioDeviceChangePending, displayVideoToggle = webRtcControls.displayVideoToggle(), isVideoEnabled = callParticipantsState.localParticipant.isVideoEnabled, displayMicToggle = webRtcControls.displayMuteAudio(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt index 5250891235..93aa11b40c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt @@ -56,6 +56,7 @@ class WebRtcCallViewModel : ViewModel() { private val callPeerRepository = CallPeerRepository(viewModelScope) private val internalMicrophoneEnabled = MutableStateFlow(true) + private val isAudioDeviceChangePending = MutableStateFlow(false) private val remoteMutedBy = MutableStateFlow(null) private val isInPipMode = MutableStateFlow(false) private val _savedLocalParticipantLandscape = MutableStateFlow(false) @@ -177,8 +178,10 @@ class WebRtcCallViewModel : ViewModel() { callParticipantsState, getWebRtcControls(), groupSize, - CallControlsState::fromViewModelData - ) + isAudioDeviceChangePending + ) { participantsState, controls, groupMemberCount, audioChangePending -> + CallControlsState.fromViewModelData(participantsState, controls, groupMemberCount, audioChangePending) + } } val callParticipantsState: Flow get() = participantsState @@ -311,6 +314,7 @@ class WebRtcCallViewModel : ViewModel() { } internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled + isAudioDeviceChangePending.value = webRtcViewModel.isAudioDeviceChangePending if (internalMicrophoneEnabled.value) { remoteMutedBy.update { null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt index a5ed4485f1..1bd6e5b114 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt @@ -121,6 +121,7 @@ class WebRtcViewModel(state: WebRtcServiceState) { val activeDevice: SignalAudioManager.AudioDevice = state.localDeviceState.activeDevice val availableDevices: Set = state.localDeviceState.availableDevices val bluetoothPermissionDenied: Boolean = state.localDeviceState.bluetoothPermissionDenied + val isAudioDeviceChangePending: Boolean = state.localDeviceState.isAudioDeviceChangePending val localParticipant: CallParticipant = createLocal( state.localDeviceState.cameraState, diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt index 926e311863..296570e5c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt @@ -265,6 +265,10 @@ class ActiveCallManager( callManager.onAudioDeviceChanged(activeDevice, devices) } + override fun onAudioDeviceChangeFailed() { + callManager.onAudioDeviceChangeFailed() + } + override fun onBluetoothPermissionDenied() { callManager.onBluetoothPermissionDenied() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java index 0f2598eedf..1905da0ec0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/DeviceAwareActionProcessor.java @@ -40,6 +40,7 @@ public abstract class DeviceAwareActionProcessor extends WebRtcActionProcessor { .changeLocalDeviceState() .setActiveDevice(activeDevice) .setAvailableDevices(availableDevices) + .setAudioDeviceChangePending(false) .build(); } @@ -50,7 +51,10 @@ public abstract class DeviceAwareActionProcessor extends WebRtcActionProcessor { RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); webRtcInteractor.setUserAudioDevice(activePeer != null ? activePeer.getId() : null, userDevice); - return currentState; + return currentState.builder() + .changeLocalDeviceState() + .setAudioDeviceChangePending(true) + .build(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index eb61ec7847..acf55346f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -360,6 +360,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleAudioDeviceChanged(s, activeDevice, availableDevices)); } + public void onAudioDeviceChangeFailed() { + process((s, p) -> p.handleAudioDeviceChangeFailed(s)); + } + public void onBluetoothPermissionDenied() { process((s, p) -> p.handleBluetoothPermissionDenied(s)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index ce161151cf..35c64660ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -497,6 +497,14 @@ public abstract class WebRtcActionProcessor { return currentState; } + public @NonNull WebRtcServiceState handleAudioDeviceChangeFailed(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleAudioDeviceChangeFailed(): clearing pending state"); + return currentState.builder() + .changeLocalDeviceState() + .setAudioDeviceChangePending(false) + .build(); + } + public @NonNull WebRtcServiceState handleBluetoothPermissionDenied(@NonNull WebRtcServiceState currentState) { return currentState.builder() .changeLocalDeviceState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt index 6fabb266e6..0aa600d9af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/LocalDeviceState.kt @@ -18,6 +18,7 @@ data class LocalDeviceState( var activeDevice: SignalAudioManager.AudioDevice = SignalAudioManager.AudioDevice.NONE, var availableDevices: Set = emptySet(), var bluetoothPermissionDenied: Boolean = false, + var isAudioDeviceChangePending: Boolean = false, var networkConnectionType: PeerConnection.AdapterType = PeerConnection.AdapterType.UNKNOWN, var handRaisedTimestamp: Long = CallParticipant.HAND_LOWERED, var remoteMutedBy: CallParticipant? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java index 786ba480e1..58f78885ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java @@ -140,6 +140,11 @@ public class WebRtcServiceStateBuilder { return this; } + public @NonNull LocalDeviceStateBuilder setAudioDeviceChangePending(boolean isAudioDeviceChangePending) { + toBuild.setAudioDeviceChangePending(isAudioDeviceChangePending); + return this; + } + public @NonNull LocalDeviceStateBuilder setNetworkConnectionType(@NonNull PeerConnection.AdapterType type) { toBuild.setNetworkConnectionType(type); return this; 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 046ac64fe0..734771c5af 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 @@ -241,6 +241,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener eventListener?.onAudioDeviceChanged(AudioDeviceMapping.fromPlatformType(candidate.type), availableCommunicationDevices.map { AudioDeviceMapping.fromPlatformType(it.type) }.toSet()) } else { Log.w(TAG, "Failed to set ${candidate.id} of type ${getDeviceTypeName(candidate.type)} as communication device.") + eventListener?.onAudioDeviceChangeFailed() } } else { val searchOrder: List = listOf(AudioDevice.BLUETOOTH, AudioDevice.WIRED_HEADSET, defaultAudioDevice, AudioDevice.EARPIECE, AudioDevice.SPEAKER_PHONE, AudioDevice.NONE).distinct() @@ -264,6 +265,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener eventListener?.onAudioDeviceChanged(AudioDeviceMapping.fromPlatformType(candidate.type), availableCommunicationDevices.map { AudioDeviceMapping.fromPlatformType(it.type) }.toSet()) } else { Log.w(TAG, "Failed to set ${candidate.id} as communication device.") + eventListener?.onAudioDeviceChangeFailed() } } } 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 74e98c84dd..094c9c3190 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 @@ -141,6 +141,7 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev interface EventListener { @JvmSuppressWildcards fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set) + fun onAudioDeviceChangeFailed() fun onBluetoothPermissionDenied() } } @@ -354,8 +355,11 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) : if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) { setAudioDevice(newAudioDevice) Log.i(TAG, "New device status: available: $audioDevices, selected: $newAudioDevice") - eventListener?.onAudioDeviceChanged(selectedAudioDevice, audioDevices) } + + // Always notify listener to clear any pending audio device change state, + // even if the device didn't actually change + eventListener?.onAudioDeviceChanged(selectedAudioDevice, audioDevices) } override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) {