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
+39 -5
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart';
import 'servicio_dispositivo_audio.dart' show idAltavozInterno;
class ConfiguracionEcualizador {
const ConfiguracionEcualizador({
@@ -51,6 +52,9 @@ class ServicioEcualizador {
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00';
/// Guard flag for the one-time `builtin_speaker` collision purge.
static const _keyColisionBasePurgaHecha = 'eq_builtin_speaker_purge_done_v1';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
@@ -59,6 +63,7 @@ class ServicioEcualizador {
Future<ConfiguracionEcualizador> cargar() async {
await migrarClavesPlaceholder();
await migrarColisionAltavozInterno();
final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs);
@@ -84,9 +89,33 @@ class ServicioEcualizador {
Future<void> migrarClavesPlaceholder() async {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, _placeholderMacLiteral);
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
}
/// One-time guarded migration: purges entries keyed by [idAltavozInterno].
///
/// `MainActivity.deviceToMap` used to hand the phone-speaker id to ANY output
/// type it could not name (LE Audio, car bus, dock). Dart then created a
/// device entry under that id, and from then on every playback through the
/// phone's own speaker matched it — permanently flagging whatever the user
/// had renamed it to as the active device. The native id is fixed; this
/// clears what the collision already persisted. Idempotent via
/// [_keyColisionBasePurgaHecha]; every other entry survives byte-for-byte.
Future<void> migrarColisionAltavozInterno() async {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, idAltavozInterno);
await prefs.setBool(_keyColisionBasePurgaHecha, true);
}
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
///
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
/// only rewritten when the key was actually present.
Future<void> _purgarDeviceId(SharedPreferences prefs, String deviceId) async {
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) {
if (presetsPorDispositivo.remove(deviceId) != null) {
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
@@ -101,8 +130,7 @@ class ServicioEcualizador {
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
final deviceIdSegmento = clave.substring(separador + 1);
return deviceIdSegmento == _placeholderMacLiteral;
return clave.substring(separador + 1) == deviceId;
}).toList();
if (clavesMatrizAPurgar.isNotEmpty) {
for (final clave in clavesMatrizAPurgar) {
@@ -115,15 +143,21 @@ class ServicioEcualizador {
prefs,
_keyNombresDispositivos,
);
if (nombresDispositivos.remove(_placeholderMacLiteral) != null) {
if (nombresDispositivos.remove(deviceId) != null) {
await _guardarMapaStrings(
prefs,
_keyNombresDispositivos,
nombresDispositivos,
);
}
}
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
/// Forgets a device completely: its preset, its custom name and every matrix
/// entry that targets it. Used by the "remove device" action so the user can
/// clear stale or duplicate entries from the known-devices list.
Future<void> eliminarDispositivo(String deviceId) async {
final prefs = await _resolverPrefs();
await _purgarDeviceId(prefs, deviceId);
}
Future<void> guardarPrincipal(PresetEcualizador preset) async {