Trying to route audio using androidx Telecom API...

This commit is contained in:
Sylvain Berfini 2023-08-28 17:06:35 +02:00
parent 39ad8347c7
commit 476eabd0fe
5 changed files with 142 additions and 56 deletions

View file

@ -54,7 +54,7 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
val notificationsManager = NotificationsManager(context) val notificationsManager = NotificationsManager(context)
private val telecomManager = TelecomManager(context) val telecomManager = TelecomManager(context)
private val activityMonitor = ActivityMonitor() private val activityMonitor = ActivityMonitor()

View file

@ -23,14 +23,18 @@ import android.telecom.DisconnectCause
import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallControlCallback import androidx.core.telecom.CallControlCallback
import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallEndpointCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.AudioDevice
import org.linphone.core.Call import org.linphone.core.Call
import org.linphone.core.CallListenerStub import org.linphone.core.CallListenerStub
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.utils.AudioRouteUtils
class TelecomCallControlCallback constructor( class TelecomCallControlCallback constructor(
private val call: Call, private val call: Call,
@ -41,14 +45,21 @@ class TelecomCallControlCallback constructor(
private const val TAG = "[Telecom Call Control Callback]" private const val TAG = "[Telecom Call Control Callback]"
} }
private var availableEndpoints: List<CallEndpointCompat> = arrayListOf()
private val callListener = object : CallListenerStub() { private val callListener = object : CallListenerStub() {
override fun onStateChanged(call: Call, state: Call.State?, message: String) { override fun onStateChanged(call: Call, state: Call.State?, message: String) {
Log.i("$TAG Call state changed [$state]") Log.i("$TAG Call state changed [$state]")
if (state == Call.State.Connected) { if (state == Call.State.Connected) {
if (call.dir == Call.Dir.Incoming) { if (call.dir == Call.Dir.Incoming) {
scope.launch { scope.launch {
Log.i("$TAG Answering call") val type = if (call.currentParams.isVideoEnabled) {
callControl.answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) // TODO CallAttributesCompat.CALL_TYPE_VIDEO_CALL
} else {
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
}
Log.i("$TAG Answering call with type [$type]")
callControl.answer(type)
} }
} else { } else {
scope.launch { scope.launch {
@ -90,6 +101,7 @@ class TelecomCallControlCallback constructor(
callControl.availableEndpoints.onEach { list -> callControl.availableEndpoints.onEach { list ->
Log.i("$TAG New available audio endpoints list") Log.i("$TAG New available audio endpoints list")
availableEndpoints = list
for (endpoint in list) { for (endpoint in list) {
Log.i("$TAG Available audio endpoint [${endpoint.name}]") Log.i("$TAG Available audio endpoint [${endpoint.name}]")
} }
@ -97,6 +109,30 @@ class TelecomCallControlCallback constructor(
callControl.currentCallEndpoint.onEach { endpoint -> callControl.currentCallEndpoint.onEach { endpoint ->
Log.i("$TAG We're asked to use [${endpoint.name}] audio endpoint") Log.i("$TAG We're asked to use [${endpoint.name}] audio endpoint")
// Change audio route in SDK, this way the usual listener will trigger
// and we'll be able to update the UI accordingly
val route = arrayListOf<AudioDevice.Type>()
when (endpoint.type) {
CallEndpointCompat.Companion.TYPE_EARPIECE -> {
route.add(AudioDevice.Type.Earpiece)
}
CallEndpointCompat.Companion.TYPE_SPEAKER -> {
route.add(AudioDevice.Type.Speaker)
}
CallEndpointCompat.Companion.TYPE_BLUETOOTH -> {
route.add(AudioDevice.Type.Bluetooth)
}
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
route.add(AudioDevice.Type.Headphones)
route.add(AudioDevice.Type.Headset)
}
else -> null
}
if (route.isNotEmpty()) {
coreContext.postOnCoreThread {
AudioRouteUtils.applyAudioRouteChangeInLinphone(call, route)
}
}
}.launchIn(scope) }.launchIn(scope)
callControl.isMuted.onEach { muted -> callControl.isMuted.onEach { muted ->
@ -105,12 +141,63 @@ class TelecomCallControlCallback constructor(
}.launchIn(scope) }.launchIn(scope)
} }
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>) {
Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]")
for (endpoint in availableEndpoints) {
Log.i(
"$TAG Found audio endpoint [${endpoint.name}] with type [${endpoint.type}]"
)
val found = when (endpoint.type) {
CallEndpointCompat.Companion.TYPE_EARPIECE -> {
routes.find { it == AudioDevice.Type.Earpiece }
}
CallEndpointCompat.Companion.TYPE_SPEAKER -> {
routes.find { it == AudioDevice.Type.Speaker }
}
CallEndpointCompat.Companion.TYPE_BLUETOOTH -> {
routes.find { it == AudioDevice.Type.Bluetooth }
}
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
routes.find { it == AudioDevice.Type.Headset || it == AudioDevice.Type.Headphones }
}
else -> null
}
if (found != null) {
Log.i(
"$TAG Found matching audio endpoint [${endpoint.name}], trying to use it"
)
scope.launch {
Log.i("$TAG Requesting audio endpoint change with [${endpoint.name}]")
var audioRouteUpdated = callControl.requestEndpointChange(endpoint)
var attempts = 1
while (!audioRouteUpdated && attempts <= 10) {
delay(100)
Log.i("$TAG Requesting audio endpoint change with [${endpoint.name}]")
audioRouteUpdated = callControl.requestEndpointChange(endpoint)
attempts += 1
}
if (!audioRouteUpdated) {
Log.e("$TAG Failed to change endpoint audio device!")
} else {
Log.i("$TAG It took [$attempts] to change endpoint audio device...")
}
}
} else {
Log.w("$TAG No matching audio endpoint found...")
}
}
}
override suspend fun onAnswer(callType: Int): Boolean { override suspend fun onAnswer(callType: Int): Boolean {
Log.i("$TAG We're asked to answer the call") Log.i("$TAG We're asked to answer the call with type [$callType]")
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (call.state == Call.State.IncomingReceived || call.state == Call.State.IncomingEarlyMedia) { if (call.state == Call.State.IncomingReceived || call.state == Call.State.IncomingEarlyMedia) {
Log.i("$TAG Answering call") Log.i("$TAG Answering call")
call.accept() coreContext.answerCall(call) // TODO: use call type
} }
} }
return true return true
@ -120,7 +207,7 @@ class TelecomCallControlCallback constructor(
Log.i("$TAG We're asked to terminate the call with reason [$disconnectCause]") Log.i("$TAG We're asked to terminate the call with reason [$disconnectCause]")
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
Log.i("$TAG Terminating call") Log.i("$TAG Terminating call")
call.terminate() call.terminate() // TODO: use cause
} }
return true return true
} }

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.AudioDevice
import org.linphone.core.Call import org.linphone.core.Call
import org.linphone.core.Core import org.linphone.core.Core
import org.linphone.core.CoreListenerStub import org.linphone.core.CoreListenerStub
@ -77,7 +78,7 @@ class TelecomManager @WorkerThread constructor(context: Context) {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val callId = call.callLog.callId.orEmpty() val callId = call.callLog.callId.orEmpty()
if (callId.isNotEmpty()) { if (callId.isNotEmpty()) {
Log.i("$TAG Storing callbacks (why?) for call ID [$callId]") Log.i("$TAG Storing our callbacks for call ID [$callId]")
map[callId] = callbacks map[callId] = callbacks
} }
} }
@ -108,4 +109,19 @@ class TelecomManager @WorkerThread constructor(context: Context) {
Log.i("$TAG Core is being stopped") Log.i("$TAG Core is being stopped")
core.removeListener(coreListener) core.removeListener(coreListener)
} }
@WorkerThread
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>, callId: String): Boolean {
Log.i(
"$TAG Looking for audio endpoint with type [${routes.first()}] for call with ID [$callId]"
)
val callControlCallback = map[callId]
if (callControlCallback == null) {
Log.w("$TAG Failed to find callbacks for call with ID [$callId]")
return false
}
callControlCallback.applyAudioRouteToCallWithId(routes)
return true
}
} }

View file

@ -182,7 +182,7 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (::call.isInitialized) { if (::call.isInitialized) {
Log.i("$TAG Answering call [$call]") Log.i("$TAG Answering call [$call]")
call.accept() coreContext.answerCall(call)
} }
} }
} }
@ -244,7 +244,12 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
Log.i("$TAG Selected audio device with ID [${device.id}]") Log.i("$TAG Selected audio device with ID [${device.id}]")
if (::call.isInitialized) { if (::call.isInitialized) {
call.outputAudioDevice = device when {
isHeadset -> AudioRouteUtils.routeAudioToHeadset(call)
isBluetooth -> AudioRouteUtils.routeAudioToBluetooth(call)
isSpeaker -> AudioRouteUtils.routeAudioToSpeaker(call)
else -> AudioRouteUtils.routeAudioToEarpiece(call)
}
} }
} }
} }

View file

@ -29,31 +29,6 @@ class AudioRouteUtils {
companion object { companion object {
private const val TAG = "[Audio Route Utils]" private const val TAG = "[Audio Route Utils]"
@WorkerThread
fun isSpeakerAudioRouteCurrentlyUsed(call: Call? = null): Boolean {
val currentCall = if (coreContext.core.callsNb > 0) {
call ?: coreContext.core.currentCall ?: coreContext.core.calls[0]
} else {
Log.w("$TAG No call found, checking audio route on Core")
null
}
val conference = coreContext.core.conference
val audioDevice = if (conference != null && conference.isIn) {
conference.outputAudioDevice
} else if (currentCall != null) {
currentCall.outputAudioDevice
} else {
coreContext.core.outputAudioDevice
}
if (audioDevice == null) return false
Log.i(
"$TAG Playback audio device currently in use is [${audioDevice.deviceName} (${audioDevice.driverName}) ${audioDevice.type}]"
)
return audioDevice.type == AudioDevice.Type.Speaker
}
@WorkerThread @WorkerThread
fun routeAudioToEarpiece(call: Call? = null) { fun routeAudioToEarpiece(call: Call? = null) {
routeAudioTo(call, arrayListOf(AudioDevice.Type.Earpiece)) routeAudioTo(call, arrayListOf(AudioDevice.Type.Earpiece))
@ -85,10 +60,8 @@ class AudioRouteUtils {
val currentCall = call ?: coreContext.core.currentCall ?: coreContext.core.calls.firstOrNull() val currentCall = call ?: coreContext.core.currentCall ?: coreContext.core.calls.firstOrNull()
if (currentCall != null) { if (currentCall != null) {
applyAudioRouteChange(currentCall, types) applyAudioRouteChange(currentCall, types)
changeCaptureDeviceToMatchAudioRoute(currentCall, types)
} else { } else {
applyAudioRouteChange(call, types) applyAudioRouteChange(null, types)
changeCaptureDeviceToMatchAudioRoute(call, types)
} }
} }
@ -96,7 +69,8 @@ class AudioRouteUtils {
private fun applyAudioRouteChange( private fun applyAudioRouteChange(
call: Call?, call: Call?,
types: List<AudioDevice.Type>, types: List<AudioDevice.Type>,
output: Boolean = true output: Boolean = true,
skipTelecom: Boolean = false
) { ) {
val currentCall = if (coreContext.core.callsNb > 0) { val currentCall = if (coreContext.core.callsNb > 0) {
call ?: coreContext.core.currentCall ?: coreContext.core.calls[0] call ?: coreContext.core.currentCall ?: coreContext.core.calls[0]
@ -105,6 +79,25 @@ class AudioRouteUtils {
null null
} }
if (!skipTelecom) {
val callId = currentCall?.callLog?.callId.orEmpty()
val success = coreContext.telecomManager.applyAudioRouteToCallWithId(types, callId)
if (!success) {
Log.w("$TAG Failed to change audio endpoint to [$types] for call ID [$callId]")
applyAudioRouteChange(currentCall, types, output, skipTelecom = true)
} else {
return
}
}
applyAudioRouteChangeInLinphone(currentCall, types, output)
}
fun applyAudioRouteChangeInLinphone(
call: Call?,
types: List<AudioDevice.Type>,
output: Boolean = true
) {
val capability = if (output) { val capability = if (output) {
AudioDevice.Capabilities.CapabilityPlay AudioDevice.Capabilities.CapabilityPlay
} else { } else {
@ -147,14 +140,14 @@ class AudioRouteUtils {
} }
return return
} }
if (currentCall != null) { if (call != null) {
Log.i( Log.i(
"$TAG Found [${audioDevice.type}] ${if (output) "playback" else "recorder"} audio device [${audioDevice.deviceName} (${audioDevice.driverName})], routing call audio to it" "$TAG Found [${audioDevice.type}] ${if (output) "playback" else "recorder"} audio device [${audioDevice.deviceName} (${audioDevice.driverName})], routing call audio to it"
) )
if (output) { if (output) {
currentCall.outputAudioDevice = audioDevice call.outputAudioDevice = audioDevice
} else { } else {
currentCall.inputAudioDevice = audioDevice call.inputAudioDevice = audioDevice
} }
} else { } else {
Log.i( Log.i(
@ -167,20 +160,5 @@ class AudioRouteUtils {
} }
} }
} }
@WorkerThread
private fun changeCaptureDeviceToMatchAudioRoute(call: Call?, types: List<AudioDevice.Type>) {
when (types.first()) {
AudioDevice.Type.Earpiece, AudioDevice.Type.Speaker -> {
Log.i(
"$TAG Audio route requested to Earpiece or Speaker, setting input to Microphone"
)
applyAudioRouteChange(call, (arrayListOf(AudioDevice.Type.Microphone)), false)
}
else -> {
Log.w("$TAG Unexpected audio device type: ${types.first()}")
}
}
}
} }
} }