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',