From de83d7bbf4a449fdd3996f4474d21b67d13b9130 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 18:12:45 +0100 Subject: [PATCH] fix(android): own Bluetooth proxies across router lifecycle --- .../com/utun/chat/data/CallAudioRouter.kt | 72 +++++++++++++------ tools/chatgui-android/build.gradle.kts | 1 + 2 files changed, 51 insertions(+), 22 deletions(-) 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 89fe4d49..c12fe67a 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 @@ -29,8 +29,9 @@ enum class AudioRoute { EARPIECE, SPEAKER, HEADSET } * BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT * (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная. * - * Владелец — CallAudioEngine (start/stop в начале/конце звонка), т.к. именно он - * управляет режимом AudioManager (MODE_IN_COMMUNICATION). + * Владельцы — CallAudioEngine и MediaAudioController. start/stop и Bluetooth callbacks + * сериализованы монитором router: media start может приходить из I/O, callbacks — с main. + * Владение profile proxy сохраняется при disconnect сервиса и заканчивается явным close. */ class CallAudioRouter { @@ -82,7 +83,7 @@ class CallAudioRouter { ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager private var preferWiredHeadset = false - private var started = false + @Volatile private var started = false private var globalRouting = true private var desiredRoute = AudioRoute.SPEAKER var telecom: UtunConnection? = null @@ -111,12 +112,13 @@ class CallAudioRouter { } } private var deviceCallback: AudioDeviceCallback? = null - private var btHeadset: BluetoothHeadset? = null - private var btProfileProxyBound = false + @Volatile private var btHeadset: BluetoothHeadset? = null // Владение, не признак подключения сервиса. + private var btProfileAdapter: BluetoothAdapter? = null // Adapter, создавший текущий proxy; под монитором router. + private var btProfileGeneration = 0L // Поколение запроса, инвалидируется до закрытия; под монитором router. /* Неизменяемый снимок, обновляется на main; capture читает уже найденные устройства. */ @Volatile private var devices: Array? = null - fun start() { + @Synchronized fun start() { if (started) return started = true val startedAt = SystemClock.elapsedRealtime() @@ -152,6 +154,7 @@ class CallAudioRouter { } @Synchronized fun stop() { + ++btProfileGeneration if (!started) { bindTelecom(null); return } started = false @@ -170,7 +173,7 @@ class CallAudioRouter { } devices = null bindTelecom(null) - LogManager.addLog("INFO", "CallAudioRouter", "stopped") + LogManager.addLog("INFO", "CallAudioRouter", "stopped btGeneration=$btProfileGeneration") } fun setRoute(r: AudioRoute, preferWired: Boolean = false, automatic: Boolean = false) { @@ -436,44 +439,69 @@ class CallAudioRouter { } /* ── Bluetooth headset profile ── */ + /** Под монитором start; listener относится только к поколению этого запроса. */ private fun bindBtHeadsetProfile() { + val generation = ++btProfileGeneration try { val adapter = BluetoothAdapter.getDefaultAdapter() ?: return - adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener { + val accepted = adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { + if (profile != BluetoothProfile.HEADSET) return + synchronized(this@CallAudioRouter) { + if (!started || generation != btProfileGeneration) { + try { + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + LogManager.addLog("INFO", "CallAudioRouter", "stale BT proxy closed proxy=$proxy " + + "generation=$generation current=$btProfileGeneration") + } catch (e: Exception) { + LogManager.addLog("ERROR", "CallAudioRouter", "stale BT proxy close failed proxy=$proxy " + + "generation=$generation current=$btProfileGeneration: ${e.message}") + } + return + } + btProfileAdapter = adapter btHeadset = proxy as BluetoothHeadset - btProfileProxyBound = true - LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected") + LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected proxy=$proxy generation=$generation") onDevicesChanged() } } override fun onServiceDisconnected(profile: Int) { - if (profile == BluetoothProfile.HEADSET) { - btHeadset = null - btProfileProxyBound = false - LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected") + if (profile != BluetoothProfile.HEADSET) return + synchronized(this@CallAudioRouter) { + if (!started || generation != btProfileGeneration) { + LogManager.addLog("DEBUG", "CallAudioRouter", "stale BT disconnect ignored " + + "generation=$generation current=$btProfileGeneration") + return + } + // Сервис отключён; proxy остаётся нашим до closeProfileProxy. + LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected, proxy retained " + + "proxy=$btHeadset generation=$generation") onDevicesChanged() } } }, BluetoothProfile.HEADSET) + LogManager.addLog(if (accepted) "INFO" else "WARN", "CallAudioRouter", "bt profile request generation=$generation accepted=$accepted") } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudioRouter", "bt profile proxy failed: ${e.message}") + LogManager.addLog("WARN", "CallAudioRouter", "bt profile request failed generation=$generation: ${e.message}") } } + /** Под монитором stop; отсоединить владение до вызова Android, даже если сервис уже отключён. */ private fun unbindBtHeadsetProfile() { + val adapter = btProfileAdapter + val proxy = btHeadset + btProfileAdapter = null + btHeadset = null try { - val adapter = BluetoothAdapter.getDefaultAdapter() - if (adapter != null && btProfileProxyBound) { - adapter.closeProfileProxy(BluetoothProfile.HEADSET, btHeadset) + if (adapter != null && proxy != null) { + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + LogManager.addLog("INFO", "CallAudioRouter", "bt owned proxy closed proxy=$proxy generation=$btProfileGeneration") } } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudioRouter", "unbind bt profile failed: ${e.message}") + LogManager.addLog("ERROR", "CallAudioRouter", "bt owned proxy close failed proxy=$proxy " + + "generation=$btProfileGeneration: ${e.message}") } - btHeadset = null - btProfileProxyBound = false } private fun hasBtConnectPermission(): Boolean = diff --git a/tools/chatgui-android/build.gradle.kts b/tools/chatgui-android/build.gradle.kts index 3a6ec8d6..c175b5f6 100644 --- a/tools/chatgui-android/build.gradle.kts +++ b/tools/chatgui-android/build.gradle.kts @@ -1,4 +1,5 @@ plugins { id("com.android.application") version "8.2.0" apply false id("org.jetbrains.kotlin.android") version "1.9.20" apply false + id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false }