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
@@ -1182,25 +1182,52 @@ class MainActivity : AudioServiceActivity() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown // The built-in speaker is the LAST resort, never a peer in this list:
val priorityOrder = listOf( // it is always present, so ranking it alongside the others made any
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, // output type absent from the list (LE Audio car stereos, car buses,
AudioDeviceInfo.TYPE_USB_HEADSET, // docks) sort BELOW it and never win — the car would connect and the
AudioDeviceInfo.TYPE_WIRED_HEADSET, // phone speaker would still be reported as the active device.
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
val best = outputs val best = outputs
.filter { it.isSink && it.id !in excludeIds } .filter { it.isSink && it.id !in excludeIds }
.sortedBy { device -> .minByOrNull { device -> outputPriority(device.type) }
val idx = priorityOrder.indexOf(device.type)
if (idx == -1) Int.MAX_VALUE else idx
}
.firstOrNull()
return deviceToMap(best) 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<String, Any> { private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) { if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker") 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 // The OS reports a placeholder MAC ("02:00:00:00:00:00") when
// BLUETOOTH_CONNECT has not been granted; treat that (and any // BLUETOOTH_CONNECT has not been granted; treat that (and any
// null/blank address) as absent instead of using it as an id. // 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) { val id = if (mac != null) {
"bt_a2dp:$mac" "bt_a2dp:$mac"
} else { } else {
@@ -1231,21 +1258,32 @@ class MainActivity : AudioServiceActivity() {
) )
} }
AudioDeviceInfo.TYPE_USB_HEADSET -> { 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( mapOf(
"id" to "usb_headset:$addr", "id" to "usb_headset:$addr",
"type" to 14, "type" to 14,
"name" to (device.productName?.toString() ?: "USB Headset"), "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( mapOf(
"id" to "builtin_speaker", "id" to "other:${device.type}:$address",
"type" to device.type, "type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"), "name" to (device.productName?.toString() ?: "Unknown"),
) )
} }
} }
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+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 // 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 // persisting a preset entry for them would create dead noise that never
// resolves to the eventual real-MAC id. // 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( final esPlaceholderCompuesto = dispositivo.id.startsWith(
prefijoPlaceholderBtName, prefijoPlaceholderBtName,
); );
@@ -424,6 +431,35 @@ class EstadoEcualizador extends ChangeNotifier {
notifyListeners(); 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. /// Returns the stored custom name for [deviceId], or an empty string if none.
String obtenerNombreDispositivo(String deviceId) => String obtenerNombreDispositivo(String deviceId) =>
_nombresDispositivos[deviceId] ?? ''; _nombresDispositivos[deviceId] ?? '';
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة", "eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
"eqDeviceNameConfirm": "حفظ", "eqDeviceNameConfirm": "حفظ",
"eqDeviceConnected": "متصل", "eqDeviceConnected": "متصل",
"eqDeviceActiveOutput": "يتم إخراج الصوت من هذا الجهاز",
"eqDeviceRemove": "إزالة الجهاز",
"eqDeviceRemoved": "تمت إزالة {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "الموسيقى المحلية (Android Auto)", "localMusicSectionTitle": "الموسيقى المحلية (Android Auto)",
"localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.", "localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.",
"localMusicFolderNotConfigured": "لم يتم تحديد مجلد", "localMusicFolderNotConfigured": "لم يتم تحديد مجلد",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার", "eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
"eqDeviceNameConfirm": "সংরক্ষণ করুন", "eqDeviceNameConfirm": "সংরক্ষণ করুন",
"eqDeviceConnected": "সংযুক্ত", "eqDeviceConnected": "সংযুক্ত",
"eqDeviceActiveOutput": "অডিও এই ডিভাইস দিয়ে বাজছে",
"eqDeviceRemove": "ডিভাইস সরান",
"eqDeviceRemoved": "{device} সরানো হয়েছে",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)", "localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)",
"localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।", "localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।",
"localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি", "localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher", "eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
"eqDeviceNameConfirm": "Speichern", "eqDeviceNameConfirm": "Speichern",
"eqDeviceConnected": "Verbunden", "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)", "localMusicSectionTitle": "Lokale Musik (Android Auto)",
"localMusicSectionDescription": "Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.", "localMusicSectionDescription": "Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.",
"localMusicFolderNotConfigured": "Kein Ordner ausgewählt", "localMusicFolderNotConfigured": "Kein Ordner ausgewählt",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "e.g. Living Room Speaker", "eqDeviceNameHint": "e.g. Living Room Speaker",
"eqDeviceNameConfirm": "Save", "eqDeviceNameConfirm": "Save",
"eqDeviceConnected": "Connected", "eqDeviceConnected": "Connected",
"eqDeviceActiveOutput": "Audio is playing through this device",
"eqDeviceRemove": "Remove device",
"eqDeviceRemoved": "{device} removed",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Local music (Android Auto)", "localMusicSectionTitle": "Local music (Android Auto)",
"localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.", "localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.",
"localMusicFolderNotConfigured": "No folder selected", "localMusicFolderNotConfigured": "No folder selected",
+8
View File
@@ -625,6 +625,14 @@
"eqDeviceNameHint": "Ej: Altavoz del living", "eqDeviceNameHint": "Ej: Altavoz del living",
"eqDeviceNameConfirm": "Guardar", "eqDeviceNameConfirm": "Guardar",
"eqDeviceConnected": "Conectado", "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)", "localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.", "localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.",
"localMusicFolderNotConfigured": "No hay carpeta seleccionada", "localMusicFolderNotConfigured": "No hay carpeta seleccionada",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "ex. : Enceinte salon", "eqDeviceNameHint": "ex. : Enceinte salon",
"eqDeviceNameConfirm": "Enregistrer", "eqDeviceNameConfirm": "Enregistrer",
"eqDeviceConnected": "Connecté", "eqDeviceConnected": "Connecté",
"eqDeviceActiveOutput": "Le son sort par cet appareil",
"eqDeviceRemove": "Supprimer lappareil",
"eqDeviceRemoved": "{device} supprimé",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musique locale (Android Auto)", "localMusicSectionTitle": "Musique locale (Android Auto)",
"localMusicSectionDescription": "Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.", "localMusicSectionDescription": "Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.",
"localMusicFolderNotConfigured": "Aucun dossier sélectionné", "localMusicFolderNotConfigured": "Aucun dossier sélectionné",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर", "eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
"eqDeviceNameConfirm": "सहेजें", "eqDeviceNameConfirm": "सहेजें",
"eqDeviceConnected": "कनेक्टेड", "eqDeviceConnected": "कनेक्टेड",
"eqDeviceActiveOutput": "ऑडियो इस डिवाइस से चल रहा है",
"eqDeviceRemove": "डिवाइस हटाएं",
"eqDeviceRemoved": "{device} हटा दिया गया",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "स्थानीय संगीत (Android Auto)", "localMusicSectionTitle": "स्थानीय संगीत (Android Auto)",
"localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।", "localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।",
"localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया", "localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Mis: Speaker ruang tamu", "eqDeviceNameHint": "Mis: Speaker ruang tamu",
"eqDeviceNameConfirm": "Simpan", "eqDeviceNameConfirm": "Simpan",
"eqDeviceConnected": "Terhubung", "eqDeviceConnected": "Terhubung",
"eqDeviceActiveOutput": "Audio keluar melalui perangkat ini",
"eqDeviceRemove": "Hapus perangkat",
"eqDeviceRemoved": "{device} dihapus",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musik lokal (Android Auto)", "localMusicSectionTitle": "Musik lokal (Android Auto)",
"localMusicSectionDescription": "Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.", "localMusicSectionDescription": "Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.",
"localMusicFolderNotConfigured": "Belum ada folder dipilih", "localMusicFolderNotConfigured": "Belum ada folder dipilih",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Es: Cassa del salotto", "eqDeviceNameHint": "Es: Cassa del salotto",
"eqDeviceNameConfirm": "Salva", "eqDeviceNameConfirm": "Salva",
"eqDeviceConnected": "Connesso", "eqDeviceConnected": "Connesso",
"eqDeviceActiveOutput": "L'audio esce da questo dispositivo",
"eqDeviceRemove": "Rimuovi dispositivo",
"eqDeviceRemoved": "{device} rimosso",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Musica locale (Android Auto)", "localMusicSectionTitle": "Musica locale (Android Auto)",
"localMusicSectionDescription": "Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall'auto.", "localMusicSectionDescription": "Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall'auto.",
"localMusicFolderNotConfigured": "Nessuna cartella selezionata", "localMusicFolderNotConfigured": "Nessuna cartella selezionata",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "例:リビングのスピーカー", "eqDeviceNameHint": "例:リビングのスピーカー",
"eqDeviceNameConfirm": "保存", "eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "接続中", "eqDeviceConnected": "接続中",
"eqDeviceActiveOutput": "この機器から音声が出力されています",
"eqDeviceRemove": "機器を削除",
"eqDeviceRemoved": "{device} を削除しました",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "ローカル音楽(Android Auto", "localMusicSectionTitle": "ローカル音楽(Android Auto",
"localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。", "localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。",
"localMusicFolderNotConfigured": "フォルダーが選択されていません", "localMusicFolderNotConfigured": "フォルダーが選択されていません",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Ex: Caixa da sala", "eqDeviceNameHint": "Ex: Caixa da sala",
"eqDeviceNameConfirm": "Salvar", "eqDeviceNameConfirm": "Salvar",
"eqDeviceConnected": "Conectado", "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)", "localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.", "localMusicSectionDescription": "Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.",
"localMusicFolderNotConfigured": "Nenhuma pasta selecionada", "localMusicFolderNotConfigured": "Nenhuma pasta selecionada",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "Напр.: Колонка в гостиной", "eqDeviceNameHint": "Напр.: Колонка в гостиной",
"eqDeviceNameConfirm": "Сохранить", "eqDeviceNameConfirm": "Сохранить",
"eqDeviceConnected": "Подключено", "eqDeviceConnected": "Подключено",
"eqDeviceActiveOutput": "Звук выводится через это устройство",
"eqDeviceRemove": "Удалить устройство",
"eqDeviceRemoved": "{device} удалено",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "Локальная музыка (Android Auto)", "localMusicSectionTitle": "Локальная музыка (Android Auto)",
"localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.", "localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.",
"localMusicFolderNotConfigured": "Папка не выбрана", "localMusicFolderNotConfigured": "Папка не выбрана",
+8
View File
@@ -666,6 +666,14 @@
"eqDeviceNameHint": "例:客厅音箱", "eqDeviceNameHint": "例:客厅音箱",
"eqDeviceNameConfirm": "保存", "eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "已连接", "eqDeviceConnected": "已连接",
"eqDeviceActiveOutput": "音频正通过此设备播放",
"eqDeviceRemove": "移除设备",
"eqDeviceRemoved": "已移除 {device}",
"@eqDeviceRemoved": {
"placeholders": {
"device": {}
}
},
"localMusicSectionTitle": "本地音乐(Android Auto", "localMusicSectionTitle": "本地音乐(Android Auto",
"localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。", "localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。",
"localMusicFolderNotConfigured": "未选择文件夹", "localMusicFolderNotConfigured": "未选择文件夹",
+18
View File
@@ -2420,6 +2420,24 @@ abstract class AppLocalizations {
/// **'Conectado'** /// **'Conectado'**
String get eqDeviceConnected; 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. /// No description provided for @localMusicSectionTitle.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
+11
View File
@@ -1313,6 +1313,17 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'متصل'; String get eqDeviceConnected => 'متصل';
@override
String get eqDeviceActiveOutput => 'يتم إخراج الصوت من هذا الجهاز';
@override
String get eqDeviceRemove => 'إزالة الجهاز';
@override
String eqDeviceRemoved(Object device) {
return 'تمت إزالة $device';
}
@override @override
String get localMusicSectionTitle => 'الموسيقى المحلية (Android Auto)'; String get localMusicSectionTitle => 'الموسيقى المحلية (Android Auto)';
+11
View File
@@ -1321,6 +1321,17 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'সংযুক্ত'; String get eqDeviceConnected => 'সংযুক্ত';
@override
String get eqDeviceActiveOutput => 'অডিও এই ডিভাইস দিয়ে বাজছে';
@override
String get eqDeviceRemove => 'ডিভাইস সরান';
@override
String eqDeviceRemoved(Object device) {
return '$device সরানো হয়েছে';
}
@override @override
String get localMusicSectionTitle => 'স্থানীয় সঙ্গীত (Android Auto)'; String get localMusicSectionTitle => 'স্থানীয় সঙ্গীত (Android Auto)';
+12
View File
@@ -1331,6 +1331,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Verbunden'; 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 @override
String get localMusicSectionTitle => 'Lokale Musik (Android Auto)'; String get localMusicSectionTitle => 'Lokale Musik (Android Auto)';
+11
View File
@@ -1317,6 +1317,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Connected'; 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 @override
String get localMusicSectionTitle => 'Local music (Android Auto)'; String get localMusicSectionTitle => 'Local music (Android Auto)';
+12
View File
@@ -1326,6 +1326,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Conectado'; 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 @override
String get localMusicSectionTitle => 'Música local (Android Auto)'; String get localMusicSectionTitle => 'Música local (Android Auto)';
+11
View File
@@ -1336,6 +1336,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Connecté'; 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 @override
String get localMusicSectionTitle => 'Musique locale (Android Auto)'; String get localMusicSectionTitle => 'Musique locale (Android Auto)';
+11
View File
@@ -1319,6 +1319,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'कनेक्टेड'; String get eqDeviceConnected => 'कनेक्टेड';
@override
String get eqDeviceActiveOutput => 'ऑडियो इस डिवाइस से चल रहा है';
@override
String get eqDeviceRemove => 'डिवाइस हटाएं';
@override
String eqDeviceRemoved(Object device) {
return '$device हटा दिया गया';
}
@override @override
String get localMusicSectionTitle => 'स्थानीय संगीत (Android Auto)'; String get localMusicSectionTitle => 'स्थानीय संगीत (Android Auto)';
+11
View File
@@ -1325,6 +1325,17 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Terhubung'; 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 @override
String get localMusicSectionTitle => 'Musik lokal (Android Auto)'; String get localMusicSectionTitle => 'Musik lokal (Android Auto)';
+11
View File
@@ -1331,6 +1331,17 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Connesso'; 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 @override
String get localMusicSectionTitle => 'Musica locale (Android Auto)'; String get localMusicSectionTitle => 'Musica locale (Android Auto)';
+11
View File
@@ -1278,6 +1278,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get eqDeviceConnected => '接続中'; String get eqDeviceConnected => '接続中';
@override
String get eqDeviceActiveOutput => 'この機器から音声が出力されています';
@override
String get eqDeviceRemove => '機器を削除';
@override
String eqDeviceRemoved(Object device) {
return '$device を削除しました';
}
@override @override
String get localMusicSectionTitle => 'ローカル音楽(Android Auto'; String get localMusicSectionTitle => 'ローカル音楽(Android Auto';
+11
View File
@@ -1323,6 +1323,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Conectado'; 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 @override
String get localMusicSectionTitle => 'Música local (Android Auto)'; String get localMusicSectionTitle => 'Música local (Android Auto)';
+11
View File
@@ -1327,6 +1327,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get eqDeviceConnected => 'Подключено'; String get eqDeviceConnected => 'Подключено';
@override
String get eqDeviceActiveOutput => 'Звук выводится через это устройство';
@override
String get eqDeviceRemove => 'Удалить устройство';
@override
String eqDeviceRemoved(Object device) {
return '$device удалено';
}
@override @override
String get localMusicSectionTitle => 'Локальная музыка (Android Auto)'; String get localMusicSectionTitle => 'Локальная музыка (Android Auto)';
+11
View File
@@ -1271,6 +1271,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get eqDeviceConnected => '已连接'; String get eqDeviceConnected => '已连接';
@override
String get eqDeviceActiveOutput => '音频正通过此设备播放';
@override
String get eqDeviceRemove => '移除设备';
@override
String eqDeviceRemoved(Object device) {
return '已移除 $device';
}
@override @override
String get localMusicSectionTitle => '本地音乐(Android Auto'; String get localMusicSectionTitle => '本地音乐(Android Auto';
+75 -3
View File
@@ -914,14 +914,28 @@ class _FilaDispositivo extends StatelessWidget {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>(); final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId; 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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
children: [ 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) 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 else
const SizedBox(width: 10), const SizedBox(width: 10),
const SizedBox(width: 8), 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 { Future<void> _abrirModal(BuildContext context) async {
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
@@ -1014,6 +1056,22 @@ class _DialogoEdicionDispositivoState
if (mounted) Navigator.of(context).pop(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@@ -1046,11 +1104,25 @@ class _DialogoEdicionDispositivoState
onCambio: (p) => setState(() => _presetActual = p), onCambio: (p) => setState(() => _presetActual = p),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar, onPressed: _guardar,
icon: const Icon(Icons.save_rounded), icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm), 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. /// entry.
const prefijoPlaceholderBtName = 'bt_a2dp:name:'; 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. /// Abstract service for audio device detection.
/// ///
/// Implementations: /// Implementations:
+39 -5
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart'; import 'persistencia_tolerante.dart';
import 'servicio_dispositivo_audio.dart' show idAltavozInterno;
class ConfiguracionEcualizador { class ConfiguracionEcualizador {
const ConfiguracionEcualizador({ const ConfiguracionEcualizador({
@@ -51,6 +52,9 @@ class ServicioEcualizador {
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps. /// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00'; 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; final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback. /// Injected startup instance (S3-R4); getInstance() is only a fallback.
@@ -59,6 +63,7 @@ class ServicioEcualizador {
Future<ConfiguracionEcualizador> cargar() async { Future<ConfiguracionEcualizador> cargar() async {
await migrarClavesPlaceholder(); await migrarClavesPlaceholder();
await migrarColisionAltavozInterno();
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs); final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs); final porEmisora = _leerPresetsPorEmisora(prefs);
@@ -84,9 +89,33 @@ class ServicioEcualizador {
Future<void> migrarClavesPlaceholder() async { Future<void> migrarClavesPlaceholder() async {
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return; 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); final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) { if (presetsPorDispositivo.remove(deviceId) != null) {
await _guardarMapa( await _guardarMapa(
prefs, prefs,
_keyPresetsPorDispositivo, _keyPresetsPorDispositivo,
@@ -101,8 +130,7 @@ class ServicioEcualizador {
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) { final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':'); final separador = clave.indexOf(':');
if (separador == -1) return false; if (separador == -1) return false;
final deviceIdSegmento = clave.substring(separador + 1); return clave.substring(separador + 1) == deviceId;
return deviceIdSegmento == _placeholderMacLiteral;
}).toList(); }).toList();
if (clavesMatrizAPurgar.isNotEmpty) { if (clavesMatrizAPurgar.isNotEmpty) {
for (final clave in clavesMatrizAPurgar) { for (final clave in clavesMatrizAPurgar) {
@@ -115,15 +143,21 @@ class ServicioEcualizador {
prefs, prefs,
_keyNombresDispositivos, _keyNombresDispositivos,
); );
if (nombresDispositivos.remove(_placeholderMacLiteral) != null) { if (nombresDispositivos.remove(deviceId) != null) {
await _guardarMapaStrings( await _guardarMapaStrings(
prefs, prefs,
_keyNombresDispositivos, _keyNombresDispositivos,
nombresDispositivos, 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 { Future<void> guardarPrincipal(PresetEcualizador preset) async {
+77
View File
@@ -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] /// Fake whose [resubscribir] stays pending until [completarResubscribir]
+24
View File
@@ -419,6 +419,30 @@ class FakeServicioEcualizador extends ServicioEcualizador {
nombresDispositivos: nombres, 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]. /// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
+9 -9
View File
@@ -196,11 +196,9 @@ void main() {
// known devices. // known devices.
expect(find.text('Known audio devices'), findsOneWidget); expect(find.text('Known audio devices'), findsOneWidget);
// The device ID should appear in the list. // The device row is listed. An unnamed device shows its transport plus
expect( // the tail of its address, not the raw id.
find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'), expect(find.text('Bluetooth · EE:FF'), findsOneWidget);
findsOneWidget,
);
}, },
); );
@@ -444,9 +442,10 @@ void main() {
expect(find.text('AirPods Pro'), findsNothing); expect(find.text('AirPods Pro'), findsNothing);
}); });
// 4.5 — approval test (regression-lock): a device never seen on the // 4.5 — a device never seen on the stream has no cached platform name, so
// stream has no cached platform name, so legacy raw-id fallback holds. // the row falls back to a humanized transport + address tail instead of the
testWidgets('4.5 no platform name yet falls back to raw id', ( // raw id, which told the user nothing.
testWidgets('4.5 no platform name yet shows a humanized transport label', (
tester, tester,
) async { ) async {
setLargeSurface(tester); setLargeSurface(tester);
@@ -467,7 +466,8 @@ void main() {
); );
await pumpStable(tester); 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 // 4.6 — permission trigger point: turning the toggle ON requests
+98 -3
View File
@@ -129,8 +129,11 @@ void main() {
await servicio.guardarPrincipal(PresetEcualizador.pop); await servicio.guardarPrincipal(PresetEcualizador.pop);
await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz); await servicio.guardarPorEmisora('station-X', PresetEcualizador.voz);
await servicio.guardarToggleMultiDispositivo(true); 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( await servicio.guardarPresetDispositivo(
'builtin_speaker', 'wired_headset',
PresetEcualizador.jazz, PresetEcualizador.jazz,
); );
@@ -139,7 +142,7 @@ void main() {
expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz)); expect(config.porEmisora['station-X'], equals(PresetEcualizador.voz));
expect(config.eqMultiDeviceEnabled, isTrue); expect(config.eqMultiDeviceEnabled, isTrue);
expect( expect(
config.presetsDispositivo['builtin_speaker'], config.presetsDispositivo['wired_headset'],
equals(PresetEcualizador.jazz), equals(PresetEcualizador.jazz),
); );
}, },
@@ -206,10 +209,13 @@ void main() {
test('1.3 round-trip save → cargar preserves all entries', () async { test('1.3 round-trip save → cargar preserves all entries', () async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs); 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 = { const nombres = {
'bt_a2dp:AA:BB': 'Living Room BT', 'bt_a2dp:AA:BB': 'Living Room BT',
'wired_headset': 'Office Headset', 'wired_headset': 'Office Headset',
'builtin_speaker': 'Built-in', 'usb_headset:1': 'Desk DAC',
}; };
await servicio.guardarNombresDispositivos(nombres); 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),
);
});
});
} }