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 9f55e151..e444e1bc 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 @@ -157,10 +157,10 @@ class CallAudioEngine { capFramesRead = 0L syncAecForRoute(router.route) - wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + wiredActive = router.telecom == null && router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null wantedWired = wiredActive router.onInputDevice = { _ -> - wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + wantedWired = router.telecom == null && router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null } router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } 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 8779c17e..74384d42 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 @@ -89,17 +89,19 @@ class CallAudioRouter { fun bindTelecom(connection: UtunConnection?) { if (telecom === connection) return telecom?.onRouteChanged = null + telecom?.onEndpointsChanged = null telecom = connection + currentOutputDevice = null; currentInputDevice = null connection?.onRouteChanged = { actual -> if (started && telecom === connection) { route = actual onChanged?.invoke(route, headsetAvailable) } } + connection?.onEndpointsChanged = { if (started && telecom === connection) refresh() } 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) @@ -147,7 +149,7 @@ class CallAudioRouter { } fun stop() { - if (!started) return + if (!started) { bindTelecom(null); return } started = false deviceCallback?.let { am.unregisterAudioDeviceCallback(it) } @@ -180,7 +182,7 @@ class CallAudioRouter { desiredRoute = r preferredRoute = r applyRoute(r) - LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable automatic=$automatic)") + LogManager.addLog("INFO", "CallAudioRouter", "requested route=$r actual=$route headsetAvailable=$headsetAvailable automatic=$automatic") if (telecom == null) onChanged?.invoke(route, headsetAvailable) } @@ -210,7 +212,7 @@ class CallAudioRouter { private fun applyRoute(r: AudioRoute) { telecom?.let { connection -> connection.routeTo(r) - LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=$route owner=telecom") + LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=${connection.confirmedRoute} owner=telecom") return } route = r @@ -366,8 +368,8 @@ class CallAudioRouter { refreshInputDevices() } - private fun isHeadsetConnected(): Boolean = - findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected() + private fun isHeadsetConnected(): Boolean = telecom?.headsetAvailable + ?: (findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()) private fun findWiredDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSink && isWiredType(it.type) } 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 40b84bc4..d377eade 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 @@ -34,11 +34,18 @@ class UtunConnection( /** Доступные CallEndpoint'ы (для requestCallEndpointChange), обновляются системой. */ private val availableEndpoints = CopyOnWriteArrayList() var onRouteChanged: ((AudioRoute) -> Unit)? = null + var onEndpointsChanged: (() -> Unit)? = null + val headsetAvailable: Boolean? get() = if (Build.VERSION.SDK_INT >= 34) { + if (availableEndpoints.isEmpty()) null else availableEndpoints.any { + it.endpointType == CallEndpoint.TYPE_BLUETOOTH || it.endpointType == CallEndpoint.TYPE_WIRED_HEADSET + } + } else callAudioState?.let { it.supportedRouteMask and (CallAudioState.ROUTE_BLUETOOTH or CallAudioState.ROUTE_WIRED_HEADSET) != 0 } var confirmedRoute: AudioRoute? = null private set private var wantedRoute: AudioRoute? = null private var requestedEndpoint: android.os.ParcelUuid? = null private var requestSerial = 0L + private var requestedLegacyRoute = 0 init { setConnectionProperties(PROPERTY_SELF_MANAGED) @@ -75,6 +82,8 @@ class UtunConnection( } if (route != null) confirmRoute(route) else LogManager.addLog("WARN", "UtunConn", "unrecognized audio route=${audioState.route}") + onEndpointsChanged?.invoke() + wantedRoute?.let { routeTo(it) } } } @@ -92,7 +101,7 @@ class UtunConnection( private fun confirmRoute(route: AudioRoute, endpoint: android.os.ParcelUuid? = null) { confirmedRoute = route if (wantedRoute == route && (requestedEndpoint == null || requestedEndpoint == endpoint)) { - wantedRoute = null; requestedEndpoint = null + wantedRoute = null; requestedLegacyRoute = 0 } LogManager.addLog("INFO", "UtunConn", "route confirmed id=$callId route=$route pending=$wantedRoute") onRouteChanged?.invoke(route) @@ -103,6 +112,7 @@ class UtunConnection( this.availableEndpoints.addAll(availableEndpoints) LogManager.addLog("DEBUG", "UtunConn", "availableEndpoints -> %s".format(availableEndpoints.joinToString { "${it.endpointName}/${it.endpointType}" })) + onEndpointsChanged?.invoke() wantedRoute?.let { routeTo(it) } } @@ -114,6 +124,8 @@ class UtunConnection( /** Завершение с причиной (коды приложения: 0 — отбой, 2 — отклонено, 7 — ошибка). */ fun markDisconnected(reason: Int) { + wantedRoute = null; requestedEndpoint = null; requestedLegacyRoute = 0; ++requestSerial + onRouteChanged = null; onEndpointsChanged = null val code = when (reason) { 2 -> DisconnectCause.REJECTED 7 -> DisconnectCause.ERROR @@ -126,8 +138,9 @@ class UtunConnection( /** Маршрутизация аудио через Telecom (CallEndpoint API на Android 14+, иначе setAudioRoute). */ fun routeTo(route: AudioRoute) { wantedRoute = route - if (confirmedRoute == route && requestedEndpoint == null) { wantedRoute = null; return } + if (confirmedRoute == route && requestedEndpoint == null && requestedLegacyRoute == 0) { wantedRoute = null; return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (requestedEndpoint != null) return // До ответа сохраняем только последнее желание пользователя. val ep = when (route) { AudioRoute.EARPIECE -> availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_EARPIECE } AudioRoute.SPEAKER -> availableEndpoints.firstOrNull { it.endpointType == CallEndpoint.TYPE_SPEAKER } @@ -143,10 +156,19 @@ class UtunConnection( requestCallEndpointChange(ep, executor, object : OutcomeReceiver { override fun onResult(result: Void?) { LogManager.addLog("INFO", "UtunConn", "endpoint change ok -> %d".format(ep.endpointType)) + if (requestSerial == serial) { + requestedEndpoint = null + wantedRoute?.takeIf { it != route }?.let { routeTo(it) } + } } override fun onError(error: CallEndpointException) { - if (requestSerial == serial) { requestedEndpoint = null; wantedRoute = null } LogManager.addLog("WARN", "UtunConn", "endpoint change failed: %s".format(error.message)) + if (requestSerial == serial) { + requestedEndpoint = null + val next = wantedRoute?.takeIf { it != route } + wantedRoute = null + next?.let { routeTo(it) } + } } }) return @@ -159,16 +181,28 @@ class UtunConnection( } return // На API 34+ один путь управления: CallEndpoint, без параллельного legacy-запроса. } + val supported = callAudioState?.supportedRouteMask ?: run { + LogManager.addLog("WARN", "UtunConn", "route waiting for audio state id=$callId requested=$route") + return + } val stateRoute = when (route) { AudioRoute.EARPIECE -> CallAudioState.ROUTE_EARPIECE AudioRoute.SPEAKER -> CallAudioState.ROUTE_SPEAKER - AudioRoute.HEADSET -> if ((callAudioState?.supportedRouteMask ?: 0) and CallAudioState.ROUTE_BLUETOOTH != 0) + AudioRoute.HEADSET -> if (supported and CallAudioState.ROUTE_BLUETOOTH != 0) CallAudioState.ROUTE_BLUETOOTH else CallAudioState.ROUTE_WIRED_HEADSET } + if (supported and stateRoute == 0) { + wantedRoute = null + LogManager.addLog("WARN", "UtunConn", "route unavailable id=$callId requested=$route supported=$supported") + return + } + if (requestedLegacyRoute == stateRoute) return + requestedLegacyRoute = stateRoute try { setAudioRoute(stateRoute) LogManager.addLog("INFO", "UtunConn", "audio route -> %d (route=%s)".format(stateRoute, route)) } catch (e: Exception) { + requestedLegacyRoute = 0; wantedRoute = null LogManager.addLog("WARN", "UtunConn", "setAudioRoute failed: %s".format(e.message)) } }