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] ?? '';
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
"eqDeviceNameConfirm": "حفظ",
"eqDeviceConnected": "متصل",
"eqDeviceActiveOutput": "يتم إخراج الصوت من هذا الجهاز",
"eqDeviceRemove": "إزالة الجهاز",
"eqDeviceRemoved": "تمت إزالة {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "الموسيقى المحلية (Android Auto)",
"localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.",
"localMusicFolderNotConfigured": "لم يتم تحديد مجلد",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
"eqDeviceConnected": "সংযুক্ত",
"eqDeviceActiveOutput": "অডিও এই ডিভাইস দিয়ে বাজছে",
"eqDeviceRemove": "ডিভাইস সরান",
"eqDeviceRemoved": "{device} সরানো হয়েছে",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)",
"localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।",
"localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
"eqDeviceNameConfirm": "Speichern",
"eqDeviceConnected": "Verbunden",
"eqDeviceActiveOutput": "Der Ton wird über dieses Gerät ausgegeben",
"eqDeviceRemove": "Gerät entfernen",
"eqDeviceRemoved": "{device} entfernt",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Lokale Musik (Android Auto)",
"localMusicSectionDescription": "Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.",
"localMusicFolderNotConfigured": "Kein Ordner ausgewählt",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "e.g. Living Room Speaker",
"eqDeviceNameConfirm": "Save",
"eqDeviceConnected": "Connected",
"eqDeviceActiveOutput": "Audio is playing through this device",
"eqDeviceRemove": "Remove device",
"eqDeviceRemoved": "{device} removed",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Local music (Android Auto)",
"localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.",
"localMusicFolderNotConfigured": "No folder selected",
+8
View File
@@ -625,6 +625,14 @@
"eqDeviceNameHint": "Ej: Altavoz del living",
"eqDeviceNameConfirm": "Guardar",
"eqDeviceConnected": "Conectado",
"eqDeviceActiveOutput": "El audio está saliendo por este dispositivo",
"eqDeviceRemove": "Quitar dispositivo",
"eqDeviceRemoved": "Se quitó {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.",
"localMusicFolderNotConfigured": "No hay carpeta seleccionada",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "ex. : Enceinte salon",
"eqDeviceNameConfirm": "Enregistrer",
"eqDeviceConnected": "Connecté",
"eqDeviceActiveOutput": "Le son sort par cet appareil",
"eqDeviceRemove": "Supprimer lappareil",
"eqDeviceRemoved": "{device} supprimé",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musique locale (Android Auto)",
"localMusicSectionDescription": "Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.",
"localMusicFolderNotConfigured": "Aucun dossier sélectionné",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
"eqDeviceNameConfirm": "सहेजें",
"eqDeviceConnected": "कनेक्टेड",
"eqDeviceActiveOutput": "ऑडियो इस डिवाइस से चल रहा है",
"eqDeviceRemove": "डिवाइस हटाएं",
"eqDeviceRemoved": "{device} हटा दिया गया",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "स्थानीय संगीत (Android Auto)",
"localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।",
"localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Mis: Speaker ruang tamu",
"eqDeviceNameConfirm": "Simpan",
"eqDeviceConnected": "Terhubung",
"eqDeviceActiveOutput": "Audio keluar melalui perangkat ini",
"eqDeviceRemove": "Hapus perangkat",
"eqDeviceRemoved": "{device} dihapus",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musik lokal (Android Auto)",
"localMusicSectionDescription": "Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.",
"localMusicFolderNotConfigured": "Belum ada folder dipilih",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Es: Cassa del salotto",
"eqDeviceNameConfirm": "Salva",
"eqDeviceConnected": "Connesso",
"eqDeviceActiveOutput": "L'audio esce da questo dispositivo",
"eqDeviceRemove": "Rimuovi dispositivo",
"eqDeviceRemoved": "{device} rimosso",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musica locale (Android Auto)",
"localMusicSectionDescription": "Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall'auto.",
"localMusicFolderNotConfigured": "Nessuna cartella selezionata",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "例:リビングのスピーカー",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "接続中",
"eqDeviceActiveOutput": "この機器から音声が出力されています",
"eqDeviceRemove": "機器を削除",
"eqDeviceRemoved": "{device} を削除しました",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "ローカル音楽(Android Auto",
"localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。",
"localMusicFolderNotConfigured": "フォルダーが選択されていません",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Ex: Caixa da sala",
"eqDeviceNameConfirm": "Salvar",
"eqDeviceConnected": "Conectado",
"eqDeviceActiveOutput": "O áudio está a sair por este dispositivo",
"eqDeviceRemove": "Remover dispositivo",
"eqDeviceRemoved": "{device} removido",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.",
"localMusicFolderNotConfigured": "Nenhuma pasta selecionada",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
"eqDeviceNameConfirm": "Сохранить",
"eqDeviceConnected": "Подключено",
"eqDeviceActiveOutput": "Звук выводится через это устройство",
"eqDeviceRemove": "Удалить устройство",
"eqDeviceRemoved": "{device} удалено",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Локальная музыка (Android Auto)",
"localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.",
"localMusicFolderNotConfigured": "Папка не выбрана",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "例:客厅音箱",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "已连接",
"eqDeviceActiveOutput": "音频正通过此设备播放",
"eqDeviceRemove": "移除设备",
"eqDeviceRemoved": "已移除 {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "本地音乐(Android Auto",
"localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。",
"localMusicFolderNotConfigured": "未选择文件夹",
+18
View File
@@ -2420,6 +2420,24 @@ abstract class AppLocalizations {
/// **'Conectado'**
String get eqDeviceConnected;
/// No description provided for @eqDeviceActiveOutput.
///
/// In es, this message translates to:
/// **'El audio está saliendo por este dispositivo'**
String get eqDeviceActiveOutput;
/// No description provided for @eqDeviceRemove.
///
/// In es, this message translates to:
/// **'Quitar dispositivo'**
String get eqDeviceRemove;
/// No description provided for @eqDeviceRemoved.
///
/// In es, this message translates to:
/// **'Se quitó {device}'**
String eqDeviceRemoved(Object device);
/// No description provided for @localMusicSectionTitle.
///
/// In es, this message translates to:
+11
View File
@@ -1313,6 +1313,17 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get eqDeviceConnected => 'متصل';
@override
String get eqDeviceActiveOutput => 'يتم إخراج الصوت من هذا الجهاز';
@override
String get eqDeviceRemove => 'إزالة الجهاز';
@override
String eqDeviceRemoved(Object device) {
return 'تمت إزالة $device';
}
@override
String get localMusicSectionTitle => 'الموسيقى المحلية (Android Auto)';
+11
View File
@@ -1321,6 +1321,17 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get eqDeviceConnected => 'সংযুক্ত';
@override
String get eqDeviceActiveOutput => 'অডিও এই ডিভাইস দিয়ে বাজছে';
@override
String get eqDeviceRemove => 'ডিভাইস সরান';
@override
String eqDeviceRemoved(Object device) {
return '$device সরানো হয়েছে';
}
@override
String get localMusicSectionTitle => 'স্থানীয় সঙ্গীত (Android Auto)';
+12
View File
@@ -1331,6 +1331,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get eqDeviceConnected => 'Verbunden';
@override
String get eqDeviceActiveOutput =>
'Der Ton wird über dieses Gerät ausgegeben';
@override
String get eqDeviceRemove => 'Gerät entfernen';
@override
String eqDeviceRemoved(Object device) {
return '$device entfernt';
}
@override
String get localMusicSectionTitle => 'Lokale Musik (Android Auto)';
+11
View File
@@ -1317,6 +1317,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get eqDeviceConnected => 'Connected';
@override
String get eqDeviceActiveOutput => 'Audio is playing through this device';
@override
String get eqDeviceRemove => 'Remove device';
@override
String eqDeviceRemoved(Object device) {
return '$device removed';
}
@override
String get localMusicSectionTitle => 'Local music (Android Auto)';
+12
View File
@@ -1326,6 +1326,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get eqDeviceConnected => 'Conectado';
@override
String get eqDeviceActiveOutput =>
'El audio está saliendo por este dispositivo';
@override
String get eqDeviceRemove => 'Quitar dispositivo';
@override
String eqDeviceRemoved(Object device) {
return 'Se quitó $device';
}
@override
String get localMusicSectionTitle => 'Música local (Android Auto)';
+11
View File
@@ -1336,6 +1336,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get eqDeviceConnected => 'Connecté';
@override
String get eqDeviceActiveOutput => 'Le son sort par cet appareil';
@override
String get eqDeviceRemove => 'Supprimer lappareil';
@override
String eqDeviceRemoved(Object device) {
return '$device supprimé';
}
@override
String get localMusicSectionTitle => 'Musique locale (Android Auto)';
+11
View File
@@ -1319,6 +1319,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get eqDeviceConnected => 'कनेक्टेड';
@override
String get eqDeviceActiveOutput => 'ऑडियो इस डिवाइस से चल रहा है';
@override
String get eqDeviceRemove => 'डिवाइस हटाएं';
@override
String eqDeviceRemoved(Object device) {
return '$device हटा दिया गया';
}
@override
String get localMusicSectionTitle => 'स्थानीय संगीत (Android Auto)';
+11
View File
@@ -1325,6 +1325,17 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get eqDeviceConnected => 'Terhubung';
@override
String get eqDeviceActiveOutput => 'Audio keluar melalui perangkat ini';
@override
String get eqDeviceRemove => 'Hapus perangkat';
@override
String eqDeviceRemoved(Object device) {
return '$device dihapus';
}
@override
String get localMusicSectionTitle => 'Musik lokal (Android Auto)';
+11
View File
@@ -1331,6 +1331,17 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get eqDeviceConnected => 'Connesso';
@override
String get eqDeviceActiveOutput => 'L\'audio esce da questo dispositivo';
@override
String get eqDeviceRemove => 'Rimuovi dispositivo';
@override
String eqDeviceRemoved(Object device) {
return '$device rimosso';
}
@override
String get localMusicSectionTitle => 'Musica locale (Android Auto)';
+11
View File
@@ -1278,6 +1278,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get eqDeviceConnected => '接続中';
@override
String get eqDeviceActiveOutput => 'この機器から音声が出力されています';
@override
String get eqDeviceRemove => '機器を削除';
@override
String eqDeviceRemoved(Object device) {
return '$device を削除しました';
}
@override
String get localMusicSectionTitle => 'ローカル音楽(Android Auto';
+11
View File
@@ -1323,6 +1323,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get eqDeviceConnected => 'Conectado';
@override
String get eqDeviceActiveOutput => 'O áudio está a sair por este dispositivo';
@override
String get eqDeviceRemove => 'Remover dispositivo';
@override
String eqDeviceRemoved(Object device) {
return '$device removido';
}
@override
String get localMusicSectionTitle => 'Música local (Android Auto)';
+11
View File
@@ -1327,6 +1327,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get eqDeviceConnected => 'Подключено';
@override
String get eqDeviceActiveOutput => 'Звук выводится через это устройство';
@override
String get eqDeviceRemove => 'Удалить устройство';
@override
String eqDeviceRemoved(Object device) {
return '$device удалено';
}
@override
String get localMusicSectionTitle => 'Локальная музыка (Android Auto)';
+11
View File
@@ -1271,6 +1271,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get eqDeviceConnected => '已连接';
@override
String get eqDeviceActiveOutput => '音频正通过此设备播放';
@override
String get eqDeviceRemove => '移除设备';
@override
String eqDeviceRemoved(Object device) {
return '已移除 $device';
}
@override
String get localMusicSectionTitle => '本地音乐(Android Auto';
+78 -6
View File
@@ -914,14 +914,28 @@ class _FilaDispositivo extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId;
final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
final displayName = _nombreLegible(
deviceId,
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
// The dot marks where audio is coming out RIGHT NOW, which is not the
// same as "paired" or "connected" — it needs a label, both for screen
// readers and for anyone wondering what a bare green dot means.
if (isActive)
const Icon(Icons.circle, size: 10, color: Colors.green)
Tooltip(
message: l10n.eqDeviceActiveOutput,
child: Icon(
Icons.circle,
size: 10,
color: Colors.green,
semanticLabel: l10n.eqDeviceActiveOutput,
),
)
else
const SizedBox(width: 10),
const SizedBox(width: 8),
@@ -954,6 +968,34 @@ class _FilaDispositivo extends StatelessWidget {
);
}
/// Turns a device id the user never named into something readable.
///
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
/// platform name is known — which is the normal case for a Bluetooth device
/// that is not currently connected, since platform names are cached in memory
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
/// the transport plus the tail of the address, which is what distinguishes
/// two otherwise identical rows.
static String _nombreLegible(String deviceId, String nombreVisible) {
if (nombreVisible != deviceId) return nombreVisible;
final separador = deviceId.indexOf(':');
if (separador == -1) return deviceId;
final transporte = deviceId.substring(0, separador);
final resto = deviceId.substring(separador + 1);
final etiqueta = switch (transporte) {
'bt_a2dp' => 'Bluetooth',
'usb_headset' => 'USB',
_ => transporte,
};
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
if (cola.isEmpty) return etiqueta;
final sufijo = cola.length >= 2
? cola.sublist(cola.length - 2).join(':')
: cola.last;
return '$etiqueta · $sufijo';
}
Future<void> _abrirModal(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
@@ -1014,6 +1056,22 @@ class _DialogoEdicionDispositivoState
if (mounted) Navigator.of(context).pop();
}
Future<void> _eliminar() async {
final eq = context.read<EstadoEcualizador>();
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final nombre = _nombreCtrl.text.trim().isEmpty
? widget.deviceId
: _nombreCtrl.text.trim();
await eq.eliminarDispositivo(widget.deviceId);
if (!mounted) return;
Navigator.of(context).pop();
messenger.showSnackBar(
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -1046,10 +1104,24 @@ class _DialogoEdicionDispositivoState
onCambio: (p) => setState(() => _presetActual = p),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
),
),
const SizedBox(width: 12),
// Lets the user clear stale or duplicate rows. The device comes
// back on its next connection, so this is recoverable.
OutlinedButton.icon(
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.eqDeviceRemove),
),
],
),
],
),
@@ -12,6 +12,16 @@ import '../modelos/dispositivo_audio.dart';
/// entry.
const prefijoPlaceholderBtName = 'bt_a2dp:name:';
/// Canonical id of the phone's own speaker.
///
/// Single source of truth shared by the collision guard in `EstadoEcualizador`
/// and the one-time purge in `ServicioEcualizador`: the phone speaker is the
/// fallback output every hierarchy level falls through to, so it must never own
/// a device-level preset entry. The native layer used to hand this id to any
/// output type it could not name (LE Audio, car bus, dock), which persisted an
/// entry that then marked the wrong device as active forever.
const idAltavozInterno = 'builtin_speaker';
/// Abstract service for audio device detection.
///
/// Implementations:
+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 {