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);