fix(eq): stop the phone speaker from impersonating a Bluetooth device
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:
@@ -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<String, Any> {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,14 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
// fallback ids for a device whose real MAC is not yet known, so
|
||||
// persisting a preset entry for them would create dead noise that never
|
||||
// resolves to the eventual real-MAC id.
|
||||
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
|
||||
// Matched by id AND by type: the native layer historically reported the
|
||||
// phone-speaker id for output types it could not name (LE Audio, car bus,
|
||||
// dock), which arrive here as `desconocido` and would otherwise slip past a
|
||||
// type-only check and persist an entry that hijacks the active-device
|
||||
// indicator forever.
|
||||
final esBase =
|
||||
dispositivo.tipo == TipoDispositivo.altavozInterno ||
|
||||
dispositivo.id == idAltavozInterno;
|
||||
final esPlaceholderCompuesto = dispositivo.id.startsWith(
|
||||
prefijoPlaceholderBtName,
|
||||
);
|
||||
@@ -424,6 +431,35 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Forgets [deviceId] completely: its device preset, its custom name and
|
||||
/// every matrix entry that targets it.
|
||||
///
|
||||
/// Lets the user clear stale or duplicate rows from the known-devices list.
|
||||
/// The device is NOT prevented from coming back: if it connects again it is
|
||||
/// re-registered from scratch, which is exactly how a user recovers from a
|
||||
/// bad entry. When the removed device is the active one, the effective preset
|
||||
/// is re-resolved so playback immediately follows the remaining hierarchy
|
||||
/// instead of keeping the deleted preset applied.
|
||||
Future<void> eliminarDispositivo(String deviceId) async {
|
||||
_presetsDispositivo.remove(deviceId);
|
||||
_nombresDispositivos.remove(deviceId);
|
||||
_nombresPlataforma.remove(deviceId);
|
||||
_presetsMatriz.removeWhere((clave, _) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
return clave.substring(separador + 1) == deviceId;
|
||||
});
|
||||
|
||||
await servicio.eliminarDispositivo(deviceId);
|
||||
|
||||
if (_dispositivoActualId == deviceId) {
|
||||
final resuelto = _resolverPresetActivo();
|
||||
_presetActual = resuelto;
|
||||
await audio.aplicarPreset(resuelto);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Returns the stored custom name for [deviceId], or an empty string if none.
|
||||
String obtenerNombreDispositivo(String deviceId) =>
|
||||
_nombresDispositivos[deviceId] ?? '';
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
|
||||
"eqDeviceNameConfirm": "حفظ",
|
||||
"eqDeviceConnected": "متصل",
|
||||
"eqDeviceActiveOutput": "يتم إخراج الصوت من هذا الجهاز",
|
||||
"eqDeviceRemove": "إزالة الجهاز",
|
||||
"eqDeviceRemoved": "تمت إزالة {device}",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "الموسيقى المحلية (Android Auto)",
|
||||
"localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.",
|
||||
"localMusicFolderNotConfigured": "لم يتم تحديد مجلد",
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
|
||||
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
|
||||
"eqDeviceConnected": "সংযুক্ত",
|
||||
"eqDeviceActiveOutput": "অডিও এই ডিভাইস দিয়ে বাজছে",
|
||||
"eqDeviceRemove": "ডিভাইস সরান",
|
||||
"eqDeviceRemoved": "{device} সরানো হয়েছে",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)",
|
||||
"localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।",
|
||||
"localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
|
||||
"eqDeviceNameConfirm": "सहेजें",
|
||||
"eqDeviceConnected": "कनेक्टेड",
|
||||
"eqDeviceActiveOutput": "ऑडियो इस डिवाइस से चल रहा है",
|
||||
"eqDeviceRemove": "डिवाइस हटाएं",
|
||||
"eqDeviceRemoved": "{device} हटा दिया गया",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "स्थानीय संगीत (Android Auto)",
|
||||
"localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।",
|
||||
"localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "例:リビングのスピーカー",
|
||||
"eqDeviceNameConfirm": "保存",
|
||||
"eqDeviceConnected": "接続中",
|
||||
"eqDeviceActiveOutput": "この機器から音声が出力されています",
|
||||
"eqDeviceRemove": "機器を削除",
|
||||
"eqDeviceRemoved": "{device} を削除しました",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "ローカル音楽(Android Auto)",
|
||||
"localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。",
|
||||
"localMusicFolderNotConfigured": "フォルダーが選択されていません",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
|
||||
"eqDeviceNameConfirm": "Сохранить",
|
||||
"eqDeviceConnected": "Подключено",
|
||||
"eqDeviceActiveOutput": "Звук выводится через это устройство",
|
||||
"eqDeviceRemove": "Удалить устройство",
|
||||
"eqDeviceRemoved": "{device} удалено",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "Локальная музыка (Android Auto)",
|
||||
"localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.",
|
||||
"localMusicFolderNotConfigured": "Папка не выбрана",
|
||||
|
||||
@@ -666,6 +666,14 @@
|
||||
"eqDeviceNameHint": "例:客厅音箱",
|
||||
"eqDeviceNameConfirm": "保存",
|
||||
"eqDeviceConnected": "已连接",
|
||||
"eqDeviceActiveOutput": "音频正通过此设备播放",
|
||||
"eqDeviceRemove": "移除设备",
|
||||
"eqDeviceRemoved": "已移除 {device}",
|
||||
"@eqDeviceRemoved": {
|
||||
"placeholders": {
|
||||
"device": {}
|
||||
}
|
||||
},
|
||||
"localMusicSectionTitle": "本地音乐(Android Auto)",
|
||||
"localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。",
|
||||
"localMusicFolderNotConfigured": "未选择文件夹",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -914,14 +914,28 @@ class _FilaDispositivo extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final eq = context.watch<EstadoEcualizador>();
|
||||
final isActive = eq.dispositivoActualId == deviceId;
|
||||
final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
|
||||
final displayName = _nombreLegible(
|
||||
deviceId,
|
||||
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// The dot marks where audio is coming out RIGHT NOW, which is not the
|
||||
// same as "paired" or "connected" — it needs a label, both for screen
|
||||
// readers and for anyone wondering what a bare green dot means.
|
||||
if (isActive)
|
||||
const Icon(Icons.circle, size: 10, color: Colors.green)
|
||||
Tooltip(
|
||||
message: l10n.eqDeviceActiveOutput,
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: Colors.green,
|
||||
semanticLabel: l10n.eqDeviceActiveOutput,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
@@ -954,6 +968,34 @@ class _FilaDispositivo extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Turns a device id the user never named into something readable.
|
||||
///
|
||||
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
|
||||
/// platform name is known — which is the normal case for a Bluetooth device
|
||||
/// that is not currently connected, since platform names are cached in memory
|
||||
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
|
||||
/// the transport plus the tail of the address, which is what distinguishes
|
||||
/// two otherwise identical rows.
|
||||
static String _nombreLegible(String deviceId, String nombreVisible) {
|
||||
if (nombreVisible != deviceId) return nombreVisible;
|
||||
|
||||
final separador = deviceId.indexOf(':');
|
||||
if (separador == -1) return deviceId;
|
||||
final transporte = deviceId.substring(0, separador);
|
||||
final resto = deviceId.substring(separador + 1);
|
||||
final etiqueta = switch (transporte) {
|
||||
'bt_a2dp' => 'Bluetooth',
|
||||
'usb_headset' => 'USB',
|
||||
_ => transporte,
|
||||
};
|
||||
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
|
||||
if (cola.isEmpty) return etiqueta;
|
||||
final sufijo = cola.length >= 2
|
||||
? cola.sublist(cola.length - 2).join(':')
|
||||
: cola.last;
|
||||
return '$etiqueta · $sufijo';
|
||||
}
|
||||
|
||||
Future<void> _abrirModal(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -1014,6 +1056,22 @@ class _DialogoEdicionDispositivoState
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _eliminar() async {
|
||||
final eq = context.read<EstadoEcualizador>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nombre = _nombreCtrl.text.trim().isEmpty
|
||||
? widget.deviceId
|
||||
: _nombreCtrl.text.trim();
|
||||
|
||||
await eq.eliminarDispositivo(widget.deviceId);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
@@ -1046,10 +1104,24 @@ class _DialogoEdicionDispositivoState
|
||||
onCambio: (p) => setState(() => _presetActual = p),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(l10n.eqDeviceNameConfirm),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.save_rounded),
|
||||
label: Text(l10n.eqDeviceNameConfirm),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Lets the user clear stale or duplicate rows. The device comes
|
||||
// back on its next connection, so this is recoverable.
|
||||
OutlinedButton.icon(
|
||||
onPressed: _eliminar,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
label: Text(l10n.eqDeviceRemove),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,6 +12,16 @@ import '../modelos/dispositivo_audio.dart';
|
||||
/// entry.
|
||||
const prefijoPlaceholderBtName = 'bt_a2dp:name:';
|
||||
|
||||
/// Canonical id of the phone's own speaker.
|
||||
///
|
||||
/// Single source of truth shared by the collision guard in `EstadoEcualizador`
|
||||
/// and the one-time purge in `ServicioEcualizador`: the phone speaker is the
|
||||
/// fallback output every hierarchy level falls through to, so it must never own
|
||||
/// a device-level preset entry. The native layer used to hand this id to any
|
||||
/// output type it could not name (LE Audio, car bus, dock), which persisted an
|
||||
/// entry that then marked the wrong device as active forever.
|
||||
const idAltavozInterno = 'builtin_speaker';
|
||||
|
||||
/// Abstract service for audio device detection.
|
||||
///
|
||||
/// Implementations:
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_dispositivo_audio.dart' show idAltavozInterno;
|
||||
|
||||
class ConfiguracionEcualizador {
|
||||
const ConfiguracionEcualizador({
|
||||
@@ -51,6 +52,9 @@ class ServicioEcualizador {
|
||||
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
|
||||
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00';
|
||||
|
||||
/// Guard flag for the one-time `builtin_speaker` collision purge.
|
||||
static const _keyColisionBasePurgaHecha = 'eq_builtin_speaker_purge_done_v1';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
|
||||
@@ -59,6 +63,7 @@ class ServicioEcualizador {
|
||||
|
||||
Future<ConfiguracionEcualizador> cargar() async {
|
||||
await migrarClavesPlaceholder();
|
||||
await migrarColisionAltavozInterno();
|
||||
final prefs = await _resolverPrefs();
|
||||
final principal = _leerPresetPrincipal(prefs);
|
||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||
@@ -84,9 +89,33 @@ class ServicioEcualizador {
|
||||
Future<void> migrarClavesPlaceholder() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return;
|
||||
await _purgarDeviceId(prefs, _placeholderMacLiteral);
|
||||
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
|
||||
}
|
||||
|
||||
/// One-time guarded migration: purges entries keyed by [idAltavozInterno].
|
||||
///
|
||||
/// `MainActivity.deviceToMap` used to hand the phone-speaker id to ANY output
|
||||
/// type it could not name (LE Audio, car bus, dock). Dart then created a
|
||||
/// device entry under that id, and from then on every playback through the
|
||||
/// phone's own speaker matched it — permanently flagging whatever the user
|
||||
/// had renamed it to as the active device. The native id is fixed; this
|
||||
/// clears what the collision already persisted. Idempotent via
|
||||
/// [_keyColisionBasePurgaHecha]; every other entry survives byte-for-byte.
|
||||
Future<void> migrarColisionAltavozInterno() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
|
||||
await _purgarDeviceId(prefs, idAltavozInterno);
|
||||
await prefs.setBool(_keyColisionBasePurgaHecha, true);
|
||||
}
|
||||
|
||||
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
|
||||
///
|
||||
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
|
||||
/// only rewritten when the key was actually present.
|
||||
Future<void> _purgarDeviceId(SharedPreferences prefs, String deviceId) async {
|
||||
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) {
|
||||
if (presetsPorDispositivo.remove(deviceId) != null) {
|
||||
await _guardarMapa(
|
||||
prefs,
|
||||
_keyPresetsPorDispositivo,
|
||||
@@ -101,8 +130,7 @@ class ServicioEcualizador {
|
||||
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
|
||||
final separador = clave.indexOf(':');
|
||||
if (separador == -1) return false;
|
||||
final deviceIdSegmento = clave.substring(separador + 1);
|
||||
return deviceIdSegmento == _placeholderMacLiteral;
|
||||
return clave.substring(separador + 1) == deviceId;
|
||||
}).toList();
|
||||
if (clavesMatrizAPurgar.isNotEmpty) {
|
||||
for (final clave in clavesMatrizAPurgar) {
|
||||
@@ -115,15 +143,21 @@ class ServicioEcualizador {
|
||||
prefs,
|
||||
_keyNombresDispositivos,
|
||||
);
|
||||
if (nombresDispositivos.remove(_placeholderMacLiteral) != null) {
|
||||
if (nombresDispositivos.remove(deviceId) != null) {
|
||||
await _guardarMapaStrings(
|
||||
prefs,
|
||||
_keyNombresDispositivos,
|
||||
nombresDispositivos,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
|
||||
/// Forgets a device completely: its preset, its custom name and every matrix
|
||||
/// entry that targets it. Used by the "remove device" action so the user can
|
||||
/// clear stale or duplicate entries from the known-devices list.
|
||||
Future<void> eliminarDispositivo(String deviceId) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
await _purgarDeviceId(prefs, deviceId);
|
||||
}
|
||||
|
||||
Future<void> guardarPrincipal(PresetEcualizador preset) async {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -419,6 +419,30 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
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].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user