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
+37 -1
View File
@@ -264,7 +264,14 @@ class EstadoEcualizador extends ChangeNotifier {
// fallback ids for a device whose real MAC is not yet known, so
// persisting a preset entry for them would create dead noise that never
// resolves to the eventual real-MAC id.
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
// Matched by id AND by type: the native layer historically reported the
// phone-speaker id for output types it could not name (LE Audio, car bus,
// dock), which arrive here as `desconocido` and would otherwise slip past a
// type-only check and persist an entry that hijacks the active-device
// indicator forever.
final esBase =
dispositivo.tipo == TipoDispositivo.altavozInterno ||
dispositivo.id == idAltavozInterno;
final esPlaceholderCompuesto = dispositivo.id.startsWith(
prefijoPlaceholderBtName,
);
@@ -424,6 +431,35 @@ class EstadoEcualizador extends ChangeNotifier {
notifyListeners();
}
/// Forgets [deviceId] completely: its device preset, its custom name and
/// every matrix entry that targets it.
///
/// Lets the user clear stale or duplicate rows from the known-devices list.
/// The device is NOT prevented from coming back: if it connects again it is
/// re-registered from scratch, which is exactly how a user recovers from a
/// bad entry. When the removed device is the active one, the effective preset
/// is re-resolved so playback immediately follows the remaining hierarchy
/// instead of keeping the deleted preset applied.
Future<void> eliminarDispositivo(String deviceId) async {
_presetsDispositivo.remove(deviceId);
_nombresDispositivos.remove(deviceId);
_nombresPlataforma.remove(deviceId);
_presetsMatriz.removeWhere((clave, _) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
});
await servicio.eliminarDispositivo(deviceId);
if (_dispositivoActualId == deviceId) {
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
}
notifyListeners();
}
/// Returns the stored custom name for [deviceId], or an empty string if none.
String obtenerNombreDispositivo(String deviceId) =>
_nombresDispositivos[deviceId] ?? '';