Start callee audio in ringrtc after communications mode change for incoming calls.

Co-authored-by: Jim Gustafson <jim@signal.org>
This commit is contained in:
Cody Henthorne
2026-08-10 16:32:57 -04:00
committed by Greyson Parrelli
co-authored by Jim Gustafson
parent ee59bb4080
commit 017f5d62e2
17 changed files with 449 additions and 104 deletions
@@ -276,6 +276,10 @@ class ActiveCallManager(
callManager.onAudioDeviceChangeFailed()
}
override fun onAudioReadyForAccept() {
callManager.onAudioReadyForAccept()
}
override fun onBluetoothPermissionDenied() {
callManager.onBluetoothPermissionDenied()
}
@@ -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();
@@ -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();
@@ -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(),
@@ -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();
@@ -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));
}
@@ -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;
@@ -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());
}
@@ -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<PeerConnection.IceServer> = mutableListOf(),
@get:JvmName("isAlwaysTurnServers") var alwaysTurnServers: Boolean = false
) {
@@ -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;
@@ -62,6 +62,13 @@ sealed class AudioManagerCommand : Parcelable {
}
}
class PrepareForAccept : AudioManagerCommand() {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<PrepareForAccept> = ParcelCheat { PrepareForAccept() }
}
}
class Start : AudioManagerCommand() {
companion object {
@JvmField
@@ -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<AudioRecordingConfiguration> 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 {
@@ -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<out AudioDeviceInfo>) {
@@ -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<AudioRecordingConfiguration>) {
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<AudioDeviceInfo> = 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<AudioDeviceInfo> = 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<AudioDeviceInfo>): 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<AudioDeviceInfo>): 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"
@@ -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<AudioDevice>)
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() {
@@ -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) {
@@ -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)
@@ -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