fix(eq): stop the phone speaker from impersonating a Bluetooth device
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:
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user