From b17c582572b3f850505bc136a30f292974b1f88d Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 11 Jul 2026 00:24:59 +0200 Subject: [PATCH] fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries Dart half of bt-device-identity. EstadoEcualizador now caches each device's platform-reported name in memory so the settings screen shows the device's own Bluetooth name instead of its raw id when no custom rename exists, and skips auto-creating preset entries for the composite-placeholder sentinel. Enabling multi-device EQ triggers the Bluetooth permission request through the new channel contract. A flag-guarded one-time migration purges only entries keyed by the exact literal placeholder id from the three per-device preference maps, since those collided entries cannot be attributed to a device. Work unit 2/2 of bt-device-identity (Dart state + migration). --- lib/estado/estado_ecualizador.dart | 49 ++++- lib/pantallas/pantalla_ajustes.dart | 24 ++- lib/servicios/servicio_dispositivo_audio.dart | 14 ++ lib/servicios/servicio_ecualizador.dart | 58 ++++++ test/estado/estado_ecualizador_test.dart | 188 ++++++++++++++++++ test/helpers/fakes.dart | 19 ++ test/pantallas/pantalla_ajustes_test.dart | 133 ++++++++++++- .../servicio_dispositivo_audio_real_test.dart | 41 ++++ .../servicio_dispositivo_audio_test.dart | 12 ++ ...ervicio_dispositivo_audio_toggle_test.dart | 3 + test/servicios/servicio_ecualizador_test.dart | 177 +++++++++++++++++ 11 files changed, 711 insertions(+), 7 deletions(-) diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 6d54dd4..19c8dd7 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -26,6 +26,12 @@ import '../servicios/servicio_ecualizador.dart'; /// When the toggle is false, resolution falls back to the original 2-level /// hierarchy (station → global) — zero behavioral change vs. prior releases. class EstadoEcualizador extends ChangeNotifier { + /// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a + /// device whose real Bluetooth MAC is not yet known (BLUETOOTH_CONNECT + /// denied or unresolved). Devices with this prefix still update the + /// platform-name cache but never auto-create a `presetsDispositivo` entry. + static const _prefijoPlaceholderCompuesto = 'bt_a2dp:name:'; + EstadoEcualizador({ required this.audio, ServicioEcualizador? servicio, @@ -54,6 +60,13 @@ class EstadoEcualizador extends ChangeNotifier { /// Custom display names for devices: deviceId → custom name. final Map _nombresDispositivos = {}; + /// Last-seen platform (Bluetooth/productName) name per deviceId. + /// + /// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices + /// re-report their name on every enumeration, so this cache self-heals + /// every session without needing a SharedPreferences key or migration. + final Map _nombresPlataforma = {}; + PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _activo = true; @@ -210,15 +223,27 @@ class EstadoEcualizador extends ChangeNotifier { Future _onDispositivoCambiado(DispositivoAudio dispositivo) async { if (!_eqMultiDeviceEnabled) return; + // Cache updates on every event regardless of whether a preset entry + // gets created below (bt-device-identity ADR-4). + _nombresPlataforma[dispositivo.id] = dispositivo.nombre; _dispositivoActualId = dispositivo.id; // First-seen device: copy the current resolved preset as its starting // point. builtin_speaker is excluded: it must always fall through to // the hierarchy (L4 global) instead of being pinned by a forced L3 // device-level copy, otherwise a later global-preset change would be - // masked by this stale entry for the base device. + // masked by this stale entry for the base device. Composite-placeholder + // ids are also excluded (bt-device-identity ADR-6): they are transient + // fallback ids for a device whose real MAC is not yet known, so + // persisting a preset entry for them would create dead noise that never + // resolves to the eventual real-MAC id. final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno; - if (!esBase && !_presetsDispositivo.containsKey(dispositivo.id)) { + final esPlaceholderCompuesto = dispositivo.id.startsWith( + _prefijoPlaceholderCompuesto, + ); + if (!esBase && + !esPlaceholderCompuesto && + !_presetsDispositivo.containsKey(dispositivo.id)) { final presetBase = _resolverPresetActivo(); _presetsDispositivo[dispositivo.id] = presetBase; await servicio.guardarPresetDispositivo(dispositivo.id, presetBase); @@ -252,6 +277,21 @@ class EstadoEcualizador extends ChangeNotifier { if (notificar) notifyListeners(); } + /// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the + /// device-management UI is opened (bt-device-identity ADR-1). + /// + /// Thin passthrough to the injected device service so callers don't need + /// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task + /// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside + /// `EstadoRadio` today, not exposed via the provider tree; routing through + /// here avoids adding wiring the tree doesn't already have). Returns false + /// when no device service is injected. + Future solicitarPermisoBluetooth() async { + final svc = _dispositivoAudio; + if (svc == null) return false; + return svc.solicitarPermisoBluetooth(); + } + Future cambiarPresetPrincipal( PresetEcualizador preset, { bool notificar = true, @@ -353,6 +393,11 @@ class EstadoEcualizador extends ChangeNotifier { String obtenerNombreDispositivo(String deviceId) => _nombresDispositivos[deviceId] ?? ''; + /// Returns the last-seen platform name for [deviceId], or an empty string + /// if none has been observed yet (bt-device-identity ADR-4). + String nombrePlataforma(String deviceId) => + _nombresPlataforma[deviceId] ?? ''; + /// Resolves the display name for [deviceId] using the fallback chain: /// custom name → [platformName] → raw [deviceId]. String nombreVisible(String deviceId, String platformName) { diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 46877d5..03b9a0f 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -698,7 +699,8 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: - () => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled), + () => + _alternarMultiDevice(eq, !multiDeviceEnabled), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -718,7 +720,7 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget { Switch.adaptive( value: multiDeviceEnabled, onChanged: - (habilitado) => eq.cambiarMultiDeviceEnabled(habilitado), + (habilitado) => _alternarMultiDevice(eq, habilitado), ), ], ), @@ -749,6 +751,17 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget { ), ); } + + /// Toggles the multi-device EQ feature and, when turning it ON, requests + /// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1) + /// so BT devices report their real MAC instead of the OS placeholder. + /// Fire-and-forget: neither call blocks the toggle UI on its result. + void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) { + unawaited(eq.cambiarMultiDeviceEnabled(habilitado)); + if (habilitado) { + unawaited(eq.solicitarPermisoBluetooth()); + } + } } /// A single device row in the known-devices list. @@ -766,7 +779,7 @@ class _FilaDispositivo extends StatelessWidget { final l10n = AppLocalizations.of(context); final eq = context.watch(); final isActive = eq.dispositivoActualId == deviceId; - final displayName = eq.nombreVisible(deviceId, ''); + final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)); return Padding( padding: const EdgeInsets.symmetric(vertical: 6), @@ -843,7 +856,10 @@ class _DialogoEdicionDispositivoState void initState() { super.initState(); final eq = context.read(); - final displayName = eq.nombreVisible(widget.deviceId, ''); + final displayName = eq.nombreVisible( + widget.deviceId, + eq.nombrePlataforma(widget.deviceId), + ); _nombreCtrl = TextEditingController(text: displayName); _presetActual = widget.preset; } diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart index 91dd545..093e0f1 100644 --- a/lib/servicios/servicio_dispositivo_audio.dart +++ b/lib/servicios/servicio_dispositivo_audio.dart @@ -21,6 +21,12 @@ abstract class ServicioDispositivoAudio { /// (method channel round-trip). Returns the cached value if already known. Future obtenerDispositivoActual(); + /// 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(); + /// Cancels the device-change subscription and releases resources. Future dispose(); } @@ -82,6 +88,14 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio { return device; } + @override + Future solicitarPermisoBluetooth() async { + final granted = await _methodChannel.invokeMethod( + 'requestBluetoothConnect', + ); + return granted ?? false; + } + @override Future dispose() async { await _eventSub?.cancel(); diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index 6ca9ce3..83e6a7d 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -43,6 +43,13 @@ class ServicioEcualizador { static const _keyPresetsMatriz = 'eq_presets_matriz_v1'; static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1'; + /// bt-device-identity ADR-5: guard flag for the one-time placeholder purge. + static const _keyPlaceholderPurgaHecha = 'eq_placeholder_purge_done_v1'; + + /// bt-device-identity ADR-3: the exact OS placeholder MAC, in the + /// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps. + static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00'; + final SharedPreferences? _prefs; /// Injected startup instance (S3-R4); getInstance() is only a fallback. @@ -50,6 +57,7 @@ class ServicioEcualizador { _prefs ?? SharedPreferences.getInstance(); Future cargar() async { + await migrarClavesPlaceholder(); final prefs = await _resolverPrefs(); final principal = _leerPresetPrincipal(prefs); final porEmisora = _leerPresetsPorEmisora(prefs); @@ -67,6 +75,56 @@ class ServicioEcualizador { ); } + /// One-time guarded migration (bt-device-identity ADR-5): purges entries + /// keyed by the exact OS placeholder MAC ([_placeholderMacLiteral]) from + /// all three device-keyed SP maps. Idempotent — short-circuits via + /// [_keyPlaceholderPurgaHecha] after the first successful run. Every other + /// entry (including near-miss keys) is preserved byte-for-byte. + Future migrarClavesPlaceholder() async { + final prefs = await _resolverPrefs(); + if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return; + + final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); + if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) { + await _guardarMapa( + prefs, + _keyPresetsPorDispositivo, + presetsPorDispositivo, + ); + } + + // Matrix keys are "stationUuid:deviceId"; split on the FIRST colon only + // (station UUIDs are RFC4122 and contain no colons — multi-device-eq + // ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id). + final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz); + final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) { + final separador = clave.indexOf(':'); + if (separador == -1) return false; + final deviceIdSegmento = clave.substring(separador + 1); + return deviceIdSegmento == _placeholderMacLiteral; + }).toList(); + if (clavesMatrizAPurgar.isNotEmpty) { + for (final clave in clavesMatrizAPurgar) { + presetsMatriz.remove(clave); + } + await _guardarMapa(prefs, _keyPresetsMatriz, presetsMatriz); + } + + final nombresDispositivos = _leerMapaStrings( + prefs, + _keyNombresDispositivos, + ); + if (nombresDispositivos.remove(_placeholderMacLiteral) != null) { + await _guardarMapaStrings( + prefs, + _keyNombresDispositivos, + nombresDispositivos, + ); + } + + await prefs.setBool(_keyPlaceholderPurgaHecha, true); + } + Future guardarPrincipal(PresetEcualizador preset) async { final prefs = await _resolverPrefs(); await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson())); diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index bfd7d07..cdfac5b 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1133,6 +1133,194 @@ void main() { // WARNING-3: FakeServicioEcualizador preserves nombresDispositivos // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // bt-device-identity Phase 3: platform-name cache + duplicate-entry guard + // --------------------------------------------------------------------------- + + group('EstadoEcualizador — platform-name cache (bt-device-identity Phase 3)', () { + const stationUuid = 'station-uuid-bt-identity'; + + test('3.1 platform name is cached from a device-change event', () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo( + const DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'AirPods Pro', + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + eq.nombrePlataforma('bt_a2dp:AA:BB:CC:DD:EE:FF'), + equals('AirPods Pro'), + ); + eq.dispose(); + }); + + test( + '3.4 composite-placeholder sentinel does not create device-list entry', + () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo( + const DispositivoAudio( + id: 'bt_a2dp:name:AirPods-Pro', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'AirPods Pro', + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + eq.presetsDispositivo.containsKey('bt_a2dp:name:AirPods-Pro'), + isFalse, + ); + expect( + eq.nombrePlataforma('bt_a2dp:name:AirPods-Pro'), + equals('AirPods Pro'), + ); + eq.dispose(); + }, + ); + + test('3.6 multiple denied-permission devices do not collide', () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + final baseline = eq.presetsDispositivo.length; + + fakeDispositivo.emitirDispositivo( + const DispositivoAudio( + id: 'bt_a2dp:name:AirPods-Pro', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'AirPods Pro', + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + fakeDispositivo.emitirDispositivo( + const DispositivoAudio( + id: 'bt_a2dp:name:Sony-WH-1000XM5', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'Sony WH-1000XM5', + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + eq.nombrePlataforma('bt_a2dp:name:AirPods-Pro'), + equals('AirPods Pro'), + ); + expect( + eq.nombrePlataforma('bt_a2dp:name:Sony-WH-1000XM5'), + equals('Sony WH-1000XM5'), + ); + expect(eq.presetsDispositivo.length, equals(baseline)); + eq.dispose(); + }); + + test( + '3.7 repeated event for known id is a no-op on preset creation ' + '(regression-lock)', + () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + const device = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'AirPods Pro', + ); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo(device); + await Future.delayed(const Duration(milliseconds: 50)); + final presetTrasPrimerEvento = eq.presetsDispositivo[device.id]; + final cantidadTrasPrimerEvento = eq.presetsDispositivo.length; + expect(presetTrasPrimerEvento, isNotNull); + + fakeDispositivo.emitirDispositivo(device); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + eq.presetsDispositivo[device.id], + equals(presetTrasPrimerEvento), + ); + expect(eq.presetsDispositivo.length, equals(cantidadTrasPrimerEvento)); + eq.dispose(); + }, + ); + + test( + '3.8 transient non-BT id during pairing handshake does not corrupt ' + 'the BT entry (regression-lock)', + () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'AirPods Pro', + ); + const builtinSpeaker = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador( + principal: PresetEcualizador.rock, + eqMultiDeviceEnabled: true, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => stationUuid, + ); + await eq.cargarPersistido(); + + // Transient builtin_speaker report mid-handshake. + fakeDispositivo.emitirDispositivo(builtinSpeaker); + await Future.delayed(const Duration(milliseconds: 50)); + + // Final BT event once the A2DP profile attaches. + fakeDispositivo.emitirDispositivo(btDevice); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(eq.presetsDispositivo.containsKey(btDevice.id), isTrue); + expect(eq.presetsDispositivo.containsKey('builtin_speaker'), isFalse); + expect( + eq.presetsDispositivo[btDevice.id], + equals(PresetEcualizador.rock), + ); + eq.dispose(); + }, + ); + }); + group('FakeServicioEcualizador — nombresDispositivos preservation (WARNING-3)', () { test( 'guardarPrincipal preserves nombresDispositivos', diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index d91e3b5..61f0515 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -438,6 +438,9 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { throw Exception('Platform channel error: device unavailable'); } + @override + Future solicitarPermisoBluetooth() async => true; + @override Future dispose() async { await _controller.close(); @@ -449,6 +452,16 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { /// Use [emitirDispositivo] to push device change events without needing /// a real platform channel. class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { + FakeServicioDispositivoAudio({this.permisoBluetoothConcedido = true}); + + /// Value returned by [solicitarPermisoBluetooth] (bt-device-identity + /// Phase 2/4 testing strategy — defaults to granted so existing tests + /// that don't care about the permission flow are unaffected). + final bool permisoBluetoothConcedido; + + /// Number of times [solicitarPermisoBluetooth] was called. + int solicitarPermisoBluetoothCalls = 0; + final _controller = StreamController.broadcast(); DispositivoAudio? _dispositivoActual; @@ -468,6 +481,12 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { ); } + @override + Future solicitarPermisoBluetooth() async { + solicitarPermisoBluetoothCalls++; + return permisoBluetoothConcedido; + } + /// Helper for tests: push a device change event. void emitirDispositivo(DispositivoAudio dispositivo) { _dispositivoActual = dispositivo; diff --git a/test/pantallas/pantalla_ajustes_test.dart b/test/pantallas/pantalla_ajustes_test.dart index d006b33..9e47cf7 100644 --- a/test/pantallas/pantalla_ajustes_test.dart +++ b/test/pantallas/pantalla_ajustes_test.dart @@ -64,6 +64,7 @@ void main() { Future crearEstado({ bool eqMultiDeviceEnabled = false, Map presetsDispositivo = const {}, + FakeServicioDispositivoAudio? dispositivoAudio, }) async { final estado = EstadoRadio( audio: FakeServicioAudio(), @@ -76,6 +77,7 @@ void main() { servicioGrabacion: _FakeGrabacion(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, + dispositivoAudio: dispositivoAudio, ); await estado.ecualizador.cargarPersistido(); return estado; @@ -99,6 +101,7 @@ void main() { Map? presetsDispositivo, Map? nombresDispositivos, String? activeDeviceId, + String nombrePlataforma = 'BT Speaker', }) async { final fakeDispositivo = activeDeviceId != null ? (FakeServicioDispositivoAudio() @@ -106,7 +109,7 @@ void main() { DispositivoAudio( id: activeDeviceId, tipo: TipoDispositivo.bluetoothA2dp, - nombre: 'BT Speaker', + nombre: nombrePlataforma, ), )) : null; @@ -380,6 +383,134 @@ void main() { ); }); }); + + // ── bt-device-identity Phase 4: display fix + permission trigger ───────── + + group('_SeccionEcualizadorAvanzado — bt-device-identity Phase 4', () { + // 4.1 — new behavior: the cached platform name (not '') now feeds + // nombreVisible, so a device with no custom rename shows its real name. + testWidgets('4.1 platform name displays with no custom rename', ( + tester, + ) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + final estado = await crearEstadoConNombres( + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + activeDeviceId: deviceId, + nombrePlataforma: 'AirPods Pro', + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + expect(find.text('AirPods Pro'), findsOneWidget); + expect(find.text(deviceId), findsNothing); + }); + + // 4.4 — triangulation companion: custom rename still wins even though + // the row now also has a cached platform name available. + testWidgets('4.4 custom rename overrides platform name', (tester) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + final estado = await crearEstadoConNombres( + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + activeDeviceId: deviceId, + nombrePlataforma: 'AirPods Pro', + nombresDispositivos: {deviceId: 'My Headphones'}, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + expect(find.text('My Headphones'), findsOneWidget); + expect(find.text('AirPods Pro'), findsNothing); + }); + + // 4.5 — approval test (regression-lock): a device never seen on the + // stream has no cached platform name, so legacy raw-id fallback holds. + testWidgets('4.5 no platform name yet falls back to raw id', ( + tester, + ) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + final estado = await crearEstadoConNombres( + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Known audio devices'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + expect(find.text(deviceId), findsOneWidget); + }); + + // 4.6 — permission trigger point: turning the toggle ON requests + // BLUETOOTH_CONNECT; turning it back OFF must not re-fire the request. + testWidgets('4.6 permission call fires on device-management open', ( + tester, + ) async { + setLargeSurface(tester); + _suppressListTileInkAssertion(); + final fakeDispositivo = FakeServicioDispositivoAudio(); + final estado = await crearEstado( + eqMultiDeviceEnabled: false, + dispositivoAudio: fakeDispositivo, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget(buildAjustes(estado)); + await pumpStable(tester); + + await tester.scrollUntilVisible( + find.text('Advanced Equalization Options'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await pumpStable(tester); + + expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(0)); + + // Toggle ON: permission requested exactly once. + await tester.tap(find.byType(Switch).last); + await pumpStable(tester); + + expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue); + expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1)); + + // Toggle OFF again: no further permission request. + await tester.tap(find.byType(Switch).last); + await pumpStable(tester); + + expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse); + expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1)); + }); + }); } // ── Infrastructure ────────────────────────────────────────────────────────── diff --git a/test/servicios/servicio_dispositivo_audio_real_test.dart b/test/servicios/servicio_dispositivo_audio_real_test.dart index e2db018..92e69dd 100644 --- a/test/servicios/servicio_dispositivo_audio_real_test.dart +++ b/test/servicios/servicio_dispositivo_audio_real_test.dart @@ -37,6 +37,16 @@ void main() { }); } + 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', @@ -105,5 +115,36 @@ void main() { 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); + }, + ); }); } diff --git a/test/servicios/servicio_dispositivo_audio_test.dart b/test/servicios/servicio_dispositivo_audio_test.dart index 3d7e8af..a083828 100644 --- a/test/servicios/servicio_dispositivo_audio_test.dart +++ b/test/servicios/servicio_dispositivo_audio_test.dart @@ -78,5 +78,17 @@ void main() { test('dispose closes the stream without error', () async { await expectLater(fake.dispose(), completes); }); + + // bt-device-identity Task 2.5: interface-completeness — every concrete + // ServicioDispositivoAudio must implement solicitarPermisoBluetooth. + test( + 'solicitarPermisoBluetooth increments the call counter and returns ' + 'the configured value', + () async { + final granted = await fake.solicitarPermisoBluetooth(); + expect(granted, isTrue); + expect(fake.solicitarPermisoBluetoothCalls, equals(1)); + }, + ); }); } diff --git a/test/servicios/servicio_dispositivo_audio_toggle_test.dart b/test/servicios/servicio_dispositivo_audio_toggle_test.dart index 74b370d..7237187 100644 --- a/test/servicios/servicio_dispositivo_audio_toggle_test.dart +++ b/test/servicios/servicio_dispositivo_audio_toggle_test.dart @@ -24,6 +24,9 @@ class NullServicioDispositivoAudio extends ServicioDispositivoAudio { ); } + @override + Future solicitarPermisoBluetooth() async => true; + @override Future dispose() async {} } diff --git a/test/servicios/servicio_ecualizador_test.dart b/test/servicios/servicio_ecualizador_test.dart index fc02a50..faf9eb6 100644 --- a/test/servicios/servicio_ecualizador_test.dart +++ b/test/servicios/servicio_ecualizador_test.dart @@ -229,4 +229,181 @@ void main() { expect(restored.nombresDispositivos['bt_a2dp:AA:BB'], equals('My Speaker')); }); }); + + // --------------------------------------------------------------------------- + // bt-device-identity Phase 5: one-time guarded migration purge + // --------------------------------------------------------------------------- + + group('ServicioEcualizador — placeholder migration purge (bt-device-identity Phase 5)', () { + const placeholderKey = 'bt_a2dp:02:00:00:00:00:00'; + const stableKey = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + test( + '5.1 migration removes only exact placeholder entries from presetsPorDispositivo', + () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetDispositivo( + placeholderKey, + PresetEcualizador.jazz, + ); + await servicio.guardarPresetDispositivo( + stableKey, + PresetEcualizador.rock, + ); + + await servicio.migrarClavesPlaceholder(); + final config = await servicio.cargar(); + + expect( + config.presetsDispositivo.containsKey(placeholderKey), + isFalse, + ); + expect( + config.presetsDispositivo[stableKey], + equals(PresetEcualizador.rock), + ); + }, + ); + + test('5.2 matrix keys purge only the placeholder segment', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetMatriz( + 'station1:$placeholderKey', + PresetEcualizador.jazz, + ); + await servicio.guardarPresetMatriz( + 'station1:$stableKey', + PresetEcualizador.rock, + ); + + await servicio.migrarClavesPlaceholder(); + final config = await servicio.cargar(); + + expect( + config.presetsMatriz.containsKey('station1:$placeholderKey'), + isFalse, + ); + expect( + config.presetsMatriz['station1:$stableKey'], + equals(PresetEcualizador.rock), + ); + }); + + test('5.3 near-miss keys are preserved (differs by one digit)', () async { + const nearMissKey = 'bt_a2dp:02:00:00:00:00:01'; + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetDispositivo( + nearMissKey, + PresetEcualizador.jazz, + ); + + await servicio.migrarClavesPlaceholder(); + final config = await servicio.cargar(); + + expect( + config.presetsDispositivo[nearMissKey], + equals(PresetEcualizador.jazz), + ); + }); + + test('5.4 migration runs once (second run is a true no-op)', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetDispositivo( + placeholderKey, + PresetEcualizador.jazz, + ); + await servicio.guardarPresetDispositivo( + stableKey, + PresetEcualizador.rock, + ); + + await servicio.migrarClavesPlaceholder(); + + // Re-seed the placeholder key directly, bypassing the flag, to prove + // the SECOND migration call is a true no-op (flag short-circuits) + // rather than merely finding nothing left to purge. + await servicio.guardarPresetDispositivo( + placeholderKey, + PresetEcualizador.bassBoost, + ); + await servicio.migrarClavesPlaceholder(); + + final config = await servicio.cargar(); + + expect( + config.presetsDispositivo[placeholderKey], + equals(PresetEcualizador.bassBoost), + ); + expect( + config.presetsDispositivo[stableKey], + equals(PresetEcualizador.rock), + ); + }); + + test( + '5.5 no placeholder entries when BLUETOOTH_CONNECT was never ' + 'requested is a no-op', + () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetDispositivo( + stableKey, + PresetEcualizador.rock, + ); + + await servicio.migrarClavesPlaceholder(); + final config = await servicio.cargar(); + + expect( + config.presetsDispositivo[stableKey], + equals(PresetEcualizador.rock), + ); + expect(config.presetsDispositivo.length, equals(1)); + }, + ); + + test('5.6 nombresDispositivos also purges the exact placeholder key', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarNombresDispositivos({ + placeholderKey: 'Ghost Device', + stableKey: 'Living Room BT', + }); + + await servicio.migrarClavesPlaceholder(); + final config = await servicio.cargar(); + + expect(config.nombresDispositivos.containsKey(placeholderKey), isFalse); + expect( + config.nombresDispositivos[stableKey], + equals('Living Room BT'), + ); + }); + + test('5.9 cargar() runs the migration automatically on first load', () async { + final prefs = await SharedPreferences.getInstance(); + final servicio = ServicioEcualizador(prefs: prefs); + await servicio.guardarPresetDispositivo( + placeholderKey, + PresetEcualizador.jazz, + ); + await servicio.guardarPresetDispositivo( + stableKey, + PresetEcualizador.rock, + ); + + // No explicit migrarClavesPlaceholder() call: cargar() must run it. + final config = await servicio.cargar(); + + expect(config.presetsDispositivo.containsKey(placeholderKey), isFalse); + expect( + config.presetsDispositivo[stableKey], + equals(PresetEcualizador.rock), + ); + }); + }); }