From 9c4219d8379e5dd46efe7dd9ef1fb537eb79d9e7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 03:11:05 +0200 Subject: [PATCH] Use Telecom as the sole route owner for Android calls --- .../com/utun/chat/data/CallAudioEngine.kt | 25 +++++-- .../com/utun/chat/data/CallAudioRouter.kt | 69 ++++++++++++++----- .../java/com/utun/chat/data/UtunConnection.kt | 63 ++++++++++++++--- 3 files changed, 126 insertions(+), 31 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index 868e1c8f..9f55e151 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -84,6 +84,21 @@ class CallAudioEngine { fun isActive(): Boolean = active + /** Позднее создание Telecom Connection: сначала снимаем собственную заявку на audio mode. */ + fun bindTelecom(connection: UtunConnection) { + if (!active || router.telecom === connection) return + if (router.telecom == null) { + try { + val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am.mode = AudioManager.MODE_NORMAL + if (Build.VERSION.SDK_INT >= 31) am.clearCommunicationDevice() + } catch (e: Exception) { + LogManager.addLog("WARN", "CallAudio", "release fallback routing failed id=$callId: ${e.message}") + } + } + router.bindTelecom(connection) + } + val diagnostics: String get() = synchronized(recordLock) { "active=$active ready=$captureReady/$playReady capture=$capFramesRead play=$playFramesWritten " + "workers=${captureThread?.isAlive}/${playThread?.isAlive} " + @@ -111,6 +126,7 @@ class CallAudioEngine { val nativeMs = SystemClock.elapsedRealtime() - startElapsedMs callId = id + router.bindTelecom(TelecomCallManager.connectionFor(id)) muted = false failed.set(false) captureReady = false; playReady = false; duplexReady = false @@ -121,7 +137,7 @@ class CallAudioEngine { prevSpeaker = am.isSpeakerphoneOn var stageAt = SystemClock.elapsedRealtime() try { - am.mode = AudioManager.MODE_IN_COMMUNICATION + if (router.telecom == null) am.mode = AudioManager.MODE_IN_COMMUNICATION } catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") } @@ -297,8 +313,9 @@ class CallAudioEngine { NativeLib.callAudioStop() router.onOutputDevice = null router.onInputDevice = null + val restoreMode = router.telecom == null router.stop() - restoreAudioMode() + if (restoreMode) restoreAudioMode() callId = 0L muted = false LogManager.addLog("INFO", "CallAudio", "stopped id=$id capture=$capFramesRead play=$playFramesWritten; workers joined") @@ -649,12 +666,12 @@ class CallAudioEngine { private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) { synchronized(trackLock) { val track = audioTrack ?: return - if (!active || dev == null) return + if (!active) return val startedAt = SystemClock.elapsedRealtime() try { val ok = track.setPreferredDevice(dev) LogManager.addLog(if (ok) "INFO" else "WARN", "CallAudio", - "track preferred device type=${dev.type} accepted=$ok totalMs=${SystemClock.elapsedRealtime() - startedAt}") + "track preferred device type=${dev?.type ?: "default"} accepted=$ok totalMs=${SystemClock.elapsedRealtime() - startedAt}") } catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "setPreferredDevice failed: ${e.message}") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 036f5a5d..8779c17e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -81,6 +81,30 @@ class CallAudioRouter { private var preferWiredHeadset = false private var started = false + private var desiredRoute = AudioRoute.SPEAKER + var telecom: UtunConnection? = null + private set + + /** Для звонка Telecom — единственный владелец маршрута; рация продолжает использовать AudioManager. */ + fun bindTelecom(connection: UtunConnection?) { + if (telecom === connection) return + telecom?.onRouteChanged = null + telecom = connection + connection?.onRouteChanged = { actual -> + if (started && telecom === connection) { + route = actual + onChanged?.invoke(route, headsetAvailable) + } + } + connection?.confirmedRoute?.let { route = it } + LogManager.addLog("INFO", "CallAudioRouter", "routing owner=${if (connection != null) "telecom" else "audio-manager"}") + if (started) { + currentOutputDevice = null; currentInputDevice = null + onOutputDevice?.invoke(null); onInputDevice?.invoke(null) + applyRoute(desiredRoute) + onChanged?.invoke(route, headsetAvailable) + } + } private var deviceCallback: AudioDeviceCallback? = null private var btHeadset: BluetoothHeadset? = null private var btProfileProxyBound = false @@ -109,9 +133,9 @@ class CallAudioRouter { devices = am.getDevices(AudioManager.GET_DEVICES_ALL) val devicesMs = SystemClock.elapsedRealtime() - devicesAt hasEarpiece = findBuiltinEarpiece() != null - route = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute + desiredRoute = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute stageAt = SystemClock.elapsedRealtime() - applyRoute(route) + applyRoute(desiredRoute) val applyMs = SystemClock.elapsedRealtime() - stageAt stageAt = SystemClock.elapsedRealtime() refresh() @@ -131,13 +155,16 @@ class CallAudioRouter { unbindBtHeadsetProfile() try { - stopBluetoothSco() - am.isBluetoothScoOn = false - clearCommunicationDevice() + if (telecom == null) { + stopBluetoothSco() + am.isBluetoothScoOn = false + clearCommunicationDevice() + } } catch (e: Exception) { LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}") } devices = null + bindTelecom(null) LogManager.addLog("INFO", "CallAudioRouter", "stopped") } @@ -149,12 +176,12 @@ class CallAudioRouter { if (!automatic) autoHeadset = false val preferenceChanged = preferWiredHeadset != preferWired preferWiredHeadset = preferWired - if (route == r && !preferenceChanged) return - route = r + if (desiredRoute == r && route == r && !preferenceChanged) return + desiredRoute = r preferredRoute = r applyRoute(r) LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable automatic=$automatic)") - onChanged?.invoke(route, headsetAvailable) + if (telecom == null) onChanged?.invoke(route, headsetAvailable) } /** Инжекция сохранённого источника аудио (из DataStore) перед стартом звонка. */ @@ -181,6 +208,12 @@ class CallAudioRouter { /* ── применение маршрута ── */ private fun applyRoute(r: AudioRoute) { + telecom?.let { connection -> + connection.routeTo(r) + LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=$route owner=telecom") + return + } + route = r val startedAt = SystemClock.elapsedRealtime() try { var dev: AudioDeviceInfo? = null @@ -294,7 +327,7 @@ class CallAudioRouter { devices = am.getDevices(AudioManager.GET_DEVICES_ALL) /* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут, * иначе SCO не замаршрутизируется автоматически */ - if (route == AudioRoute.HEADSET) applyRoute(route) + if (telecom == null && route == AudioRoute.HEADSET) applyRoute(route) refresh() } @@ -308,22 +341,22 @@ class CallAudioRouter { /* авторежим: маршрут следует за гарнитурой */ if (autoHeadset) { val want = if (now) AudioRoute.HEADSET else fallbackRoute - if (route != want) { - route = want - applyRoute(route) + if (desiredRoute != want) { + desiredRoute = want + applyRoute(want) LogManager.addLog("INFO", "CallAudioRouter", "auto route -> $want") } } else { /* гарнитуру отключили — откатываемся на fallback-маршрут */ - if (!now && route == AudioRoute.HEADSET) { - route = fallbackRoute - applyRoute(route) + if (!now && desiredRoute == AudioRoute.HEADSET) { + desiredRoute = fallbackRoute + applyRoute(desiredRoute) LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> $fallbackRoute") } /* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */ - if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) { - route = AudioRoute.HEADSET - applyRoute(route) + if (now && !prev && preferredRoute == AudioRoute.HEADSET && desiredRoute != AudioRoute.HEADSET) { + desiredRoute = AudioRoute.HEADSET + applyRoute(desiredRoute) LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET") } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt index 7a0250b8..40b84bc4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt @@ -33,6 +33,12 @@ class UtunConnection( /** Доступные CallEndpoint'ы (для requestCallEndpointChange), обновляются системой. */ private val availableEndpoints = CopyOnWriteArrayList() + var onRouteChanged: ((AudioRoute) -> Unit)? = null + var confirmedRoute: AudioRoute? = null + private set + private var wantedRoute: AudioRoute? = null + private var requestedEndpoint: android.os.ParcelUuid? = null + private var requestSerial = 0L init { setConnectionProperties(PROPERTY_SELF_MANAGED) @@ -60,6 +66,36 @@ class UtunConnection( override fun onCallAudioStateChanged(audioState: CallAudioState?) { LogManager.addLog("DEBUG", "UtunConn", "audioState route=%s muted=%s".format(audioState?.route ?: -1, audioState?.isMuted ?: false)) + if (Build.VERSION.SDK_INT < 34 && audioState != null) { + val route = when (audioState.route) { + CallAudioState.ROUTE_EARPIECE -> AudioRoute.EARPIECE + CallAudioState.ROUTE_SPEAKER -> AudioRoute.SPEAKER + CallAudioState.ROUTE_BLUETOOTH, CallAudioState.ROUTE_WIRED_HEADSET -> AudioRoute.HEADSET + else -> null + } + if (route != null) confirmRoute(route) + else LogManager.addLog("WARN", "UtunConn", "unrecognized audio route=${audioState.route}") + } + } + + override fun onCallEndpointChanged(endpoint: CallEndpoint) { + val route = when (endpoint.endpointType) { + CallEndpoint.TYPE_EARPIECE -> AudioRoute.EARPIECE + CallEndpoint.TYPE_SPEAKER -> AudioRoute.SPEAKER + CallEndpoint.TYPE_BLUETOOTH, CallEndpoint.TYPE_WIRED_HEADSET -> AudioRoute.HEADSET + else -> null + } + if (route != null) confirmRoute(route, endpoint.identifier) + else LogManager.addLog("WARN", "UtunConn", "unrecognized endpoint type=${endpoint.endpointType}") + } + + private fun confirmRoute(route: AudioRoute, endpoint: android.os.ParcelUuid? = null) { + confirmedRoute = route + if (wantedRoute == route && (requestedEndpoint == null || requestedEndpoint == endpoint)) { + wantedRoute = null; requestedEndpoint = null + } + LogManager.addLog("INFO", "UtunConn", "route confirmed id=$callId route=$route pending=$wantedRoute") + onRouteChanged?.invoke(route) } override fun onAvailableCallEndpointsChanged(availableEndpoints: MutableList) { @@ -67,6 +103,7 @@ class UtunConnection( this.availableEndpoints.addAll(availableEndpoints) LogManager.addLog("DEBUG", "UtunConn", "availableEndpoints -> %s".format(availableEndpoints.joinToString { "${it.endpointName}/${it.endpointType}" })) + wantedRoute?.let { routeTo(it) } } /** Перевести вызов в звонящее состояние (входящий). */ @@ -88,10 +125,19 @@ class UtunConnection( /** Маршрутизация аудио через Telecom (CallEndpoint API на Android 14+, иначе setAudioRoute). */ fun routeTo(route: AudioRoute) { + wantedRoute = route + if (confirmedRoute == route && requestedEndpoint == null) { wantedRoute = null; return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val type = endpointTypeFor(route) - val ep = availableEndpoints.firstOrNull { it.endpointType == type } + val ep = when (route) { + AudioRoute.EARPIECE -> availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_EARPIECE } + AudioRoute.SPEAKER -> availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_SPEAKER } + AudioRoute.HEADSET -> availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_BLUETOOTH } + ?: availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_WIRED_HEADSET } + } if (ep != null) { + if (requestedEndpoint == ep.identifier) return + requestedEndpoint = ep.identifier + val serial = ++requestSerial try { val executor = ContextCompat.getMainExecutor(ChatApplication.instance) requestCallEndpointChange(ep, executor, object : OutcomeReceiver { @@ -99,21 +145,25 @@ class UtunConnection( LogManager.addLog("INFO", "UtunConn", "endpoint change ok -> %d".format(ep.endpointType)) } override fun onError(error: CallEndpointException) { + if (requestSerial == serial) { requestedEndpoint = null; wantedRoute = null } LogManager.addLog("WARN", "UtunConn", "endpoint change failed: %s".format(error.message)) } }) return } catch (e: Exception) { + requestedEndpoint = null; wantedRoute = null LogManager.addLog("WARN", "UtunConn", "requestCallEndpointChange threw: %s".format(e.message)) } } else { - LogManager.addLog("DEBUG", "UtunConn", "no endpoint of type %d, fallback to setAudioRoute".format(type)) + LogManager.addLog("WARN", "UtunConn", "route waiting for endpoint id=$callId requested=$route") } + return // На API 34+ один путь управления: CallEndpoint, без параллельного legacy-запроса. } val stateRoute = when (route) { AudioRoute.EARPIECE -> CallAudioState.ROUTE_EARPIECE AudioRoute.SPEAKER -> CallAudioState.ROUTE_SPEAKER - AudioRoute.HEADSET -> CallAudioState.ROUTE_WIRED_OR_EARPIECE + AudioRoute.HEADSET -> if ((callAudioState?.supportedRouteMask ?: 0) and CallAudioState.ROUTE_BLUETOOTH != 0) + CallAudioState.ROUTE_BLUETOOTH else CallAudioState.ROUTE_WIRED_HEADSET } try { setAudioRoute(stateRoute) @@ -123,9 +173,4 @@ class UtunConnection( } } - private fun endpointTypeFor(route: AudioRoute): Int = when (route) { - AudioRoute.EARPIECE -> CallEndpoint.TYPE_EARPIECE - AudioRoute.SPEAKER -> CallEndpoint.TYPE_SPEAKER - AudioRoute.HEADSET -> CallEndpoint.TYPE_WIRED_HEADSET - } }