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
+98 -3
View File
@@ -129,8 +129,11 @@ void main() {
await servicio.guardarPrincipal(PresetEcualizador.pop);
await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz);
await servicio.guardarToggleMultiDispositivo(true);
// Any device id EXCEPT builtin_speaker: the phone speaker is the
// fallback every hierarchy level falls through to, so it never owns a
// device preset and cargar() purges it (see the collision-purge group).
await servicio.guardarPresetDispositivo(
'builtin_speaker',
'wired_headset',
PresetEcualizador.jazz,
);
@@ -139,7 +142,7 @@ void main() {
expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz));
expect(config.eqMultiDeviceEnabled, isTrue);
expect(
config.presetsDispositivo['builtin_speaker'],
config.presetsDispositivo['wired_headset'],
equals(PresetEcualizador.jazz),
);
},
@@ -206,10 +209,13 @@ void main() {
test('1.3 round-trip save → cargar preserves all entries', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
// builtin_speaker is deliberately absent: cargar() purges it, since the
// phone speaker is the fallback output and never a nameable device row
// (see the collision-purge group).
const nombres = {
'bt_a2dp:AA:BB': 'Living Room BT',
'wired_headset': 'Office Headset',
'builtin_speaker': 'Built-in',
'usb_headset:1': 'Desk DAC',
};
await servicio.guardarNombresDispositivos(nombres);
@@ -570,4 +576,93 @@ void main() {
);
},
);
// ---------------------------------------------------------------------------
// builtin_speaker id-collision purge
//
// MainActivity.deviceToMap used to hand the phone-speaker id to ANY output
// type it did not know by name (LE Audio, car bus, dock). Dart then created a
// device entry keyed 'builtin_speaker', which permanently marked whatever the
// user renamed it to as the active device every time audio played through the
// phone's own speaker. The native id is fixed; this purge clears the entries
// that collision already persisted.
// ---------------------------------------------------------------------------
group('ServicioEcualizador — builtin_speaker collision purge', () {
const baseKey = 'builtin_speaker';
const stableKey = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
test('removes the builtin_speaker preset and keeps its siblings', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.jazz);
await servicio.guardarPresetDispositivo(
stableKey,
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsDispositivo.containsKey(baseKey), isFalse);
expect(
config.presetsDispositivo[stableKey],
equals(PresetEcualizador.rock),
);
});
test('removes matrix entries whose device segment is the base id', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetMatriz(
'station1:$baseKey',
PresetEcualizador.jazz,
);
await servicio.guardarPresetMatriz(
'station1:$stableKey',
PresetEcualizador.rock,
);
final config = await servicio.cargar();
expect(config.presetsMatriz.containsKey('station1:$baseKey'), isFalse);
expect(
config.presetsMatriz['station1:$stableKey'],
equals(PresetEcualizador.rock),
);
});
test('removes the custom name saved against the base id', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarNombresDispositivos({
baseKey: 'Omoda',
stableKey: 'Travel headphones',
});
final config = await servicio.cargar();
expect(config.nombresDispositivos.containsKey(baseKey), isFalse);
expect(
config.nombresDispositivos[stableKey],
equals('Travel headphones'),
);
});
test('is guarded: a re-seeded base entry survives a second load', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.jazz);
await servicio.cargar();
// Re-seed after the flag is set, to prove the guard short-circuits rather
// than the purge merely finding nothing left to remove.
await servicio.guardarPresetDispositivo(baseKey, PresetEcualizador.rock);
final config = await servicio.cargar();
expect(
config.presetsDispositivo[baseKey],
equals(PresetEcualizador.rock),
);
});
});
}