Browse Source

Use Telecom as the sole route owner for Android calls

master
evgeny 5 days ago
parent
commit
9c4219d837
  1. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  2. 69
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  3. 63
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt

25
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}")
}

69
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")
}
}

63
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<CallEndpoint>()
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<CallEndpoint>) {
@ -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<Void, CallEndpointException> {
@ -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
}
}

Loading…
Cancel
Save