From 017f5d62e26109693a929ba1fa0dac0811f7161e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Mon, 10 Aug 2026 11:33:05 -0400 Subject: [PATCH] Start callee audio in ringrtc after communications mode change for incoming calls. Co-authored-by: Jim Gustafson --- .../service/webrtc/ActiveCallManager.kt | 4 + .../BeginCallActionProcessorDelegate.java | 3 +- .../CallSetupActionProcessorDelegate.java | 3 +- .../webrtc/IncomingCallActionProcessor.java | 31 ++- .../webrtc/OutgoingCallActionProcessor.java | 2 +- .../service/webrtc/SignalCallManager.java | 4 + .../service/webrtc/WebRtcActionProcessor.java | 5 + .../service/webrtc/WebRtcInteractor.java | 4 + .../service/webrtc/state/CallSetupState.kt | 1 + .../state/WebRtcServiceStateBuilder.java | 5 + .../webrtc/audio/AudioManagerCommand.kt | 7 + .../webrtc/audio/AudioManagerCompat.java | 8 +- .../audio/FullSignalAudioManagerApi31.kt | 187 +++++++++++------- .../webrtc/audio/SignalAudioManager.kt | 153 +++++++++++--- .../webrtc/audio/TelecomAudioManager.kt | 12 ++ .../webrtc/IncomingCallActionProcessorTest.kt | 35 +++- .../audio/FullSignalAudioManagerApi31Test.kt | 89 +++++++++ 17 files changed, 449 insertions(+), 104 deletions(-) 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 79edfc5567..debd331061 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 @@ -276,6 +276,10 @@ class ActiveCallManager( callManager.onAudioDeviceChangeFailed() } + override fun onAudioReadyForAccept() { + callManager.onAudioReadyForAccept() + } + override fun onBluetoothPermissionDenied() { callManager.onBluetoothPermissionDenied() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java index 99f86edf39..965446678d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -115,8 +115,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { protected @NonNull WebRtcServiceState handleStartIncomingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer, @NonNull OfferMessage.Type offerType) { remotePeer.answering(); - Log.i(tag, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode()); - + Log.i(tag, "assign activePeer callId: " + remotePeer.getCallId() + " key: " + remotePeer.hashCode() + " offerType: " + offerType + " telecomSupported: " + AndroidTelecomUtil.getTelecomSupported()); boolean isRemoteVideoOffer = currentState.getCallSetupState(remotePeer).isRemoteVideoOffer(); Recipient recipient = remotePeer.getRecipient(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java index f8ae68685b..db670bd9f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java @@ -5,7 +5,6 @@ import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallManager; -import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter; import org.thoughtcrime.securesms.ringrtc.RemotePeer; @@ -34,7 +33,7 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { return currentState; } - Log.i(tag, "handleCallConnected(): call_id: " + remotePeer.getCallId()); + Log.i(tag, "handleCallConnected(): call_id: " + remotePeer.getCallId() + " callState: " + currentState.getCallInfoState().getCallState()); RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java index 5bd6d11f24..0fda5b5cc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java @@ -136,7 +136,7 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { protected @NonNull WebRtcServiceState handleAcceptCall(@NonNull WebRtcServiceState currentState, boolean answerWithVideo) { RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); - Log.i(TAG, "handleAcceptCall(): call_id: " + activePeer.getCallId()); + Log.i(TAG, "handleAcceptCall(): call_id: " + activePeer.getCallId() + " answerWithVideo: " + answerWithVideo); OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter(); router.setVanitySink(null); @@ -153,6 +153,28 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { .changeCallSetupState(activePeer.getCallId()) .acceptWithVideo(answerWithVideo) .accepted(true) + .waitForAudio(true) + .build(); + + webRtcInteractor.prepareAudioForAccept(); + + return currentState; + } + + @Override + protected @NonNull WebRtcServiceState handleAudioReadyForAccept(@NonNull WebRtcServiceState currentState) { + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + + if (activePeer == null || !currentState.getCallSetupState(activePeer.getCallId()).shouldWaitForAudio()) { + Log.w(TAG, "handleAudioReadyForAccept(): call is no longer waiting to be accepted, ignoring"); + return currentState; + } + + Log.i(TAG, "handleAudioReadyForAccept(): call_id: " + activePeer.getCallId()); + + currentState = currentState.builder() + .changeCallSetupState(activePeer.getCallId()) + .waitForAudio(false) .build(); try { @@ -172,7 +194,8 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { return currentState; } - if (currentState.getCallSetupState(activePeer).isAccepted()) { + // Reject deny if the call has already been fully accepted by RingRTC + if (currentState.getCallSetupState(activePeer).isAccepted() && !currentState.getCallSetupState(activePeer).shouldWaitForAudio()) { Log.w(TAG, "Cannot deny after call has been accepted!"); return currentState; } @@ -240,12 +263,12 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { } protected @NonNull WebRtcServiceState handleLocalRinging(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { - Log.i(TAG, "handleLocalRinging(): call_id: " + remotePeer.getCallId()); - RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); Recipient recipient = remotePeer.getRecipient(); boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient); + Log.i(TAG, "handleLocalRinging(): call_id: " + remotePeer.getCallId() + " shouldDisturbUser: " + shouldDisturbUserWithCall); + activePeer.localRinging(); SignalDatabase.calls().insertOneToOneCall(remotePeer.getCallId().longValue(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index 96abb7aeb2..8ef6d5129d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -62,7 +62,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { @Override protected @NonNull WebRtcServiceState handleStartOutgoingCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer, @NonNull OfferMessage.Type offerType) { - Log.i(TAG, "handleStartOutgoingCall():"); + Log.i(TAG, "handleStartOutgoingCall(): offerType: " + offerType); WebRtcServiceStateBuilder builder = currentState.builder(); remotePeer.dialing(); 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 5d9473d38e..fdf88412a3 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 @@ -381,6 +381,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleAudioDeviceChangeFailed(s)); } + public void onAudioReadyForAccept() { + process((s, p) -> p.handleAudioReadyForAccept(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 4c73954820..b33a2b169e 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 @@ -365,6 +365,11 @@ public abstract class WebRtcActionProcessor { return currentState; } + protected @NonNull WebRtcServiceState handleAudioReadyForAccept(@NonNull WebRtcServiceState currentState) { + Log.i(tag, "handleAudioReadyForAccept not processed"); + return currentState; + } + protected @NonNull WebRtcServiceState handleReceivedOfferWhileActive(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { Log.i(tag, "handleReceivedOfferWhileActive not processed"); return currentState; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java index 8e15a0d54a..5e27b648ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java @@ -154,6 +154,10 @@ public class WebRtcInteractor { ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Stop(playDisconnect)); } + void prepareAudioForAccept() { + ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.PrepareForAccept()); + } + void startAudioCommunication() { ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Start()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt index 647d3023f9..7ba0f6e067 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallSetupState.kt @@ -17,6 +17,7 @@ data class CallSetupState( var ringerRecipient: Recipient = Recipient.UNKNOWN, @get:JvmName("shouldWaitForTelecomApproval") var waitForTelecom: Boolean = false, @get:JvmName("isTelecomApproved") var telecomApproved: Boolean = false, + @get:JvmName("shouldWaitForAudio") var waitForAudio: Boolean = false, var iceServers: MutableList = mutableListOf(), @get:JvmName("isAlwaysTurnServers") var alwaysTurnServers: Boolean = false ) { 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 a6ae751c18..ff9374beea 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 @@ -211,6 +211,11 @@ public class WebRtcServiceStateBuilder { return this; } + public @NonNull CallSetupStateBuilder waitForAudio(boolean waitForAudio) { + toBuild.setWaitForAudio(waitForAudio); + return this; + } + public @NonNull CallSetupStateBuilder sentJoinedMessage(boolean sentJoinedMessage) { toBuild.setSentJoinedMessage(sentJoinedMessage); return this; diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt index d05ef88d29..743710abc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt @@ -62,6 +62,13 @@ sealed class AudioManagerCommand : Parcelable { } } + class PrepareForAccept : AudioManagerCommand() { + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ParcelCheat { PrepareForAccept() } + } + } + class Start : AudioManagerCommand() { companion object { @JvmField diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java index da4ad86315..2480d3c59f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCompat.java @@ -10,6 +10,7 @@ import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; +import android.media.AudioRecordingConfiguration; import android.media.SoundPool; import android.os.Build; import android.os.Handler; @@ -198,6 +199,11 @@ public abstract class AudioManagerCompat { audioManager.unregisterAudioRecordingCallback(callback); } + @RequiresApi(24) + public @NonNull List getActiveRecordingConfigurations() { + return audioManager.getActiveRecordingConfigurations(); + } + @SuppressLint("WrongConstant") public boolean isWiredHeadsetOn() { AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); @@ -323,7 +329,7 @@ public abstract class AudioManagerCompat { .setOnAudioFocusChangeListener(onAudioFocusChangeListener) .build(); } else { - Log.w(TAG, "Trying again to request audio focus"); + Log.w(TAG, "Trying to request audio focus"); } try { 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 2213525b3a..9d162ca8fa 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 @@ -24,6 +24,8 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener private var savedIsSpeakerPhoneOn = false private var hasWiredHeadset = false + private var appliedCommunicationDeviceId: Int? = null + private val deviceCallback = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array) { @@ -39,7 +41,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener private val communicationDeviceChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device -> if (device != null) { - Log.i(TAG, "OnCommunicationDeviceChangedListener: id: ${device.id} type: ${getDeviceTypeName(device.type)}") + Log.i(TAG, "OnCommunicationDeviceChangedListener: id: ${device.id} type: ${getDeviceTypeName(device.type)} mode: ${getModeName(requestedMode)} state: $state") if (state == State.RUNNING && userSelectedAudioDevice != null && device.id != userSelectedAudioDevice?.id) { Log.w(TAG, "OnCommunicationDeviceChangedListener: Device changed to ${device.id} but user selected ${userSelectedAudioDevice?.id}. Re-asserting user selection.") logRoutingContext("OnCommunicationDeviceChangedListener", device) @@ -51,28 +53,41 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener } private val modeChangedListener = AudioManager.OnModeChangedListener { mode -> - Log.i(TAG, "OnModeChangedListener: ${getModeName(mode)}") - if (state == State.RUNNING && mode != AudioManager.MODE_IN_COMMUNICATION) { - Log.w(TAG, "OnModeChangedListener: Not MODE_IN_COMMUNICATION during a call. state: $state") - logRoutingContext("OnModeChangedListener") + Log.i(TAG, "OnModeChangedListener: applied ${getModeName(mode)} (requested ${getModeName(requestedMode)}) state: $state") + + appliedMode = mode + + if (mode == AudioManager.MODE_IN_COMMUNICATION) { + Log.i(TAG, "OnModeChangedListener: commDevice: ${describeDevice(androidAudioManager.communicationDevice)} micMute: ${androidAudioManager.isMicrophoneMute}") + notifyReadyForAccept("mode applied") + } else if (state != State.UNINITIALIZED) { + if (state == State.RUNNING) { + Log.w(TAG, "OnModeChangedListener: Not MODE_IN_COMMUNICATION during a call. state: $state") + } + logRoutingContext("OnModeChangedListener", mode = mode) } } private val audioRecordingCallback = object : AudioManager.AudioRecordingCallback() { override fun onRecordingConfigChanged(configs: List) { if (configs.isEmpty()) { - Log.i(TAG, "AudioRecordingCallback: no active recordings") + Log.i(TAG, "AudioRecordingCallback: no active recordings state: $state mode: ${getModeName(requestedMode)}") } else { for (config in configs) { val deviceName = config.audioDevice?.let { getDeviceTypeName(it.type) } ?: "null" - Log.i(TAG, "AudioRecordingCallback: silenced: ${config.isClientSilenced} source: ${getAudioSourceName(config.audioSource)} device: $deviceName") + val description = "AudioRecordingCallback: silenced: ${config.isClientSilenced} source: ${getAudioSourceName(config.audioSource)} device: $deviceName state: $state mode: ${getModeName(requestedMode)}" + if (config.isClientSilenced) { + Log.w(TAG, description) + } else { + Log.i(TAG, description) + } } } } } override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) { - Log.d(TAG, "setDefaultAudioDevice(): currentDefault: $defaultAudioDevice device: $newDefaultDevice clearUser: $clearUserEarpieceSelection") + Log.i(TAG, "setDefaultAudioDevice(): currentDefault: $defaultAudioDevice device: $newDefaultDevice clearUser: $clearUserEarpieceSelection state: $state mode: ${getModeName(requestedMode)}") defaultAudioDevice = when (newDefaultDevice) { AudioDevice.SPEAKER_PHONE -> newDefaultDevice AudioDevice.EARPIECE -> { @@ -87,31 +102,26 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener val userSelectedDeviceType: AudioDevice = userSelectedAudioDevice?.type?.let { AudioDeviceMapping.fromPlatformType(it) } ?: AudioDevice.NONE if (clearUserEarpieceSelection && userSelectedDeviceType == AudioDevice.EARPIECE) { - Log.d(TAG, "Clearing user setting of earpiece") + Log.i(TAG, "Clearing user setting of earpiece") userSelectedAudioDevice = null } - Log.d(TAG, "New default: $defaultAudioDevice userSelected: ${userSelectedAudioDevice?.id} of type ${userSelectedAudioDevice?.type}") + Log.i(TAG, "New default: $defaultAudioDevice userSelected: ${userSelectedAudioDevice?.id} of type ${userSelectedAudioDevice?.type}") updateAudioDeviceState() } override fun initialize() { if (state == State.UNINITIALIZED) { savedAudioMode = androidAudioManager.mode + requestedMode = savedAudioMode + appliedMode = savedAudioMode savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute hasWiredHeadset = androidAudioManager.isWiredHeadsetOn Log.i(TAG, "initialize: savedMode: ${getModeName(savedAudioMode)} savedSpeaker: $savedIsSpeakerPhoneOn savedMicMute: $savedIsMicrophoneMute wiredHeadset: $hasWiredHeadset") - val focusGained = androidAudioManager.requestCallAudioFocus() - if (!focusGained) { - Log.w(TAG, "initialize: audio focus request failed, scheduling retry") - handler.postDelayed({ - val retryGained = androidAudioManager.requestCallAudioFocus() - Log.i(TAG, "initialize: audio focus retry result: $retryGained") - }, 500) - } + requestCallAudioFocus("initialize") setMicrophoneMute(false) @@ -125,36 +135,50 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener state = State.PREINITIALIZED - Log.d(TAG, "Initialized") + Log.i(TAG, "initialize: complete. mode: ${getModeName(requestedMode)}") + } else { + Log.i(TAG, "initialize: skipping, state: $state") + } + } + + override fun onPrepareForAccept() { + Log.i(TAG, "onPrepareForAccept: state: $state previousMode: ${getModeName(requestedMode)} appliedMode: ${getModeName(appliedMode)}") + + incomingRinger.stop() + requestCallAudioFocus("onPrepareForAccept") + setMicrophoneMute(false) + + // The mode is global but owned per app, so always claim it even when another app holds it, + // but only expect a callback when the global mode actually moves. + val expectModeChange = requestedMode != AudioManager.MODE_IN_COMMUNICATION || appliedMode != AudioManager.MODE_IN_COMMUNICATION + setMode(AudioManager.MODE_IN_COMMUNICATION, "onPrepareForAccept") + + if (!expectModeChange) { + notifyReadyForAccept("no mode change expected") } } override fun start() { - Log.i(TAG, "start: currentState: $state currentMode: ${getModeName(androidAudioManager.mode)}") + Log.i(TAG, "start: currentState: $state previousMode: ${getModeName(requestedMode)} appliedMode: ${getModeName(appliedMode)} prepared: $preparedForAccept") incomingRinger.stop() outgoingRinger.stop() + requestCallAudioFocus("start") - val focusGained = androidAudioManager.requestCallAudioFocus() - if (!focusGained) { - Log.w(TAG, "start: audio focus request failed, scheduling retry") - handler.postDelayed({ - val retryGained = androidAudioManager.requestCallAudioFocus() - Log.i(TAG, "start: audio focus retry result: $retryGained") - }, 500) + // Only the accept path can skip this, and only because the listener proves the mode is in effect. + if (!preparedForAccept || appliedMode != AudioManager.MODE_IN_COMMUNICATION) { + setMode(AudioManager.MODE_IN_COMMUNICATION, "start") } state = State.RUNNING - Log.i(TAG, "start: setting mode to MODE_IN_COMMUNICATION") - androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + logActiveRecordingConfigurations("start") + val volume: Float = androidAudioManager.ringVolumeWithMinimum() soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f) - - Log.d(TAG, "Started") } override fun stop(playDisconnect: Boolean) { - Log.i(TAG, "stop: playDisconnect: $playDisconnect currentState: $state") + Log.i(TAG, "stop: playDisconnect: $playDisconnect currentState: $state currentMode: ${getModeName(requestedMode)}") incomingRinger.stop() outgoingRinger.stop() @@ -179,17 +203,19 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener "Therefore skipping audio device reset." ) } else { - Log.i(TAG, "stop: restoring mode to ${getModeName(savedAudioMode)}") + Log.i(TAG, "stop: restoring mode to ${getModeName(savedAudioMode)} speaker: $savedIsSpeakerPhoneOn micMute: $savedIsMicrophoneMute") androidAudioManager.clearCommunicationDevice() setSpeakerphoneOn(savedIsSpeakerPhoneOn) setMicrophoneMute(savedIsMicrophoneMute) - androidAudioManager.mode = savedAudioMode + setMode(savedAudioMode, "stop") } androidAudioManager.abandonCallAudioFocus() - Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams") state = State.UNINITIALIZED + resetAcceptState() + appliedMode = AudioManager.MODE_INVALID + appliedCommunicationDeviceId = null - Log.d(TAG, "Stopped") + Log.i(TAG, "stop: complete. mode: ${getModeName(requestedMode)}") } override fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean) { @@ -197,10 +223,10 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener throw IllegalArgumentException("Must supply a device address for API 31+.") } - Log.d(TAG, "Selecting $device") - userSelectedAudioDevice = androidAudioManager.availableCommunicationDevices.find { it.id == device } + Log.i(TAG, "selectAudioDevice(): requested: $device resolved: ${describeDevice(userSelectedAudioDevice)} state: $state mode: ${getModeName(requestedMode)}") + updateAudioDeviceState() } @@ -217,6 +243,14 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener val availableCommunicationDevices: List = androidAudioManager.availableCommunicationDevices + Log.i( + TAG, + "updateAudioDeviceState(): state: $state mode: ${getModeName(requestedMode)}\n" + + " default: $defaultAudioDevice userSelected: ${describeDevice(userSelectedAudioDevice)}\n" + + " current: ${describeDevice(currentAudioDevice)}\n" + + " available: ${describeDevices(availableCommunicationDevices)}" + ) + if (userSelectedAudioDevice != null && availableCommunicationDevices.none { it.id == userSelectedAudioDevice?.id }) { Log.w(TAG, "User selected device ${userSelectedAudioDevice?.id} of type ${userSelectedAudioDevice?.type?.let { getDeviceTypeName(it) }} is no longer available. Clearing user selection.") userSelectedAudioDevice = null @@ -224,7 +258,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener var candidate: AudioDeviceInfo? = userSelectedAudioDevice if (candidate != null && candidate.id != 0) { - val result = androidAudioManager.setCommunicationDevice(candidate) + val result = setCommunicationDeviceIfNeeded(candidate, currentAudioDevice) if (result) { eventListener?.onAudioDeviceChanged(AudioDeviceMapping.fromPlatformType(candidate.type), availableCommunicationDevices.map { AudioDeviceMapping.fromPlatformType(it.type) }.toSet()) } else { @@ -247,24 +281,50 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener null -> { Log.e(TAG, "Tried to switch audio devices but could not find suitable device in list of types: ${availableCommunicationDevices.map { getDeviceTypeName(it.type) }.joinToString()}") androidAudioManager.clearCommunicationDevice() + appliedCommunicationDeviceId = null eventListener?.onAudioDeviceChangeFailed() } else -> { - Log.d(TAG, "Switching to new device of type ${getDeviceTypeName(candidate.type)} from ${currentAudioDevice?.type?.let { getDeviceTypeName(it) }}") - val result = androidAudioManager.setCommunicationDevice(candidate) - if (result) { - Log.w(TAG, "Succeeded in setting ${candidate.id} (type: ${getDeviceTypeName(candidate.type)}) as communication device.") + if (setCommunicationDeviceIfNeeded(candidate, currentAudioDevice)) { 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() } } } } } - private fun logRoutingContext(event: String, callbackDevice: AudioDeviceInfo? = null) { - val mode = androidAudioManager.mode + + private fun setCommunicationDeviceIfNeeded(device: AudioDeviceInfo, currentDevice: AudioDeviceInfo?): Boolean { + if (device.id == appliedCommunicationDeviceId && device.id == currentDevice?.id) { + Log.i(TAG, "setCommunicationDevice(${describeDevice(device)}) skipped, already routed") + return true + } + + val result = androidAudioManager.setCommunicationDevice(device) + val description = "setCommunicationDevice(${describeDevice(device)}) returned $result in ${getModeName(requestedMode)}, previous: ${describeDevice(currentDevice)}" + if (result) { + appliedCommunicationDeviceId = device.id + Log.i(TAG, description) + } else { + Log.w(TAG, description) + } + return result + } + + private fun logActiveRecordingConfigurations(event: String) { + val configs = androidAudioManager.activeRecordingConfigurations + if (configs.isEmpty()) { + Log.w(TAG, "$event: activeRecordingConfigurations: none mode: ${getModeName(requestedMode)}") + return + } + for (config in configs) { + val deviceName = config.audioDevice?.let { getDeviceTypeName(it.type) } ?: "null" + Log.i(TAG, "$event: activeRecordingConfiguration: silenced: ${config.isClientSilenced} source: ${getAudioSourceName(config.audioSource)} device: $deviceName mode: ${getModeName(requestedMode)}") + } + } + + private fun logRoutingContext(event: String, callbackDevice: AudioDeviceInfo? = null, mode: Int = appliedMode) { val currentDevice: AudioDeviceInfo? = androidAudioManager.communicationDevice val availableDevices: List = androidAudioManager.availableCommunicationDevices val selectedStillAvailable = userSelectedAudioDevice?.let { selected -> @@ -273,19 +333,23 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener val probableCause = when { mode != AudioManager.MODE_IN_COMMUNICATION -> "mode_not_in_communication" userSelectedAudioDevice != null && !selectedStillAvailable -> "user_selected_device_disconnected" + state != State.RUNNING -> "expected_before_accept" else -> "platform_or_competing_app_reroute" } - Log.w( - TAG, - "$event: probableCause: $probableCause state: $state mode: ${getModeName(mode)} " + - "defaultDevice: $defaultAudioDevice callbackDevice: ${describeDevice(callbackDevice)} " + - "userSelected: ${describeDevice(userSelectedAudioDevice)} " + - "currentDevice: ${describeDevice(currentDevice)} availableDevices: ${describeDevices(availableDevices)}" - ) - } - private fun describeDevices(devices: List): String { - return devices.joinToString(prefix = "[", postfix = "]") { describeDevice(it) } + val description = "$event: probableCause: $probableCause state: $state mode: ${getModeName(mode)}\n" + + " defaultDevice: $defaultAudioDevice callbackDevice: ${describeDevice(callbackDevice)}\n" + + " userSelected: ${describeDevice(userSelectedAudioDevice)}\n" + + " currentDevice: ${describeDevice(currentDevice)}\n" + + " availableDevices: ${describeDevices(availableDevices)}" + if (state == State.RUNNING) { + Log.w(TAG, description) + } else { + Log.i(TAG, description) + } } + + private fun describeDevices(devices: List): String = devices.joinToString(prefix = "[", postfix = "]") { describeDevice(it) } + private fun describeDevice(device: AudioDeviceInfo?): String { if (device == null) { return "null" @@ -294,17 +358,6 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener return "${device.id}:${getDeviceTypeName(device.type)}:$productName" } - private fun getModeName(mode: Int): String { - return when (mode) { - AudioManager.MODE_NORMAL -> "MODE_NORMAL" - AudioManager.MODE_RINGTONE -> "MODE_RINGTONE" - AudioManager.MODE_IN_CALL -> "MODE_IN_CALL" - AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION" - AudioManager.MODE_CALL_SCREENING -> "MODE_CALL_SCREENING" - else -> "UNKNOWN($mode)" - } - } - private fun getDeviceTypeName(type: Int): String { return when (type) { AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "BUILTIN_EARPIECE" 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 ebd6d91331..ea80b3e6ac 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 @@ -14,6 +14,7 @@ import android.media.AudioManager import android.media.SoundPool import android.net.Uri import android.os.Build +import androidx.annotation.VisibleForTesting import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log @@ -51,7 +52,28 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev protected var savedAudioMode = AudioManager.MODE_INVALID protected var savedIsMicrophoneMute = false + /** What we last asked for, which may not be in effect yet. Logged in place of the blocking getMode(). */ + protected var requestedMode = AudioManager.MODE_INVALID + + /** What the platform last reported as in effect. Branch on this rather than [requestedMode]. */ + protected var appliedMode = AudioManager.MODE_INVALID + + protected var preparedForAccept = false + + private var awaitingAudioForAccept = false + private var prepareForAcceptStartedAt = 0L + + private val acceptGateTimeout = Runnable { + Log.w(TAG, "Audio not reported ready within ${ACCEPT_GATE_TIMEOUT_MS}ms, accepting anyway") + notifyReadyForAccept("timed out") + } + companion object { + private const val FOCUS_RETRY_DELAY_MS = 500L + + @VisibleForTesting + const val ACCEPT_GATE_TIMEOUT_MS = 1500L + @SuppressLint("NewApi") @JvmStatic fun create(context: Context, eventListener: EventListener?, canUseTelecom: Boolean): SignalAudioManager { @@ -66,9 +88,11 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev } fun handleCommand(command: AudioManagerCommand) { - handler.post { + val posted = handler.post { + Log.i(TAG, "handleCommand(): ${command.javaClass.simpleName} state: $state requestedMode: ${getModeName(requestedMode)}") when (command) { is AudioManagerCommand.Initialize -> initialize() + is AudioManagerCommand.PrepareForAccept -> prepareForAccept() is AudioManagerCommand.Start -> start() is AudioManagerCommand.Stop -> stop(command.playDisconnect) is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.recipientId, command.device, command.clearUserEarpieceSelection) @@ -79,6 +103,38 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev is AudioManagerCommand.PlayStateChangeUp -> playStateChangeUp() } } + + if (!posted) { + Log.w(TAG, "handleCommand(): could not enqueue ${command.javaClass.simpleName}, handler is shut down") + if (command is AudioManagerCommand.PrepareForAccept) { + eventListener?.onAudioReadyForAccept() + } + } + } + + /** Starts the backstop first, so an implementation that never reports back only delays the accept. */ + private fun prepareForAccept() { + preparedForAccept = true + awaitingAudioForAccept = true + prepareForAcceptStartedAt = System.currentTimeMillis() + handler.postDelayed(acceptGateTimeout, ACCEPT_GATE_TIMEOUT_MS) + onPrepareForAccept() + } + + protected fun notifyReadyForAccept(reason: String) { + if (!awaitingAudioForAccept) { + return + } + awaitingAudioForAccept = false + handler.removeCallbacks(acceptGateTimeout) + Log.i(TAG, "notifyReadyForAccept(): $reason after ${System.currentTimeMillis() - prepareForAcceptStartedAt}ms") + eventListener?.onAudioReadyForAccept() + } + + protected fun resetAcceptState() { + preparedForAccept = false + awaitingAudioForAccept = false + handler.removeCallbacks(acceptGateTimeout) } fun shutdown() { @@ -103,20 +159,69 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev protected abstract fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) protected abstract fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean) + /** Must end in [notifyReadyForAccept]; otherwise the accept waits for [ACCEPT_GATE_TIMEOUT_MS]. */ + protected open fun onPrepareForAccept() { + Log.i(TAG, "onPrepareForAccept(): state: $state previousMode: ${getModeName(requestedMode)}") + + incomingRinger.stop() + requestCallAudioFocus("onPrepareForAccept") + setMicrophoneMute(false) + setMode(AudioManager.MODE_IN_COMMUNICATION, "onPrepareForAccept") + + // Nothing reports mode changes before API 31, but the setter blocks there, so a read back + // confirms the mode is applied. + val readBackStart = System.currentTimeMillis() + appliedMode = androidAudioManager.mode + Log.i(TAG, "onPrepareForAccept(): mode read back as ${getModeName(appliedMode)} after ${System.currentTimeMillis() - readBackStart}ms") + + notifyReadyForAccept("mode applied per read back") + } + protected open fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) { - Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate") - androidAudioManager.mode = AudioManager.MODE_RINGTONE + Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate previousMode: ${getModeName(requestedMode)}") + setMode(AudioManager.MODE_RINGTONE, "startIncomingRinger") setMicrophoneMute(false) incomingRinger.start(ringtoneUri, vibrate) } protected open fun startOutgoingRinger() { - Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice") - androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice previousMode: ${getModeName(requestedMode)}") + setMode(AudioManager.MODE_IN_COMMUNICATION, "startOutgoingRinger") setMicrophoneMute(false) outgoingRinger.start(OutgoingRinger.Type.RINGING) } + protected fun requestCallAudioFocus(caller: String) { + val gained = androidAudioManager.requestCallAudioFocus() + Log.i(TAG, "$caller: audio focus gained: $gained") + + if (!gained) { + Log.w(TAG, "$caller: audio focus request failed, scheduling retry") + handler.postDelayed({ + Log.i(TAG, "$caller: audio focus retry result: ${androidAudioManager.requestCallAudioFocus()}") + }, FOCUS_RETRY_DELAY_MS) + } + } + + /** The setter returns long before the platform applies the change. */ + protected fun setMode(mode: Int, caller: String) { + val start = System.currentTimeMillis() + requestedMode = mode + androidAudioManager.mode = mode + Log.i(TAG, "$caller: requested ${getModeName(mode)}, setter returned in ${System.currentTimeMillis() - start}ms") + } + + protected fun getModeName(mode: Int): String { + return when (mode) { + AudioManager.MODE_NORMAL -> "MODE_NORMAL" + AudioManager.MODE_RINGTONE -> "MODE_RINGTONE" + AudioManager.MODE_IN_CALL -> "MODE_IN_CALL" + AudioManager.MODE_IN_COMMUNICATION -> "MODE_IN_COMMUNICATION" + AudioManager.MODE_CALL_SCREENING -> "MODE_CALL_SCREENING" + else -> "UNKNOWN($mode)" + } + } + protected open fun silenceIncomingRinger() { Log.i(TAG, "silenceIncomingRinger():") incomingRinger.stop() @@ -124,6 +229,7 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev protected fun setMicrophoneMute(on: Boolean) { if (androidAudioManager.isMicrophoneMute != on) { + Log.i(TAG, "setMicrophoneMute(): changing system microphone mute to $on") androidAudioManager.isMicrophoneMute = on } } @@ -169,6 +275,9 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev fun onAudioDeviceChanged(activeDevice: AudioDevice, devices: Set) fun onAudioDeviceChangeFailed() fun onBluetoothPermissionDenied() + + /** The device is in MODE_IN_COMMUNICATION and the incoming call may now be accepted. */ + fun onAudioReadyForAccept() } } @@ -202,18 +311,17 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) : private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null override fun initialize() { - Log.i(TAG, "Initializing audio manager state: $state") + Log.i(TAG, "initialize(): state: $state") if (state == State.UNINITIALIZED) { savedAudioMode = androidAudioManager.mode + requestedMode = savedAudioMode + appliedMode = savedAudioMode savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute hasWiredHeadset = androidAudioManager.isWiredHeadsetOn - val focusedGained = androidAudioManager.requestCallAudioFocus() - if (!focusedGained) { - handler.postDelayed({ androidAudioManager.requestCallAudioFocus() }, 500) - } + requestCallAudioFocus("initialize") setMicrophoneMute(false) @@ -227,38 +335,30 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) : context.registerReceiver(wiredHeadsetReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG)) state = State.PREINITIALIZED - - Log.d(TAG, "Initialized") } } override fun start() { - Log.d(TAG, "Starting. state: $state") + Log.i(TAG, "start(): state: $state previousMode: ${getModeName(requestedMode)} appliedMode: ${getModeName(appliedMode)} prepared: $preparedForAccept") if (state == State.RUNNING) { - Log.w(TAG, "Skipping, already active") + Log.w(TAG, "start(): skipping, already active") return } incomingRinger.stop() outgoingRinger.stop() + requestCallAudioFocus("start") - val focusedGained = androidAudioManager.requestCallAudioFocus() - if (!focusedGained) { - handler.postDelayed({ androidAudioManager.requestCallAudioFocus() }, 500) - } + setMode(AudioManager.MODE_IN_COMMUNICATION, "start") state = State.RUNNING - androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION - val volume: Float = androidAudioManager.ringVolumeWithMinimum() soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f) - - Log.d(TAG, "Started") } override fun stop(playDisconnect: Boolean) { - Log.d(TAG, "Stopping. state: $state") + Log.i(TAG, "stop(): playDisconnect: $playDisconnect state: $state currentMode: ${getModeName(requestedMode)}") incomingRinger.stop() outgoingRinger.stop() @@ -269,6 +369,8 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) : } state = State.UNINITIALIZED + resetAcceptState() + appliedMode = AudioManager.MODE_INVALID context.safeUnregisterReceiver(wiredHeadsetReceiver) wiredHeadsetReceiver = null @@ -277,12 +379,11 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) : setSpeakerphoneOn(savedIsSpeakerPhoneOn) setMicrophoneMute(savedIsMicrophoneMute) - androidAudioManager.mode = savedAudioMode + setMode(savedAudioMode, "stop") androidAudioManager.abandonCallAudioFocus() - Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams") - Log.d(TAG, "Stopped") + Log.i(TAG, "stop(): complete") } override fun onAudioDeviceUpdated() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt index fe8a92c617..d6e636617e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.webrtc.audio import android.content.Context +import android.media.AudioManager import androidx.annotation.RequiresApi import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.recipients.RecipientId @@ -35,12 +36,21 @@ class TelecomAudioManager(context: Context, eventListener: EventListener?) : Sig Log.i(TAG, "initialize(): state=$state") if (state == State.UNINITIALIZED) { savedAudioMode = androidAudioManager.mode + requestedMode = savedAudioMode + appliedMode = savedAudioMode savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute setMicrophoneMute(false) state = State.PREINITIALIZED } } + override fun onPrepareForAccept() { + Log.i(TAG, "onPrepareForAccept(): state=$state, let telecom framework manage the mode") + incomingRinger.stop() + setMicrophoneMute(false) + notifyReadyForAccept("telecom manages the mode") + } + override fun start() { Log.i(TAG, "start(): state=$state") if (state == State.RUNNING) { @@ -75,6 +85,8 @@ class TelecomAudioManager(context: Context, eventListener: EventListener?) : Sig } state = State.UNINITIALIZED + resetAcceptState() + appliedMode = AudioManager.MODE_INVALID } override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessorTest.kt index 1bd312d6ae..be1c523973 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessorTest.kt @@ -65,15 +65,48 @@ class IncomingCallActionProcessorTest { } @Test - fun `Given a ringing video call, when I handleAcceptCall, then I expect the call setup state to be accepted`() { + fun `Given a ringing video call, when I handleAcceptCall, then I expect the audio to be prepared before accepting`() { val state = incomingRingingCall() val result = processor.handleAcceptCall(state, true) assertThat(result.getCallSetupState(CALL_ID).isAccepted).isTrue() + verify { webRtcInteractor.prepareAudioForAccept() } + verify(exactly = 0) { callManager.acceptCall(any()) } + } + + @Test + fun `Given an accepted call, when the audio is ready, then I expect the call to be accepted`() { + val state = processor.handleAcceptCall(incomingRingingCall(), true) + + processor.handleAudioReadyForAccept(state) + verify { callManager.acceptCall(CALL_ID) } } + @Test + fun `Given a call denied while the audio was preparing, when the audio is ready, then I expect no accept`() { + val accepted = processor.handleAcceptCall(incomingRingingCall(), true) + val denied = processor.handleDenyCall(accepted) + + processor.handleAudioReadyForAccept(denied) + + assertThat(denied.callInfoState.activePeer).isNull() + verify(exactly = 0) { callManager.acceptCall(any()) } + } + + @Test + fun `Given an accepted call still waiting on audio, when I handleDenyCall, then I expect the call to be rejected`() { + val state = processor.handleAcceptCall(incomingRingingCall(), true) + val activePeer = state.callInfoState.requireActivePeer() + + val result = processor.handleDenyCall(state) + + verify { webRtcInteractor.rejectIncomingCall(activePeer.id) } + verify { callManager.hangup() } + assertThat(result.callInfoState.activePeer).isNull() + } + @Test fun `Given a ringing unaccepted video call, when I enable vanity, then I expect the vanity camera to start`() { val state = incomingRingingCall(accepted = false, cameraEnabled = false) diff --git a/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt b/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt index 7651fa8b81..51753f2955 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31Test.kt @@ -22,11 +22,13 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf 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 java.time.Duration import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -121,6 +123,81 @@ class FullSignalAudioManagerApi31Test { } } + @Test + fun `Given the mode was never applied for the accept, when I start, then I expect the mode to be asserted`() { + val manager = FullSignalAudioManagerApi31(AppDependencies.application, eventListener) + + try { + setState(manager, SignalAudioManager.State.PREINITIALIZED) + setPreparedForAccept(manager, true) + setAppliedMode(manager, AudioManager.MODE_RINGTONE) + + clearMocks(androidAudioManager, answers = false, recordedCalls = true) + + manager.handleCommand(AudioManagerCommand.Start()) + + verify(timeout = 2_000) { androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION } + } finally { + shutdownManager(manager) + } + } + + @Test + fun `Given the mode was applied for the accept, when I start, then I expect no redundant mode change`() { + val manager = FullSignalAudioManagerApi31(AppDependencies.application, eventListener) + + try { + setState(manager, SignalAudioManager.State.PREINITIALIZED) + setPreparedForAccept(manager, true) + setAppliedMode(manager, AudioManager.MODE_IN_COMMUNICATION) + + clearMocks(androidAudioManager, answers = false, recordedCalls = true) + + manager.handleCommand(AudioManagerCommand.Start()) + + // Waits for the last call start() makes, so the mode decision has already been taken. + verify(timeout = 2_000) { androidAudioManager.ringVolumeWithMinimum() } + verify(exactly = 0) { androidAudioManager.mode = any() } + } finally { + shutdownManager(manager) + } + } + + @Test + fun `Given the mode change is never reported, when the backstop expires, then I expect the accept to proceed`() { + val earpiece = createDevice(30, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, "Phone earpiece") + + every { androidAudioManager.communicationDevice } returns earpiece + every { androidAudioManager.availableCommunicationDevices } returns listOf(earpiece) + every { androidAudioManager.requestCallAudioFocus() } returns true + + val manager = FullSignalAudioManagerApi31(AppDependencies.application, eventListener) + + try { + setState(manager, SignalAudioManager.State.PREINITIALIZED) + setAppliedMode(manager, AudioManager.MODE_RINGTONE) + + manager.handleCommand(AudioManagerCommand.PrepareForAccept()) + + // The mode change is never reported, so only the backstop can release the accept. + idleAudioHandler(manager, SignalAudioManager.ACCEPT_GATE_TIMEOUT_MS / 2) + verify(exactly = 0) { eventListener.onAudioReadyForAccept() } + + idleAudioHandler(manager, SignalAudioManager.ACCEPT_GATE_TIMEOUT_MS) + verify { eventListener.onAudioReadyForAccept() } + } finally { + shutdownManager(manager) + } + } + + private fun idleAudioHandler(manager: FullSignalAudioManagerApi31, millis: Long) { + val handlerField = SignalAudioManager::class.java.getDeclaredField("handler") + handlerField.isAccessible = true + val handler = handlerField.get(manager) as SignalAudioHandler + + shadowOf(handler.looper).idleFor(Duration.ofMillis(millis)) + } + private fun createDevice(id: Int, type: Int, productName: String): AudioDeviceInfo { return mockk { every { this@mockk.id } returns id @@ -154,6 +231,18 @@ class FullSignalAudioManagerApi31Test { stateField.set(manager, state) } + private fun setPreparedForAccept(manager: FullSignalAudioManagerApi31, prepared: Boolean) { + val field = SignalAudioManager::class.java.getDeclaredField("preparedForAccept") + field.isAccessible = true + field.set(manager, prepared) + } + + private fun setAppliedMode(manager: FullSignalAudioManagerApi31, mode: Int) { + val field = SignalAudioManager::class.java.getDeclaredField("appliedMode") + field.isAccessible = true + field.set(manager, mode) + } + private fun setUserSelectedAudioDevice(manager: FullSignalAudioManagerApi31, device: AudioDeviceInfo?) { val userSelectedField = FullSignalAudioManagerApi31::class.java.getDeclaredField("userSelectedAudioDevice") userSelectedField.isAccessible = true