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
@@ -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);