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 + + + + + + + +