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

@@ -654,6 +654,7 @@ dependencies {
implementation(libs.androidx.concurrent.futures)
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometric)
implementation(libs.androidx.core.telecom)
implementation(libs.androidx.sharetarget)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.asynclayoutinflater)

View File

@@ -1282,16 +1282,6 @@
android:enabled="true"
android:exported="false" />
<service
android:name=".service.webrtc.AndroidCallConnectionService"
android:exported="true"
android:foregroundServiceType="phoneCall"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
tools:targetApi="28">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<service
android:name=".components.voice.VoiceNotePlaybackService"
@@ -1401,7 +1391,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"

View File

@@ -224,7 +224,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)

View File

@@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toImmutableList
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
import org.thoughtcrime.securesms.webrtc.audio.AudioDeviceMapping
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
import org.signal.core.ui.R as CoreUiR
@@ -38,17 +39,40 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
}
}
private fun getDeviceList(context: Context): Pair<List<AudioOutputOption>, Int>? {
val telecomDevices = AndroidTelecomUtil.getAvailableAudioOutputOptions()
if (telecomDevices != null) {
return telecomDevices to AndroidTelecomUtil.getCurrentEndpointDeviceId()
}
val am = AppDependencies.androidCallAudioManager
val devices = am.availableCommunicationDevices
.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }
.distinctBy { it.deviceType.name }
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
if (devices.isEmpty()) return null
return devices to (am.communicationDevice?.id ?: -1)
}
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
return when (this.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headphones)
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
else -> this.productName
}
}
}
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
val am = AppDependencies.androidCallAudioManager
if (am.availableCommunicationDevices.isEmpty()) {
val (devices, currentDeviceId) = getDeviceList(fragmentActivity) ?: run {
Toast.makeText(fragmentActivity, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
return null
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(fragmentActivity).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
val currentDeviceId = am.communicationDevice?.id ?: -1
if (devices.size < threshold) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
if (devices.isEmpty()) return null
@@ -68,8 +92,8 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
fun Picker(threshold: Int) {
val context = LocalContext.current
val am = AppDependencies.androidCallAudioManager
if (am.availableCommunicationDevices.isEmpty()) {
val deviceList = getDeviceList(context)
if (deviceList == null) {
LaunchedEffect(Unit) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
stateUpdater.hidePicker()
@@ -77,8 +101,7 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
return
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
val currentDeviceId = am.communicationDevice?.id ?: -1
val (devices, currentDeviceId) = deviceList
if (devices.size < threshold) {
LaunchedEffect(Unit) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
@@ -101,7 +124,12 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
@RequiresApi(31)
val onAudioDeviceSelected: (AudioOutputOption) -> Unit = {
Log.d(TAG, "User selected audio device of type ${it.deviceType}")
audioOutputChangedListener.audioOutputChanged(WebRtcAudioDevice(it.toWebRtcAudioOutput(), it.deviceId))
val webRtcDevice = if (AndroidTelecomUtil.hasActiveController()) {
WebRtcAudioDevice(it.toWebRtcAudioOutput(), null)
} else {
WebRtcAudioDevice(it.toWebRtcAudioOutput(), it.deviceId)
}
audioOutputChangedListener.audioOutputChanged(webRtcDevice)
when (it.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> {
@@ -123,35 +151,22 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
}
}
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
return when (this.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headphones)
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
else -> this.productName
}
}
/**
* Cycles to the next audio device without showing a picker.
* Uses the system device list to resolve the actual device ID, falling back to
* type-based lookup from app-tracked state when the current communication device is unknown.
*/
fun cycleToNextDevice() {
val am = AppDependencies.androidCallAudioManager
val devices: List<AudioOutputOption> = am.availableCommunicationDevices
.map { AudioOutputOption("", AudioDeviceMapping.fromPlatformType(it.type), it.id) }
.distinctBy { it.deviceType.name }
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
val (devices, currentDeviceId) = getDeviceList(AppDependencies.application) ?: run {
Log.w(TAG, "cycleToNextDevice: no available communication devices")
return
}
if (devices.isEmpty()) {
Log.w(TAG, "cycleToNextDevice: no available communication devices")
return
}
val currentDeviceId = am.communicationDevice?.id ?: -1
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index != -1) {

View File

@@ -1285,7 +1285,16 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
@RequiresApi(31)
override fun onAudioOutputChanged31(audioOutput: WebRtcAudioDevice) {
maybeDisplaySpeakerphonePopup(audioOutput.webRtcAudioOutput)
AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(audioOutput.deviceId!!))
if (audioOutput.deviceId != null) {
AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(audioOutput.deviceId))
} else {
when (audioOutput.webRtcAudioOutput) {
WebRtcAudioOutput.HANDSET -> handleSetAudioHandset()
WebRtcAudioOutput.SPEAKER -> handleSetAudioSpeaker()
WebRtcAudioOutput.BLUETOOTH_HEADSET -> handleSetAudioBluetooth()
WebRtcAudioOutput.WIRED_HEADSET -> handleSetAudioWiredHeadset()
}
}
}
override fun onVideoChanged(isVideoEnabled: Boolean) {

View File

@@ -212,7 +212,14 @@ class ActiveCallManager(
fun sendAudioCommand(audioCommand: AudioManagerCommand) {
if (signalAudioManager == null) {
signalAudioManager = create(application, this)
val canUseTelecom = if (audioCommand is AudioManagerCommand.Initialize) {
!audioCommand.isGroupCall
} else {
Log.w(TAG, "First AudioCommand received was not Initialize, skipping Telecom usage, command: ${audioCommand.javaClass.simpleName}")
false
}
signalAudioManager = create(context = application, eventListener = this, canUseTelecom = canUseTelecom)
}
Log.i(TAG, "Sending audio command [" + audioCommand.javaClass.simpleName + "] to " + signalAudioManager?.javaClass?.simpleName)
@@ -310,17 +317,23 @@ class ActiveCallManager(
@get:RequiresApi(30)
override val serviceType: Int
get() {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
val telecom = Build.VERSION.SDK_INT >= 34 && AndroidTelecomUtil.hasActiveController()
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
return if (telecom) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
type
}
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
return type
}
@Suppress("DEPRECATION")

View File

@@ -1,116 +0,0 @@
package org.thoughtcrime.securesms.service.webrtc
import android.content.Context
import android.content.Intent
import android.telecom.CallAudioState
import android.telecom.Connection
import androidx.annotation.RequiresApi
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCommand
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
/**
* Signal implementation for the telecom system connection. Provides an interaction point for the system to
* inform us about changes in the telecom system. Created and returned by [AndroidCallConnectionService].
*/
@RequiresApi(26)
class AndroidCallConnection(
private val context: Context,
private val recipientId: RecipientId,
isOutgoing: Boolean,
private val isVideoCall: Boolean
) : Connection() {
private var needToResetAudioRoute = isOutgoing && !isVideoCall
private var initialAudioRoute: SignalAudioManager.AudioDevice? = null
init {
connectionProperties = PROPERTY_SELF_MANAGED
connectionCapabilities = CAPABILITY_SUPPORTS_VT_LOCAL_BIDIRECTIONAL or
CAPABILITY_SUPPORTS_VT_REMOTE_BIDIRECTIONAL or
CAPABILITY_MUTE
}
override fun onShowIncomingCallUi() {
Log.i(TAG, "onShowIncomingCallUi()")
ActiveCallManager.update(context, CallNotificationBuilder.TYPE_INCOMING_CONNECTING, recipientId, isVideoCall)
setRinging()
}
override fun onCallAudioStateChanged(state: CallAudioState) {
Log.i(TAG, "onCallAudioStateChanged($state)")
val activeDevice = state.route.toDevices().firstOrNull() ?: SignalAudioManager.AudioDevice.EARPIECE
val availableDevices = state.supportedRouteMask.toDevices()
AppDependencies.signalCallManager.onAudioDeviceChanged(activeDevice, availableDevices)
if (needToResetAudioRoute) {
if (initialAudioRoute == null) {
initialAudioRoute = activeDevice
} else if (activeDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE) {
Log.i(TAG, "Resetting audio route from SPEAKER_PHONE to $initialAudioRoute")
AndroidTelecomUtil.selectAudioDevice(recipientId, initialAudioRoute!!)
needToResetAudioRoute = false
}
}
}
override fun onAnswer(videoState: Int) {
Log.i(TAG, "onAnswer($videoState)")
if (Permissions.hasAll(context, android.Manifest.permission.RECORD_AUDIO)) {
AppDependencies.signalCallManager.acceptCall(false)
} else {
val intent = CallIntent.Builder(context)
.withAddedIntentFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.withAction(if (isVideoCall) CallIntent.Action.ANSWER_VIDEO else CallIntent.Action.ANSWER_AUDIO)
.build()
context.startActivity(intent)
}
}
override fun onSilence() {
ActiveCallManager.sendAudioManagerCommand(context, AudioManagerCommand.SilenceIncomingRinger())
}
override fun onReject() {
Log.i(TAG, "onReject()")
ActiveCallManager.denyCall()
}
override fun onDisconnect() {
Log.i(TAG, "onDisconnect()")
ActiveCallManager.hangup()
}
companion object {
private val TAG: String = Log.tag(AndroidCallConnection::class.java)
}
}
private fun Int.toDevices(): Set<SignalAudioManager.AudioDevice> {
val devices = mutableSetOf<SignalAudioManager.AudioDevice>()
if (this and CallAudioState.ROUTE_BLUETOOTH != 0) {
devices += SignalAudioManager.AudioDevice.BLUETOOTH
}
if (this and CallAudioState.ROUTE_EARPIECE != 0) {
devices += SignalAudioManager.AudioDevice.EARPIECE
}
if (this and CallAudioState.ROUTE_WIRED_HEADSET != 0) {
devices += SignalAudioManager.AudioDevice.WIRED_HEADSET
}
if (this and CallAudioState.ROUTE_SPEAKER != 0) {
devices += SignalAudioManager.AudioDevice.SPEAKER_PHONE
}
return devices
}

View File

@@ -1,116 +0,0 @@
package org.thoughtcrime.securesms.service.webrtc
import android.net.Uri
import android.os.Bundle
import android.telecom.Connection
import android.telecom.ConnectionRequest
import android.telecom.ConnectionService
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import androidx.annotation.RequiresApi
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Signal implementation of the Android telecom [ConnectionService]. The system binds to this service
* when we inform the [TelecomManager] of a new incoming or outgoing call. It'll then call the appropriate
* create/failure method to let us know how to proceed.
*/
@RequiresApi(26)
class AndroidCallConnectionService : ConnectionService() {
override fun onCreateIncomingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
): Connection {
val (recipientId: RecipientId, callId: Long, isVideoCall: Boolean) = request.getOurExtras()
Log.i(TAG, "onCreateIncomingConnection($recipientId)")
val recipient = Recipient.resolved(recipientId)
val displayName = recipient.getDisplayName(this)
val connection = AndroidCallConnection(
context = applicationContext,
recipientId = recipientId,
isOutgoing = false,
isVideoCall = isVideoCall
).apply {
setInitializing()
if (SignalStore.settings.messageNotificationsPrivacy.isDisplayContact && recipient.e164.isPresent) {
setAddress(Uri.fromParts("tel", recipient.e164.get(), null), TelecomManager.PRESENTATION_ALLOWED)
setCallerDisplayName(displayName, TelecomManager.PRESENTATION_ALLOWED)
}
videoState = request.videoState
extras = request.extras
setRinging()
}
AndroidTelecomUtil.connections[recipientId] = connection
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
return connection
}
override fun onCreateIncomingConnectionFailed(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
) {
val (recipientId: RecipientId, callId: Long) = request.getOurExtras()
Log.i(TAG, "onCreateIncomingConnectionFailed($recipientId)")
AppDependencies.signalCallManager.dropCall(callId)
}
override fun onCreateOutgoingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
): Connection {
val (recipientId: RecipientId, callId: Long, isVideoCall: Boolean) = request.getOurExtras()
Log.i(TAG, "onCreateOutgoingConnection($recipientId)")
val connection = AndroidCallConnection(
context = applicationContext,
recipientId = recipientId,
isOutgoing = true,
isVideoCall = isVideoCall
).apply {
videoState = request.videoState
extras = request.extras
setDialing()
}
AndroidTelecomUtil.connections[recipientId] = connection
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
return connection
}
override fun onCreateOutgoingConnectionFailed(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
) {
val (recipientId: RecipientId, callId: Long) = request.getOurExtras()
Log.i(TAG, "onCreateOutgoingConnectionFailed($recipientId)")
AppDependencies.signalCallManager.dropCall(callId)
}
companion object {
private val TAG: String = Log.tag(AndroidCallConnectionService::class.java)
const val KEY_RECIPIENT_ID = "org.thoughtcrime.securesms.RECIPIENT_ID"
const val KEY_CALL_ID = "org.thoughtcrime.securesms.CALL_ID"
const val KEY_VIDEO_CALL = "org.thoughtcrime.securesms.VIDEO_CALL"
}
private fun ConnectionRequest.getOurExtras(): ServiceExtras {
val ourExtras: Bundle = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS) ?: extras
val recipientId: RecipientId = RecipientId.from(ourExtras.getString(KEY_RECIPIENT_ID)!!)
val callId: Long = ourExtras.getLong(KEY_CALL_ID)
val isVideoCall: Boolean = ourExtras.getBoolean(KEY_VIDEO_CALL, false)
return ServiceExtras(recipientId, callId, isVideoCall)
}
private data class ServiceExtras(val recipientId: RecipientId, val callId: Long, val isVideoCall: Boolean)
}

View File

@@ -1,23 +1,15 @@
package org.thoughtcrime.securesms.service.webrtc
import android.annotation.SuppressLint
import android.content.ComponentName
import android.net.Uri
import android.os.Build
import android.os.Process
import android.telecom.CallAudioState
import android.telecom.Connection
import android.telecom.DisconnectCause
import android.telecom.DisconnectCause.REJECTED
import android.telecom.DisconnectCause.UNKNOWN
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.telecom.VideoProfile
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.telecom.CallsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.webrtc.AudioOutputOption
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -25,172 +17,194 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
/**
* Wrapper around various [TelecomManager] methods to make dealing with SDK versions easier. Also
* maintains a global list of all Signal [AndroidCallConnection]s associated with their [RecipientId].
* There should really only be one ever, but there may be times when dealing with glare or a busy that two
* may kick off.
* Wrapper around Jetpack [CallsManager] to manage telecom integration. Maintains a global map of
* [TelecomCallController] instances associated with their [RecipientId].
*/
@SuppressLint("NewApi", "InlinedApi")
@SuppressLint("NewApi")
object AndroidTelecomUtil {
private val TAG = Log.tag(AndroidTelecomUtil::class.java)
private val context = AppDependencies.application
private var systemRejected = false
private var accountRegistered = false
private var registered = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val callsManager: CallsManager by lazy { CallsManager(context) }
@JvmStatic
val telecomSupported: Boolean
get() {
if (Build.VERSION.SDK_INT >= 26 && !systemRejected && isTelecomAllowedForDevice()) {
if (!accountRegistered) {
if (Build.VERSION.SDK_INT >= 34 && !systemRejected && isTelecomAllowedForDevice()) {
if (!registered) {
registerPhoneAccount()
}
if (accountRegistered) {
val phoneAccount = ContextCompat.getSystemService(context, TelecomManager::class.java)!!.getPhoneAccount(getPhoneAccountHandle())
if (phoneAccount != null && phoneAccount.isEnabled) {
return true
}
}
return registered
}
return false
}
@JvmStatic
val connections: MutableMap<RecipientId, AndroidCallConnection> = mutableMapOf()
private val controllers: MutableMap<RecipientId, TelecomCallController> = mutableMapOf()
@JvmStatic
fun registerPhoneAccount() {
if (Build.VERSION.SDK_INT >= 26 && !systemRejected) {
Log.i(TAG, "Registering phone account")
val phoneAccount = PhoneAccount.Builder(getPhoneAccountHandle(), "Signal")
.setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED or PhoneAccount.CAPABILITY_VIDEO_CALLING)
.build()
if (Build.VERSION.SDK_INT >= 34 && !systemRejected) {
Log.i(TAG, "Registering with CallsManager")
try {
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.registerPhoneAccount(phoneAccount)
Log.i(TAG, "Phone account registered successfully")
accountRegistered = true
callsManager.registerAppWithTelecom(
CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING
)
Log.i(TAG, "CallsManager registration successful")
registered = true
} catch (e: Exception) {
Log.w(TAG, "Unable to register telecom account", e)
Log.w(TAG, "Unable to register with CallsManager", e)
systemRejected = true
}
}
}
@JvmStatic
@RequiresApi(26)
fun getPhoneAccountHandle(): PhoneAccountHandle {
return PhoneAccountHandle(ComponentName(context, AndroidCallConnectionService::class.java), context.packageName, Process.myUserHandle())
}
@JvmStatic
fun addIncomingCall(recipientId: RecipientId, callId: Long, remoteVideoOffer: Boolean): Boolean {
if (telecomSupported) {
val telecomBundle = bundleOf(
TelecomManager.EXTRA_INCOMING_CALL_EXTRAS to bundleOf(
AndroidCallConnectionService.KEY_RECIPIENT_ID to recipientId.serialize(),
AndroidCallConnectionService.KEY_CALL_ID to callId,
AndroidCallConnectionService.KEY_VIDEO_CALL to remoteVideoOffer,
TelecomManager.EXTRA_INCOMING_VIDEO_STATE to if (remoteVideoOffer) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY
),
TelecomManager.EXTRA_INCOMING_VIDEO_STATE to if (remoteVideoOffer) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY
Log.i(TAG, "addIncomingCall(recipientId=$recipientId, callId=$callId, videoOffer=$remoteVideoOffer)")
val controller = TelecomCallController(
context = context,
recipientId = recipientId,
callId = callId,
isVideoCall = remoteVideoOffer,
isOutgoing = false,
callsManager = callsManager
)
try {
Log.i(TAG, "Adding incoming call $telecomBundle")
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.addNewIncomingCall(getPhoneAccountHandle(), telecomBundle)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to add incoming call", e)
systemRejected = true
return false
synchronized(controllers) {
controllers[recipientId] = controller
}
}
return true
}
@JvmStatic
fun reject(recipientId: RecipientId) {
if (telecomSupported) {
connections[recipientId]?.setDisconnected(DisconnectCause(REJECTED))
}
}
@JvmStatic
fun activateCall(recipientId: RecipientId) {
if (telecomSupported) {
connections[recipientId]?.setActive()
}
}
@JvmStatic
fun terminateCall(recipientId: RecipientId) {
if (telecomSupported) {
connections[recipientId]?.let { connection ->
if (connection.disconnectCause == null) {
connection.setDisconnected(DisconnectCause(UNKNOWN))
scope.launch {
try {
Log.i(TAG, "Incoming call controller starting for recipientId=$recipientId callId=$callId")
controller.start()
Log.i(TAG, "Incoming call controller scope ended normally for recipientId=$recipientId callId=$callId")
} catch (e: Exception) {
Log.w(TAG, "addIncomingCall failed for recipientId=$recipientId callId=$callId", e)
systemRejected = true
AppDependencies.signalCallManager.dropCall(callId)
} finally {
Log.i(TAG, "Removing incoming controller for recipientId=$recipientId")
synchronized(controllers) {
controllers.remove(recipientId)
}
}
connection.destroy()
connections.remove(recipientId)
}
}
return true
}
@JvmStatic
fun addOutgoingCall(recipientId: RecipientId, callId: Long, isVideoCall: Boolean): Boolean {
if (telecomSupported) {
val telecomBundle = bundleOf(
TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE to getPhoneAccountHandle(),
TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE to if (isVideoCall) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY,
TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS to bundleOf(
AndroidCallConnectionService.KEY_RECIPIENT_ID to recipientId.serialize(),
AndroidCallConnectionService.KEY_CALL_ID to callId,
AndroidCallConnectionService.KEY_VIDEO_CALL to isVideoCall
)
Log.i(TAG, "addOutgoingCall(recipientId=$recipientId, callId=$callId, isVideoCall=$isVideoCall)")
val controller = TelecomCallController(
context = context,
recipientId = recipientId,
callId = callId,
isVideoCall = isVideoCall,
isOutgoing = true,
callsManager = callsManager
)
try {
Log.i(TAG, "Adding outgoing call $telecomBundle")
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.placeCall(recipientId.generateTelecomE164(), telecomBundle)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to add outgoing call", e)
systemRejected = true
return false
synchronized(controllers) {
controllers[recipientId] = controller
}
scope.launch {
try {
Log.i(TAG, "Outgoing call controller starting for recipientId=$recipientId callId=$callId")
controller.start()
Log.i(TAG, "Outgoing call controller scope ended normally for recipientId=$recipientId callId=$callId")
} catch (e: Exception) {
Log.w(TAG, "addOutgoingCall failed for recipientId=$recipientId callId=$callId", e)
systemRejected = true
AppDependencies.signalCallManager.dropCall(callId)
} finally {
Log.i(TAG, "Removing outgoing controller for recipientId=$recipientId")
synchronized(controllers) {
controllers.remove(recipientId)
}
}
}
}
return true
}
@Suppress("DEPRECATION")
fun selectAudioDevice(recipientId: RecipientId, device: SignalAudioManager.AudioDevice) {
@JvmStatic
fun activateCall(recipientId: RecipientId) {
if (telecomSupported) {
val connection: AndroidCallConnection? = connections[recipientId]
Log.i(TAG, "Selecting audio route: $device connection: ${connection != null}")
if (connection?.callAudioState != null) {
when (device) {
SignalAudioManager.AudioDevice.SPEAKER_PHONE -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_SPEAKER)
SignalAudioManager.AudioDevice.BLUETOOTH -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_BLUETOOTH)
SignalAudioManager.AudioDevice.WIRED_HEADSET -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_WIRED_HEADSET)
else -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_EARPIECE)
}
Log.i(TAG, "activateCall(recipientId=$recipientId) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
synchronized(controllers) {
controllers[recipientId]?.activate()
}
}
}
@Suppress("DEPRECATION")
fun getSelectedAudioDevice(recipientId: RecipientId): SignalAudioManager.AudioDevice {
@JvmStatic
@JvmOverloads
fun terminateCall(recipientId: RecipientId, disconnectCause: Int = DisconnectCause.REMOTE) {
if (telecomSupported) {
val connection: AndroidCallConnection? = connections[recipientId]
if (connection?.callAudioState != null) {
return when (connection.callAudioState.route) {
CallAudioState.ROUTE_SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
CallAudioState.ROUTE_BLUETOOTH -> SignalAudioManager.AudioDevice.BLUETOOTH
CallAudioState.ROUTE_WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
else -> SignalAudioManager.AudioDevice.EARPIECE
}
Log.i(TAG, "terminateCall(recipientId=$recipientId, cause=$disconnectCause) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
synchronized(controllers) {
controllers[recipientId]?.disconnect(disconnectCause)
}
}
return SignalAudioManager.AudioDevice.NONE
}
@JvmStatic
fun reject(recipientId: RecipientId) {
if (telecomSupported) {
Log.i(TAG, "reject(recipientId=$recipientId) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
synchronized(controllers) {
controllers[recipientId]?.disconnect(DisconnectCause.REJECTED)
}
}
}
fun getActiveAudioDevice(recipientId: RecipientId): SignalAudioManager.AudioDevice {
return synchronized(controllers) {
controllers[recipientId]?.currentAudioDevice ?: SignalAudioManager.AudioDevice.NONE
}
}
fun selectAudioDevice(recipientId: RecipientId, device: SignalAudioManager.AudioDevice) {
if (telecomSupported) {
synchronized(controllers) {
val controller = controllers[recipientId]
Log.i(TAG, "Selecting audio route: $device controller: ${controller != null}")
controller?.requestEndpointChange(device)
}
}
}
@JvmStatic
fun getAvailableAudioOutputOptions(): List<AudioOutputOption>? {
if (!telecomSupported) return null
return synchronized(controllers) {
controllers.values.firstOrNull()?.getAvailableAudioOutputOptions()
}
}
@JvmStatic
fun getCurrentEndpointDeviceId(): Int {
return synchronized(controllers) {
controllers.values.firstOrNull()?.getCurrentEndpointDeviceId() ?: -1
}
}
@JvmStatic
fun getCurrentActiveAudioDevice(): SignalAudioManager.AudioDevice {
return synchronized(controllers) {
controllers.values.firstOrNull()?.currentAudioDevice ?: SignalAudioManager.AudioDevice.NONE
}
}
@JvmStatic
fun hasActiveController(): Boolean {
return synchronized(controllers) { controllers.isNotEmpty() }
}
private fun isTelecomAllowedForDevice(): Boolean {
@@ -200,16 +214,3 @@ object AndroidTelecomUtil {
return RingRtcDynamicConfiguration.isTelecomAllowedForDevice()
}
}
@Suppress("DEPRECATION")
@RequiresApi(26)
private fun Connection.setAudioRouteIfDifferent(newRoute: Int) {
if (callAudioState.route != newRoute) {
setAudioRoute(newRoute)
}
}
private fun RecipientId.generateTelecomE164(): Uri {
val pseudoNumber = toLong().toString().padEnd(10, '0').replaceRange(3..5, "555")
return Uri.fromParts("tel", "+1$pseudoNumber", null)
}

View File

@@ -124,7 +124,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer);
}
webRtcInteractor.retrieveTurnServers(remotePeer);
webRtcInteractor.initializeAudioForCall();
webRtcInteractor.initializeAudioForCall(false);
if (!webRtcInteractor.addNewIncomingCall(remotePeer.getId(), remotePeer.getCallId().longValue(), offerType == OfferMessage.Type.VIDEO_CALL)) {
Log.i(tag, "Unable to add new incoming call");

View File

@@ -178,7 +178,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, currentState.getCallInfoState().getCallRecipient(), true);
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context));
webRtcInteractor.initializeAudioForCall();
webRtcInteractor.initializeAudioForCall(true);
try {
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());

View File

@@ -122,7 +122,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState, RemotePeer.GROUP_CALL_ID.longValue());
webRtcInteractor.initializeAudioForCall();
webRtcInteractor.initializeAudioForCall(true);
boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient.resolve());
@@ -256,7 +256,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, currentState.getCallInfoState().getCallRecipient(), true);
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context));
webRtcInteractor.initializeAudioForCall();
webRtcInteractor.initializeAudioForCall(true);
try {
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());

View File

@@ -72,11 +72,11 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
boolean isVideoCall = offerType == OfferMessage.Type.VIDEO_CALL;
webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer, isVideoCall);
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context, isVideoCall, false));
webRtcInteractor.initializeAudioForCall(false);
webRtcInteractor.setDefaultAudioDevice(remotePeer.getId(),
isVideoCall ? SignalAudioManager.AudioDevice.SPEAKER_PHONE : SignalAudioManager.AudioDevice.EARPIECE,
false);
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context, isVideoCall, false));
webRtcInteractor.initializeAudioForCall();
webRtcInteractor.startOutgoingRinger();
if (!webRtcInteractor.addNewOutgoingCall(remotePeer.getId(), remotePeer.getCallId().longValue(), isVideoCall)) {

View File

@@ -0,0 +1,268 @@
package org.thoughtcrime.securesms.service.webrtc
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.telecom.DisconnectCause
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationManagerCompat
import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallEndpointCompat
import androidx.core.telecom.CallsManager
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.AudioOutputOption
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
sealed class TelecomCommand {
object Activate : TelecomCommand()
data class Disconnect(val cause: Int) : TelecomCommand()
data class ChangeEndpoint(val device: SignalAudioManager.AudioDevice) : TelecomCommand()
}
@RequiresApi(26)
class TelecomCallController(
private val context: Context,
private val recipientId: RecipientId,
private val callId: Long,
private val isVideoCall: Boolean,
private val isOutgoing: Boolean,
private val callsManager: CallsManager
) {
companion object {
private val TAG: String = Log.tag(TelecomCallController::class.java)
}
private val commandChannel = Channel<TelecomCommand>(Channel.BUFFERED)
@Volatile
var currentAudioDevice: SignalAudioManager.AudioDevice = SignalAudioManager.AudioDevice.NONE
private set
@Volatile
private var cachedEndpoints: List<CallEndpointCompat> = emptyList()
@Volatile
private var disconnected: Boolean = false
fun getAvailableAudioOutputOptions(): List<AudioOutputOption> {
return cachedEndpoints
.map { AudioOutputOption(it.name.toString(), it.type.toAudioDevice(), it.type) }
.distinctBy { it.deviceType }
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
}
fun getCurrentEndpointDeviceId(): Int {
return cachedEndpoints.firstOrNull { it.type.toAudioDevice() == currentAudioDevice }?.type ?: -1
}
fun activate() {
Log.i(TAG, "activate() recipientId=$recipientId callId=$callId")
commandChannel.trySend(TelecomCommand.Activate)
}
fun disconnect(cause: Int) {
if (disconnected) {
Log.i(TAG, "disconnect(cause=$cause) already disconnected, ignoring")
return
}
disconnected = true
Log.i(TAG, "disconnect(cause=$cause) recipientId=$recipientId callId=$callId")
commandChannel.trySend(TelecomCommand.Disconnect(cause))
}
fun requestEndpointChange(device: SignalAudioManager.AudioDevice) {
Log.i(TAG, "requestEndpointChange($device) recipientId=$recipientId")
commandChannel.trySend(TelecomCommand.ChangeEndpoint(device))
}
suspend fun start() {
val recipient = Recipient.resolved(recipientId)
val displayName = if (SignalStore.settings.messageNotificationsPrivacy.isDisplayContact) recipient.getDisplayName(context) else context.getString(R.string.Recipient_signal_call)
val address = Uri.fromParts("sip", recipientId.serialize(), null)
val direction = if (isOutgoing) CallAttributesCompat.DIRECTION_OUTGOING else CallAttributesCompat.DIRECTION_INCOMING
val callType = if (isVideoCall) CallAttributesCompat.CALL_TYPE_VIDEO_CALL else CallAttributesCompat.CALL_TYPE_AUDIO_CALL
val attributes = CallAttributesCompat(
displayName = displayName,
address = address,
direction = direction,
callType = callType
)
Log.i(TAG, "start() recipientId=$recipientId callId=$callId isOutgoing=$isOutgoing isVideo=$isVideoCall")
callsManager.addCall(
callAttributes = attributes,
onAnswer = { callType -> onAnswer(callType) },
onDisconnect = { cause -> onDisconnect(cause) },
onSetActive = { onSetActive() },
onSetInactive = { onSetInactive() }
) {
Log.i(TAG, "addCall block entered, callControlScope active for callId=$callId")
if (isOutgoing) {
Log.i(TAG, "Posting outgoing call notification immediately for callId=$callId")
try {
val notification = CallNotificationBuilder.getCallInProgressNotification(
context,
CallNotificationBuilder.TYPE_OUTGOING_RINGING,
recipient,
isVideoCall,
true
)
NotificationManagerCompat.from(context).notify(CallNotificationBuilder.WEBRTC_NOTIFICATION, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Failed to post outgoing call notification", e)
}
}
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
Log.i(TAG, "setTelecomApproved fired for callId=$callId recipientId=$recipientId")
var needToResetAudioRoute = isOutgoing && !isVideoCall
var initialEndpoint: SignalAudioManager.AudioDevice? = null
launch {
currentCallEndpoint.collect { endpoint ->
val activeDevice = endpoint.type.toAudioDevice()
Log.i(TAG, "currentCallEndpoint changed: ${endpoint.name} (type=${endpoint.type}) -> $activeDevice")
currentAudioDevice = activeDevice
val available = cachedEndpoints.map { it.type.toAudioDevice() }.toSet()
AppDependencies.signalCallManager.onAudioDeviceChanged(activeDevice, available)
if (needToResetAudioRoute) {
if (initialEndpoint == null) {
initialEndpoint = activeDevice
} else if (activeDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE) {
Log.i(TAG, "Resetting audio route from SPEAKER_PHONE to $initialEndpoint")
val resetTarget = cachedEndpoints.firstOrNull { it.type.toAudioDevice() == initialEndpoint }
if (resetTarget != null) {
requestEndpointChange(resetTarget)
}
needToResetAudioRoute = false
}
}
}
}
launch {
isMuted.collect { muted ->
Log.i(TAG, "isMuted changed: $muted for callId=$callId")
AppDependencies.signalCallManager.setMuteAudio(muted)
}
}
launch {
availableEndpoints.collect { endpoints ->
cachedEndpoints = endpoints
val available = endpoints.map { it.type.toAudioDevice() }.toSet()
Log.i(TAG, "availableEndpoints changed: $available (${endpoints.size} endpoints)")
AppDependencies.signalCallManager.onAudioDeviceChanged(currentAudioDevice, available)
}
}
launch {
for (command in commandChannel) {
when (command) {
is TelecomCommand.Activate -> {
val result = setActive()
Log.i(TAG, "setActive result: $result")
needToResetAudioRoute = false
}
is TelecomCommand.Disconnect -> {
val result = disconnect(DisconnectCause(command.cause))
Log.i(TAG, "disconnect result: $result")
break
}
is TelecomCommand.ChangeEndpoint -> {
val targetDevice = command.device
val target = cachedEndpoints.firstOrNull { it.type.toAudioDevice() == targetDevice }
if (target != null) {
val result = requestEndpointChange(target)
Log.i(TAG, "requestEndpointChange($targetDevice) result: $result")
} else {
Log.w(TAG, "No endpoint found for device: $targetDevice, available: ${cachedEndpoints.map { it.type.toAudioDevice() }}")
}
}
}
}
}
}
}
private fun onAnswer(callType: Int) {
val hasRecordAudio = Permissions.hasAll(context, android.Manifest.permission.RECORD_AUDIO)
Log.i(TAG, "onAnswer(callType=$callType) recipientId=$recipientId hasRecordAudio=$hasRecordAudio")
if (hasRecordAudio) {
AppDependencies.signalCallManager.acceptCall(false)
} else {
Log.i(TAG, "Missing RECORD_AUDIO permission, launching CallIntent activity")
val intent = CallIntent.Builder(context)
.withAddedIntentFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.withAction(if (isVideoCall) CallIntent.Action.ANSWER_VIDEO else CallIntent.Action.ANSWER_AUDIO)
.build()
context.startActivity(intent)
}
}
private fun onDisconnect(cause: DisconnectCause) {
Log.i(TAG, "onDisconnect(code=${cause.code}, reason=${cause.reason})")
when (cause.code) {
DisconnectCause.REJECTED -> {
Log.i(TAG, "Call rejected via system UI")
ActiveCallManager.denyCall()
}
DisconnectCause.LOCAL -> {
Log.i(TAG, "Local hangup via system UI")
ActiveCallManager.hangup()
}
DisconnectCause.REMOTE,
DisconnectCause.MISSED,
DisconnectCause.CANCELED -> {
Log.i(TAG, "Remote/missed/canceled disconnect, no action needed (handled by Signal processors)")
}
DisconnectCause.ERROR -> {
Log.w(TAG, "Disconnect due to error, performing local hangup as fallback")
ActiveCallManager.hangup()
}
else -> {
Log.w(TAG, "Unknown disconnect cause: ${cause.code}, performing local hangup")
ActiveCallManager.hangup()
}
}
}
private fun onSetActive() {
Log.i(TAG, "onSetActive()")
}
private fun onSetInactive() {
Log.i(TAG, "onSetInactive()")
}
}
@RequiresApi(26)
private fun Int.toAudioDevice(): SignalAudioManager.AudioDevice {
return when (this) {
CallEndpointCompat.TYPE_EARPIECE -> SignalAudioManager.AudioDevice.EARPIECE
CallEndpointCompat.TYPE_SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
CallEndpointCompat.TYPE_BLUETOOTH -> SignalAudioManager.AudioDevice.BLUETOOTH
CallEndpointCompat.TYPE_WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
CallEndpointCompat.TYPE_STREAMING -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
else -> SignalAudioManager.AudioDevice.EARPIECE
}
}

View File

@@ -290,7 +290,7 @@ public abstract class WebRtcActionProcessor {
RemotePeer peer = currentState.getCallInfoState().getPeerByCallId(new CallId(callId));
if (peer == null || !peer.callIdEquals(currentState.getCallInfoState().getActivePeer())) {
Log.w(tag, "Received telecom approval after call terminated. callId: " + callId + " recipient: " + recipientId);
webRtcInteractor.terminateCall(recipientId);
webRtcInteractor.terminateCall(recipientId, android.telecom.DisconnectCause.LOCAL);
return currentState;
}

View File

@@ -138,8 +138,8 @@ public class WebRtcInteractor {
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.SilenceIncomingRinger());
}
void initializeAudioForCall() {
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Initialize());
void initializeAudioForCall(boolean isGroupCall) {
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Initialize(isGroupCall));
}
void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) {
@@ -186,6 +186,10 @@ public class WebRtcInteractor {
AndroidTelecomUtil.terminateCall(recipientId);
}
public void terminateCall(RecipientId recipientId, int disconnectCause) {
AndroidTelecomUtil.terminateCall(recipientId, disconnectCause);
}
public boolean addNewIncomingCall(RecipientId recipientId, long callId, boolean remoteVideoOffer) {
return AndroidTelecomUtil.addIncomingCall(recipientId, callId, remoteVideoOffer);
}

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)
}
}
}

View File

@@ -66,10 +66,11 @@ class WebRtcAudioPicker31Test {
pickerHidden = false
}
private fun createDevice(type: Int, id: Int): AudioDeviceInfo {
private fun createDevice(type: Int, id: Int, name: String = "Device $id"): AudioDeviceInfo {
return mockk<AudioDeviceInfo> {
every { getType() } returns type
every { getId() } returns id
every { getProductName() } returns name
}
}