diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index aa67a62db9..5388e98379 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5a61d6ab45..5401e10130 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1282,16 +1282,6 @@
android:enabled="true"
android:exported="false" />
-
-
-
-
-
+ android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
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)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt
index 993b0aea64..a82fa9d118 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt
@@ -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, 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 = 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 = 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 = 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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
index 67d000aa37..b81da89c1f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt
index e1671b0b0a..c1022f2ed8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/ActiveCallManager.kt
@@ -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")
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnection.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnection.kt
deleted file mode 100644
index 6f1ca6d43a..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnection.kt
+++ /dev/null
@@ -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 {
- val devices = mutableSetOf()
-
- 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
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnectionService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnectionService.kt
deleted file mode 100644
index 310a5567ec..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidCallConnectionService.kt
+++ /dev/null
@@ -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)
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidTelecomUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidTelecomUtil.kt
index 69e51147c5..12addf6fd3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidTelecomUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/AndroidTelecomUtil.kt
@@ -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 = mutableMapOf()
+ private val controllers: MutableMap = 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? {
+ 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)
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java
index 6dd7947657..a1aa7598e2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java
@@ -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");
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java
index 8ad0b3c8fd..56cced783d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java
@@ -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());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java
index 9ded59220c..58d6b8a63b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java
@@ -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());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java
index f367dc4215..7677168a9e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java
@@ -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)) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/TelecomCallController.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/TelecomCallController.kt
new file mode 100644
index 0000000000..0256784962
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/TelecomCallController.kt
@@ -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(Channel.BUFFERED)
+
+ @Volatile
+ var currentAudioDevice: SignalAudioManager.AudioDevice = SignalAudioManager.AudioDevice.NONE
+ private set
+
+ @Volatile
+ private var cachedEndpoints: List = emptyList()
+
+ @Volatile
+ private var disconnected: Boolean = false
+
+ fun getAvailableAudioOutputOptions(): List {
+ 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
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java
index 3669ee72bb..b4b2244445 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java
@@ -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;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java
index 219c3431cd..d5d5f7a957 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java
@@ -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);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
index 4791f7aec2..479c4114b7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
@@ -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);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt
index 8bc95e18ec..270d725b2d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/AudioManagerCommand.kt
@@ -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 = ParcelCheat { Initialize() }
+ val CREATOR: Parcelable.Creator = ParcelCheat { Initialize(ParcelUtil.readBoolean(it)) }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt
index 88ba87ffde..2213525b3a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/FullSignalAudioManagerApi31.kt
@@ -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()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt
index 2fb237e8fb..7f3b7e2435 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt
new file mode 100644
index 0000000000..fe8a92c617
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/TelecomAudioManager.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt
index 118ed5e73d..a511128f86 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt
+++ b/app/src/test/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31Test.kt
@@ -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 {
every { getType() } returns type
every { getId() } returns id
+ every { getProductName() } returns name
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8b500c3bff..a624f3715c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,7 @@ androidx-lifecycle-navigation3 = "2.10.0"
androidx-media3 = "1.9.1"
androidx-navigation = "2.8.5"
androidx-navigation3-core = "1.0.0"
+androidx-core-telecom = "1.0.1"
androidx-window = "1.3.0"
glide = "4.15.1"
libsignal-client = "0.88.1"
@@ -139,6 +140,7 @@ androidx-profileinstaller = "androidx.profileinstaller:profileinstaller:1.4.1"
androidx-asynclayoutinflater = "androidx.asynclayoutinflater:asynclayoutinflater:1.1.0-alpha01"
androidx-asynclayoutinflater-appcompat = "androidx.asynclayoutinflater:asynclayoutinflater-appcompat:1.1.0-alpha01"
androidx-emoji2 = "androidx.emoji2:emoji2:1.5.0"
+androidx-core-telecom = { module = "androidx.core:core-telecom", version.ref = "androidx-core-telecom" }
androidx-documentfile = "androidx.documentfile:documentfile:1.0.1"
androidx-credentials = "androidx.credentials:credentials:1.5.0"
androidx-credentials-compat = "androidx.credentials:credentials-play-services-auth:1.5.0"
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 034827e74e..245b289986 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -3969,6 +3969,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+