Files
pluriwave/lib/servicios/servicio_dispositivo_audio.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

210 lines
8.0 KiB
Dart

import 'dart:async';
import 'package:flutter/services.dart';
import '../modelos/dispositivo_audio.dart';
/// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a BT
/// device whose real MAC is not yet known (BLUETOOTH_CONNECT denied or
/// unresolved). Single source of truth for the marker shared by
/// `EstadoEcualizador` and the Auto EQ persistence-targeting logic: ids with
/// this prefix are transient and must never receive a device-level preset
/// 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';
/// 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.
///
/// Implementations:
/// - [ServicioDispositivoAudioReal] — platform channel (Android/iOS)
/// - `FakeServicioDispositivoAudio` (test/helpers/fakes.dart) — for unit tests
abstract class ServicioDispositivoAudio {
/// The last known active audio output device, or null if none detected yet.
DispositivoAudio? get dispositivoActual;
/// A broadcast stream that emits whenever the active audio output device
/// changes (connect or disconnect event from the platform).
Stream<DispositivoAudio> get onDispositivoCambiado;
/// Requests the current active device from the platform synchronously
/// (method channel round-trip). Returns the cached value if already known.
Future<DispositivoAudio> obtenerDispositivoActual();
/// Cancels the current device-change subscription and subscribes again.
///
/// Sends the platform `cancel`+`listen` control messages, which re-triggers
/// `onListen` on the CURRENT activity's stream handler and re-registers the
/// native `AudioDeviceCallback`. Needed because the Flutter engine outlives
/// the Activity (`AudioServiceActivity`): after an activity recreation the
/// new handler never saw a `listen`, so its event sink stays null and
/// device events stop reaching Dart until this resync runs.
Future<void> resubscribir();
/// Requests the `BLUETOOTH_CONNECT` runtime permission (API 31+) at the
/// point the device-management UI is opened (bt-device-identity ADR-1).
/// Returns true when granted or not required (SDK < 31, iOS); false when
/// denied.
Future<bool> solicitarPermisoBluetooth();
/// Returns the names the system remembers for every PAIRED Bluetooth device,
/// keyed by uppercase MAC.
///
/// `AudioDeviceInfo.productName` only exists for devices the platform is
/// enumerating right now, i.e. currently connected ones, so a paired device
/// sitting in a drawer can never report its own name. The bond list is the
/// authoritative source that survives disconnection.
///
/// Returns an empty map when the answer is unavailable rather than throwing:
/// `BLUETOOTH_CONNECT` may be denied, and a missing name must degrade to the
/// id fallback, never break device resolution.
Future<Map<String, String>> obtenerNombresEmparejados() async => const {};
/// Cancels the device-change subscription and releases resources.
Future<void> dispose();
}
/// Platform channel implementation of [ServicioDispositivoAudio].
///
/// Talks to `pluriwave/audio_devices` on Android (Kotlin) and iOS (Swift).
/// Type int constants (from platform channel protocol):
/// 2 → altavozInterno (builtin_speaker)
/// 3 → auricularesCable (wired_headset)
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
/// 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 {
static const _channelName = 'pluriwave/audio_devices';
final MethodChannel _methodChannel;
final EventChannel _eventChannel;
final _controller = StreamController<DispositivoAudio>.broadcast();
StreamSubscription<dynamic>? _eventSub;
DispositivoAudio? _dispositivoActual;
ServicioDispositivoAudioReal({
MethodChannel? methodChannel,
EventChannel? eventChannel,
}) : _methodChannel = methodChannel ?? const MethodChannel(_channelName),
_eventChannel = eventChannel ?? const EventChannel(_channelName) {
_subscribeToEvents();
}
void _subscribeToEvents() {
_eventSub = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is Map) {
final device = _mapToDispositivo(Map<String, dynamic>.from(event));
_dispositivoActual = device;
_controller.add(device);
}
},
onError: (_) {
/* swallow platform errors; stream continues */
},
cancelOnError: false,
);
}
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
final raw = await _methodChannel.invokeMethod<Map>('getActiveDevice');
final map = Map<String, dynamic>.from(raw ?? {});
final device = _mapToDispositivo(map);
_dispositivoActual = device;
return device;
}
@override
Future<void> resubscribir() async {
await _eventSub?.cancel();
_subscribeToEvents();
}
@override
Future<bool> solicitarPermisoBluetooth() async {
final granted = await _methodChannel.invokeMethod<bool>(
'requestBluetoothConnect',
);
return granted ?? false;
}
@override
Future<Map<String, String>> obtenerNombresEmparejados() async {
try {
final raw = await _methodChannel.invokeMethod<Map>('getBondedDeviceNames');
if (raw == null) return const {};
return {
for (final entry in raw.entries)
if (entry.key is String && entry.value is String)
(entry.key as String).toUpperCase(): entry.value as String,
};
} catch (_) {
// Denied permission or no adapter: fall back to the id, never throw.
return const {};
}
}
@override
Future<void> dispose() async {
await _eventSub?.cancel();
await _controller.close();
}
/// Builds a [DispositivoAudio] from the raw platform-channel map shape.
///
/// Public so headless consumers (the Android Auto handler's one-shot
/// `getActiveDevice` query) reuse the exact same mapping without opening a
/// second event-channel subscription, which would steal this service's
/// stream handler on the Dart side.
static DispositivoAudio dispositivoDesdeMapa(Map<String, dynamic> map) =>
_mapToDispositivo(map);
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> map) {
final id = map['id'] as String? ?? 'builtin_speaker';
final type = map['type'] as int? ?? 2;
final name = map['name'] as String? ?? '';
return DispositivoAudio(id: id, tipo: _tipoDesdeInt(type), nombre: name);
}
static TipoDispositivo _tipoDesdeInt(int type) {
switch (type) {
case 2:
return TipoDispositivo.altavozInterno;
case 3:
return TipoDispositivo.auricularesCable;
case 8:
return TipoDispositivo.bluetoothA2dp;
case 22:
return TipoDispositivo.usbAudio;
default:
return TipoDispositivo.desconocido;
}
}
}