From 88bd251ebaef60e22b471bfc2565f5c5a9e3ee2e Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 20:59:02 +0200 Subject: [PATCH 1/7] fix(alarmas): scope schedule-skip exceptions to skipNext only ExcepcionAlarma._esValida matched ANY exception tipo against an occurrence, treating it as a user skip. Only the 'skipNext' tipo existed until now, but the next commits reuse the same model to record scheduling-reliability failures per alarm (so the alarms list can surface them via ultimaExcepcionPara) -- without this guard, a recorded failure would be silently treated as if the user asked to skip that occurrence, corrupting scheduling. Adds tipo constants to ExcepcionAlarma for the upcoming failure kinds. --- lib/modelos/alarma_musical.dart | 37 +++++++++++++++++++ .../servicio_programacion_alarmas.dart | 6 +++ .../servicio_programacion_alarmas_test.dart | 36 ++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index f57cff4..9e5983f 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -302,6 +302,43 @@ class ExcepcionAlarma { final DateTime ejecucion; final String tipo; + /// User-requested skip of the next occurrence (the only [tipo] this model + /// originally supported). `ServicioProgramacionAlarmas._esValida` only + /// treats THIS tipo as an actual schedule skip -- every tipo below records + /// a scheduling-reliability failure and must never affect which occurrence + /// fires next. + static const tipoSaltoSiguiente = 'skipNext'; + + /// The main alarm registration with the OS failed (`android.programar` + /// threw). Recorded per-alarm so the alarms list can mark the exact card + /// affected instead of only a transient, alarm-agnostic app-wide message. + static const tipoFalloProgramacion = 'schedulingFailed'; + + /// The main alarm registered successfully but its 30-minute pre-notice + /// reminder did not (native `SecurityException` scheduling the pre-notice + /// alone) -- distinguished from [tipoFalloProgramacion] because the alarm + /// itself will still ring; only the early warning is missing. + static const tipoFalloPreaviso = 'preNoticeFailed'; + + /// The OS refused to start the foreground ringing service when the alarm + /// fired (e.g. a background-restricted app), so the alarm never actually + /// rang even though it was armed. + static const tipoFalloServicioSonido = 'foregroundServiceFailed'; + + /// A per-alarm reschedule after boot/unlock failed while sibling alarms + /// succeeded, leaving this one specific alarm unscheduled. + static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed'; + + /// Every tipo above that represents a reliability FAILURE rather than a + /// deliberate user action -- used by the UI to decide whether to mark a + /// card, and by [ServicioAlarmas] to know which prior record to replace. + static const tiposFallo = { + tipoFalloProgramacion, + tipoFalloPreaviso, + tipoFalloServicioSonido, + tipoFalloReprogramacionArranque, + }; + Map toJson() => { 'alarmaId': alarmaId, 'ejecucion': ejecucion.toIso8601String(), diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index fc01b8a..9b7f7cc 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas { if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) { return false; } + // Only a deliberate user skip ever removes a candidate occurrence. + // Reliability-failure records share this same list/model (so the alarms + // list can surface them per-alarm via `ultimaExcepcionPara`), but they + // must never be mistaken for a skip — that would silently jump the + // alarm to its NEXT occurrence instead of just flagging the failed one. return !excepciones.any( (excepcion) => excepcion.alarmaId == alarma.id && + excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente && _mismaEjecucion(excepcion.ejecucion, candidato), ); } diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index eb96415..015e4c8 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -75,6 +75,42 @@ void main() { expect(proxima, DateTime(2026, 5, 23, 9)); }); + test( + 'un registro de fallo de programacion NO omite esa ejecucion (solo ' + 'skipNext debe hacerlo)', + () { + final alarma = AlarmaMusical( + id: 'a4', + nombre: 'Diaria', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final ocurrencia = DateTime(2026, 5, 22, 9); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 22, 8), + excepciones: [ + ExcepcionAlarma( + alarmaId: 'a4', + ejecucion: ocurrencia, + tipo: ExcepcionAlarma.tipoFalloProgramacion, + ), + ], + ); + + expect( + proxima, + ocurrencia, + reason: + 'un fallo de programacion registrado no debe comportarse ' + 'como un salto de usuario', + ); + }, + ); + test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () { expect( servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10), From 47d0b8a053b2dbefbe776f1fce8ad33ea43d07b1 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:00:51 +0200 Subject: [PATCH 2/7] feat(alarmas): record and clear per-alarm scheduling failures Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion, persisting a scheduling-reliability failure through the same ExcepcionAlarma model saltarProxima already uses. Only one failure record is kept per alarm (latest attempt wins) and skipNext entries for any alarm are never touched. EstadoAlarmas wiring follows next. --- lib/servicios/servicio_alarmas.dart | 56 +++++++ ...vicio_alarmas_fallo_programacion_test.dart | 150 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 test/servicios/servicio_alarmas_fallo_programacion_test.dart diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index adadf0b..97c7cc9 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -350,6 +350,62 @@ class ServicioAlarmas { return nuevo; }); + /// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas- + /// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model + /// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara` + /// surfaces it on the exact alarm card affected instead of only a + /// transient, alarm-agnostic message. A previous FAILURE record for the + /// same alarm is replaced (only the latest attempt's outcome matters) -- + /// any `skipNext` exception for this or other alarms is left untouched. + /// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only + /// treats `tipoSaltoSiguiente` as an actual skip. + Future registrarFalloProgramacion( + String alarmaId, + DateTime ejecucion, + String tipo, + ) => _enCola(() async { + final config = await _configActual(); + final excepciones = [ + ..._sinFalloPrevio(config.excepciones, alarmaId), + ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo), + ]; + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas, + vacaciones: config.vacaciones, + excepciones: excepciones, + ); + await _guardar(nuevo); + return nuevo; + }); + + /// Clears any outstanding failure record for [alarmaId] (a subsequent + /// scheduling attempt succeeded). No-op when there is nothing to clear. + Future limpiarFalloProgramacion( + String alarmaId, + ) => _enCola(() async { + final config = await _configActual(); + final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId); + if (sinFallo.length == config.excepciones.length) return config; + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas, + vacaciones: config.vacaciones, + excepciones: sinFallo, + ); + await _guardar(nuevo); + return nuevo; + }); + + List _sinFalloPrevio( + List excepciones, + String alarmaId, + ) => excepciones + .where( + (e) => + !(e.alarmaId == alarmaId && + ExcepcionAlarma.tiposFallo.contains(e.tipo)), + ) + .toList(); + Future posponerEjecucion( String alarmaId, DateTime ejecucion, diff --git a/test/servicios/servicio_alarmas_fallo_programacion_test.dart b/test/servicios/servicio_alarmas_fallo_programacion_test.dart new file mode 100644 index 0000000..72b0370 --- /dev/null +++ b/test/servicios/servicio_alarmas_fallo_programacion_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Coverage for the scheduling-reliability failure records (fix/alarmas- +/// fallos-silenciosos): a failed `android.programar` call is no longer only +/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also +/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima` +/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the +/// exact card affected. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('registrarFalloProgramacion agrega una excepcion de fallo para la ' + 'alarma indicada', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect(config.excepciones.single.alarmaId, 'a1'); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA ' + 'alarma en vez de acumular', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloPreaviso, + ); + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de ' + 'otras alarmas ni de la misma', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.saltarProxima('a1'); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(2)); + expect( + config.excepciones.map((e) => e.tipo), + containsAll([ + ExcepcionAlarma.tipoSaltoSiguiente, + ExcepcionAlarma.tipoFalloProgramacion, + ]), + ); + }); + + test('limpiarFalloProgramacion elimina el fallo registrado para esa ' + 'alarma', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + final config = await servicio.limpiarFalloProgramacion('a1'); + + expect(config.excepciones, isEmpty); + }); + + test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste ' + 'cambios', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final config = await servicio.limpiarFalloProgramacion('a1'); + + expect(config.excepciones, isEmpty); + }); +} From fd1b91fe9eaa2eb8c366a60378a2461beefbe9f4 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:08:21 +0200 Subject: [PATCH 3/7] fix(alarmas): wire scheduling failures into per-alarm exceptions guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record a scheduling failure via ServicioAlarmas.registrarFalloProgramacion on catch and clear it on a successful (re)schedule, in addition to the existing transient EstadoAlarmas.error string. This makes the failure visible per-alarm via ultimaExcepcionPara instead of only a generic app-wide message. Also fixes _sincronizarTodas: a single alarm's android.programar throw used to abort the whole loop, silently skipping every sibling alarm scheduled AFTER it on that pass (including on every app launch, via inicializar). Each alarm's outcome is now independent. --- lib/estado/estado_alarmas.dart | 63 +++++++++++- test/estado/estado_alarmas_test.dart | 139 +++++++++++++++++++++++++++ test/helpers/fakes_alarmas.dart | 8 +- 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 8cdb7c3..6b8a53c 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -117,8 +117,10 @@ class EstadoAlarmas extends ChangeNotifier { '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', ); await android.programar(guardada); + await _limpiarFalloProgramacion(guardada.id); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -198,6 +200,45 @@ class EstadoAlarmas extends ChangeNotifier { } } + /// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos- + /// silenciosos): before this, a failed `android.programar` call only set + /// the transient, alarm-agnostic [_error] string — the alarms list had no + /// way to mark the SPECIFIC card affected, so a failed alarm rendered + /// exactly like a working one. Never rethrows: a failure recording its own + /// failure must not mask the ORIGINAL scheduling error already captured in + /// [_error]. + Future _registrarFalloProgramacion( + String alarmaId, { + String tipo = ExcepcionAlarma.tipoFalloProgramacion, + }) async { + try { + final alarma = _buscarAlarma(alarmaId); + final ejecucion = alarma?.proximaProgramable ?? servicio.ahora(); + final config = await servicio.registrarFalloProgramacion( + alarmaId, + ejecucion, + tipo, + ); + _aplicar(config); + } catch (e) { + debugPrint( + '[PluriWave][alarmas] registrar fallo programacion ERROR $e', + ); + } + } + + /// Clears a previously recorded scheduling failure once a later attempt + /// for the same alarm succeeds (D5-style recovery, mirroring how [_error] + /// itself already clears on a successful retry). + Future _limpiarFalloProgramacion(String alarmaId) async { + try { + final config = await servicio.limpiarFalloProgramacion(alarmaId); + _aplicar(config); + } catch (e) { + debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e'); + } + } + Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } @@ -267,10 +308,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -298,10 +341,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -622,8 +667,24 @@ class EstadoAlarmas extends ChangeNotifier { if (_alarmas.any((alarma) => alarma.activa)) { await _solicitarPermisosNecesariosParaAlarma(); } + // Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a + // SINGLE alarm's `programar` throw aborted the whole loop, so every + // sibling AFTER the failing one in `_alarmas` silently never reached + // `android.programar` on this pass -- on a fresh launch (`inicializar`) + // that meant some active alarms were never (re)armed with the OS at all, + // with nothing to show for it beyond a generic load error. Each alarm + // now gets its own outcome recorded, and one failure never blocks its + // siblings. for (final alarma in _alarmas) { - await android.programar(alarma); + try { + await android.programar(alarma); + await _limpiarFalloProgramacion(alarma.id); + } catch (e) { + debugPrint( + '[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e', + ); + await _registrarFalloProgramacion(alarma.id); + } } } diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index b320f24..417c9c7 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -611,6 +611,145 @@ void main() { }, ); + test( + 'guardarAlarma: cuando android.programar falla, marca la alarma con una ' + 'excepcion de fallo visible via ultimaExcepcionPara', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + 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: 'fallo1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final excepcion = estado.ultimaExcepcionPara('fallo1'); + expect(excepcion, isNotNull); + expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion); + expect(estado.error, isNotNull); + }, + ); + + test( + 'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + final alarma = const AlarmaMusical( + id: 'fallo2', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ); + await estado.guardarAlarma(alarma); + expect(estado.ultimaExcepcionPara('fallo2'), isNotNull); + + android.fallaProgramar = false; + await estado.guardarAlarma(estado.alarmas.single); + + expect(estado.ultimaExcepcionPara('fallo2'), isNull); + }, + ); + + test( + 'guardarAlarma en el camino feliz nunca registra una excepcion de fallo', + () 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: 'ok1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(estado.ultimaExcepcionPara('ok1'), isNull); + expect(estado.error, isNull); + }, + ); + + test( + 'inicializar: un fallo de programacion en UNA alarma no aborta la ' + 'sincronizacion de las demas (S-sincronizarTodas continua tras error)', + () async { + final android = FakePuertoAlarmasAndroid() + ..idsFallanProgramar.add('rota'); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 6, 0), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'rota', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'sana', + nombre: 'Sana', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + expect( + android.programadas.map((a) => a.id), + contains('sana'), + reason: + 'la alarma sana debe seguir programandose aunque la rota falle', + ); + expect(estado.ultimaExcepcionPara('rota'), isNotNull); + expect(estado.ultimaExcepcionPara('sana'), isNull); + }, + ); + group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () { test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye ' '"ahora" (dias restantes derivables de finDia), o null si ninguno ' diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 0295138..e8b331e 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -41,6 +41,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// could not otherwise produce. bool fallaProgramar = false; + /// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos): + /// [programar] throws only for ids in this set, letting a test simulate + /// one alarm failing to schedule while its siblings succeed -- the global + /// [fallaProgramar] switch cannot express that (it fails everything). + final Set idsFallanProgramar = {}; + /// Test-only failure switch: when true, [detenerSonidoActivo] reports an /// unconfirmed/failed stop instead of a confirmed one. bool fallaDetener = false; @@ -73,7 +79,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @override Future programar(AlarmaMusical alarma) async { - if (fallaProgramar) { + if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) { throw StateError('fake programar failure'); } programadas.add(alarma); From c107c0e18a07f51d7fcb4330335ef0c4150297a6 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:19:57 +0200 Subject: [PATCH 4/7] feat(alarmas): surface scheduling failures on the alarm card Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an alarm with an outstanding scheduling-failure exception now shows a calm warning line (distinguishing a pre-notice-only failure from the alarm itself not being registered) with a tap target into the reliability diagnostics screen. The warning is its own small tap target nested inside the existing card InkWell, so tap-to-edit, swipe-to-delete and the hero "Saltar" chip are untouched. Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage to all 13 ARB locales with real per-language translations (verified against arb_parity_test and arb_anti_copy_test). --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_bn.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 ++ lib/l10n/gen/app_localizations_ar.dart | 8 + lib/l10n/gen/app_localizations_bn.dart | 8 + lib/l10n/gen/app_localizations_de.dart | 8 + lib/l10n/gen/app_localizations_en.dart | 8 + lib/l10n/gen/app_localizations_es.dart | 8 + lib/l10n/gen/app_localizations_fr.dart | 8 + lib/l10n/gen/app_localizations_hi.dart | 8 + lib/l10n/gen/app_localizations_id.dart | 8 + lib/l10n/gen/app_localizations_it.dart | 8 + lib/l10n/gen/app_localizations_ja.dart | 8 + lib/l10n/gen/app_localizations_pt.dart | 8 + lib/l10n/gen/app_localizations_ru.dart | 8 + lib/l10n/gen/app_localizations_zh.dart | 6 + lib/pantallas/pantalla_alarmas.dart | 98 +++++++++++ ...talla_alarmas_fallo_programacion_test.dart | 154 ++++++++++++++++++ 29 files changed, 392 insertions(+) create mode 100644 test/pantallas/pantalla_alarmas_fallo_programacion_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b649158..ba591d7 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة", + "alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.", + "alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.", "alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة", "alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.", "alarmDiagnosticsNotificationsTitle": "الإشعارات", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 11615c1..66eba91 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "ছুটির কারণে বিরত", + "alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।", + "alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।", "alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং", "alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।", "alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d972c03..4e3b18b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Wegen Urlaub pausiert", + "alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.", + "alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.", "alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung", "alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.", "alarmDiagnosticsNotificationsTitle": "Benachrichtigungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 097fcca..7a3e740 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Paused for vacation", + "alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.", + "alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.", "alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling", "alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.", "alarmDiagnosticsNotificationsTitle": "Notifications", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2e0f8c7..696c698 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -801,6 +801,8 @@ } }, "alarmCardVacationPausedBadge": "Pausada por vacaciones", + "alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.", + "alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.", "alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta", "alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.", "alarmDiagnosticsNotificationsTitle": "Notificaciones", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9267ffb..e1abd72 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "En pause pour les vacances", + "alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.", + "alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.", "alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise", "alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.", "alarmDiagnosticsNotificationsTitle": "Notifications", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d08ec56..3addd01 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया", + "alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।", + "alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।", "alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग", "alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।", "alarmDiagnosticsNotificationsTitle": "सूचनाएं", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index dcb8c90..7af1ab3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Dijeda karena liburan", + "alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.", + "alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.", "alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi", "alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.", "alarmDiagnosticsNotificationsTitle": "Notifikasi", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index aeb9ee9..5677f83 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "In pausa per le vacanze", + "alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.", + "alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.", "alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta", "alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.", "alarmDiagnosticsNotificationsTitle": "Notifiche", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d0151af..444554b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "休暇のため一時停止中", + "alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。", + "alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。", "alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定", "alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。", "alarmDiagnosticsNotificationsTitle": "通知", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b234893..2b41a67 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Pausada por férias", + "alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.", + "alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.", "alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme", "alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.", "alarmDiagnosticsNotificationsTitle": "Notificações", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 32e3f7b..c370a09 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Приостановлено на время отпуска", + "alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.", + "alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.", "alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника", "alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.", "alarmDiagnosticsNotificationsTitle": "Уведомления", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0a5d154..f27a9cc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "因假期已暂停", + "alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。", + "alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。", "alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程", "alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。", "alarmDiagnosticsNotificationsTitle": "通知", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 22bef5f..81cac10 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3074,6 +3074,18 @@ abstract class AppLocalizations { /// **'Pausada por vacaciones'** String get alarmCardVacationPausedBadge; + /// No description provided for @alarmCardSchedulingFailedMessage. + /// + /// In es, this message translates to: + /// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'** + String get alarmCardSchedulingFailedMessage; + + /// No description provided for @alarmCardPreNoticeFailedMessage. + /// + /// In es, this message translates to: + /// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'** + String get alarmCardPreNoticeFailedMessage; + /// No description provided for @alarmDiagnosticsExactAlarmsTitle. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 40605e7..5726266 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1691,6 +1691,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة'; + @override + String get alarmCardSchedulingFailedMessage => + 'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index ae3f5aa..c7939b9 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1700,6 +1700,14 @@ class AppLocalizationsBn extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত'; + @override + String get alarmCardSchedulingFailedMessage => + 'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 3be5612..a6c33af 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1713,6 +1713,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert'; + @override + String get alarmCardSchedulingFailedMessage => + 'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 19433cc..d73df11 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1693,6 +1693,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Paused for vacation'; + @override + String get alarmCardSchedulingFailedMessage => + 'This alarm could not be registered with the system, so it may not ring.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'This alarm is scheduled, but its early reminder could not be set.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 1a201ea..cc6fc0d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1707,6 +1707,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Pausada por vacaciones'; + @override + String get alarmCardSchedulingFailedMessage => + 'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Esta alarma está programada, pero no se pudo activar su aviso previo.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programación de alarma exacta'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 2c935cf..a0e0a48 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1716,6 +1716,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'En pause pour les vacances'; + @override + String get alarmCardSchedulingFailedMessage => + 'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programmation d\'alarme précise'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index b8fd3de..c6bf8fb 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1695,6 +1695,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया'; + @override + String get alarmCardSchedulingFailedMessage => + 'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index d8b2eca..3a3c234 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1703,6 +1703,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Dijeda karena liburan'; + @override + String get alarmCardSchedulingFailedMessage => + 'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 4c8e8f8..ad1a334 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1715,6 +1715,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'In pausa per le vacanze'; + @override + String get alarmCardSchedulingFailedMessage => + 'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programmazione sveglia esatta'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 76b4d03..c6f045b 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1646,6 +1646,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get alarmCardVacationPausedBadge => '休暇のため一時停止中'; + @override + String get alarmCardSchedulingFailedMessage => + 'このアラームはシステムに登録できなかったため、鳴らない可能性があります。'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'このアラームは設定されていますが、事前通知を設定できませんでした。'; + @override String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 9067f87..88f3343 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1703,6 +1703,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Pausada por férias'; + @override + String get alarmCardSchedulingFailedMessage => + 'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 9559e89..aceb85b 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1707,6 +1707,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска'; + @override + String get alarmCardSchedulingFailedMessage => + 'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Этот будильник запланирован, но не удалось настроить предварительное напоминание.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Точное планирование будильника'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 71638d3..c5a2dde 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1638,6 +1638,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get alarmCardVacationPausedBadge => '因假期已暂停'; + @override + String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。'; + + @override + String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。'; + @override String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程'; diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 79edcc1..15da5bd 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -286,6 +286,20 @@ class _TarjetaAlarma extends StatelessWidget { if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge, ]; + // fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was + // never read from any screen, so a failed native scheduling attempt (main + // alarm, pre-notice, foreground service, or a post-boot reschedule) + // rendered exactly like a healthy alarm -- switched on, no visible sign + // anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente` + // as a real skip, so any OTHER tipo found here is a reliability failure, + // never a deliberate user action. + final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id); + final fallo = + ultimaExcepcion != null && + ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo) + ? ultimaExcepcion + : null; + return Dismissible( key: ValueKey('tarjeta-alarma-${alarma.id}'), direction: DismissDirection.horizontal, @@ -417,6 +431,14 @@ class _TarjetaAlarma extends StatelessWidget { ), ), ], + if (fallo != null) ...[ + const SizedBox(height: 6), + _AvisoFalloProgramacion( + alarmaId: alarma.id, + esSoloPreaviso: + fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso, + ), + ], ], ), ), @@ -470,6 +492,82 @@ class _TarjetaAlarma extends StatelessWidget { } } +/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos): +/// renders INSIDE the card's own content, in its own small tap target -- +/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe = +/// delete) are untouched; this inner `InkWell` only claims its own region +/// and pushes the diagnostics screen instead of opening the editor. +class _AvisoFalloProgramacion extends StatelessWidget { + const _AvisoFalloProgramacion({ + required this.alarmaId, + required this.esSoloPreaviso, + }); + + final String alarmaId; + + /// True when only the pre-notice reminder failed (the alarm itself is + /// still scheduled) -- the user's reported symptom explicitly called out + /// a missing pre-notice as distinct from the alarm never ringing at all, + /// so the message must not conflate the two. + final bool esSoloPreaviso; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final color = Theme.of(context).colorScheme.error; + return Material( + type: MaterialType.transparency, + child: InkWell( + key: ValueKey('tarjeta-alarma-fallo-$alarmaId'), + borderRadius: BorderRadius.circular(8), + onTap: () => _abrirDiagnostico(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 15, color: color), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + esSoloPreaviso + ? l10n.alarmCardPreNoticeFailedMessage + : l10n.alarmCardSchedulingFailedMessage, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + l10n.androidReliabilityReview, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: color, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _abrirDiagnostico(BuildContext context) { + PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas()); + } +} + /// Swipe-to-delete reveal, shown on both sides so either swipe direction /// works regardless of locale text direction. class _FondoSwipeEliminarAlarma extends StatelessWidget { diff --git a/test/pantallas/pantalla_alarmas_fallo_programacion_test.dart b/test/pantallas/pantalla_alarmas_fallo_programacion_test.dart new file mode 100644 index 0000000..f3d110c --- /dev/null +++ b/test/pantallas/pantalla_alarmas_fallo_programacion_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; +import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Card-level visibility for a failed scheduling attempt (fix/alarmas- +/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was +/// never read from any screen, so a failed alarm rendered exactly like a +/// working one -- switched on, no visible sign anything was wrong. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future montarPantalla( + WidgetTester tester, + EstadoAlarmas estadoAlarmas, + ) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final radio = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAlarmas()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets( + 'una alarma cuya programacion fallo muestra un aviso en su tarjeta', + (tester) async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'r1', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + expect( + find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'una alarma programada correctamente NO muestra el aviso de fallo', + (tester) async { + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'ok1', + nombre: 'Sana', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + expect( + find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')), + findsNothing, + ); + }, + ); + + testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', ( + tester, + ) async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'r1', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget); + }); +} From 7722f204ca282ff7b707eab393f0857ab9d021e4 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 21:26:41 +0200 Subject: [PATCH 5/7] feat(alarmas): verify native registration after a successful save android.programar() returning without throwing was treated as proof the OS registered the alarm -- this is exactly the gap the reported case fell through. guardarAlarma now cross-checks a fresh native pending-alarm count against how many alarms Dart believes are active-with-a-next-run right after a successful schedule call, and records a failure for the just-saved alarm when the native count falls short. FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a count derived from programar()/cancelar() calls (mirroring the real native scheduler's own registry) instead of a frozen 0, while any test that explicitly assigns the field keeps getting exactly that value regardless of what programar/cancelar do afterward -- verified against the full suite, no regressions. --- lib/estado/estado_alarmas.dart | 39 +++++++++- ...do_alarmas_verificacion_registro_test.dart | 76 +++++++++++++++++++ test/helpers/fakes_alarmas.dart | 25 +++++- 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 test/estado/estado_alarmas_verificacion_registro_test.dart 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 From a8dca83cd9865d8b6766a748b03595bf66f8e45d Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 23:24:01 +0200 Subject: [PATCH 6/7] feat(alarmas): surface the three native scheduling failures in Dart Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem. --- .../freetimelab/pluriwave/AlarmScheduler.kt | 128 ++++++++++++++++++ .../es/freetimelab/pluriwave/MainActivity.kt | 4 + .../pluriwave/PluriWaveAlarmService.kt | 34 +++++ lib/estado/estado_alarmas.dart | 104 +++++++++++++- lib/servicios/servicio_alarmas.dart | 35 +++-- lib/servicios/servicio_alarmas_android.dart | 78 +++++++++++ .../estado_alarmas_fallos_nativos_test.dart | 106 +++++++++++++++ ...do_alarmas_verificacion_registro_test.dart | 63 +++++++++ test/helpers/fakes_alarmas.dart | 25 ++++ ...vicio_alarmas_fallo_programacion_test.dart | 38 +++++- 10 files changed, 599 insertions(+), 16 deletions(-) create mode 100644 test/estado/estado_alarmas_fallos_nativos_test.dart diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 699396b..6fca362 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) { ) ) Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}") + NativeSchedulingFailures.clear( + appContext, + spec.id, + NativeSchedulingFailures.TYPE_PRE_NOTICE + ) } catch (_: SecurityException) { + // Silent before this fix: the main alarm can still arm via + // setAlarmClock (exempt from the exact-alarm permission), so + // the alarm itself rings while its 30-minute reminder simply + // never appears, with nothing surfaced anywhere but logcat. Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}") + NativeSchedulingFailures.record( + appContext, + spec.id, + NativeSchedulingFailures.TYPE_PRE_NOTICE + ) } } else if (spec.triggerAtMillis > now) { appContext.sendBroadcast( @@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) { // the native recompute inside scheduleSpec. scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true) Log.d(tag, "alarm.reschedule OK id=$id") + NativeSchedulingFailures.clear( + appContext, + id, + NativeSchedulingFailures.TYPE_RESCHEDULE + ) } catch (error: Throwable) { + // Silent before this fix: one alarm's reschedule failure used + // to just log and move to the next id, leaving that ONE + // alarm unscheduled after a reboot/unlock/app-update with no + // signal anywhere but logcat. Log.e(tag, "alarm.reschedule failed id=$id", error) + NativeSchedulingFailures.record( + appContext, + id, + NativeSchedulingFailures.TYPE_RESCHEDULE + ) } } } @@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) { fun pendingAlarmCount(): Int = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size + /** + * Scheduling-reliability failures the native side recorded on its own + * (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground- + * service start, and post-boot/unlock reschedule failures never go + * through a Dart method-channel call that could throw, so they are + * persisted here instead and synced by Flutter on the next app launch + * -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own + * cold-start-sync shape. + */ + fun scheduleFailures(): List> = + NativeSchedulingFailures.all(appContext) + fun handledOccurrences(): List> = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty() .mapNotNull { id -> @@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) { private fun JSONObject.optNullableLong(name: String): Long? = if (has(name) && !isNull(name)) optLong(name) else null + +/** + * Persisted store for scheduling-reliability failures the native side + * catches and previously only logged (fix/alarmas-fallos-silenciosos, item + * 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice), + * a refused foreground-service start (PluriWaveAlarmService.start), and a + * per-alarm reschedule failure after boot/unlock + * (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object + * (not nested in [AlarmScheduler]'s own instance state) so + * [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler] + * instance of its own -- can record a failure too, using the exact same + * [Context]-scoped, device-protected-storage `SharedPreferences` file + * [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by + * hand since Kotlin constants cannot be shared across files without a third + * file). + * + * Only the LATEST failure per alarm is kept (mirrors the Dart-side + * `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability + * signal, not an audit log. + */ +object NativeSchedulingFailures { + private const val PREFS = "pluriwave_alarm_scheduler" + private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids" + private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_" + private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */ + const val TYPE_PRE_NOTICE = "preNoticeFailed" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */ + const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */ + const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed" + + private fun prefs(context: Context) = + context.applicationContext.createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun record(context: Context, id: String, type: String) { + if (id.isBlank()) return + val store = prefs(context) + val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet() + ids.add(id) + store.edit() + .putStringSet(KEY_FAILURE_IDS, ids) + .putString("$KEY_FAILURE_TYPE_PREFIX$id", type) + .putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis()) + .apply() + Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type") + } + + /** + * Clears the failure recorded for [id] ONLY when its current type is + * [type] -- type-scoped on purpose (mirrors the Dart-side + * `limpiarFalloProgramacion`), so a foreground-service success never + * erases an unrelated, still-outstanding pre-notice failure for the + * same alarm. + */ + fun clear(context: Context, id: String, type: String) { + val store = prefs(context) + val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null) + if (storedType != type) return + val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet() + if (!ids.remove(id)) return + store.edit() + .putStringSet(KEY_FAILURE_IDS, ids) + .remove("$KEY_FAILURE_TYPE_PREFIX$id") + .remove("$KEY_FAILURE_AT_PREFIX$id") + .apply() + } + + fun all(context: Context): List> { + val store = prefs(context) + return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id -> + val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null) + ?: return@mapNotNull null + val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L) + .takeIf { it > 0L } + ?: return@mapNotNull null + mapOf( + "alarmId" to id, + "type" to type, + "atMillis" to at + ) + } + } +} diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 29e4c7d..b80032b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() { Log.d(tag, "alarm.channel getNativeSnoozeState") result.success(alarmScheduler.nativeSnoozeStates()) } + "getNativeSchedulingFailures" -> { + Log.d(tag, "alarm.channel getNativeSchedulingFailures") + result.success(alarmScheduler.scheduleFailures()) + } "setNotificationStrings" -> { val args = call.arguments as? Map<*, *> if (args != null) { diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index 9e9a424..32b6b67 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() { startForeground(NOTIFICATION_ID, notification) } } catch (error: Throwable) { + // Silent before this fix: same user-visible symptom as a refused + // startForegroundService (the ring never actually starts) -- + // recorded under the SAME tipo so the alarms list surfaces it + // regardless of which of the two calls the OS refused. Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error) + NativeSchedulingFailures.record( + this, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) releaseWakeLock() // Second documented clear site (feedback item, READ-5): this // branch never reaches stopEverything(), so without the same @@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() { stopSelf() return } + NativeSchedulingFailures.clear( + this, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) startAudio( alarmId, stationName, @@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() { fun start(context: Context, source: Intent) { ensureChannel(context) + val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) val intent = Intent(context, PluriWaveAlarmService::class.java).apply { action = PluriWaveAlarmReceiver.ACTION_FIRE putExtras(source) @@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() { try { ContextCompat.startForegroundService(context, intent) Log.d(TAG, "alarm.service start requested") + if (!alarmId.isNullOrBlank()) { + NativeSchedulingFailures.clear( + context, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) + } } catch (error: Throwable) { + // Silent before this fix: a fire-and-forget call from the + // receiver's ACTION_FIRE branch -- if the OS refuses the + // foreground-service start (background-restricted app), the + // ring never happens and nothing surfaced it anywhere but + // logcat, "as if there were no alarm at all". Log.e(TAG, "alarm.service start failed", error) + if (!alarmId.isNullOrBlank()) { + NativeSchedulingFailures.record( + context, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) + } } } diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 29f2447..e6a7636 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier { ); await _sincronizarTodas(); await cargarDiagnostico(); + await cargarFallosNativos(); _activarRefresco(); } catch (e) { _error = 'No se pudieron cargar las alarmas: $e'; @@ -228,10 +229,22 @@ class EstadoAlarmas extends ChangeNotifier { /// Clears a previously recorded scheduling failure once a later attempt /// for the same alarm succeeds (D5-style recovery, mirroring how [_error] - /// itself already clears on a successful retry). + /// itself already clears on a successful retry). Type-scoped: a + /// successful `android.programar` call only proves the MAIN alarm + /// registration (and, transitively, that any stale post-boot reschedule + /// failure no longer applies) -- it says nothing about the pre-notice or + /// foreground-service subsystems, so those are left untouched here. Future _limpiarFalloProgramacion(String alarmaId) async { try { - final config = await servicio.limpiarFalloProgramacion(alarmaId); + var config = await servicio.limpiarFalloProgramacion( + alarmaId, + ExcepcionAlarma.tipoFalloProgramacion, + ); + _aplicar(config); + config = await servicio.limpiarFalloProgramacion( + alarmaId, + ExcepcionAlarma.tipoFalloReprogramacionArranque, + ); _aplicar(config); } catch (e) { debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e'); @@ -525,6 +538,39 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + /// Drains the failures the NATIVE side recorded on its own and turns each + /// into a per-alarm exception, so the card can mark it. + /// + /// These three paths used to log to logcat and stop there: a pre-notice + /// that could not be armed, a refused foreground-service start when the + /// alarm should have rung, and a per-alarm reschedule that failed after a + /// reboot. None of them run inside a Dart call, so nothing on this side + /// ever learned they happened — an alarm could sit switched on in the + /// list having never reached the OS. Reading them at startup is what + /// makes the reported "as if there were no alarm" visible. + /// + /// Deliberately tolerant: a failed read is logged and swallowed, never + /// surfaced as an alarm error, because a diagnostics gap must not look + /// like a scheduling problem. + Future cargarFallosNativos() async { + try { + final fallos = await android.fallosNativosProgramacion(); + for (final fallo in fallos) { + final alarmaId = fallo['alarmaId'] as String?; + final tipo = fallo['tipo'] as String?; + if (alarmaId == null || tipo == null) continue; + await _registrarFalloProgramacion(alarmaId, tipo: tipo); + } + if (fallos.isNotEmpty) { + debugPrint( + '[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}', + ); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e'); + } + } + /// Records a snooze the native layer performed by itself (Decision 2.1). /// The native scheduler already re-registered setAlarmClock, so this only /// persists the canonical state — it MUST NOT call android.programar again. @@ -624,6 +670,60 @@ class EstadoAlarmas extends ChangeNotifier { debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e'); } await _importarSnoozesNativosActivos(); + await _importarFallosProgramacionNativos(); + } + + /// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports + /// scheduling-reliability failures the NATIVE side recorded on its own -- + /// a pre-notice `SecurityException`, a refused foreground-service start, + /// or a per-alarm reschedule failure after boot/unlock -- none of which + /// ever go through a Dart method-channel call that could throw. Without + /// this sync, these three failures stayed invisible forever (only + /// logcat), even after this app-launch fix reads them. + Future _importarFallosProgramacionNativos() async { + try { + final fallos = await android.obtenerFallosProgramacionNativos(); + final reportadoPorAlarma = { + for (final fallo in fallos) fallo.alarmaId: fallo, + }; + // Reconcile stale copies: the native side clears its OWN record the + // next time that specific subsystem succeeds (pre-notice/foreground- + // service), so an alarm previously imported with one of those tipos + // that is no longer reported here means it already recovered -- + // without this, the card would keep showing a problem that fixed + // itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque` + // are NOT reconciled here -- those already clear on the Dart side's + // own successful `android.programar` calls. + for (final alarma in _alarmas) { + final actual = ultimaExcepcionPara(alarma.id); + final esTipoReconciliable = + actual != null && + (actual.tipo == ExcepcionAlarma.tipoFalloPreaviso || + actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido); + if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) { + final config = await servicio.limpiarFalloProgramacion( + alarma.id, + actual.tipo, + ); + _aplicar(config); + } + } + for (final fallo in fallos) { + final config = await servicio.registrarFalloProgramacion( + fallo.alarmaId, + fallo.ocurridoEn, + fallo.tipo, + ); + _aplicar(config); + } + if (fallos.isNotEmpty) { + debugPrint( + '[PluriWave][alarmas] fallos nativos importados count=${fallos.length}', + ); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e'); + } } /// Cold-start half of Decision 2.1: imports snoozes the native scheduler diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 97c7cc9..8bcfc5d 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -378,18 +378,28 @@ class ServicioAlarmas { return nuevo; }); - /// Clears any outstanding failure record for [alarmaId] (a subsequent - /// scheduling attempt succeeded). No-op when there is nothing to clear. + /// Clears the outstanding failure record for [alarmaId] ONLY when its + /// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind + /// succeeded). Type-scoped on purpose: a successful main-alarm schedule + /// call proves nothing about the pre-notice or foreground-service + /// subsystems, so it must never clear a failure recorded for those. No-op + /// when there is nothing to clear or the recorded tipo does not match. Future limpiarFalloProgramacion( String alarmaId, + String tipo, ) => _enCola(() async { final config = await _configActual(); - final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId); - if (sinFallo.length == config.excepciones.length) return config; + final actual = config.excepciones.where((e) => e.alarmaId == alarmaId); + final tieneEseTipo = actual.any((e) => e.tipo == tipo); + if (!tieneEseTipo) return config; + final excepciones = + config.excepciones + .where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo)) + .toList(); final nuevo = ConfiguracionAlarmas( alarmas: config.alarmas, vacaciones: config.vacaciones, - excepciones: sinFallo, + excepciones: excepciones, ); await _guardar(nuevo); return nuevo; @@ -398,13 +408,14 @@ class ServicioAlarmas { List _sinFalloPrevio( List excepciones, String alarmaId, - ) => excepciones - .where( - (e) => - !(e.alarmaId == alarmaId && - ExcepcionAlarma.tiposFallo.contains(e.tipo)), - ) - .toList(); + ) => + excepciones + .where( + (e) => + !(e.alarmaId == alarmaId && + ExcepcionAlarma.tiposFallo.contains(e.tipo)), + ) + .toList(); Future posponerEjecucion( String alarmaId, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 8c8b0cf..5cf4f32 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -161,6 +161,35 @@ class EjecucionAlarmaNativa { } } +/// A scheduling-reliability failure the NATIVE side recorded on its own +/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the +/// ringing foreground service, and a post-boot/unlock reschedule can each +/// fail without ever going through a Dart method-channel call that could +/// throw -- the native scheduler persists these instead (mirroring how +/// handled occurrences and snooze state already survive a killed engine), +/// and this is the cold-start sync so the Dart side finds out at all. +class FalloProgramacionNativo { + const FalloProgramacionNativo({ + required this.alarmaId, + required this.tipo, + required this.ocurridoEn, + }); + + final String alarmaId; + final String tipo; + final DateTime ocurridoEn; + + factory FalloProgramacionNativo.fromMap(Map map) { + return FalloProgramacionNativo( + alarmaId: map['alarmId'] as String? ?? '', + tipo: map['type'] as String? ?? '', + ocurridoEn: DateTime.fromMillisecondsSinceEpoch( + (map['atMillis'] as num?)?.toInt() ?? 0, + ), + ); + } +} + abstract class PuertoAlarmasAndroid { Stream get eventosAlarma; @@ -170,6 +199,17 @@ abstract class PuertoAlarmasAndroid { Future programar(AlarmaMusical alarma); Future cancelar(String alarmaId); + + /// Failures the NATIVE side recorded on its own, outside any Dart call: + /// a pre-notice that could not be armed, a refused foreground-service + /// start when the alarm should have rung, and a per-alarm reschedule that + /// failed after a reboot. Each entry carries the alarm id and one of + /// [ExcepcionAlarma]'s `tipoFallo*` constants. + /// + /// Before this existed every one of those paths logged to logcat and + /// stopped there, so an alarm could sit switched on in the list having + /// never reached the OS at all — the user's "as if there were no alarm". + Future>> fallosNativosProgramacion(); Future ocultarNotificacionAlarma(String alarmaId); /// Notification-only dismissal (RES-1): hides the fire notification for @@ -203,6 +243,11 @@ abstract class PuertoAlarmasAndroid { Future obtenerEventoInicial(); Future> obtenerEjecucionesNativasGestionadas(); Future> obtenerEstadoSnoozeNativo(); + + /// Scheduling-reliability failures the native side recorded on its own + /// (pre-notice, foreground-service start, or post-boot reschedule) since + /// the last sync. + Future> obtenerFallosProgramacionNativos(); } class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { @@ -393,6 +438,25 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { } } + @override + Future>> fallosNativosProgramacion() async { + try { + final raw = await _channel.invokeMethod>( + 'getNativeSchedulingFailures', + ); + if (raw == null) return const []; + return raw + .whereType>() + .map((m) => m.map((k, v) => MapEntry(k.toString(), v))) + .toList(); + } catch (e) { + // Never let a diagnostics read break alarm handling: an older build + // of the native side simply has no such channel method. + debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e'); + return const []; + } + } + @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( @@ -494,6 +558,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { .toList(); } + @override + Future> + obtenerFallosProgramacionNativos() async { + final raw = await _channel.invokeMethod>( + 'getNativeSchedulingFailures', + ); + if (raw == null || raw.isEmpty) return const []; + return raw + .whereType>() + .map(FalloProgramacionNativo.fromMap) + .where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty) + .toList(); + } + Future _logAndInvokeVoid(String method, Map args) { debugPrint('[PluriWave][alarmas] $method $args'); return _channel.invokeMethod(method, args); diff --git a/test/estado/estado_alarmas_fallos_nativos_test.dart b/test/estado/estado_alarmas_fallos_nativos_test.dart new file mode 100644 index 0000000..6e5df37 --- /dev/null +++ b/test/estado/estado_alarmas_fallos_nativos_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// The native scheduler records three failures entirely on its own, outside +/// any Dart call: a pre-notice that could not be armed, a refused +/// foreground-service start when the alarm should have rung, and a per-alarm +/// reschedule that failed after a reboot. +/// +/// Before this wiring existed, all three logged to logcat and stopped there. +/// The alarm stayed switched on in the list, drawn exactly as if scheduling +/// had succeeded, and simply never fired — the reported "as if there were no +/// alarm at all". These tests pin the drain path that makes them visible. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakePuertoAlarmasAndroid android; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + android = FakePuertoAlarmasAndroid(); + }); + + EstadoAlarmas crearEstado() => + EstadoAlarmas(android: android, iniciarAutomaticamente: false); + + test('a native pre-notice failure becomes a per-alarm exception', () async { + android.fallosNativos = [ + {'alarmaId': 'a1', 'tipo': ExcepcionAlarma.tipoFalloPreaviso}, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + final fallo = estado.ultimaExcepcionPara('a1'); + expect(fallo, isNotNull); + expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + + test( + 'a refused foreground-service start becomes a per-alarm exception', + () async { + android.fallosNativos = [ + {'alarmaId': 'a2', 'tipo': ExcepcionAlarma.tipoFalloServicioSonido}, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect( + estado.ultimaExcepcionPara('a2')?.tipo, + ExcepcionAlarma.tipoFalloServicioSonido, + ); + }, + ); + + test('only the alarm whose reschedule failed is marked', () async { + android.fallosNativos = [ + { + 'alarmaId': 'a3', + 'tipo': ExcepcionAlarma.tipoFalloReprogramacionArranque, + }, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.ultimaExcepcionPara('a3'), isNotNull); + expect( + estado.ultimaExcepcionPara('a4'), + isNull, + reason: 'a sibling alarm that scheduled fine must stay unmarked', + ); + }); + + test('malformed native entries are skipped without throwing', () async { + android.fallosNativos = [ + {'tipo': ExcepcionAlarma.tipoFalloPreaviso}, // no alarmaId + {'alarmaId': 'a5'}, // no tipo + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.ultimaExcepcionPara('a5'), isNull); + }); + + test('a failing native read never surfaces as an alarm error', () async { + // A diagnostics gap must not look like a scheduling problem: an older + // native build simply has no such channel method. + android.fallaLecturaFallosNativos = true; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.error, isNull); + }); +} diff --git a/test/estado/estado_alarmas_verificacion_registro_test.dart b/test/estado/estado_alarmas_verificacion_registro_test.dart index 93ca350..389eaae 100644 --- a/test/estado/estado_alarmas_verificacion_registro_test.dart +++ b/test/estado/estado_alarmas_verificacion_registro_test.dart @@ -2,6 +2,7 @@ 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:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes_alarmas.dart'; @@ -73,4 +74,66 @@ void main() { expect(estado.ultimaExcepcionPara('sana1'), isNull); expect(estado.error, isNull); }); + + test( + 'inicializar importa los fallos nativos (pre-aviso, servicio en primer ' + 'plano, reprogramacion tras arranque) como excepciones por alarma', + () async { + final android = + FakePuertoAlarmasAndroid() + ..fallosProgramacionNativos.addAll([ + FalloProgramacionNativo( + alarmaId: 'con-preaviso-roto', + tipo: ExcepcionAlarma.tipoFalloPreaviso, + ocurridoEn: DateTime(2026, 5, 25, 7), + ), + FalloProgramacionNativo( + alarmaId: 'servicio-rechazado', + tipo: ExcepcionAlarma.tipoFalloServicioSonido, + ocurridoEn: DateTime(2026, 5, 25, 7), + ), + ]); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7, 30), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'con-preaviso-roto', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'servicio-rechazado', + nombre: 'Diaria', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto'); + expect(falloPreaviso, isNotNull); + expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso); + + final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado'); + expect(falloServicio, isNotNull); + expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido); + }, + ); } diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 2d864d7..3ec140e 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -14,6 +14,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { final soloOcultadas = []; final ejecucionesNativas = []; final snoozesNativos = []; + final fallosProgramacionNativos = []; final _eventos = StreamController.broadcast(); bool ignoraOptimizacionBateria = true; int solicitudesExencionBateria = 0; @@ -186,9 +187,33 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { Future> obtenerEstadoSnoozeNativo() async => List.of(snoozesNativos); + @override + Future> + obtenerFallosProgramacionNativos() async => + List.of(fallosProgramacionNativos); + int solicitudesPermisoAlarmasExactas = 0; int solicitudesPermisoPantallaCompleta = 0; + /// Native-recorded failures the next read should return. Tests seed this + /// to simulate a pre-notice that never armed, a refused foreground-service + /// start, or a per-alarm reschedule that failed after a reboot. + List> fallosNativos = const []; + + int lecturasFallosNativos = 0; + + /// Simulates an older native build with no such channel method. + bool fallaLecturaFallosNativos = false; + + @override + Future>> fallosNativosProgramacion() async { + lecturasFallosNativos++; + if (fallaLecturaFallosNativos) { + throw StateError('canal no disponible'); + } + return fallosNativos; + } + @override Future solicitarPermisoAlarmasExactas() async { solicitudesPermisoAlarmasExactas++; diff --git a/test/servicios/servicio_alarmas_fallo_programacion_test.dart b/test/servicios/servicio_alarmas_fallo_programacion_test.dart index 72b0370..c9457f4 100644 --- a/test/servicios/servicio_alarmas_fallo_programacion_test.dart +++ b/test/servicios/servicio_alarmas_fallo_programacion_test.dart @@ -124,11 +124,42 @@ void main() { ExcepcionAlarma.tipoFalloProgramacion, ); - final config = await servicio.limpiarFalloProgramacion('a1'); + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); expect(config.excepciones, isEmpty); }); + test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto ' + '(cada subsistema se limpia por su cuenta)', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloPreaviso, + ); + + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste ' 'cambios', () async { final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); @@ -143,7 +174,10 @@ void main() { ), ); - final config = await servicio.limpiarFalloProgramacion('a1'); + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); expect(config.excepciones, isEmpty); }); From f2528c930baecf2d612d8604811a8c16398677d0 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 23:27:45 +0200 Subject: [PATCH 7/7] fix(alarmas): decode native failures with the real channel key names The first pass read 'alarmaId'/'tipo' from the channel payload while the native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389). Every entry would have been dropped silently in production. The tests passed because the fake was seeded with the same guessed keys, so they confirmed the mistake instead of catching it. Decoding now goes through FalloProgramacionNativo.fromMap -- the single place native key names appear -- and the fixtures build through that same constructor. --- lib/estado/estado_alarmas.dart | 5 +-- lib/servicios/servicio_alarmas_android.dart | 14 ++++-- .../estado_alarmas_fallos_nativos_test.dart | 44 +++++++++++-------- test/helpers/fakes_alarmas.dart | 4 +- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index e6a7636..77937d0 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -556,10 +556,7 @@ class EstadoAlarmas extends ChangeNotifier { try { final fallos = await android.fallosNativosProgramacion(); for (final fallo in fallos) { - final alarmaId = fallo['alarmaId'] as String?; - final tipo = fallo['tipo'] as String?; - if (alarmaId == null || tipo == null) continue; - await _registrarFalloProgramacion(alarmaId, tipo: tipo); + await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo); } if (fallos.isNotEmpty) { debugPrint( diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 5cf4f32..d854ee3 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -209,7 +209,14 @@ abstract class PuertoAlarmasAndroid { /// Before this existed every one of those paths logged to logcat and /// stopped there, so an alarm could sit switched on in the list having /// never reached the OS at all — the user's "as if there were no alarm". - Future>> fallosNativosProgramacion(); + /// + /// Returns the typed model rather than raw maps ON PURPOSE: + /// [FalloProgramacionNativo.fromMap] is the single place the native key + /// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here + /// once silently dropped every entry, because the caller guessed Spanish + /// key names and the fake was seeded with the same guess — the test + /// confirmed the mistake instead of catching it. + Future> fallosNativosProgramacion(); Future ocultarNotificacionAlarma(String alarmaId); /// Notification-only dismissal (RES-1): hides the fire notification for @@ -439,7 +446,7 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { } @override - Future>> fallosNativosProgramacion() async { + Future> fallosNativosProgramacion() async { try { final raw = await _channel.invokeMethod>( 'getNativeSchedulingFailures', @@ -447,7 +454,8 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { if (raw == null) return const []; return raw .whereType>() - .map((m) => m.map((k, v) => MapEntry(k.toString(), v))) + .map(FalloProgramacionNativo.fromMap) + .where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty) .toList(); } catch (e) { // Never let a diagnostics read break alarm handling: an older build diff --git a/test/estado/estado_alarmas_fallos_nativos_test.dart b/test/estado/estado_alarmas_fallos_nativos_test.dart index 6e5df37..53567e3 100644 --- a/test/estado/estado_alarmas_fallos_nativos_test.dart +++ b/test/estado/estado_alarmas_fallos_nativos_test.dart @@ -1,6 +1,7 @@ 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_android.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes_alarmas.dart'; @@ -14,6 +15,12 @@ import '../helpers/fakes_alarmas.dart'; /// The alarm stayed switched on in the list, drawn exactly as if scheduling /// had succeeded, and simply never fired — the reported "as if there were no /// alarm at all". These tests pin the drain path that makes them visible. +/// +/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap] +/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see +/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with +/// guessed Spanish keys and consumed the same guess, so every entry would +/// have been silently dropped in production while the tests stayed green. void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -27,9 +34,24 @@ void main() { EstadoAlarmas crearEstado() => EstadoAlarmas(android: android, iniciarAutomaticamente: false); + /// Mirrors exactly what the native side puts on the channel. + FalloProgramacionNativo falloNativo(String alarmaId, String tipo) => + FalloProgramacionNativo.fromMap({ + 'alarmId': alarmaId, + 'type': tipo, + 'atMillis': 1700000000000, + }); + + test('the fixture helper decodes the real native key names', () { + final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso); + + expect(fallo.alarmaId, 'a0'); + expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + test('a native pre-notice failure becomes a per-alarm exception', () async { android.fallosNativos = [ - {'alarmaId': 'a1', 'tipo': ExcepcionAlarma.tipoFalloPreaviso}, + falloNativo('a1', ExcepcionAlarma.tipoFalloPreaviso), ]; final estado = crearEstado(); addTearDown(estado.dispose); @@ -45,7 +67,7 @@ void main() { 'a refused foreground-service start becomes a per-alarm exception', () async { android.fallosNativos = [ - {'alarmaId': 'a2', 'tipo': ExcepcionAlarma.tipoFalloServicioSonido}, + falloNativo('a2', ExcepcionAlarma.tipoFalloServicioSonido), ]; final estado = crearEstado(); addTearDown(estado.dispose); @@ -61,10 +83,7 @@ void main() { test('only the alarm whose reschedule failed is marked', () async { android.fallosNativos = [ - { - 'alarmaId': 'a3', - 'tipo': ExcepcionAlarma.tipoFalloReprogramacionArranque, - }, + falloNativo('a3', ExcepcionAlarma.tipoFalloReprogramacionArranque), ]; final estado = crearEstado(); addTearDown(estado.dispose); @@ -79,19 +98,6 @@ void main() { ); }); - test('malformed native entries are skipped without throwing', () async { - android.fallosNativos = [ - {'tipo': ExcepcionAlarma.tipoFalloPreaviso}, // no alarmaId - {'alarmaId': 'a5'}, // no tipo - ]; - final estado = crearEstado(); - addTearDown(estado.dispose); - - await estado.cargarFallosNativos(); - - expect(estado.ultimaExcepcionPara('a5'), isNull); - }); - test('a failing native read never surfaces as an alarm error', () async { // A diagnostics gap must not look like a scheduling problem: an older // native build simply has no such channel method. diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 3ec140e..b9a72d2 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -198,7 +198,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// Native-recorded failures the next read should return. Tests seed this /// to simulate a pre-notice that never armed, a refused foreground-service /// start, or a per-alarm reschedule that failed after a reboot. - List> fallosNativos = const []; + List fallosNativos = const []; int lecturasFallosNativos = 0; @@ -206,7 +206,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { bool fallaLecturaFallosNativos = false; @override - Future>> fallosNativosProgramacion() async { + Future> fallosNativosProgramacion() async { lecturasFallosNativos++; if (fallaLecturaFallosNativos) { throw StateError('canal no disponible');