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"),
)
}
+13 -2
View File
@@ -22,6 +22,13 @@ const prefijoPlaceholderBtName = 'bt_a2dp:name:';
/// entry that then marked the wrong device as active forever.
const idAltavozInterno = 'builtin_speaker';
/// Id prefix for outputs the native layer does not name individually
/// (`other:<AudioDeviceInfo type>:<address>`): LE Audio stereos, car buses,
/// hearing aids, docks. Kept distinct from every other id namespace so such a
/// device can never collide with [idAltavozInterno], and so a bad batch of them
/// can be purged wholesale.
const prefijoDispositivoOtro = 'other:';
/// Abstract service for audio device detection.
///
/// Implementations:
@@ -79,7 +86,11 @@ abstract class ServicioDispositivoAudio {
/// 2 → altavozInterno (builtin_speaker)
/// 3 → auricularesCable (wired_headset)
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
/// 14 → usbAudio (`usb_headset:<address>`)
/// 22 → usbAudio (`usb_headset:<address>`)
///
/// Values match `AudioDeviceInfo.TYPE_*`. Note 22, NOT 14: type 14 is
/// `TYPE_FM`, an output plenty of phones expose permanently, and mapping it to
/// USB audio made a phone's own FM sink look like a connected headset.
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
static const _channelName = 'pluriwave/audio_devices';
@@ -189,7 +200,7 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
return TipoDispositivo.auricularesCable;
case 8:
return TipoDispositivo.bluetoothA2dp;
case 14:
case 22:
return TipoDispositivo.usbAudio;
default:
return TipoDispositivo.desconocido;
+57 -1
View File
@@ -4,7 +4,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart';
import 'servicio_dispositivo_audio.dart' show idAltavozInterno;
import 'servicio_dispositivo_audio.dart'
show idAltavozInterno, prefijoDispositivoOtro;
class ConfiguracionEcualizador {
const ConfiguracionEcualizador({
@@ -106,9 +107,64 @@ class ServicioEcualizador {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, idAltavozInterno);
await _purgarPrefijoDispositivo(prefs, prefijoDispositivoOtro);
await prefs.setBool(_keyColisionBasePurgaHecha, true);
}
/// Removes every device-keyed entry whose id starts with [prefijo].
///
/// Used for the `other:` namespace: a build that ranked every unlisted
/// `AudioDeviceInfo` type above the phone speaker selected permanent internal
/// sinks (TYPE_FM on Xiaomi, TYPE_BUILTIN_SPEAKER_SAFE elsewhere) as the
/// active output and persisted rows for them. Genuine external outputs in
/// this namespace are re-registered on their next connection, so clearing the
/// whole prefix costs nothing and needs no per-type knowledge here.
Future<void> _purgarPrefijoDispositivo(
SharedPreferences prefs,
String prefijo,
) async {
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final dispositivos = presetsPorDispositivo.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos);
final nombresAPurgar = nombres.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final matriz = _leerMapa(prefs, _keyPresetsMatriz);
final matrizAPurgar = matriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1).startsWith(prefijo);
}).toList();
for (final clave in dispositivos) {
presetsPorDispositivo.remove(clave);
}
for (final clave in nombresAPurgar) {
nombres.remove(clave);
}
for (final clave in matrizAPurgar) {
matriz.remove(clave);
}
if (dispositivos.isNotEmpty) {
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
presetsPorDispositivo,
);
}
if (nombresAPurgar.isNotEmpty) {
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
}
if (matrizAPurgar.isNotEmpty) {
await _guardarMapa(prefs, _keyPresetsMatriz, matriz);
}
}
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
///
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
@@ -81,10 +81,12 @@ void main() {
expect(device.tipo, TipoDispositivo.bluetoothA2dp);
});
test('obtenerDispositivoActual maps usb_headset (type=14)', () async {
// AudioDeviceInfo.TYPE_USB_HEADSET is 22, not 14. This mapping used to say
// 14, which is TYPE_FM — an output many phones expose permanently.
test('obtenerDispositivoActual maps usb_headset (type=22)', () async {
stubGetActiveDevice({
'id': 'usb_headset:0001:0002',
'type': 14,
'type': 22,
'name': 'USB Headset',
});
final device = await servicio.obtenerDispositivoActual();
@@ -92,6 +94,17 @@ void main() {
expect(device.tipo, TipoDispositivo.usbAudio);
});
test('type 14 is FM, never USB audio', () async {
stubGetActiveDevice({
'id': 'other:14:4',
'type': 14,
'name': 'FM',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.tipo, isNot(TipoDispositivo.usbAudio));
expect(device.tipo, TipoDispositivo.desconocido);
});
test('obtenerDispositivoActual maps unknown type to desconocido', () async {
stubGetActiveDevice({
'id': 'unknown_device',
@@ -648,6 +648,37 @@ void main() {
);
});
// A build that ranked every unlisted AudioDeviceInfo type above the phone
// speaker selected permanent internal sinks (TYPE_FM on Xiaomi) as the
// active output and persisted `other:` rows for them.
test('removes other: rows persisted for internal sinks', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(
'other:14:4',
PresetEcualizador.jazz,
);
await servicio.guardarNombresDispositivos({'other:14:4': 'Omoda'});
await servicio.guardarPresetMatriz(
'station1:other:14:4',
PresetEcualizador.pop,
);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsDispositivo.containsKey('other:14:4'), isFalse);
expect(config.nombresDispositivos.containsKey('other:14:4'), isFalse);
expect(config.presetsMatriz.containsKey('station1:other:14:4'), isFalse);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
test('is guarded: a re-seeded base entry survives a second load', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);