mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +01:00
Reintroduce preliminary telecom support for 1:1 calling.
This commit is contained in:
committed by
Michelle Tang
parent
1b6cfe9fc6
commit
a62f07db11
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user