fix(eq): stop the phone speaker from impersonating a Bluetooth device
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s

deviceToMap handed the builtin_speaker id to EVERY output type its `when`
did not name. A car stereo on LE Audio (TYPE_BLE_HEADSET) or an automotive
bus (TYPE_BUS) therefore arrived in Dart under the phone speaker's own id,
carrying a type that maps to `desconocido` -- which slipped past the
type-only esBase guard and persisted a device entry keyed builtin_speaker.
From that moment on, every playback through the phone's own speaker matched
that entry, so the green active-output dot stayed pinned to whatever the user
had renamed it to (a car, in the reported case) whether or not anything was
connected. The dot was never wrong; the row was poisoned.

Give unnamed output types their own `other:<type>:<address>` id namespace,
and match esBase by id as well as by type so no future native regression can
re-create the collision. A guarded one-time migration purges what the
collision already persisted from all three device-keyed maps.

Fix the ranking too: builtin_speaker sat inside the priority list as a peer,
so any type absent from that list sorted BELOW the always-present speaker
and could never win. The speaker is now the explicit last resort, externally
connected outputs outrank it, and virtual or call-only sinks (earpiece,
telephony, remote submix, SCO) are ranked below it so they can never be
reported as where music is playing.

Route every AudioDeviceInfo.getAddress read through a version-guarded
helper. It is API 28 with minSdk 24, and two pre-existing unguarded calls in
this same method were latent NoSuchMethodError crashes on Android 7-8.1.
Android lint for :app goes from 8 errors to 6.

Also lets the user manage the list, which is how they recover from a bad
entry without waiting for a release: a remove action clears a device's
preset, name and matrix entries, unnamed rows show their transport and
address tail instead of a raw bt_a2dp:AA:BB:... id, and the green dot
finally carries a tooltip and a semantics label saying what it means.

Device QA pending for wired and USB outputs: no jack or adapter available to
exercise those paths. Their detection is unchanged by this commit.
This commit is contained in:
2026-07-25 16:10:36 +02:00
parent eee2ae98d0
commit 39ead7bea4
36 changed files with 694 additions and 41 deletions
@@ -1182,25 +1182,52 @@ class MainActivity : AudioServiceActivity() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown
val priorityOrder = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
// The built-in speaker is the LAST resort, never a peer in this list:
// it is always present, so ranking it alongside the others made any
// output type absent from the list (LE Audio car stereos, car buses,
// docks) sort BELOW it and never win — the car would connect and the
// phone speaker would still be reported as the active device.
val best = outputs
.filter { it.isSink && it.id !in excludeIds }
.sortedBy { device ->
val idx = priorityOrder.indexOf(device.type)
if (idx == -1) Int.MAX_VALUE else idx
}
.firstOrNull()
.minByOrNull { device -> outputPriority(device.type) }
return deviceToMap(best)
}
/**
* Ranks an [AudioDeviceInfo] type as a media output candidate; lower wins.
*
* Externally connected outputs outrank the built-in speaker, including types
* this build does not name individually. Virtual and call-only sinks
* (earpiece, telephony, remote submix) are pushed below the speaker so they
* can never be reported as where music is playing.
*/
private fun outputPriority(type: Int): Int = when (type) {
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> 0
AudioDeviceInfo.TYPE_USB_HEADSET -> 1
AudioDeviceInfo.TYPE_USB_DEVICE -> 2
AudioDeviceInfo.TYPE_WIRED_HEADSET -> 3
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> 4
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> 90
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_TELEPHONY,
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> 99
// Everything else is an output the user physically connected (LE Audio,
// hearing aids, car bus, dock, HDMI): above the speaker, below the
// types named above so a known match always wins a tie.
else -> 50
}
/**
* [AudioDeviceInfo.getAddress] is API 28 while minSdk is 24, so reading it
* unguarded throws NoSuchMethodError on Android 7-8.1. Below API 28 there is
* no address to read and callers fall back to a non-MAC identity (the
* `bt_a2dp:name:` placeholder shape, or the device's session id).
*/
private fun deviceAddress(device: AudioDeviceInfo): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) device.address else null
private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
@@ -1216,7 +1243,7 @@ class MainActivity : AudioServiceActivity() {
// The OS reports a placeholder MAC ("02:00:00:00:00:00") when
// BLUETOOTH_CONNECT has not been granted; treat that (and any
// null/blank address) as absent instead of using it as an id.
val mac = device.address?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
val mac = deviceAddress(device)?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
val id = if (mac != null) {
"bt_a2dp:$mac"
} else {
@@ -1231,19 +1258,30 @@ class MainActivity : AudioServiceActivity() {
)
}
AudioDeviceInfo.TYPE_USB_HEADSET -> {
val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString()
val addr = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
"type" to 14,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
else ->
// NEVER reuse builtin_speaker's id here. An output this build does
// not name individually (LE Audio car stereo, car bus, dock) would
// collide with the phone's own speaker: Dart persisted a device
// entry under that shared id, and from then on every playback
// through the phone speaker matched it, pinning the green
// active-device marker to the wrong row forever. The type is kept
// verbatim so Dart can still tell it apart from a real speaker.
else -> {
val address = deviceAddress(device)
?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
?: device.id.toString()
mapOf(
"id" to "builtin_speaker",
"id" to "other:${device.type}:$address",
"type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"),
)
}
}
}