fix(android): own Bluetooth proxies across router lifecycle

This commit is contained in:
evgeny committed 2026-10-26 20:21:58 +01:00
1 parent 3987295707
commit de83d7bbf4
2 files changed
+51 -22

No files matched your search

@@ -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<AudioDeviceInfo>? = 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 =
+1
View File
@@ -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
}