fix(eq): stop the phone's FM sink from posing as the active output
Regression from the previous commit. Ranking every AudioDeviceInfo type this
build does not name individually ABOVE the built-in speaker was meant to let
a car stereo on LE Audio or an automotive bus win. It also promoted the
internal sinks a phone exposes permanently: on the Xiaomi test device
AudioManager reports TYPE_FM (14) as an output, so getActiveAudioDevice
picked it over the real speaker with nothing connected at all. Confirmed on
device:
audio_devices.onListen -> {id=other:14:4, type=14, name=2412DPC0AG}
It then reached Dart under an `other:14:4` id whose type is neither the base
speaker nor a known one, slipped past the collision guard and had a preset
row persisted for it -- reinstating the exact symptom this series set out to
kill: a permanent green active-output dot on a device that was not connected.
Replace the deny-by-omission ranking with an explicit allow list of outputs a
user actually connects. The built-in speaker sits below all of them and above
everything else, so any sink that physically exists but is never where media
plays (TYPE_FM, TYPE_BUILTIN_SPEAKER_SAFE, telephony, remote submix) can no
longer be selected. A one-time purge clears the `other:` rows the bad build
persisted; genuine ones re-register on their next connection.
Fix the USB type constant while here: TYPE_USB_HEADSET is 22, not 14, and 14
is TYPE_FM. The Kotlin USB branch hardcoded 14 and the Dart type table
mirrored the same mistake, so the two cancelled out for real USB headsets
while making a phone's own FM sink decode as USB audio. Both now use 22.
Verified with javap against android.jar (android-36) rather than trusting the
comment that introduced the error.
This commit is contained in:
@@ -1216,7 +1216,9 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
* or still the OS placeholder ("02:00:00:00:00:00", seen
|
* or still the OS placeholder ("02:00:00:00:00:00", seen
|
||||||
* without BLUETOOTH_CONNECT); productName colons are
|
* without BLUETOOTH_CONNECT); productName colons are
|
||||||
* sanitized to '-' to preserve the matrix-key delimiter
|
* sanitized to '-' to preserve the matrix-key delimiter
|
||||||
* - "usb_headset:<address>" — TYPE_USB_HEADSET (14)
|
* - "usb_headset:<address>" — TYPE_USB_HEADSET (22)
|
||||||
|
* - "other:<type>:<address>" — any other external output this build
|
||||||
|
* does not name individually
|
||||||
* - "builtin_speaker" — fallback when API < 23
|
* - "builtin_speaker" — fallback when API < 23
|
||||||
*
|
*
|
||||||
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
|
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
|
||||||
@@ -1240,29 +1242,49 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
return deviceToMap(best)
|
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.
|
* Ranks an [AudioDeviceInfo] type as a media output candidate; lower wins.
|
||||||
*
|
*
|
||||||
* Externally connected outputs outrank the built-in speaker, including types
|
* The built-in speaker is the fallback, so it sits below every external
|
||||||
* this build does not name individually. Virtual and call-only sinks
|
* output and above everything else — including outputs that physically
|
||||||
* (earpiece, telephony, remote submix) are pushed below the speaker so they
|
* exist but are never where media plays.
|
||||||
* can never be reported as where music is playing.
|
|
||||||
*/
|
*/
|
||||||
private fun outputPriority(type: Int): Int = when (type) {
|
private fun outputPriority(type: Int): Int {
|
||||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> 0
|
val index = externalOutputPriority.indexOf(type)
|
||||||
AudioDeviceInfo.TYPE_USB_HEADSET -> 1
|
if (index >= 0) return index
|
||||||
AudioDeviceInfo.TYPE_USB_DEVICE -> 2
|
return if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) 90 else 99
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1307,7 +1329,10 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
val addr = deviceAddress(device)?.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,
|
// 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"),
|
"name" to (device.productName?.toString() ?: "USB Headset"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const prefijoPlaceholderBtName = 'bt_a2dp:name:';
|
|||||||
/// entry that then marked the wrong device as active forever.
|
/// entry that then marked the wrong device as active forever.
|
||||||
const idAltavozInterno = 'builtin_speaker';
|
const idAltavozInterno = 'builtin_speaker';
|
||||||
|
|
||||||
|
/// Id prefix for outputs the native layer does not name individually
|
||||||
|
/// (`other:<AudioDeviceInfo type>:<address>`): 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.
|
/// Abstract service for audio device detection.
|
||||||
///
|
///
|
||||||
/// Implementations:
|
/// Implementations:
|
||||||
@@ -79,7 +86,11 @@ abstract class ServicioDispositivoAudio {
|
|||||||
/// 2 → altavozInterno (builtin_speaker)
|
/// 2 → altavozInterno (builtin_speaker)
|
||||||
/// 3 → auricularesCable (wired_headset)
|
/// 3 → auricularesCable (wired_headset)
|
||||||
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
|
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
|
||||||
/// 14 → usbAudio (`usb_headset:<address>`)
|
/// 22 → usbAudio (`usb_headset:<address>`)
|
||||||
|
///
|
||||||
|
/// 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 {
|
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
||||||
static const _channelName = 'pluriwave/audio_devices';
|
static const _channelName = 'pluriwave/audio_devices';
|
||||||
|
|
||||||
@@ -189,7 +200,7 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
|||||||
return TipoDispositivo.auricularesCable;
|
return TipoDispositivo.auricularesCable;
|
||||||
case 8:
|
case 8:
|
||||||
return TipoDispositivo.bluetoothA2dp;
|
return TipoDispositivo.bluetoothA2dp;
|
||||||
case 14:
|
case 22:
|
||||||
return TipoDispositivo.usbAudio;
|
return TipoDispositivo.usbAudio;
|
||||||
default:
|
default:
|
||||||
return TipoDispositivo.desconocido;
|
return TipoDispositivo.desconocido;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ 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;
|
import 'servicio_dispositivo_audio.dart'
|
||||||
|
show idAltavozInterno, prefijoDispositivoOtro;
|
||||||
|
|
||||||
class ConfiguracionEcualizador {
|
class ConfiguracionEcualizador {
|
||||||
const ConfiguracionEcualizador({
|
const ConfiguracionEcualizador({
|
||||||
@@ -106,9 +107,64 @@ class ServicioEcualizador {
|
|||||||
final prefs = await _resolverPrefs();
|
final prefs = await _resolverPrefs();
|
||||||
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
|
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
|
||||||
await _purgarDeviceId(prefs, idAltavozInterno);
|
await _purgarDeviceId(prefs, idAltavozInterno);
|
||||||
|
await _purgarPrefijoDispositivo(prefs, prefijoDispositivoOtro);
|
||||||
await prefs.setBool(_keyColisionBasePurgaHecha, true);
|
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<void> _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.
|
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
|
||||||
///
|
///
|
||||||
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
|
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ void main() {
|
|||||||
expect(device.tipo, TipoDispositivo.bluetoothA2dp);
|
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({
|
stubGetActiveDevice({
|
||||||
'id': 'usb_headset:0001:0002',
|
'id': 'usb_headset:0001:0002',
|
||||||
'type': 14,
|
'type': 22,
|
||||||
'name': 'USB Headset',
|
'name': 'USB Headset',
|
||||||
});
|
});
|
||||||
final device = await servicio.obtenerDispositivoActual();
|
final device = await servicio.obtenerDispositivoActual();
|
||||||
@@ -92,6 +94,17 @@ void main() {
|
|||||||
expect(device.tipo, TipoDispositivo.usbAudio);
|
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 {
|
test('obtenerDispositivoActual maps unknown type to desconocido', () async {
|
||||||
stubGetActiveDevice({
|
stubGetActiveDevice({
|
||||||
'id': 'unknown_device',
|
'id': 'unknown_device',
|
||||||
|
|||||||
@@ -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 {
|
test('is guarded: a re-seeded base entry survives a second load', () async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final servicio = ServicioEcualizador(prefs: prefs);
|
final servicio = ServicioEcualizador(prefs: prefs);
|
||||||
|
|||||||
Reference in New Issue
Block a user