Files
pluriwave/lib/servicios/servicio_dispositivo_audio.dart
T
FreeTLab 1d5332453f fix(audio): make the audio diagnostics visible in release builds
Every diagnostic line in the audio path used `dart:developer`'s `log()`.
That function writes to the VM service, which a RELEASE build does not
have — so in the only build that ever runs in a car, all eleven of them
went nowhere. `debugPrint`/`print` do reach logcat in release; `log()`
does not.

That includes the two channels built specifically to end the guessing:
- `registrarErrorAudioService`, which subscribes to
  `AudioService.asyncError` so the plugin's swallowed platform exceptions
  stop vanishing (b0271fa). It moved them from a dropped PublishSubject
  to a dropped log call.
- `_trazarEstadoPublicado`, the published-state trace added in 7054a4c to
  settle why the car's play button never becomes pause.

So "no evidence" was never a quiet app. It was an app writing its
evidence somewhere release builds discard. Several rounds of hypotheses
were argued without data that the app was already producing.

All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one
filter catches the audio path and the existing alarm lines together:

  adb logcat | grep PluriWave

No behaviour changes. Tests: 1158, unchanged.
2026-08-06 21:48:20 +02:00

212 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;
}
}
}