A Bluetooth device only reports its own name through AudioDeviceInfo.productName while it is enumerated as an active output, i.e. while it is connected. Paired-but-switched-off devices therefore had no name to fall back on, and the platform-name cache is in-memory only by design (bt-device-identity ADR-4), so it self-heals per session ONLY for whatever happens to be connected. Every other device showed its raw id. Android already knows those names: BluetoothAdapter.getBondedDevices() lists every pairing with its name and MAC, connected or not, and nothing in this app was asking. Read it and seed the platform-name cache from it, keyed bt_a2dp:<uppercase MAC> to match the ids the audio layer emits. Seeded BEFORE the active-device query so a live enumeration name, being the fresher of the two, still wins; a user's custom name outranks both. Re-read on refrescarDispositivoActual so pairing or renaming a device in system settings shows up as soon as the list becomes visible. Reading the bond list is gated by BLUETOOTH_CONNECT from API 31 and by the legacy BLUETOOTH permission below it, so declare the latter with maxSdkVersion 30. It is a normal permission: granted at install, no runtime prompt, no new friction. When the answer is unavailable — permission denied, no adapter, Bluetooth off — both layers return an empty map rather than throwing, and the row degrades to the id exactly as before. Does not help rows persisted under a bt_a2dp:name: placeholder id: those never had a MAC to match against.
199 lines
7.4 KiB
Dart
199 lines
7.4 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';
|
|
|
|
/// 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>`)
|
|
/// 14 → usbAudio (`usb_headset:<address>`)
|
|
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 14:
|
|
return TipoDispositivo.usbAudio;
|
|
default:
|
|
return TipoDispositivo.desconocido;
|
|
}
|
|
}
|
|
}
|