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:
@@ -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]
|
||||
|
||||
@@ -419,6 +419,30 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
nombresDispositivos: nombres,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> eliminarDispositivo(String deviceId) async {
|
||||
final presets = Map<String, PresetEcualizador>.from(
|
||||
_config.presetsDispositivo,
|
||||
)..remove(deviceId);
|
||||
final nombres = Map<String, String>.from(_config.nombresDispositivos)
|
||||
..remove(deviceId);
|
||||
final matriz = Map<String, PresetEcualizador>.from(_config.presetsMatriz)
|
||||
..removeWhere((clave, _) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1) == deviceId;
|
||||
});
|
||||
_config = ConfiguracionEcualizador(
|
||||
principal: _config.principal,
|
||||
porEmisora: _config.porEmisora,
|
||||
activo: _config.activo,
|
||||
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
||||
presetsDispositivo: presets,
|
||||
presetsMatriz: matriz,
|
||||
nombresDispositivos: nombres,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
||||
|
||||
@@ -196,11 +196,9 @@ void main() {
|
||||
// known devices.
|
||||
expect(find.text('Known audio devices'), findsOneWidget);
|
||||
|
||||
// The device ID should appear in the list.
|
||||
expect(
|
||||
find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The device row is listed. An unnamed device shows its transport plus
|
||||
// the tail of its address, not the raw id.
|
||||
expect(find.text('Bluetooth · EE:FF'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -444,9 +442,10 @@ void main() {
|
||||
expect(find.text('AirPods Pro'), findsNothing);
|
||||
});
|
||||
|
||||
// 4.5 — approval test (regression-lock): a device never seen on the
|
||||
// stream has no cached platform name, so legacy raw-id fallback holds.
|
||||
testWidgets('4.5 no platform name yet falls back to raw id', (
|
||||
// 4.5 — a device never seen on the stream has no cached platform name, so
|
||||
// the row falls back to a humanized transport + address tail instead of the
|
||||
// raw id, which told the user nothing.
|
||||
testWidgets('4.5 no platform name yet shows a humanized transport label', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
@@ -467,7 +466,8 @@ void main() {
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text(deviceId), findsOneWidget);
|
||||
expect(find.text('Bluetooth · EE:FF'), findsOneWidget);
|
||||
expect(find.text(deviceId), findsNothing);
|
||||
});
|
||||
|
||||
// 4.6 — permission trigger point: turning the toggle ON requests
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user