fix(eq): stop the phone's FM sink from posing as the active output
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s

Regression from the previous commit. Ranking every AudioDeviceInfo type this
build does not name individually ABOVE the built-in speaker was meant to let
a car stereo on LE Audio or an automotive bus win. It also promoted the
internal sinks a phone exposes permanently: on the Xiaomi test device
AudioManager reports TYPE_FM (14) as an output, so getActiveAudioDevice
picked it over the real speaker with nothing connected at all. Confirmed on
device:

  audio_devices.onListen -> {id=other:14:4, type=14, name=2412DPC0AG}

It then reached Dart under an `other:14:4` id whose type is neither the base
speaker nor a known one, slipped past the collision guard and had a preset
row persisted for it -- reinstating the exact symptom this series set out to
kill: a permanent green active-output dot on a device that was not connected.

Replace the deny-by-omission ranking with an explicit allow list of outputs a
user actually connects. The built-in speaker sits below all of them and above
everything else, so any sink that physically exists but is never where media
plays (TYPE_FM, TYPE_BUILTIN_SPEAKER_SAFE, telephony, remote submix) can no
longer be selected. A one-time purge clears the `other:` rows the bad build
persisted; genuine ones re-register on their next connection.

Fix the USB type constant while here: TYPE_USB_HEADSET is 22, not 14, and 14
is TYPE_FM. The Kotlin USB branch hardcoded 14 and the Dart type table
mirrored the same mistake, so the two cancelled out for real USB headsets
while making a phone's own FM sink decode as USB audio. Both now use 22.

Verified with javap against android.jar (android-36) rather than trusting the
comment that introduced the error.
This commit is contained in:
2026-07-25 20:43:47 +02:00
parent c94bc3d770
commit 4042cf5ffd
5 changed files with 162 additions and 26 deletions
@@ -1216,7 +1216,9 @@ class MainActivity : AudioServiceActivity() {
* or still the OS placeholder ("02:00:00:00:00:00", seen
* without BLUETOOTH_CONNECT); productName colons are
* sanitized to '-' to preserve the matrix-key delimiter
* - "usb_headset:<address>" — TYPE_USB_HEADSET (14)
* - "usb_headset:<address>" — TYPE_USB_HEADSET (22)
* - "other:<type>:<address>" — any other external output this build
* does not name individually
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
@@ -1240,29 +1242,49 @@ class MainActivity : AudioServiceActivity() {
return deviceToMap(best)
}
/**
* Media outputs a user actively connects, in the order they should win when
* several are present. Index IS the priority.
*
* This is an ALLOW list on purpose. Ranking "everything not named here"
* above the built-in speaker looks equivalent and is not: a phone
* permanently exposes internal sinks that are legitimate outputs but never
* where music is playing -- TYPE_FM (14) on this project's Xiaomi test
* device, TYPE_BUILTIN_SPEAKER_SAFE (24) on many others. Those outranked
* the real speaker, got reported as the active device and had a preset row
* persisted for them.
*/
private val externalOutputPriority = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_BLE_BROADCAST,
AudioDeviceInfo.TYPE_HEARING_AID,
AudioDeviceInfo.TYPE_BUS,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_LINE_ANALOG,
AudioDeviceInfo.TYPE_LINE_DIGITAL,
AudioDeviceInfo.TYPE_AUX_LINE,
AudioDeviceInfo.TYPE_DOCK,
AudioDeviceInfo.TYPE_HDMI,
AudioDeviceInfo.TYPE_HDMI_ARC,
)
/**
* 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.
* The built-in speaker is the fallback, so it sits below every external
* output and above everything else — including outputs that physically
* exist but are never where media plays.
*/
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
private fun outputPriority(type: Int): Int {
val index = externalOutputPriority.indexOf(type)
if (index >= 0) return index
return if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) 90 else 99
}
/**
@@ -1307,7 +1329,10 @@ class MainActivity : AudioServiceActivity() {
val addr = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
"type" to 14,
// Send the real constant (22). The hardcoded 14 that used to
// sit here is TYPE_FM, and Dart mirrored the mistake, so a
// phone's own FM sink decoded as a USB headset.
"type" to AudioDeviceInfo.TYPE_USB_HEADSET,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}