From 3ebb41aa9d10207bf7dab86d0606e101fcbc7646 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 11 Jul 2026 23:02:13 +0200 Subject: [PATCH] fix(alarm): arm a just-passed occurrence instead of skipping it a day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native next-occurrence recompute required the trigger to be strictly in the future, while the Dart side keeps an occurrence whose trigger passed within a 90s tolerance. When the periodic resync re-armed an alarm microseconds after its trigger (app foregrounded, the 60s tick straddling the trigger instant), computeNextTriggerMillis recomputed the next weekday/daily occurrence as tomorrow and, through the shared FLAG_UPDATE_CURRENT fire PendingIntent, replaced the in-flight fire before AlarmManager delivered it. The alarm never rang until the screen was turned on and the Dart watchdog caught it late. computeNextTriggerMillis now mirrors Dart's toleranciaDisparoInminente: base is lowered by a 90s grace window so a just-passed occurrence is armed (and delivered ~immediately) rather than pushed to the next day. The handledFloor (lastHandledAtMillis + 60s) stays a hard lower bound, so an already-fired occurrence can never be re-selected — no double-fire. Dart contract tests lock the boundary the native constant must track. Native verification is on-device (no JVM test harness). --- .../freetimelab/pluriwave/AlarmScheduler.kt | 16 ++++++- .../servicio_programacion_alarmas_test.dart | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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 e260f86..e19210f 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -717,7 +717,12 @@ class AlarmScheduler(private val context: Context) { val now = System.currentTimeMillis() spec.snoozeUntilMillis?.let { if (it > now) return it } if (!spec.enabled) return null - val base = maxOf(now, (spec.lastHandledAtMillis ?: 0L) + 60_000L) + // handledFloor is a HARD lower bound: an occurrence already fired + // (onAlarmFired records lastHandledAtMillis) can never be re-selected, + // so the grace window below can lower `base` toward the past without + // ever risking a double-fire. + val handledFloor = (spec.lastHandledAtMillis ?: 0L) + 60_000L + val base = maxOf(now - IMMINENT_TOLERANCE_MILLIS, handledFloor) return when (spec.scheduleType) { SCHEDULE_UNICA -> computeOneShot(spec, base) SCHEDULE_DIAS_SEMANA -> computeWeekday(spec, base) @@ -1042,6 +1047,15 @@ class AlarmScheduler(private val context: Context) { private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L private const val SCHEDULE_UNICA = "unica" private const val SCHEDULE_DIAS_SEMANA = "diasSemana" + + // Mirror Dart's ServicioProgramacionAlarmas.toleranciaDisparoInminente + // (90s): an occurrence whose trigger just passed within this window + // must still be armed (and delivered ~immediately) instead of being + // skipped to the next day. Without it, a re-arm landing microseconds + // after the trigger (e.g. the periodic resync straddling the trigger + // instant while the app is foregrounded) recomputes the next + // occurrence as tomorrow and cancels the in-flight fire. + private const val IMMINENT_TOLERANCE_MILLIS = 90_000L } } diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index a6d2aae..7590270 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -167,6 +167,50 @@ void main() { expect(proxima, DateTime(2026, 5, 26, 7, 30)); }); + test('mantiene la ocurrencia diaria cuyo disparo acaba de pasar', () { + // Contract the native AlarmScheduler.computeNextTriggerMillis MUST + // mirror: a trigger that just passed within toleranciaDisparoInminente + // (90s) and has NOT been handled is kept (normalized to desde + 2s), + // never skipped to tomorrow. The native side lacking this window is + // what recomputed the next occurrence as tomorrow and cancelled the + // in-flight fire, so the alarm never rang until the screen was turned + // on. If this expectation changes, the native constant + // IMMINENT_TOLERANCE_MILLIS must change with it. + final alarma = AlarmaMusical( + id: 'a-inminente-diaria', + nombre: 'Diaria inminente', + hora: 22, + minuto: 35, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 7, 11, 22, 35, 30), + ); + + expect(proxima, DateTime(2026, 7, 11, 22, 35, 32)); + }); + + test('mantiene la ocurrencia por dias de semana recien pasada', () { + final alarma = AlarmaMusical( + id: 'a-inminente-semana', + nombre: 'Semana inminente', + hora: 22, + minuto: 35, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: const [1, 2, 3, 4, 5, 6, 7], + ); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 7, 11, 22, 35, 30), + ); + + expect(proxima, DateTime(2026, 7, 11, 22, 35, 32)); + }); + test('calcula siguiente por dias de semana despues de ejecucion', () { final alarma = AlarmaMusical( id: 'a7',