diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index bd18e06..75b2dad 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -1216,7 +1216,9 @@ class MainActivity : AudioServiceActivity() {
* or still the OS placeholder ("02:00:00:00:00:00", seen
* without BLUETOOTH_CONNECT); productName colons are
* sanitized to '-' to preserve the matrix-key delimiter
- * - "usb_headset:
" — TYPE_USB_HEADSET (14)
+ * - "usb_headset:" — TYPE_USB_HEADSET (22)
+ * - "other::" — any other external output this build
+ * does not name individually
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
@@ -1240,29 +1242,49 @@ class MainActivity : AudioServiceActivity() {
return deviceToMap(best)
}
+ /**
+ * Media outputs a user actively connects, in the order they should win when
+ * several are present. Index IS the priority.
+ *
+ * This is an ALLOW list on purpose. Ranking "everything not named here"
+ * above the built-in speaker looks equivalent and is not: a phone
+ * permanently exposes internal sinks that are legitimate outputs but never
+ * where music is playing -- TYPE_FM (14) on this project's Xiaomi test
+ * device, TYPE_BUILTIN_SPEAKER_SAFE (24) on many others. Those outranked
+ * the real speaker, got reported as the active device and had a preset row
+ * persisted for them.
+ */
+ private val externalOutputPriority = listOf(
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
+ AudioDeviceInfo.TYPE_BLE_HEADSET,
+ AudioDeviceInfo.TYPE_BLE_SPEAKER,
+ AudioDeviceInfo.TYPE_BLE_BROADCAST,
+ AudioDeviceInfo.TYPE_HEARING_AID,
+ AudioDeviceInfo.TYPE_BUS,
+ AudioDeviceInfo.TYPE_USB_HEADSET,
+ AudioDeviceInfo.TYPE_USB_DEVICE,
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
+ AudioDeviceInfo.TYPE_LINE_ANALOG,
+ AudioDeviceInfo.TYPE_LINE_DIGITAL,
+ AudioDeviceInfo.TYPE_AUX_LINE,
+ AudioDeviceInfo.TYPE_DOCK,
+ AudioDeviceInfo.TYPE_HDMI,
+ AudioDeviceInfo.TYPE_HDMI_ARC,
+ )
+
/**
* 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.
+ * The built-in speaker is the fallback, so it sits below every external
+ * output and above everything else — including outputs that physically
+ * exist but are never where media plays.
*/
- 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
+ private fun outputPriority(type: Int): Int {
+ val index = externalOutputPriority.indexOf(type)
+ if (index >= 0) return index
+ return if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) 90 else 99
}
/**
@@ -1307,7 +1329,10 @@ class MainActivity : AudioServiceActivity() {
val addr = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
- "type" to 14,
+ // Send the real constant (22). The hardcoded 14 that used to
+ // sit here is TYPE_FM, and Dart mirrored the mistake, so a
+ // phone's own FM sink decoded as a USB headset.
+ "type" to AudioDeviceInfo.TYPE_USB_HEADSET,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart
index 080d600..fa274ea 100644
--- a/lib/servicios/servicio_dispositivo_audio.dart
+++ b/lib/servicios/servicio_dispositivo_audio.dart
@@ -22,6 +22,13 @@ const prefijoPlaceholderBtName = 'bt_a2dp:name:';
/// entry that then marked the wrong device as active forever.
const idAltavozInterno = 'builtin_speaker';
+/// Id prefix for outputs the native layer does not name individually
+/// (`other::`): LE Audio stereos, car buses,
+/// hearing aids, docks. Kept distinct from every other id namespace so such a
+/// device can never collide with [idAltavozInterno], and so a bad batch of them
+/// can be purged wholesale.
+const prefijoDispositivoOtro = 'other:';
+
/// Abstract service for audio device detection.
///
/// Implementations:
@@ -79,7 +86,11 @@ abstract class ServicioDispositivoAudio {
/// 2 → altavozInterno (builtin_speaker)
/// 3 → auricularesCable (wired_headset)
/// 8 → bluetoothA2dp (`bt_a2dp:`)
-/// 14 → usbAudio (`usb_headset:`)
+/// 22 → usbAudio (`usb_headset:`)
+///
+/// Values match `AudioDeviceInfo.TYPE_*`. Note 22, NOT 14: type 14 is
+/// `TYPE_FM`, an output plenty of phones expose permanently, and mapping it to
+/// USB audio made a phone's own FM sink look like a connected headset.
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
static const _channelName = 'pluriwave/audio_devices';
@@ -189,7 +200,7 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
return TipoDispositivo.auricularesCable;
case 8:
return TipoDispositivo.bluetoothA2dp;
- case 14:
+ case 22:
return TipoDispositivo.usbAudio;
default:
return TipoDispositivo.desconocido;
diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart
index 3eeb2db..efdbb47 100644
--- a/lib/servicios/servicio_ecualizador.dart
+++ b/lib/servicios/servicio_ecualizador.dart
@@ -4,7 +4,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart';
-import 'servicio_dispositivo_audio.dart' show idAltavozInterno;
+import 'servicio_dispositivo_audio.dart'
+ show idAltavozInterno, prefijoDispositivoOtro;
class ConfiguracionEcualizador {
const ConfiguracionEcualizador({
@@ -106,9 +107,64 @@ class ServicioEcualizador {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, idAltavozInterno);
+ await _purgarPrefijoDispositivo(prefs, prefijoDispositivoOtro);
await prefs.setBool(_keyColisionBasePurgaHecha, true);
}
+ /// Removes every device-keyed entry whose id starts with [prefijo].
+ ///
+ /// Used for the `other:` namespace: a build that ranked every unlisted
+ /// `AudioDeviceInfo` type above the phone speaker selected permanent internal
+ /// sinks (TYPE_FM on Xiaomi, TYPE_BUILTIN_SPEAKER_SAFE elsewhere) as the
+ /// active output and persisted rows for them. Genuine external outputs in
+ /// this namespace are re-registered on their next connection, so clearing the
+ /// whole prefix costs nothing and needs no per-type knowledge here.
+ Future _purgarPrefijoDispositivo(
+ SharedPreferences prefs,
+ String prefijo,
+ ) async {
+ final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
+ final dispositivos = presetsPorDispositivo.keys
+ .where((clave) => clave.startsWith(prefijo))
+ .toList();
+
+ final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos);
+ final nombresAPurgar = nombres.keys
+ .where((clave) => clave.startsWith(prefijo))
+ .toList();
+
+ final matriz = _leerMapa(prefs, _keyPresetsMatriz);
+ final matrizAPurgar = matriz.keys.where((clave) {
+ final separador = clave.indexOf(':');
+ if (separador == -1) return false;
+ return clave.substring(separador + 1).startsWith(prefijo);
+ }).toList();
+
+ for (final clave in dispositivos) {
+ presetsPorDispositivo.remove(clave);
+ }
+ for (final clave in nombresAPurgar) {
+ nombres.remove(clave);
+ }
+ for (final clave in matrizAPurgar) {
+ matriz.remove(clave);
+ }
+
+ if (dispositivos.isNotEmpty) {
+ await _guardarMapa(
+ prefs,
+ _keyPresetsPorDispositivo,
+ presetsPorDispositivo,
+ );
+ }
+ if (nombresAPurgar.isNotEmpty) {
+ await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
+ }
+ if (matrizAPurgar.isNotEmpty) {
+ await _guardarMapa(prefs, _keyPresetsMatriz, matriz);
+ }
+ }
+
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
///
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
diff --git a/test/servicios/servicio_dispositivo_audio_real_test.dart b/test/servicios/servicio_dispositivo_audio_real_test.dart
index 0d8b3ea..1475fad 100644
--- a/test/servicios/servicio_dispositivo_audio_real_test.dart
+++ b/test/servicios/servicio_dispositivo_audio_real_test.dart
@@ -81,10 +81,12 @@ void main() {
expect(device.tipo, TipoDispositivo.bluetoothA2dp);
});
- test('obtenerDispositivoActual maps usb_headset (type=14)', () async {
+ // AudioDeviceInfo.TYPE_USB_HEADSET is 22, not 14. This mapping used to say
+ // 14, which is TYPE_FM — an output many phones expose permanently.
+ test('obtenerDispositivoActual maps usb_headset (type=22)', () async {
stubGetActiveDevice({
'id': 'usb_headset:0001:0002',
- 'type': 14,
+ 'type': 22,
'name': 'USB Headset',
});
final device = await servicio.obtenerDispositivoActual();
@@ -92,6 +94,17 @@ void main() {
expect(device.tipo, TipoDispositivo.usbAudio);
});
+ test('type 14 is FM, never USB audio', () async {
+ stubGetActiveDevice({
+ 'id': 'other:14:4',
+ 'type': 14,
+ 'name': 'FM',
+ });
+ final device = await servicio.obtenerDispositivoActual();
+ expect(device.tipo, isNot(TipoDispositivo.usbAudio));
+ expect(device.tipo, TipoDispositivo.desconocido);
+ });
+
test('obtenerDispositivoActual maps unknown type to desconocido', () async {
stubGetActiveDevice({
'id': 'unknown_device',
diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart
index 3970878..8c5a7b2 100644
--- a/test/servicios/servicio_ecualizador_test.dart
+++ b/test/servicios/servicio_ecualizador_test.dart
@@ -648,6 +648,37 @@ void main() {
);
});
+ // A build that ranked every unlisted AudioDeviceInfo type above the phone
+ // speaker selected permanent internal sinks (TYPE_FM on Xiaomi) as the
+ // active output and persisted `other:` rows for them.
+ test('removes other: rows persisted for internal sinks', () async {
+ final prefs = await SharedPreferences.getInstance();
+ final servicio = ServicioEcualizador(prefs: prefs);
+ await servicio.guardarPresetDispositivo(
+ 'other:14:4',
+ PresetEcualizador.jazz,
+ );
+ await servicio.guardarNombresDispositivos({'other:14:4': 'Omoda'});
+ await servicio.guardarPresetMatriz(
+ 'station1:other:14:4',
+ PresetEcualizador.pop,
+ );
+ await servicio.guardarPresetDispositivo(
+ stableKey,
+ PresetEcualizador.rock,
+ );
+
+ final config = await servicio.cargar();
+
+ expect(config.presetsDispositivo.containsKey('other:14:4'), isFalse);
+ expect(config.nombresDispositivos.containsKey('other:14:4'), isFalse);
+ expect(config.presetsMatriz.containsKey('station1:other:14:4'), isFalse);
+ expect(
+ config.presetsDispositivo[stableKey],
+ equals(PresetEcualizador.rock),
+ );
+ });
+
test('is guarded: a re-seeded base entry survives a second load', () async {
final prefs = await SharedPreferences.getInstance();
final servicio = ServicioEcualizador(prefs: prefs);