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.
This commit is contained in:
1 parent
0d45bdbad6
commit
a6ae4df3ff
4 files changed
+614
-113
No files matched your search
@@ -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<out AudioDeviceInfo>?) = onDevicesChanged()
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
|
||||
private fun changed() {
|
||||
synchronized(this@CallAudioRouter) {
|
||||
if (started && deviceCallback === this) onDevicesChanged()
|
||||
}
|
||||
}
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = changed()
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,17 +47,47 @@ class AudioManager {
|
||||
check(!failAbandon) { "abandon unavailable" }
|
||||
return abandonResult
|
||||
}
|
||||
val operations = mutableListOf<String>()
|
||||
var failures = emptySet<String>()
|
||||
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<AudioDeviceCallback>()
|
||||
private val devices = arrayOf(AudioDeviceInfo(1, 2, true, false, "speaker"), AudioDeviceInfo(2, 15, false, true, "mic"))
|
||||
val availableCommunicationDevices: List<AudioDeviceInfo> 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<AudioDeviceInfo> { check(flags == GET_DEVICES_ALL); return devices }
|
||||
fun setCommunicationDevice(device: AudioDeviceInfo): Boolean = device in devices
|
||||
fun clearCommunicationDevice() {}
|
||||
fun startBluetoothSco() {}
|
||||
fun stopBluetoothSco() {}
|
||||
val callbackHistory = mutableListOf<AudioDeviceCallback>()
|
||||
var devices = arrayOf(AudioDeviceInfo(1, 2, true, false, "speaker"), AudioDeviceInfo(2, 15, false, true, "mic"))
|
||||
var communicationDevices: List<AudioDeviceInfo>? = null
|
||||
var communicationDevice: AudioDeviceInfo? = null
|
||||
private set
|
||||
var acceptCommunicationDevice = true
|
||||
val availableCommunicationDevices: List<AudioDeviceInfo> 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<AudioDeviceInfo> { 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") }
|
||||
}
|
||||
+7
-1
@@ -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<AudioRoute>()
|
||||
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; вывод можно отключить для конкурентной серии. */
|
||||
|
||||
+426
@@ -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<Throwable?>()
|
||||
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<Throwable?>()
|
||||
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<Throwable?>, body: () -> Unit) = Thread {
|
||||
try { body() } catch (failure: Throwable) { error.compareAndSet(null, failure) }
|
||||
}.apply { isDaemon = true; start() }
|
||||
|
||||
private fun join(workers: List<Thread>, error: AtomicReference<Throwable?>) {
|
||||
workers.forEach { it.join(5000); assertFalse("worker stuck", it.isAlive) }
|
||||
error.get()?.let { throw AssertionError("router worker failed", it) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user