Ignore Telecom Manager endpoints availability/requests, using our own preferred endpoint policy (to workaround device disconnect/reconnect not always notified)

This commit is contained in:
Sylvain Berfini 2025-11-07 16:09:30 +01:00
parent d5c836b8b5
commit 3f22a596db
5 changed files with 63 additions and 207 deletions

View file

@ -52,6 +52,7 @@ import org.linphone.telecom.TelecomManager
import org.linphone.ui.call.CallActivity
import org.linphone.utils.ActivityMonitor
import org.linphone.utils.AppUtils
import org.linphone.utils.AudioUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
@ -143,20 +144,29 @@ class CoreContext
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
if (!addedDevices.isNullOrEmpty()) {
Log.i("$TAG [${addedDevices.size}] new device(s) have been added:")
var atLeastOneNewDeviceIsBluetooth = false
for (device in addedDevices) {
Log.i(
"$TAG Added device [${device.productName}] with ID [${device.id}] and type [${device.type}]"
)
when (device.type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLE_SPEAKER, AudioDeviceInfo.TYPE_HEARING_AID -> {
atLeastOneNewDeviceIsBluetooth = true
}
}
}
if (telecomManager.getCurrentlyFollowedCalls() <= 0) {
Log.i("$TAG No call found in Telecom's CallsManager, reloading sound devices in 500ms")
postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500)
} else {
Log.i(
"$TAG At least one active call in Telecom's CallsManager, let it handle the added device(s)"
)
}
Log.i("$TAG Reloading sound devices in 500ms")
postOnCoreThreadDelayed({
Log.i("$TAG Reloading sound devices")
core.reloadSoundDevices()
if (atLeastOneNewDeviceIsBluetooth && core.callsNb > 0 && corePreferences.routeAudioToBluetoothWhenPossible) {
Log.i("$TAG It seems a bluetooth device is now available, trying to route audio to it")
AudioUtils.routeAudioToEitherBluetoothOrHearingAid()
}
}, 500)
}
}
@ -169,14 +179,12 @@ class CoreContext
"$TAG Removed device [${device.id}][${device.productName}][${device.type}]"
)
}
if (telecomManager.getCurrentlyFollowedCalls() <= 0) {
Log.i("$TAG No call found in Telecom's CallsManager, reloading sound devices in 500ms")
postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500)
} else {
Log.i(
"$TAG At least one active call in Telecom's CallsManager, let it handle the removed device(s)"
)
}
Log.i("$TAG Reloading sound devices in 500ms")
postOnCoreThreadDelayed({
Log.i("$TAG Reloading sound devices")
core.reloadSoundDevices()
}, 500)
}
}
}
@ -348,10 +356,20 @@ class CoreContext
)
}
}
Call.State.OutgoingRinging, Call.State.OutgoingEarlyMedia -> {
if (corePreferences.routeAudioToBluetoothWhenPossible) {
Log.i("$TAG Trying to route audio to either bluetooth or hearing aid if available")
AudioUtils.routeAudioToEitherBluetoothOrHearingAid(call)
}
}
Call.State.Connected -> {
postOnMainThread {
showCallActivity()
}
if (corePreferences.routeAudioToBluetoothWhenPossible) {
Log.i("$TAG Call is connected, trying to route audio to either bluetooth or hearing aid if available")
AudioUtils.routeAudioToEitherBluetoothOrHearingAid(call)
}
}
Call.State.StreamsRunning -> {
if (previousCallState == Call.State.Connected) {
@ -407,6 +425,11 @@ class CoreContext
Log.i("$TAG Available audio devices list was updated")
}
@WorkerThread
override fun onFirstCallStarted(core: Core) {
Log.i("$TAG First call started")
}
@WorkerThread
override fun onLastCallEnded(core: Core) {
Log.i("$TAG Last call ended")

View file

@ -127,6 +127,13 @@ class CorePreferences
// Call settings
// This won't be done if bluetooth or wired headset is used
@get:AnyThread @set:WorkerThread
var routeAudioToBluetoothWhenPossible: Boolean
get() = config.getBool("app", "route_audio_to_bluetooth_when_possible", true)
set(value) {
config.setBool("app", "route_audio_to_bluetooth_when_possible", value)
}
@get:AnyThread @set:WorkerThread
var routeAudioToSpeakerWhenVideoIsEnabled: Boolean
get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", true)

View file

@ -26,13 +26,11 @@ import androidx.core.telecom.CallControlResult
import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallEndpointCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.AudioDevice
import org.linphone.core.Call
import org.linphone.core.CallListenerStub
import org.linphone.core.Reason
@ -125,75 +123,11 @@ class TelecomCallControlCallback(
}
callControl.availableEndpoints.onEach { list ->
Log.i("$TAG New available audio endpoints list")
if (availableEndpoints != list) {
Log.i(
"$TAG List size of available audio endpoints has changed, reload sound devices in SDK in [$DELAY_BEFORE_RELOADING_SOUND_DEVICES_MS] ms"
)
coreContext.postOnCoreThreadDelayed({ core ->
core.reloadSoundDevices()
Log.i("$TAG Sound devices reloaded")
}, DELAY_BEFORE_RELOADING_SOUND_DEVICES_MS)
}
availableEndpoints = list
for (endpoint in list) {
Log.i("$TAG Available audio endpoint [${endpoint.name}]")
}
Log.i("$TAG New available audio endpoints list but ignoring it")
}.launchIn(scope)
callControl.currentCallEndpoint.onEach { endpoint ->
var newEndpointToUse = endpoint
if (endpointUpdateRequestFromLinphone) {
Log.i("$TAG Linphone requests to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}]")
} else {
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}]")
}
val requestedEndpoint = latestLinphoneRequestedEndpoint
if (endpointUpdateRequestFromLinphone && requestedEndpoint != null && requestedEndpoint != endpoint) {
Log.w("$TAG WARNING: Linphone requested endpoint [${requestedEndpoint.name}] but Telecom Manager notified endpoint [${endpoint.name}], trying to use the one we requested anyway")
newEndpointToUse = requestedEndpoint
}
val type = newEndpointToUse.type
currentEndpoint = type
if (!endpointUpdateRequestFromLinphone && !coreContext.isConnectedToAndroidAuto && (type == CallEndpointCompat.Companion.TYPE_EARPIECE || type == CallEndpointCompat.Companion.TYPE_SPEAKER)) {
endpointUpdateRequestFromLinphone = false
Log.w("$TAG Device isn't connected to Android Auto, do not follow system request to change audio endpoint to [${newEndpointToUse.name}] with type [${endpointTypeToString(type)}]")
return@onEach
}
endpointUpdateRequestFromLinphone = false
// 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 (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)
route.add(AudioDevice.Type.HearingAid)
}
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
route.add(AudioDevice.Type.Headphones)
route.add(AudioDevice.Type.Headset)
}
}
if (route.isNotEmpty()) {
coreContext.postOnCoreThread {
if (!AudioUtils.applyAudioRouteChangeInLinphone(call, route)) {
Log.w("$TAG Failed to apply audio route change, trying again in 200ms")
coreContext.postOnCoreThreadDelayed({
AudioUtils.applyAudioRouteChangeInLinphone(call, route)
}, 200)
}
}
}
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}], ignoring it")
}.launchIn(scope)
callControl.isMuted.onEach { muted ->
@ -224,89 +158,12 @@ class TelecomCallControlCallback(
}.launchIn(scope)
}
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>): Boolean {
Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]")
var wiredHeadsetFound = false
var skippedBecauseAlreadyInUse = false
for (endpoint in availableEndpoints) {
Log.i(
"$TAG Found audio endpoint [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]"
)
val matches = 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 || it == AudioDevice.Type.HearingAid }
}
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
wiredHeadsetFound = true
routes.find { it == AudioDevice.Type.Headset || it == AudioDevice.Type.Headphones }
}
else -> null
}
if (matches != null) {
Log.i(
"$TAG Found matching audio endpoint [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}], trying to use it"
)
if (currentEndpoint == endpoint.type) {
Log.w("$TAG Endpoint already in use, skipping")
skippedBecauseAlreadyInUse = true
continue
}
var success = false
scope.launch {
Log.i("$TAG Requesting audio endpoint change to [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]")
endpointUpdateRequestFromLinphone = true
latestLinphoneRequestedEndpoint = endpoint
var result: CallControlResult = callControl.requestEndpointChange(endpoint)
var attempts = 1
while (result is CallControlResult.Error && attempts <= 2) {
delay(100)
Log.i(
"$TAG Previous attempt failed [$result], requesting again audio endpoint change to [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]"
)
result = callControl.requestEndpointChange(endpoint)
attempts += 1
}
if (result is CallControlResult.Error) {
Log.e("$TAG Failed to change endpoint audio device, error [$result]")
} else {
Log.i(
"$TAG It took [$attempts] attempt(s) to change endpoint audio device..."
)
currentEndpoint = endpoint.type
success = true
}
}
return success
}
}
if (routes.size == 1 && routes[0] == AudioDevice.Type.Earpiece && wiredHeadsetFound) {
Log.e("$TAG User asked for earpiece but endpoint doesn't exists!")
} else if (skippedBecauseAlreadyInUse) {
Log.w("$TAG This endpoint was already in use (according to Telecom Manager), force changing the device in Linphone just in case")
} else {
Log.e("$TAG No matching endpoint found")
}
return false
}
private fun answerCall() {
val isVideo = LinphoneUtils.isVideoEnabled(call)
val type = if (isVideo) {
CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
} else {
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
}
scope.launch {
Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call")
@ -389,12 +246,12 @@ class TelecomCallControlCallback(
private fun endpointTypeToString(type: Int): String {
return when (type) {
CallEndpointCompat.Companion.TYPE_UNKNOWN -> "UNKNOWN"
CallEndpointCompat.Companion.TYPE_EARPIECE -> "EARPIECE"
CallEndpointCompat.Companion.TYPE_BLUETOOTH -> "BLUETOOTH"
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> "WIRED HEADSET"
CallEndpointCompat.Companion.TYPE_SPEAKER -> "SPEAKER"
CallEndpointCompat.Companion.TYPE_STREAMING -> "STREAMING"
CallEndpointCompat.TYPE_UNKNOWN -> "UNKNOWN"
CallEndpointCompat.TYPE_EARPIECE -> "EARPIECE"
CallEndpointCompat.TYPE_BLUETOOTH -> "BLUETOOTH"
CallEndpointCompat.TYPE_WIRED_HEADSET -> "WIRED HEADSET"
CallEndpointCompat.TYPE_SPEAKER -> "SPEAKER"
CallEndpointCompat.TYPE_STREAMING -> "STREAMING"
else -> "UNEXPECTED: $type"
}
}

View file

@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.AudioDevice
import org.linphone.core.Call
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
@ -89,11 +88,6 @@ class TelecomManager
}
}
@WorkerThread
fun getCurrentlyFollowedCalls(): Int {
return currentlyFollowedCalls
}
@WorkerThread
fun onCallCreated(call: Call) {
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]")
@ -208,18 +202,4 @@ class TelecomManager
Log.i("$TAG Core is being stopped")
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
}
return callControlCallback.applyAudioRouteToCallWithId(routes)
}
}

View file

@ -55,6 +55,11 @@ class AudioUtils {
routeAudioTo(call, arrayListOf(AudioDevice.Type.HearingAid))
}
@WorkerThread
fun routeAudioToEitherBluetoothOrHearingAid(call: Call? = null) {
routeAudioTo(call, arrayListOf(AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid))
}
@WorkerThread
fun routeAudioToHeadset(call: Call? = null) {
routeAudioTo(
@ -85,8 +90,7 @@ class AudioUtils {
private fun applyAudioRouteChange(
call: Call?,
types: List<AudioDevice.Type>,
output: Boolean = true,
skipTelecom: Boolean = false
output: Boolean = true
) {
val currentCall = if (coreContext.core.callsNb > 0) {
call ?: coreContext.core.currentCall ?: coreContext.core.calls[0]
@ -94,22 +98,7 @@ class AudioUtils {
Log.w("$TAG No call found, setting audio route on Core")
null
}
if (!skipTelecom) {
val callId = currentCall?.callLog?.callId.orEmpty()
Log.i("$TAG Trying to change audio endpoint using Telecom Manager APIs")
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 {
Log.i("$TAG It seems audio endpoint update using Telecom Manager was successful")
return
}
} else {
Log.i("$TAG Trying to change audio endpoint directly in Linphone SDK")
applyAudioRouteChangeInLinphone(currentCall, types, output)
}
applyAudioRouteChangeInLinphone(currentCall, types, output)
}
fun applyAudioRouteChangeInLinphone(