Files
pluriwave/test/servicios/servicio_dispositivo_audio_real_test.dart
T
FreeTLab 4042cf5ffd
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
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.
2026-07-25 20:43:47 +02:00

220 lines
7.3 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
// Type int constants from the platform channel protocol (design doc):
// 2 = builtin_speaker, 3 = wired_headset, 8 = bt_a2dp, 14 = usb_headset
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ServicioDispositivoAudioReal', () {
const methodChannelName = 'pluriwave/audio_devices';
late ServicioDispositivoAudioReal servicio;
setUp(() {
servicio = ServicioDispositivoAudioReal();
});
tearDown(() async {
await servicio.dispose();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(methodChannelName),
null,
);
});
void stubGetActiveDevice(Map<String, dynamic> response) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel(methodChannelName), (
call,
) async {
if (call.method == 'getActiveDevice') return response;
return null;
});
}
void stubRequestBluetoothConnect(bool? response) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel(methodChannelName), (
call,
) async {
if (call.method == 'requestBluetoothConnect') return response;
return null;
});
}
test('obtenerDispositivoActual maps builtin_speaker (type=2)', () async {
stubGetActiveDevice({
'id': 'builtin_speaker',
'type': 2,
'name': 'Speaker',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'builtin_speaker');
expect(device.tipo, TipoDispositivo.altavozInterno);
expect(device.nombre, 'Speaker');
});
test('obtenerDispositivoActual maps wired_headset (type=3)', () async {
stubGetActiveDevice({
'id': 'wired_headset',
'type': 3,
'name': 'Wired Headset',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'wired_headset');
expect(device.tipo, TipoDispositivo.auricularesCable);
});
test('obtenerDispositivoActual maps bt_a2dp (type=8)', () async {
stubGetActiveDevice({
'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF',
'type': 8,
'name': 'My BT Device',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF');
expect(device.tipo, TipoDispositivo.bluetoothA2dp);
});
// 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': 22,
'name': 'USB Headset',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.id, 'usb_headset:0001:0002');
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',
'type': 99,
'name': 'Unknown',
});
final device = await servicio.obtenerDispositivoActual();
expect(device.tipo, TipoDispositivo.desconocido);
});
test('obtenerDispositivoActual updates dispositivoActual cache', () async {
stubGetActiveDevice({
'id': 'builtin_speaker',
'type': 2,
'name': 'Speaker',
});
await servicio.obtenerDispositivoActual();
expect(servicio.dispositivoActual?.id, 'builtin_speaker');
});
test('onDispositivoCambiado is a broadcast stream', () {
expect(servicio.onDispositivoCambiado.isBroadcast, isTrue);
});
// ── bt-device-identity Phase 2: permission contract ──────────────────────
test(
'solicitarPermisoBluetooth invokes requestBluetoothConnect and '
'returns true when granted',
() async {
stubRequestBluetoothConnect(true);
final granted = await servicio.solicitarPermisoBluetooth();
expect(granted, isTrue);
},
);
test(
'solicitarPermisoBluetooth returns false when the platform denies',
() async {
stubRequestBluetoothConnect(false);
final granted = await servicio.solicitarPermisoBluetooth();
expect(granted, isFalse);
},
);
test(
'solicitarPermisoBluetooth returns false when the platform channel '
'returns null',
() async {
stubRequestBluetoothConnect(null);
final granted = await servicio.solicitarPermisoBluetooth();
expect(granted, isFalse);
},
);
// ── Fix: robust active-device detection (stale green dot) ────────────────
test(
'resubscribir sends cancel+listen to the event channel and forwards '
'the device the fresh onListen emits',
() async {
var listens = 0;
var cancels = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockStreamHandler(
const EventChannel(methodChannelName),
MockStreamHandler.inline(
onListen: (arguments, events) {
listens++;
// Mirrors the native onListen immediate resync: every
// (re)subscription receives the current active device.
events.success({
'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF',
'type': 8,
'name': 'My BT Device',
});
},
onCancel: (arguments) => cancels++,
),
);
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockStreamHandler(
const EventChannel(methodChannelName),
null,
);
});
final servicioLocal = ServicioDispositivoAudioReal();
addTearDown(servicioLocal.dispose);
// The constructor's initial subscription delivers the first resync.
final primero = await servicioLocal.onDispositivoCambiado.first;
expect(primero.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF');
expect(listens, 1);
expect(cancels, 0);
final segundoFuturo = servicioLocal.onDispositivoCambiado.first;
await servicioLocal.resubscribir();
final segundo = await segundoFuturo;
expect(cancels, 1);
expect(listens, 2);
expect(segundo.tipo, TipoDispositivo.bluetoothA2dp);
expect(
servicioLocal.dispositivoActual?.id,
'bt_a2dp:AA:BB:CC:DD:EE:FF',
);
},
);
});
}