Reintroduce preliminary telecom support for 1:1 calling.

This commit is contained in:
Cody Henthorne
2026-03-13 13:19:59 -04:00
committed by Michelle Tang
parent 1b6cfe9fc6
commit a62f07db11
24 changed files with 677 additions and 489 deletions

View File

@@ -16,6 +16,7 @@ import org.signal.core.util.PendingIntentFlags;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
@@ -93,7 +94,7 @@ public class CallNotificationBuilder {
.setSmallIcon(R.drawable.ic_call_secure_white_24dp)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setContentTitle(recipient.getDisplayName(context));
.setContentTitle(SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact() ? recipient.getDisplayName(context) : context.getString(R.string.Recipient_signal_call));
if (type == TYPE_INCOMING_CONNECTING) {
builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting));
@@ -106,8 +107,15 @@ public class CallNotificationBuilder {
builder.setCategory(NotificationCompat.CATEGORY_CALL);
builder.setFullScreenIntent(pendingIntent, true);
Person person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
Person person;
if (SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
} else {
person = new Person.Builder().setName(context.getString(R.string.Recipient_signal_call))
.build();
}
builder.addPerson(person);
@@ -130,8 +138,15 @@ public class CallNotificationBuilder {
builder.setPriority(NotificationCompat.PRIORITY_DEFAULT);
builder.setCategory(NotificationCompat.CATEGORY_CALL);
Person person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
Person person;
if (SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
} else {
person = new Person.Builder().setName(context.getString(R.string.Recipient_signal_call))
.build();
}
builder.addPerson(person);

View File

@@ -20,10 +20,14 @@ sealed class AudioManagerCommand : Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) = Unit
override fun describeContents(): Int = 0
class Initialize : AudioManagerCommand() {
class Initialize(val isGroupCall: Boolean = false) : AudioManagerCommand() {
override fun writeToParcel(parcel: Parcel, flags: Int) {
ParcelUtil.writeBoolean(parcel, isGroupCall)
}
companion object {
@JvmField
val CREATOR: Parcelable.Creator<Initialize> = ParcelCheat { Initialize() }
val CREATOR: Parcelable.Creator<Initialize> = ParcelCheat { Initialize(ParcelUtil.readBoolean(it)) }
}
}

View File

@@ -6,7 +6,6 @@ import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.AudioRecordingConfiguration
import android.media.MediaRecorder
import android.net.Uri
import androidx.annotation.RequiresApi
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -22,9 +21,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener
private var defaultAudioDevice: AudioDevice = AudioDevice.EARPIECE
private var userSelectedAudioDevice: AudioDeviceInfo? = null
private var savedAudioMode = AudioManager.MODE_INVALID
private var savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false
private var hasWiredHeadset = false
private val deviceCallback = object : AudioDeviceCallback() {
@@ -207,32 +204,12 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener
updateAudioDeviceState()
}
override fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
Log.i(TAG, "startIncomingRinger: uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate currentMode: ${getModeName(androidAudioManager.mode)}")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
incomingRinger.start(ringtoneUri, vibrate)
}
override fun startOutgoingRinger() {
Log.i(TAG, "startOutgoingRinger: currentDevice: $selectedAudioDevice currentMode: ${getModeName(androidAudioManager.mode)}")
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setMicrophoneMute(false)
outgoingRinger.start(OutgoingRinger.Type.RINGING)
}
private fun setSpeakerphoneOn(on: Boolean) {
if (androidAudioManager.isSpeakerphoneOn != on) {
androidAudioManager.isSpeakerphoneOn = on
}
}
private fun setMicrophoneMute(on: Boolean) {
if (androidAudioManager.isMicrophoneMute != on) {
androidAudioManager.isMicrophoneMute = on
}
}
private fun updateAudioDeviceState() {
handler.assertHandlerThread()

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.webrtc.audio
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.audio.AudioDeviceUpdatedListener
import org.thoughtcrime.securesms.audio.SignalBluetoothManager
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.whispersystems.signalservice.api.util.Preconditions
@@ -46,10 +48,16 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev
private val stateChangeUpSoundId = soundPool.load(context, R.raw.notification_simple_01, 1)
protected var savedAudioMode = AudioManager.MODE_INVALID
protected var savedIsMicrophoneMute = false
companion object {
@SuppressLint("NewApi")
@JvmStatic
fun create(context: Context, eventListener: EventListener?): SignalAudioManager {
return if (Build.VERSION.SDK_INT >= 31) {
fun create(context: Context, eventListener: EventListener?, canUseTelecom: Boolean): SignalAudioManager {
return if (canUseTelecom && AndroidTelecomUtil.telecomSupported) {
TelecomAudioManager(context, eventListener)
} else if (Build.VERSION.SDK_INT >= 31) {
FullSignalAudioManagerApi31(context, eventListener)
} else {
FullSignalAudioManager(context, eventListener)
@@ -94,14 +102,32 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev
protected abstract fun stop(playDisconnect: Boolean)
protected abstract fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean)
protected abstract fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean)
protected abstract fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean)
protected abstract fun startOutgoingRinger()
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
setMicrophoneMute(false)
incomingRinger.start(ringtoneUri, vibrate)
}
protected open fun startOutgoingRinger() {
Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice")
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setMicrophoneMute(false)
outgoingRinger.start(OutgoingRinger.Type.RINGING)
}
protected open fun silenceIncomingRinger() {
Log.i(TAG, "silenceIncomingRinger():")
incomingRinger.stop()
}
protected fun setMicrophoneMute(on: Boolean) {
if (androidAudioManager.isMicrophoneMute != on) {
androidAudioManager.isMicrophoneMute = on
}
}
enum class AudioDevice {
SPEAKER_PHONE,
WIRED_HEADSET,
@@ -168,9 +194,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
private var userSelectedAudioDevice: AudioDevice = AudioDevice.NONE
private var previousBluetoothState: SignalBluetoothManager.State? = null
private var savedAudioMode = AudioManager.MODE_INVALID
private var savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false
private var hasWiredHeadset = false
private var autoSwitchToWiredHeadset = true
private var autoSwitchToBluetooth = true
@@ -419,29 +443,6 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
}
}
private fun setMicrophoneMute(on: Boolean) {
if (androidAudioManager.isMicrophoneMute != on) {
androidAudioManager.isMicrophoneMute = on
}
}
override fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate")
androidAudioManager.mode = AudioManager.MODE_RINGTONE
setMicrophoneMute(false)
incomingRinger.start(ringtoneUri, vibrate)
}
override fun startOutgoingRinger() {
Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice")
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setMicrophoneMute(false)
outgoingRinger.start(OutgoingRinger.Type.RINGING)
}
private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) {
Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic")
hasWiredHeadset = pluggedIn

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.webrtc.audio
import android.content.Context
import androidx.annotation.RequiresApi
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
/**
* Lightweight [SignalAudioManager] used when Jetpack Core Telecom is managing the call.
*
* Core Telecom owns device routing (earpiece, speaker, bluetooth, wired headset) and audio focus
* via the platform telecom framework. This manager only handles:
* - Audio mode transitions (MODE_RINGTONE / MODE_IN_COMMUNICATION)
* - Ringtone and sound effect playback
* - Mic mute state
* - Forwarding user device selection to Core Telecom via [AndroidTelecomUtil]
*
* Device availability and active device updates flow from [org.thoughtcrime.securesms.service.webrtc.TelecomCallController] directly
* to [org.thoughtcrime.securesms.service.webrtc.SignalCallManager.onAudioDeviceChanged], bypassing this class entirely.
*/
@RequiresApi(34)
class TelecomAudioManager(context: Context, eventListener: EventListener?) : SignalAudioManager(context, eventListener) {
companion object {
private val TAG = Log.tag(TelecomAudioManager::class)
}
override fun initialize() {
Log.i(TAG, "initialize(): state=$state")
if (state == State.UNINITIALIZED) {
savedAudioMode = androidAudioManager.mode
savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute
setMicrophoneMute(false)
state = State.PREINITIALIZED
}
}
override fun start() {
Log.i(TAG, "start(): state=$state")
if (state == State.RUNNING) {
Log.w(TAG, "Skipping, already active")
return
}
incomingRinger.stop()
outgoingRinger.stop()
state = State.RUNNING
Log.i(TAG, "start(): platform audio mode is ${androidAudioManager.mode}, not overriding — letting telecom framework manage")
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f)
}
override fun stop(playDisconnect: Boolean) {
Log.i(TAG, "stop(): playDisconnect=$playDisconnect state=$state")
incomingRinger.stop()
outgoingRinger.stop()
if (playDisconnect && state != State.UNINITIALIZED) {
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f)
}
if (state != State.UNINITIALIZED) {
setMicrophoneMute(savedIsMicrophoneMute)
}
state = State.UNINITIALIZED
}
override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) {
if (recipientId != null) {
val currentDevice = AndroidTelecomUtil.getActiveAudioDevice(recipientId)
if (currentDevice == AudioDevice.BLUETOOTH || currentDevice == AudioDevice.WIRED_HEADSET) {
Log.i(TAG, "setDefaultAudioDevice(): device=$newDefaultDevice, but current device is $currentDevice — keeping external device")
return
}
if (newDefaultDevice == AudioDevice.EARPIECE) {
Log.i(TAG, "setDefaultAudioDevice(): device=EARPIECE — no-op, letting telecom framework decide default routing")
return
}
Log.i(TAG, "setDefaultAudioDevice(): device=$newDefaultDevice (delegating to telecom)")
AndroidTelecomUtil.selectAudioDevice(recipientId, newDefaultDevice)
}
}
override fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean) {
val audioDevice: AudioDevice = if (isId) {
Log.w(TAG, "selectAudioDevice(): unexpected isId=true for telecom call, ignoring")
return
} else {
AudioDevice.entries[device]
}
Log.i(TAG, "selectAudioDevice(): device=$audioDevice (delegating to telecom)")
if (recipientId != null) {
AndroidTelecomUtil.selectAudioDevice(recipientId, audioDevice)
}
}
}