diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 6b8a53c..29f2447 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -118,6 +118,7 @@ class EstadoAlarmas extends ChangeNotifier { ); await android.programar(guardada); await _limpiarFalloProgramacion(guardada.id); + await _verificarRegistroNativo(guardada.id); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; await _registrarFalloProgramacion(alarma.id); @@ -221,9 +222,7 @@ class EstadoAlarmas extends ChangeNotifier { ); _aplicar(config); } catch (e) { - debugPrint( - '[PluriWave][alarmas] registrar fallo programacion ERROR $e', - ); + debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e'); } } @@ -239,6 +238,40 @@ class EstadoAlarmas extends ChangeNotifier { } } + /// Verifies the OS genuinely registered [alarmaId] after a successful + /// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a + /// scheduling call that returns without throwing is not proof enough by + /// itself -- this cross-check against the native pending-alarm count is + /// exactly what would have caught the reported "alarm never rings, no + /// exception anywhere" case. Compares a FRESH native count against how + /// many alarms Dart believes are currently active-with-a-next-run; a + /// native count that falls short is recorded as a failure for the alarm + /// the user just interacted with. Never overrides an already-caught + /// programar() exception (this only runs on ITS success path). + Future _verificarRegistroNativo(String alarmaId) async { + try { + final alarma = _buscarAlarma(alarmaId); + if (alarma == null || + !alarma.activa || + alarma.proximaProgramable == null) { + return; + } + final diag = await android.diagnostico(); + _diagnostico = diag; + final esperadas = + _alarmas + .where((a) => a.activa && a.proximaProgramable != null) + .length; + if (diag.alarmasNativasPendientes < esperadas) { + _error = + 'Alarma guardada, pero el sistema no confirma que quedó registrada.'; + await _registrarFalloProgramacion(alarmaId); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e'); + } + } + Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } diff --git a/test/estado/estado_alarmas_verificacion_registro_test.dart b/test/estado/estado_alarmas_verificacion_registro_test.dart new file mode 100644 index 0000000..93ca350 --- /dev/null +++ b/test/estado/estado_alarmas_verificacion_registro_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually +/// registered, and say so if it is not". `android.programar` returning +/// without throwing is not proof enough by itself -- this is the check that +/// would have caught the reported case immediately (the native side can +/// silently fail to persist the registration even when the channel call +/// itself reports success). +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('guardarAlarma detecta que el conteo nativo no refleja la alarma ' + 'guardada, aunque android.programar no haya lanzado', () async { + // Fixed at 0 regardless of what programar() does internally -- + // simulates the native side accepting the channel call but never + // actually persisting the registration. + final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'silenciosa1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final excepcion = estado.ultimaExcepcionPara('silenciosa1'); + expect(excepcion, isNotNull); + expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion); + expect(estado.error, isNotNull); + }); + + test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra ' + 'fallo alguno', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'sana1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(estado.ultimaExcepcionPara('sana1'), isNull); + expect(estado.error, isNull); + }); +} diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index e8b331e..2d864d7 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -26,10 +26,27 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { bool puedeProgramarExactas = true; bool notificacionesPermitidas = true; bool puedeUsarPantallaCompleta = true; - int alarmasNativasPendientes = 0; String fabricante = 'test'; int versionSdk = 35; + /// Ids [programar] most recently scheduled as active-with-a-next-run (kept + /// in sync with [cancelar] too), mirroring the real native scheduler's own + /// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify + /// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s + /// DEFAULT so a test that never touches that field gets a value that + /// tracks reality instead of a frozen `0` -- a test that explicitly + /// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases + /// do, to model a stale/corrupt native count on purpose) keeps getting + /// EXACTLY that value regardless of what programar/cancelar do afterward. + final _idsRegistradosNativamente = {}; + int? _alarmasNativasPendientesFijado; + + int get alarmasNativasPendientes => + _alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length; + + set alarmasNativasPendientes(int valor) => + _alarmasNativasPendientesFijado = valor; + /// Test-only failure switch (diagnostics screen, "intent not resolving" /// coverage): when true, every `abrir*`/`solicitar*` system-screen action /// below reports failure (as a real device does when a ROM lacks that @@ -83,11 +100,17 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { throw StateError('fake programar failure'); } programadas.add(alarma); + if (alarma.activa && alarma.proximaProgramable != null) { + _idsRegistradosNativamente.add(alarma.id); + } else { + _idsRegistradosNativamente.remove(alarma.id); + } } @override Future cancelar(String alarmaId) async { canceladas.add(alarmaId); + _idsRegistradosNativamente.remove(alarmaId); } @override