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
+77
View File
@@ -1538,6 +1538,83 @@ void main() {
},
);
});
// ---------------------------------------------------------------------------
// builtin_speaker id collision + device removal
// ---------------------------------------------------------------------------
group('EstadoEcualizador — builtin_speaker collision guard', () {
test(
'an unknown-type device reported under the base id creates no entry',
() async {
final servicio = FakeServicioEcualizador(eqMultiDeviceEnabled: true);
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: servicio,
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
// What the old native else-branch emitted for an LE Audio car stereo:
// the phone-speaker id carrying a type Dart maps to `desconocido`.
fakeDispositivo.emitirDispositivo(
const DispositivoAudio(
id: 'builtin_speaker',
tipo: TipoDispositivo.desconocido,
nombre: 'Omoda',
),
);
await Future<void>.delayed(Duration.zero);
expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse);
eq.dispose();
},
);
test('eliminarDispositivo clears preset, name and matrix entries', () async {
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
const stationUuid = 'station-uuid-123';
const otroId = 'bt_a2dp:11:22:33:44:55:66';
final servicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
deviceId: PresetEcualizador.jazz,
otroId: PresetEcualizador.rock,
},
presetsMatriz: {
'$stationUuid:$deviceId': PresetEcualizador.bassBoost,
'$stationUuid:$otroId': PresetEcualizador.pop,
},
nombresDispositivos: {deviceId: 'Omoda', otroId: 'Travel headphones'},
);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: servicio,
dispositivoAudio: FakeServicioDispositivoAudio(),
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.eliminarDispositivo(deviceId);
expect(eq.presetsDispositivo.containsKey(deviceId), isFalse);
expect(eq.nombresDispositivos.containsKey(deviceId), isFalse);
expect(eq.presetsMatriz.containsKey('$stationUuid:$deviceId'), isFalse);
// Sibling device untouched.
expect(eq.presetsDispositivo[otroId], equals(PresetEcualizador.rock));
expect(eq.nombresDispositivos[otroId], equals('Travel headphones'));
expect(
eq.presetsMatriz['$stationUuid:$otroId'],
equals(PresetEcualizador.pop),
);
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
});
});
}
/// Fake whose [resubscribir] stays pending until [completarResubscribir]