diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index dffe554..7061237 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -1182,25 +1182,52 @@ class MainActivity : AudioServiceActivity() { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - // Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown - val priorityOrder = listOf( - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, - AudioDeviceInfo.TYPE_USB_HEADSET, - AudioDeviceInfo.TYPE_WIRED_HEADSET, - AudioDeviceInfo.TYPE_WIRED_HEADPHONES, - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, - ) + // The built-in speaker is the LAST resort, never a peer in this list: + // it is always present, so ranking it alongside the others made any + // output type absent from the list (LE Audio car stereos, car buses, + // docks) sort BELOW it and never win — the car would connect and the + // phone speaker would still be reported as the active device. val best = outputs .filter { it.isSink && it.id !in excludeIds } - .sortedBy { device -> - val idx = priorityOrder.indexOf(device.type) - if (idx == -1) Int.MAX_VALUE else idx - } - .firstOrNull() + .minByOrNull { device -> outputPriority(device.type) } return deviceToMap(best) } + /** + * Ranks an [AudioDeviceInfo] type as a media output candidate; lower wins. + * + * Externally connected outputs outrank the built-in speaker, including types + * this build does not name individually. Virtual and call-only sinks + * (earpiece, telephony, remote submix) are pushed below the speaker so they + * can never be reported as where music is playing. + */ + private fun outputPriority(type: Int): Int = when (type) { + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> 0 + AudioDeviceInfo.TYPE_USB_HEADSET -> 1 + AudioDeviceInfo.TYPE_USB_DEVICE -> 2 + AudioDeviceInfo.TYPE_WIRED_HEADSET -> 3 + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> 4 + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> 90 + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_TELEPHONY, + AudioDeviceInfo.TYPE_REMOTE_SUBMIX, + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> 99 + // Everything else is an output the user physically connected (LE Audio, + // hearing aids, car bus, dock, HDMI): above the speaker, below the + // types named above so a known match always wins a tie. + else -> 50 + } + + /** + * [AudioDeviceInfo.getAddress] is API 28 while minSdk is 24, so reading it + * unguarded throws NoSuchMethodError on Android 7-8.1. Below API 28 there is + * no address to read and callers fall back to a non-MAC identity (the + * `bt_a2dp:name:` placeholder shape, or the device's session id). + */ + private fun deviceAddress(device: AudioDeviceInfo): String? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) device.address else null + private fun deviceToMap(device: AudioDeviceInfo?): Map { if (device == null) { return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker") @@ -1216,7 +1243,7 @@ class MainActivity : AudioServiceActivity() { // The OS reports a placeholder MAC ("02:00:00:00:00:00") when // BLUETOOTH_CONNECT has not been granted; treat that (and any // null/blank address) as absent instead of using it as an id. - val mac = device.address?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder } + val mac = deviceAddress(device)?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder } val id = if (mac != null) { "bt_a2dp:$mac" } else { @@ -1231,19 +1258,30 @@ class MainActivity : AudioServiceActivity() { ) } AudioDeviceInfo.TYPE_USB_HEADSET -> { - val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString() + val addr = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString() mapOf( "id" to "usb_headset:$addr", "type" to 14, "name" to (device.productName?.toString() ?: "USB Headset"), ) } - else -> + // NEVER reuse builtin_speaker's id here. An output this build does + // not name individually (LE Audio car stereo, car bus, dock) would + // collide with the phone's own speaker: Dart persisted a device + // entry under that shared id, and from then on every playback + // through the phone speaker matched it, pinning the green + // active-device marker to the wrong row forever. The type is kept + // verbatim so Dart can still tell it apart from a real speaker. + else -> { + val address = deviceAddress(device) + ?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder } + ?: device.id.toString() mapOf( - "id" to "builtin_speaker", + "id" to "other:${device.type}:$address", "type" to device.type, "name" to (device.productName?.toString() ?: "Unknown"), ) + } } } diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index a6fdaaa..f757b46 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -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 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] ?? ''; diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index e0f2afa..fad6a43 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة", "eqDeviceNameConfirm": "حفظ", "eqDeviceConnected": "متصل", + "eqDeviceActiveOutput": "يتم إخراج الصوت من هذا الجهاز", + "eqDeviceRemove": "إزالة الجهاز", + "eqDeviceRemoved": "تمت إزالة {device}", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "الموسيقى المحلية (Android Auto)", "localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.", "localMusicFolderNotConfigured": "لم يتم تحديد مجلد", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index b539636..5b990c0 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার", "eqDeviceNameConfirm": "সংরক্ষণ করুন", "eqDeviceConnected": "সংযুক্ত", + "eqDeviceActiveOutput": "অডিও এই ডিভাইস দিয়ে বাজছে", + "eqDeviceRemove": "ডিভাইস সরান", + "eqDeviceRemoved": "{device} সরানো হয়েছে", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)", "localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।", "localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b61cdc6..a6ff7fb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8979e8..61ebbf0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4ecaebd..a352c1c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 65d7071..faac260 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "ex. : Enceinte salon", "eqDeviceNameConfirm": "Enregistrer", "eqDeviceConnected": "Connecté", + "eqDeviceActiveOutput": "Le son sort par cet appareil", + "eqDeviceRemove": "Supprimer l’appareil", + "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é", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f02202d..a72d625 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर", "eqDeviceNameConfirm": "सहेजें", "eqDeviceConnected": "कनेक्टेड", + "eqDeviceActiveOutput": "ऑडियो इस डिवाइस से चल रहा है", + "eqDeviceRemove": "डिवाइस हटाएं", + "eqDeviceRemoved": "{device} हटा दिया गया", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "स्थानीय संगीत (Android Auto)", "localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।", "localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index df0829b..e4fb3e1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 420f979..aeee2d8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 36a34bf..9692047 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "例:リビングのスピーカー", "eqDeviceNameConfirm": "保存", "eqDeviceConnected": "接続中", + "eqDeviceActiveOutput": "この機器から音声が出力されています", + "eqDeviceRemove": "機器を削除", + "eqDeviceRemoved": "{device} を削除しました", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "ローカル音楽(Android Auto)", "localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。", "localMusicFolderNotConfigured": "フォルダーが選択されていません", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 33be3e7..c318d5b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4393140..86dd547 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "Напр.: Колонка в гостиной", "eqDeviceNameConfirm": "Сохранить", "eqDeviceConnected": "Подключено", + "eqDeviceActiveOutput": "Звук выводится через это устройство", + "eqDeviceRemove": "Удалить устройство", + "eqDeviceRemoved": "{device} удалено", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "Локальная музыка (Android Auto)", "localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.", "localMusicFolderNotConfigured": "Папка не выбрана", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1b185db..778a7ac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -666,6 +666,14 @@ "eqDeviceNameHint": "例:客厅音箱", "eqDeviceNameConfirm": "保存", "eqDeviceConnected": "已连接", + "eqDeviceActiveOutput": "音频正通过此设备播放", + "eqDeviceRemove": "移除设备", + "eqDeviceRemoved": "已移除 {device}", + "@eqDeviceRemoved": { + "placeholders": { + "device": {} + } + }, "localMusicSectionTitle": "本地音乐(Android Auto)", "localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。", "localMusicFolderNotConfigured": "未选择文件夹", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 390b140..9a7e805 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index d9125c9..ea9ef4b 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 653a332..18e5f92 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 28600ed..407538a 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 969af11..56bd3a2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 8e69f61..8981351 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 9ba66b0..0668147 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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 l’appareil'; + + @override + String eqDeviceRemoved(Object device) { + return '$device supprimé'; + } + @override String get localMusicSectionTitle => 'Musique locale (Android Auto)'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index edfa192..1a1e374 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 69e1d00..4a84a54 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 676271d..120f9f4 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 13d1107..d8af535 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 98a8e45..8bc094a 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index f200d01..e77670b 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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)'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index fae7db1..c7a225d 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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)'; diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 701f6cf..a41ea59 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -914,14 +914,28 @@ class _FilaDispositivo extends StatelessWidget { final l10n = AppLocalizations.of(context); final eq = context.watch(); 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 _abrirModal(BuildContext context) async { await showModalBottomSheet( context: context, @@ -1014,6 +1056,22 @@ class _DialogoEdicionDispositivoState if (mounted) Navigator.of(context).pop(); } + Future _eliminar() async { + final eq = context.read(); + 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), + ), + ], ), ], ), diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart index 60422c3..303f849 100644 --- a/lib/servicios/servicio_dispositivo_audio.dart +++ b/lib/servicios/servicio_dispositivo_audio.dart @@ -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: diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index 1f407a6..3eeb2db 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -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 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 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 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 _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 eliminarDispositivo(String deviceId) async { + final prefs = await _resolverPrefs(); + await _purgarDeviceId(prefs, deviceId); } Future guardarPrincipal(PresetEcualizador preset) async { diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 6380072..6ba3dba 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -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.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] diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 8f58491..18e2162 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -419,6 +419,30 @@ class FakeServicioEcualizador extends ServicioEcualizador { nombresDispositivos: nombres, ); } + + @override + Future eliminarDispositivo(String deviceId) async { + final presets = Map.from( + _config.presetsDispositivo, + )..remove(deviceId); + final nombres = Map.from(_config.nombresDispositivos) + ..remove(deviceId); + final matriz = Map.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]. diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart index cc175ed..64a6b0c 100644 --- a/test/pantallas/pantalla_ajustes_test.dart +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -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 diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart index 0aa8e8b..3970878 100644 --- a/test/servicios/servicio_ecualizador_test.dart +++ b/test/servicios/servicio_ecualizador_test.dart @@ -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), + ); + }); + }); }