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::
`): 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 get onDispositivoCambiado; /// Requests the current active device from the platform synchronously /// (method channel round-trip). Returns the cached value if already known. Future 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 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 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> obtenerNombresEmparejados() async => const {}; /// Cancels the device-change subscription and releases resources. Future 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:`) /// 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'; final MethodChannel _methodChannel; final EventChannel _eventChannel; final _controller = StreamController.broadcast(); StreamSubscription? _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.from(event)); _dispositivoActual = device; _controller.add(device); } }, onError: (_) { /* swallow platform errors; stream continues */ }, cancelOnError: false, ); } @override DispositivoAudio? get dispositivoActual => _dispositivoActual; @override Stream get onDispositivoCambiado => _controller.stream; @override Future obtenerDispositivoActual() async { final raw = await _methodChannel.invokeMethod('getActiveDevice'); final map = Map.from(raw ?? {}); final device = _mapToDispositivo(map); _dispositivoActual = device; return device; } @override Future resubscribir() async { await _eventSub?.cancel(); _subscribeToEvents(); } @override Future solicitarPermisoBluetooth() async { final granted = await _methodChannel.invokeMethod( 'requestBluetoothConnect', ); return granted ?? false; } @override Future> obtenerNombresEmparejados() async { try { final raw = await _methodChannel.invokeMethod( '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 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 map) => _mapToDispositivo(map); static DispositivoAudio _mapToDispositivo(Map 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; } } }