From a6ae4df3ff2e1398ecd0977715108a95b6bb39a0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 23:53:21 +0100 Subject: [PATCH] Harden Android audio router lifecycle and route selection Roll back failed startup, finish independent stop cleanup, invalidate stale device and Telecom callbacks, and serialize router mutations. Publish input/output targets after accepted selection and support retries of rejected requests. Separate modern communication device selection from legacy SCO, refresh replaced descriptors, and support A2DP and USB input-only devices in per-track mode. Clarify routing and observer ownership contracts. Validation: 45 router scenarios, 390 JVM tests, assembleDebug, core build, and full check.sh (225 tests plus proxy/burst/load) passed. Previous router fails 25 of the 30 new cases. --- .../com/utun/chat/data/CallAudioRouter.kt | 245 +++++----- .../main/kotlin/android/media/HostAudio.kt | 48 +- .../utun/chat/data/RouterHostEnvironment.kt | 8 +- .../chat/data/CallAudioRouterRoutingTest.kt | 426 ++++++++++++++++++ 4 files changed, 614 insertions(+), 113 deletions(-) create mode 100644 tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioRouterRoutingTest.kt 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 208ad146..e5044d1d 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 @@ -19,18 +19,22 @@ import com.utun.chat.ChatApplication enum class AudioRoute { EARPIECE, SPEAKER, HEADSET } /** - * Маршрутизация аудио звонка поверх AudioManager + мониторинг гарнитуры. + * Маршрутизация звонка и общего media-аудио; мониторинг проводных/USB и Bluetooth устройств. * * - EARPIECE: разговорный динамик (isSpeakerphoneOn=false). * - SPEAKER: громкая связь (isSpeakerphoneOn=true). - * - HEADSET: проводная/USB гарнитура или Bluetooth (SCO) — если подключена. + * - HEADSET: проводная/USB, Bluetooth SCO/LE; без глобального маршрута также A2DP. * * Гарнитура детектится через AudioDeviceCallback (проводная, USB, SCO) и * BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT - * (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная. + * нельзя читать этот профиль; опубликованные AudioManager устройства остаются доступны. * - * Владельцы — CallAudioEngine и MediaAudioController. start/stop и Bluetooth callbacks - * сериализованы монитором router: media start может приходить из I/O, callbacks — с main. + * Владельцы — CallAudioEngine и MediaAudioController. Все изменения сериализованы монитором router: + * media start может приходить из I/O, callbacks — с main. Observer вызывается синхронно под монитором, + * должен завершаться быстро и не ждать другой поток, которому нужен router. + * start откатывает частичную инициализацию и бросает ошибку владельцу; stop выполняет best-effort cleanup. + * AudioManager: route — обработанная категория, current* — цели, null при ожидании BT. Отказ не публикует новую цель. + * Физический маршрут подтверждает AudioTrack/AudioRecord; при Telecom route меняется только по его подтверждению. * Владение profile proxy сохраняется при disconnect сервиса и заканчивается явным close. */ class CallAudioRouter { @@ -58,23 +62,23 @@ class CallAudioRouter { private set /** Уведомление об изменении маршрута или доступности гарнитуры. */ - var onChanged: ((AudioRoute, Boolean) -> Unit)? = null + @Volatile var onChanged: ((AudioRoute, Boolean) -> Unit)? = null /** Начало перестройки тракта: кратковременные underrun не характеризуют размер буфера. */ - var onRouteRequested: (() -> Unit)? = null + @Volatile var onRouteRequested: (() -> Unit)? = null /** Уведомление об изменении доступности входных устройств (проводная, BT). */ - var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null + @Volatile var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null /** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */ - var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null + @Volatile var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null @Volatile var currentOutputDevice: AudioDeviceInfo? = null private set /** Целевое устройство захвата (для прямого роутирования AudioRecord через setPreferredDevice). * non-null только для гарнитуры (проводная/USB/BT-SCO); для динамика/громкой — null (дефолт). */ - var onInputDevice: ((AudioDeviceInfo?) -> Unit)? = null + @Volatile var onInputDevice: ((AudioDeviceInfo?) -> Unit)? = null @Volatile var currentInputDevice: AudioDeviceInfo? = null private set @@ -86,28 +90,38 @@ class CallAudioRouter { @Volatile private var started = false private var globalRouting = true private var desiredRoute = AudioRoute.SPEAKER - var telecom: UtunConnection? = null + private var routeApplied = false + private var telecomGeneration = 0L + @Volatile var telecom: UtunConnection? = null private set /** Для звонка Telecom — единственный владелец маршрута; рация продолжает использовать AudioManager. */ - fun bindTelecom(connection: UtunConnection?) { + @Synchronized fun bindTelecom(connection: UtunConnection?) { if (telecom === connection) return + val generation = ++telecomGeneration telecom?.onRouteChanged = null telecom?.onEndpointsChanged = null telecom = connection + routeApplied = false currentOutputDevice = null; currentInputDevice = null connection?.onRouteChanged = { actual -> - if (started && telecom === connection) { - route = actual - onChanged?.invoke(route, headsetAvailable) + synchronized(this) { + if (started && telecom === connection && generation == telecomGeneration) { + route = actual + onChanged?.invoke(route, headsetAvailable) + } + } + } + connection?.onEndpointsChanged = { + synchronized(this) { + if (started && telecom === connection && generation == telecomGeneration) onDevicesChanged() } } - 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) { onOutputDevice?.invoke(null); onInputDevice?.invoke(null) - applyRoute(desiredRoute) + onDevicesChanged(force = true) onChanged?.invoke(route, headsetAvailable) } } @@ -124,33 +138,41 @@ class CallAudioRouter { val startedAt = SystemClock.elapsedRealtime() val cb = object : AudioDeviceCallback() { - override fun onAudioDevicesAdded(addedDevices: Array?) = onDevicesChanged() - override fun onAudioDevicesRemoved(removedDevices: Array?) = onDevicesChanged() + private fun changed() { + synchronized(this@CallAudioRouter) { + if (started && deviceCallback === this) onDevicesChanged() + } + } + override fun onAudioDevicesAdded(addedDevices: Array?) = changed() + override fun onAudioDevicesRemoved(removedDevices: Array?) = changed() } - var stageAt = SystemClock.elapsedRealtime() - am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper())) - deviceCallback = cb - val callbackMs = SystemClock.elapsedRealtime() - stageAt + try { + var stageAt = SystemClock.elapsedRealtime() + deviceCallback = cb + am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper())) + val callbackMs = SystemClock.elapsedRealtime() - stageAt - stageAt = SystemClock.elapsedRealtime() - bindBtHeadsetProfile() - val btProfileMs = SystemClock.elapsedRealtime() - stageAt + stageAt = SystemClock.elapsedRealtime() + bindBtHeadsetProfile() + val btProfileMs = SystemClock.elapsedRealtime() - stageAt - val devicesAt = SystemClock.elapsedRealtime() - devices = am.getDevices(AudioManager.GET_DEVICES_ALL) - val devicesMs = SystemClock.elapsedRealtime() - devicesAt - hasEarpiece = findBuiltinEarpiece() != null - desiredRoute = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute - stageAt = SystemClock.elapsedRealtime() - applyRoute(desiredRoute) - val applyMs = SystemClock.elapsedRealtime() - stageAt - stageAt = SystemClock.elapsedRealtime() - refresh(forceAuto = true) - val refreshMs = SystemClock.elapsedRealtime() - stageAt - LogManager.addLog("INFO", "CallAudioRouter", - "started totalMs=${SystemClock.elapsedRealtime() - startedAt} callbackMs=$callbackMs btProfileMs=$btProfileMs " + - "devicesMs=$devicesMs applyMs=$applyMs refreshMs=$refreshMs " + - "devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute") + val devicesAt = SystemClock.elapsedRealtime() + devices = am.getDevices(AudioManager.GET_DEVICES_ALL) + val devicesMs = SystemClock.elapsedRealtime() - devicesAt + hasEarpiece = findBuiltinEarpiece() != null + desiredRoute = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute + stageAt = SystemClock.elapsedRealtime() + check(refresh(forceAuto = true)) { "Initial route request rejected: $desiredRoute" } + val refreshMs = SystemClock.elapsedRealtime() - stageAt + LogManager.addLog("INFO", "CallAudioRouter", + "started totalMs=${SystemClock.elapsedRealtime() - startedAt} callbackMs=$callbackMs btProfileMs=$btProfileMs " + + "devicesMs=$devicesMs refreshMs=$refreshMs " + + "devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute") + } catch (e: Exception) { + LogManager.addLog("ERROR", "CallAudioRouter", "start failed, releasing partial lifecycle: $e") + stop() + throw e + } } @Synchronized fun stop() { @@ -158,48 +180,53 @@ class CallAudioRouter { if (!started) { bindTelecom(null); return } started = false - deviceCallback?.let { am.unregisterAudioDeviceCallback(it) } + val callback = deviceCallback deviceCallback = null + try { callback?.let { am.unregisterAudioDeviceCallback(it) } } catch (e: Exception) { + LogManager.addLog("ERROR", "CallAudioRouter", "device callback unregister failed: $e") + } unbindBtHeadsetProfile() try { if (telecom == null && globalRouting) { stopBluetoothSco() - am.isBluetoothScoOn = false clearCommunicationDevice() } } catch (e: Exception) { LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}") } devices = null + routeApplied = false + currentInputDevice = null; currentOutputDevice = null + headsetAvailable = false; wiredInputAvailable = false; btInputAvailable = false bindTelecom(null) LogManager.addLog("INFO", "CallAudioRouter", "stopped btGeneration=$btProfileGeneration") } - fun setRoute(r: AudioRoute, preferWired: Boolean = false, automatic: Boolean = false) { + @Synchronized fun setRoute(r: AudioRoute, preferWired: Boolean = false, automatic: Boolean = false) { if (r == AudioRoute.HEADSET && !headsetAvailable) { LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") return } val preferenceChanged = preferWiredHeadset != preferWired preferWiredHeadset = preferWired - if (desiredRoute == r && route == r && !preferenceChanged) return + if (desiredRoute == r && route == r && !preferenceChanged && routeApplied) return desiredRoute = r preferredRoute = r if (!automatic && r != AudioRoute.HEADSET) fallbackRoute = r - applyRoute(r) + if (!started || !applyRoute(r)) return LogManager.addLog("INFO", "CallAudioRouter", "requested route=$r actual=$route headsetAvailable=$headsetAvailable automatic=$automatic") if (telecom == null) onChanged?.invoke(route, headsetAvailable) } /** Инжекция сохранённого источника аудио (из DataStore) перед стартом звонка. */ - fun setPreferredRoute(r: AudioRoute) { + @Synchronized fun setPreferredRoute(r: AudioRoute) { preferredRoute = r LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r") } /** Включить/выключить автоследование; reapply немедленно применяет авторежим к текущим устройствам. */ - fun setAutoHeadset(enabled: Boolean) { + @Synchronized fun setAutoHeadset(enabled: Boolean) { val resetPreference = enabled && preferWiredHeadset autoHeadset = enabled if (enabled) preferWiredHeadset = false @@ -208,7 +235,7 @@ class CallAudioRouter { } /** Немедленно переоценить маршрут (например, после включения автоследования). */ - fun reapply() { + @Synchronized fun reapply() { if (!started) return devices = am.getDevices(AudioManager.GET_DEVICES_ALL) refresh(forceAuto = true) @@ -221,7 +248,6 @@ class CallAudioRouter { if (globalRouting == enabled) return if (!enabled && started && telecom == null) { stopBluetoothSco() - am.isBluetoothScoOn = false clearCommunicationDevice() } globalRouting = enabled @@ -229,64 +255,67 @@ class CallAudioRouter { if (started) applyRoute(desiredRoute) } - @Synchronized private fun applyRoute(r: AudioRoute) { - onRouteRequested?.invoke() - telecom?.let { connection -> - connection.routeTo(r) - LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=${connection.confirmedRoute} owner=telecom") - return - } - route = r + @Synchronized private fun applyRoute(r: AudioRoute): Boolean { + routeApplied = false val startedAt = SystemClock.elapsedRealtime() try { + onRouteRequested?.invoke() + telecom?.let { connection -> + connection.routeTo(r) + routeApplied = true + LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=${connection.confirmedRoute} owner=telecom") + return true + } + val resolved = if (r == AudioRoute.EARPIECE && !hasEarpiece) AudioRoute.SPEAKER else r if (!globalRouting) { val wired = findWiredDevice() - val headset = if (preferWiredHeadset && wired != null) wired else findBtMediaDevice() ?: findBtScoDevice() ?: wired - val output = when (r) { + val wiredInput = wiredInputDevice() + val headset = if (preferWiredHeadset && (wired != null || wiredInput != null)) wired ?: findBuiltinSpeaker() + else findBtMediaDevice() ?: findBtScoDevice() ?: wired ?: findBuiltinSpeaker()?.takeIf { wiredInput != null } + val output = when (resolved) { AudioRoute.SPEAKER -> findBuiltinSpeaker() AudioRoute.EARPIECE -> findBuiltinEarpiece() ?: findBuiltinSpeaker() AudioRoute.HEADSET -> headset } - val input = if (r != AudioRoute.HEADSET) null + val input = if (resolved != AudioRoute.HEADSET) null else if (headset != null && isBtType(headset.type)) btInputDevice() else wiredInputDevice() currentOutputDevice = output; currentInputDevice = input + route = resolved; routeApplied = true onOutputDevice?.invoke(output); onInputDevice?.invoke(input) - LogManager.addLog("INFO", "CallAudioRouter", "per-track route=$r output=${output?.id} input=${input?.id}") - return + LogManager.addLog("INFO", "CallAudioRouter", "per-track requested=$r route=$route output=${output?.id} input=${input?.id}") + return true } var dev: AudioDeviceInfo? = null var inDev: AudioDeviceInfo? = null - when (r) { + when (resolved) { AudioRoute.EARPIECE -> { stopBluetoothSco() - am.isBluetoothScoOn = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dev = findBuiltinEarpiece() ?: findBuiltinSpeaker() - if (dev != null) am.setCommunicationDevice(dev) - else am.isSpeakerphoneOn = false + val target = findBuiltinEarpiece() ?: findBuiltinSpeaker() ?: error("No built-in output for $resolved") + check(am.setCommunicationDevice(target)) { "Communication device rejected id=${target.id}" } + dev = target } else { am.isSpeakerphoneOn = false } } AudioRoute.SPEAKER -> { stopBluetoothSco() - am.isBluetoothScoOn = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dev = findBuiltinSpeaker() - if (dev != null) am.setCommunicationDevice(dev) - else am.isSpeakerphoneOn = true + val target = findBuiltinSpeaker() ?: error("No built-in output for $resolved") + check(am.setCommunicationDevice(target)) { "Communication device rejected id=${target.id}" } + dev = target } else { am.isSpeakerphoneOn = true } } AudioRoute.HEADSET -> { - am.isSpeakerphoneOn = false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) am.isSpeakerphoneOn = false val wired = findWiredDevice() val useWired = preferWiredHeadset && wired != null val bt = if (useWired) null else findBtScoDevice() if (bt != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - am.setCommunicationDevice(bt) + check(am.setCommunicationDevice(bt)) { "Communication device rejected id=${bt.id}" } } else { startBluetoothSco() } @@ -295,15 +324,16 @@ class CallAudioRouter { } else if (!useWired && isBtHeadsetProfileConnected()) { /* классический BT: SCO ещё не поднят — запускаем и ждём * AudioDeviceCallback, который пере-применит маршрут */ - startBluetoothSco() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) startBluetoothSco() + else LogManager.addLog("INFO", "CallAudioRouter", "BT route waiting for communication endpoint") } else { stopBluetoothSco() - am.isBluetoothScoOn = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) { - am.setCommunicationDevice(wired) + check(am.setCommunicationDevice(wired)) { "Communication device rejected id=${wired.id}" } } else { /* API<31: проводная гарнитура роутится автоматически при speakerphone=off */ - am.isSpeakerphoneOn = false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) am.isSpeakerphoneOn = false + else error("Headset output unavailable") } dev = wired inDev = wiredInputDevice() @@ -311,15 +341,19 @@ class CallAudioRouter { } } currentOutputDevice = dev - onOutputDevice?.invoke(dev) currentInputDevice = inDev + route = resolved; routeApplied = true + onOutputDevice?.invoke(dev) onInputDevice?.invoke(inDev) LogManager.addLog("INFO", "CallAudioRouter", - "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} " + + "requested=$r route=$route dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} " + "preferWired=$preferWiredHeadset mode=${am.mode} speaker=${am.isSpeakerphoneOn} " + "totalMs=${SystemClock.elapsedRealtime() - startedAt}") + return true } catch (e: Exception) { - LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") + routeApplied = false + LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute requested=$r retained=$route failed: $e") + return false } } @@ -360,56 +394,61 @@ class CallAudioRouter { } /* ── мониторинг гарнитуры ── */ - private fun onDevicesChanged() { + @Synchronized private fun onDevicesChanged(force: Boolean = false) { if (!started) return - val previous = devices?.map { "${it.id}/${it.type}/${it.isSink}/${it.isSource}" }?.sorted() - devices = am.getDevices(AudioManager.GET_DEVICES_ALL) - val current = devices?.map { "${it.id}/${it.type}/${it.isSink}/${it.isSource}" }?.sorted() - if (previous != current) LogManager.addLog("INFO", "CallAudioRouter", - "devices changed id/type/output/input=$current telecomHeadset=${telecom?.headsetAvailable} route=$route") - /* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут, - * иначе SCO не замаршрутизируется автоматически */ - if (telecom == null && route == AudioRoute.HEADSET) applyRoute(route) - refresh() + try { + val previous = devices?.map { "${it.id}/${it.type}/${it.isSink}/${it.isSource}" }?.sorted() + devices = am.getDevices(AudioManager.GET_DEVICES_ALL) + val current = devices?.map { "${it.id}/${it.type}/${it.isSink}/${it.isSource}" }?.sorted() + if (previous != current) LogManager.addLog("INFO", "CallAudioRouter", + "devices changed id/type/output/input=$current telecomHeadset=${telecom?.headsetAvailable} route=$route") + hasEarpiece = findBuiltinEarpiece() != null + // Замена descriptor требует нового запроса даже при прежней категории маршрута. + refresh(forceAuto = force || previous != current || route == AudioRoute.HEADSET) + } catch (e: Exception) { + LogManager.addLog("WARN", "CallAudioRouter", "device refresh failed: $e") + } } - private fun refresh(forceAuto: Boolean = false) { + private fun refresh(forceAuto: Boolean = false): Boolean { val now = isHeadsetConnected() val prev = headsetAvailable headsetAvailable = now if (now != prev) { LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now") } + val previousDesired = desiredRoute /* авторежим: маршрут следует за гарнитурой */ if (autoHeadset && (forceAuto || now != prev)) { val want = if (now) AudioRoute.HEADSET else fallbackRoute if (desiredRoute != want) { desiredRoute = want - applyRoute(want) LogManager.addLog("INFO", "CallAudioRouter", "auto route -> $want") } } else if (!autoHeadset) { /* гарнитуру отключили — откатываемся на fallback-маршрут */ if (!now && desiredRoute == AudioRoute.HEADSET) { desiredRoute = fallbackRoute - applyRoute(desiredRoute) LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> $fallbackRoute") } /* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */ if (now && !prev && preferredRoute == AudioRoute.HEADSET && desiredRoute != AudioRoute.HEADSET) { desiredRoute = AudioRoute.HEADSET - applyRoute(desiredRoute) LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET") } } - if (now != prev || route == AudioRoute.HEADSET) { + val applied = if (forceAuto || desiredRoute != previousDesired || !routeApplied) applyRoute(desiredRoute) else true + if (applied && (now != prev || route == AudioRoute.HEADSET)) { onChanged?.invoke(route, now) } refreshInputDevices() + return applied } private fun isHeadsetConnected(): Boolean = telecom?.headsetAvailable - ?: (findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()) + ?: (findWiredDevice() != null || (!globalRouting && findBtMediaDevice() != null) || + (!globalRouting && wiredInputDevice() != null) || + findBtScoDevice() != null || isBtHeadsetProfileConnected()) private fun findWiredDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSink && isWiredType(it.type) } @@ -527,17 +566,17 @@ class CallAudioRouter { } private fun startBluetoothSco() { - try { - am.startBluetoothSco() - am.isBluetoothScoOn = true - } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudioRouter", "startBluetoothSco failed: ${e.message}") - } + am.startBluetoothSco() + am.isBluetoothScoOn = true } private fun stopBluetoothSco() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return try { am.stopBluetoothSco() } catch (e: Exception) { LogManager.addLog("WARN", "CallAudioRouter", "stopBluetoothSco failed: ${e.message}") } + try { am.isBluetoothScoOn = false } catch (e: Exception) { + LogManager.addLog("WARN", "CallAudioRouter", "clear Bluetooth SCO flag failed: ${e.message}") + } } } diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostAudio.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostAudio.kt index 5360cf9a..9f12ac5b 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostAudio.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostAudio.kt @@ -47,17 +47,47 @@ class AudioManager { check(!failAbandon) { "abandon unavailable" } return abandonResult } + val operations = mutableListOf() + var failures = emptySet() + private fun operation(name: String) { + operations.add(name) + check(name !in failures) { "audio operation failed: $name" } + } var isBluetoothScoOn = false + set(value) { operation("sco=$value"); field = value } var isSpeakerphoneOn = false + set(value) { operation("speaker=$value"); field = value } var mode = 0 val callbacks = mutableSetOf() - private val devices = arrayOf(AudioDeviceInfo(1, 2, true, false, "speaker"), AudioDeviceInfo(2, 15, false, true, "mic")) - val availableCommunicationDevices: List get() = devices.filter { it.isSink } - fun registerAudioDeviceCallback(callback: AudioDeviceCallback, handler: Handler) { check(callbacks.add(callback)) } - fun unregisterAudioDeviceCallback(callback: AudioDeviceCallback) { check(callbacks.remove(callback)) } - fun getDevices(flags: Int): Array { check(flags == GET_DEVICES_ALL); return devices } - fun setCommunicationDevice(device: AudioDeviceInfo): Boolean = device in devices - fun clearCommunicationDevice() {} - fun startBluetoothSco() {} - fun stopBluetoothSco() {} + val callbackHistory = mutableListOf() + var devices = arrayOf(AudioDeviceInfo(1, 2, true, false, "speaker"), AudioDeviceInfo(2, 15, false, true, "mic")) + var communicationDevices: List? = null + var communicationDevice: AudioDeviceInfo? = null + private set + var acceptCommunicationDevice = true + val availableCommunicationDevices: List get() { + operation("available") + return communicationDevices ?: devices.filter { it.isSink && it.type != AudioDeviceInfo.TYPE_BLUETOOTH_A2DP } + } + fun registerAudioDeviceCallback(callback: AudioDeviceCallback, handler: Handler) { + callbackHistory.add(callback) + operation("register") + check(callbacks.add(callback)) + operation("register after") + } + fun unregisterAudioDeviceCallback(callback: AudioDeviceCallback) { + operation("unregister") + callbacks.remove(callback) // Android допускает отсутствие такой регистрации. + operation("unregister after") + } + fun getDevices(flags: Int): Array { operation("devices"); check(flags == GET_DEVICES_ALL); return devices.copyOf() } + fun setCommunicationDevice(device: AudioDeviceInfo): Boolean { + operation("select=${device.id}") + if (!acceptCommunicationDevice || device !in availableCommunicationDevices) return false + communicationDevice = device + return true + } + fun clearCommunicationDevice() { operation("clear"); communicationDevice = null } + fun startBluetoothSco() { operation("start sco") } + fun stopBluetoothSco() { operation("stop sco") } } diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt index 77fcb4e5..803c78e0 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt @@ -12,7 +12,13 @@ class UtunConnection { var onEndpointsChanged: (() -> Unit)? = null var confirmedRoute: AudioRoute? = null var headsetAvailable: Boolean? = null - fun routeTo(route: AudioRoute) {} + val routeRequests = mutableListOf() + var failRoute = false + fun routeTo(route: AudioRoute) { + check(!failRoute) { "Telecom request unavailable" } + routeRequests.add(route) + } + fun confirm(route: AudioRoute) { confirmedRoute = route; onRouteChanged?.invoke(route) } } /** Собирает настоящий LogManager API router; вывод можно отключить для конкурентной серии. */ diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioRouterRoutingTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioRouterRoutingTest.kt new file mode 100644 index 00000000..f9598b78 --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioRouterRoutingTest.kt @@ -0,0 +1,426 @@ +package com.utun.chat.data + +import android.bluetooth.BluetoothAdapter +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import com.utun.chat.ChatApplication +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящий router: отказ API, запоздалые события, цели ввода/вывода и независимое владение Telecom. */ +class CallAudioRouterRoutingTest { + private lateinit var audio: AudioManager + private lateinit var router: CallAudioRouter + private lateinit var adapter: BluetoothAdapter + private val speaker = device(1, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) + private val earpiece = device(2, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) + private val mic = device(3, AudioDeviceInfo.TYPE_BUILTIN_MIC, input = true) + private val wired = device(4, AudioDeviceInfo.TYPE_USB_HEADSET) + private val wiredMic = device(5, AudioDeviceInfo.TYPE_USB_HEADSET, input = true) + private val bt = device(6, AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + private val btMic = device(7, AudioDeviceInfo.TYPE_BLUETOOTH_SCO, input = true) + + private fun device(id: Int, type: Int, input: Boolean = false) = AudioDeviceInfo(id, type, !input, input, "device-$id") + + @Before fun prepare() { + Build.VERSION.SDK_INT = 36 + audio = AudioManager().also { AudioManager.shared = it; it.devices = arrayOf(speaker, earpiece, mic) } + ChatApplication.instance = Context() + adapter = BluetoothAdapter() + BluetoothAdapter.current = null + router = CallAudioRouter() + LogManager.entries.clear(); LogManager.console = true + } + + @After fun release() { + audio.failures = emptySet() + router.onOutputDevice = null; router.onChanged = null + router.stop() + // Неполученные proxy принадлежат отменённому запросу до доставки callback Android. + adapter.requests.forEachIndexed { index, request -> if (request.proxy in adapter.live) adapter.connect(index) } + assertTrue("callback leak", audio.callbacks.isEmpty()) + assertTrue("proxy leak", adapter.live.isEmpty()) + Build.VERSION.SDK_INT = 36 + } + + private fun diagnostic(fragment: String) = assertTrue("Missing diagnostic: $fragment", + LogManager.entries.any { it.message.contains(fragment) && it.level in listOf("WARN", "ERROR") }) + + private fun event() = audio.callbacks.single().onAudioDevicesAdded(emptyArray()) + private fun hardwareOperations() = audio.operations.filter { + it.startsWith("select=") || it.startsWith("sco=") || it.startsWith("speaker=") || it in listOf("clear", "start sco", "stop sco") + } + + @Test fun failedStartMatrixRollsBackAndAllowsFreshStart() { + for (failure in listOf("register", "register after", "devices", "available", "select=1")) { + audio.failures = setOf(failure) + assertThrows(IllegalStateException::class.java) { router.start() } + assertTrue(failure, audio.callbacks.isEmpty()) + assertNull(router.currentOutputDevice) + assertFalse(router.headsetAvailable) + audio.failures = emptySet() + router.start() + assertEquals(speaker, router.currentOutputDevice) + assertEquals(1, audio.callbacks.size) + router.stop() + } + diagnostic("start failed, releasing partial lifecycle") + } + + @Test fun rejectedInitialSelectionDoesNotPoisonStartedFlag() { + audio.acceptCommunicationDevice = false + assertThrows(IllegalStateException::class.java) { router.start() } + assertTrue(audio.callbacks.isEmpty()) + audio.acceptCommunicationDevice = true + router.start() + assertEquals(speaker, audio.communicationDevice) + assertEquals(2, audio.callbackHistory.size) + diagnostic("Communication device rejected") + } + + @Test fun failedStartInvalidatesPendingBluetoothProxy() { + BluetoothAdapter.current = adapter + audio.failures = setOf("devices") + assertThrows(IllegalStateException::class.java) { router.start() } + adapter.connect(0) + assertTrue(adapter.live.isEmpty()) + assertFalse(router.headsetAvailable) + } + + @Test fun observerFailureDuringStartStillReleasesPartialLifecycle() { + router.onOutputDevice = { error("observer failed") } + assertThrows(IllegalStateException::class.java) { router.start() } + assertTrue(audio.callbacks.isEmpty()) + assertNull(router.currentOutputDevice) + router.onOutputDevice = null + router.start() + assertEquals(speaker, router.currentOutputDevice) + } + + @Test fun unregisterExceptionDoesNotSkipProxyOrRouteCleanup() { + BluetoothAdapter.current = adapter + router.start(); adapter.connect(0) + audio.failures = setOf("unregister after") + router.stop() + assertTrue(adapter.live.isEmpty()) + assertTrue(audio.callbacks.isEmpty()) + assertNull(audio.communicationDevice) + assertNull(router.currentOutputDevice) + assertFalse(router.btInputAvailable) + diagnostic("device callback unregister failed") + } + + @Test fun failedUnregisterCannotDeliverIntoStoppedLifecycle() { + router.start() + val callback = audio.callbacks.single() + audio.failures = setOf("unregister") + try { + router.stop() + val count = audio.operations.size + callback.onAudioDevicesAdded(arrayOf(wired)) + assertEquals(count, audio.operations.size) + assertNull(audio.communicationDevice) + assertNull(router.currentOutputDevice) + } finally { + // Имитация отказавшей ОС оставляет регистрацию: убрать только fixture после проверки best-effort stop. + audio.failures = emptySet(); audio.unregisterAudioDeviceCallback(callback) + } + diagnostic("device callback unregister failed") + } + + @Test fun clearFailureStillClearsAllPublishedSnapshots() { + audio.devices += arrayOf(wired, wiredMic) + router.setAutoHeadset(true); router.start() + assertTrue(router.headsetAvailable) + audio.failures = setOf("clear") + router.stop() + assertNull(router.currentOutputDevice); assertNull(router.currentInputDevice) + assertFalse(router.headsetAvailable); assertFalse(router.wiredInputAvailable); assertFalse(router.btInputAvailable) + assertTrue(audio.callbacks.isEmpty()) + diagnostic("stop cleanup failed") + } + + @Test fun oldDeviceCallbackCannotChangeRestartedRouter() { + router.start() + val old = audio.callbacks.single() + router.stop(); router.start() + audio.devices += arrayOf(wired, wiredMic) + val count = audio.operations.size + old.onAudioDevicesAdded(arrayOf(wired)) + old.onAudioDevicesRemoved(arrayOf(speaker)) + assertEquals(count, audio.operations.size) + assertFalse(router.headsetAvailable) + event() + assertTrue(router.headsetAvailable) + } + + @Test fun deviceQueryFailureDoesNotEscapeCallbackAndLaterEventRecovers() { + router.setAutoHeadset(true); router.start() + audio.devices += arrayOf(wired, wiredMic) + audio.failures = setOf("devices") + event() + assertFalse(router.headsetAvailable) + diagnostic("device refresh failed") + audio.failures = emptySet(); event() + assertEquals(AudioRoute.HEADSET, router.route) + assertEquals(wired, router.currentOutputDevice) + } + + @Test fun rejectedRouteRetainsTargetsAndIdenticalRetryWorks() { + router.start() + var changes = 0 + router.onOutputDevice = { changes++ } + audio.acceptCommunicationDevice = false + router.setRoute(AudioRoute.EARPIECE) + assertEquals(AudioRoute.SPEAKER, router.route) + assertEquals(speaker, router.currentOutputDevice) + assertEquals(0, changes) + audio.acceptCommunicationDevice = true + router.setRoute(AudioRoute.EARPIECE) + assertEquals(AudioRoute.EARPIECE, router.route) + assertEquals(earpiece, router.currentOutputDevice) + assertEquals(1, changes) + } + + @Test fun failedDeviceChangeWithinHeadsetCategoryCanBeRetried() { + audio.devices += arrayOf(wired, wiredMic, bt, btMic) + router.start(); router.setRoute(AudioRoute.HEADSET) + assertEquals(bt, router.currentOutputDevice) + audio.acceptCommunicationDevice = false + router.setRoute(AudioRoute.HEADSET, preferWired = true) + assertEquals(bt, router.currentOutputDevice) + assertEquals(btMic, router.currentInputDevice) + audio.acceptCommunicationDevice = true + router.setRoute(AudioRoute.HEADSET, preferWired = true) + assertEquals(wired, router.currentOutputDevice) + assertEquals(wiredMic, router.currentInputDevice) + } + + @Test fun inputAndOutputTargetsArePublishedTogetherBeforeObservers() { + audio.devices += arrayOf(wired, wiredMic) + router.start() + var inputSeen: AudioDeviceInfo? = null + router.onOutputDevice = { if (it == wired) inputSeen = router.currentInputDevice } + router.setRoute(AudioRoute.HEADSET) + assertEquals(wiredMic, inputSeen) + } + + @Test fun forcedReapplyRequestsSameCategoryAgain() { + router.start() + val before = audio.operations.count { it == "select=1" } + router.reapply() + assertEquals(before + 1, audio.operations.count { it == "select=1" }) + } + + @Test fun deviceReplacementRefreshesTargetsWithoutCategoryChange() { + router.start() + val replacement = device(10, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) + audio.devices = arrayOf(replacement, earpiece, mic) + event() + assertEquals(AudioRoute.SPEAKER, router.route) + assertEquals(replacement, router.currentOutputDevice) + assertEquals(replacement, audio.communicationDevice) + } + + @Test fun stoppedSettersStorePreferencesWithoutTouchingHardware() { + router.setRoute(AudioRoute.EARPIECE) + router.setAutoHeadset(true) + router.setGlobalRouting(false) + router.reapply() + assertTrue(hardwareOperations().isEmpty()) + router.start() + assertEquals(AudioRoute.EARPIECE, router.route) + } + + @Test fun missingEarpiecePublishesSpeakerFallback() { + audio.devices = arrayOf(speaker, mic) + router.setPreferredRoute(AudioRoute.EARPIECE) + router.start() + assertFalse(router.hasEarpiece) + assertEquals(AudioRoute.SPEAKER, router.route) + assertEquals(speaker, router.currentOutputDevice) + } + + @Test fun automaticHeadsetFollowsManualFallbackAndKeepsPreferredRoute() { + router.setAutoHeadset(true); router.start() + router.setRoute(AudioRoute.SPEAKER) + audio.devices += arrayOf(wired, wiredMic); event() + assertEquals(AudioRoute.HEADSET, router.route) + audio.devices = arrayOf(speaker, earpiece, mic); event() + assertEquals(AudioRoute.SPEAKER, router.route) + assertEquals(AudioRoute.SPEAKER, router.preferredRoute) + } + + @Test fun manualHeadsetPreferenceRestoresAfterReconnection() { + router.setPreferredRoute(AudioRoute.HEADSET); router.start() + assertEquals(AudioRoute.EARPIECE, router.route) + audio.devices += arrayOf(wired, wiredMic); event() + assertEquals(AudioRoute.HEADSET, router.route) + audio.devices = arrayOf(speaker, earpiece, mic); event() + assertEquals(AudioRoute.EARPIECE, router.route) + assertEquals(AudioRoute.HEADSET, router.preferredRoute) + } + + @Test fun perTrackA2dpUsesMediaOutputWithoutGlobalRouting() { + val a2dp = device(8, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP) + audio.devices += a2dp + router.setGlobalRouting(false); router.start() + assertTrue(router.headsetAvailable) + router.setRoute(AudioRoute.HEADSET) + assertEquals(a2dp, router.currentOutputDevice) + assertNull(router.currentInputDevice) + router.stop() + assertTrue(hardwareOperations().isEmpty()) + } + + @Test fun perTrackUsbMicrophoneUsesBuiltInSpeakerAndWiredInput() { + audio.devices += wiredMic + router.setGlobalRouting(false); router.start() + assertTrue(router.headsetAvailable) + router.setRoute(AudioRoute.HEADSET, preferWired = true) + assertEquals(speaker, router.currentOutputDevice) + assertEquals(wiredMic, router.currentInputDevice) + assertTrue(hardwareOperations().isEmpty()) + } + + @Test fun modernBluetoothWaitsForCommunicationEndpointWithoutLegacySco() { + BluetoothAdapter.current = adapter + router.setAutoHeadset(true); router.start(); adapter.connect(0) + assertEquals(AudioRoute.HEADSET, router.route) + assertNull(router.currentOutputDevice) + assertTrue(audio.operations.none { it.contains("sco") || it.startsWith("speaker=") }) + audio.devices += arrayOf(bt, btMic); event() + assertEquals(bt, router.currentOutputDevice) + assertEquals(btMic, router.currentInputDevice) + assertEquals(bt, audio.communicationDevice) + } + + @Test fun modernBleUsesCommunicationSelection() { + val le = device(8, AudioDeviceInfo.TYPE_BLE_HEADSET) + val leMic = device(9, AudioDeviceInfo.TYPE_BLE_HEADSET, input = true) + audio.devices += arrayOf(le, leMic) + router.setAutoHeadset(true); router.start() + assertEquals(le, audio.communicationDevice) + assertEquals(leMic, router.currentInputDevice) + assertTrue(audio.operations.none { it.contains("sco") }) + } + + @Test fun legacyBluetoothUsesScoAndCanReturnToSpeaker() { + Build.VERSION.SDK_INT = 30 + BluetoothAdapter.current = adapter + router.start(); adapter.connect(0) + router.setRoute(AudioRoute.HEADSET) + assertTrue(audio.isBluetoothScoOn) + assertTrue(audio.operations.contains("start sco")) + router.setRoute(AudioRoute.SPEAKER) + assertFalse(audio.isBluetoothScoOn) + assertTrue(audio.isSpeakerphoneOn) + assertTrue(audio.operations.none { it.startsWith("select=") }) + } + + @Test fun legacyScoFailureRetainsPreviousCategory() { + Build.VERSION.SDK_INT = 30 + BluetoothAdapter.current = adapter + router.start(); adapter.connect(0) + audio.failures = setOf("start sco") + router.setRoute(AudioRoute.HEADSET) + assertEquals(AudioRoute.SPEAKER, router.route) + diagnostic("start sco") + } + + @Test fun telecomOwnsRouteAndAvailabilityWithoutGlobalCommands() { + val connection = UtunConnection().apply { headsetAvailable = true; confirmedRoute = AudioRoute.EARPIECE } + router.bindTelecom(connection); router.start() + assertTrue(router.headsetAvailable) + router.setRoute(AudioRoute.HEADSET) + assertEquals(AudioRoute.EARPIECE, router.route) + connection.confirm(AudioRoute.HEADSET) + assertEquals(AudioRoute.HEADSET, router.route) + router.stop() + assertTrue(hardwareOperations().isEmpty()) + assertNull(connection.onRouteChanged); assertNull(connection.onEndpointsChanged) + } + + @Test fun bindingLiveTelecomImmediatelyRefreshesAvailability() { + router.start() + assertFalse(router.headsetAvailable) + val connection = UtunConnection().apply { headsetAvailable = true } + router.bindTelecom(connection) + assertTrue(router.headsetAvailable) + assertNull(router.currentOutputDevice); assertNull(router.currentInputDevice) + assertEquals(listOf(AudioRoute.SPEAKER), connection.routeRequests) + } + + @Test fun oldTelecomCallbacksAreIgnoredEvenAfterRebindingSameConnection() { + val connection = UtunConnection() + router.bindTelecom(connection); router.start() + val oldRoute = requireNotNull(connection.onRouteChanged) + val oldEndpoints = requireNotNull(connection.onEndpointsChanged) + router.stop(); router.bindTelecom(connection); router.start() + connection.confirm(AudioRoute.SPEAKER) + val count = audio.operations.size + oldRoute(AudioRoute.HEADSET); oldEndpoints() + assertEquals(AudioRoute.SPEAKER, router.route) + assertEquals(count, audio.operations.size) + } + + @Test fun telecomRequestExceptionRollsBackStartupAndCanRetry() { + val connection = UtunConnection().apply { failRoute = true } + router.bindTelecom(connection) + assertThrows(IllegalStateException::class.java) { router.start() } + assertNull(router.telecom); assertTrue(audio.callbacks.isEmpty()) + connection.failRoute = false + router.bindTelecom(connection); router.start() + assertEquals(listOf(AudioRoute.SPEAKER), connection.routeRequests) + } + + @Test fun callbackAndStopSerializeBeforeClearingSnapshot() { + router.start() + val callback = audio.callbacks.single() + router.setAutoHeadset(true) + val entered = CountDownLatch(1); val resume = CountDownLatch(1); val stopped = CountDownLatch(1) + val error = AtomicReference() + router.onOutputDevice = { entered.countDown(); check(resume.await(5, TimeUnit.SECONDS)) } + audio.devices += arrayOf(wired, wiredMic) + val event = worker(error) { callback.onAudioDevicesAdded(emptyArray()) } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val stop = worker(error) { router.stop(); stopped.countDown() } + try { assertFalse(stopped.await(50, TimeUnit.MILLISECONDS)) } finally { resume.countDown() } + join(listOf(event, stop), error) + assertNull(router.currentOutputDevice); assertNull(audio.communicationDevice) + assertFalse(router.headsetAvailable) + } + + @Test fun concurrentRouteDeviceEventAndStopLeaveNoHardwareSelection() { + LogManager.console = false + repeat(32) { + router.setPreferredRoute(AudioRoute.SPEAKER); router.start() + val callback = audio.callbacks.single() + val ready = CountDownLatch(3); val go = CountDownLatch(1) + val error = AtomicReference() + val actions = listOf<() -> Unit>({ callback.onAudioDevicesAdded(emptyArray()) }, + { router.setRoute(AudioRoute.EARPIECE) }, { router.stop() }) + val workers = actions.map { action -> worker(error) { ready.countDown(); check(go.await(5, TimeUnit.SECONDS)); action() } } + assertTrue(ready.await(5, TimeUnit.SECONDS)); go.countDown() + join(workers, error) + assertTrue(audio.callbacks.isEmpty()) + assertNull(router.currentOutputDevice); assertNull(audio.communicationDevice) + } + } + + private fun worker(error: AtomicReference, body: () -> Unit) = Thread { + try { body() } catch (failure: Throwable) { error.compareAndSet(null, failure) } + }.apply { isDaemon = true; start() } + + private fun join(workers: List, error: AtomicReference) { + workers.forEach { it.join(5000); assertFalse("worker stuck", it.isAlive) } + error.get()?.let { throw AssertionError("router worker failed", it) } + } +}