fix(alarm): make all date math wall-clock correct across DST and timezone changes
Full time-domain audit (three shipped date bugs prompted it) found one
root cause and two latent travel defects, all now fixed:
Day-stepping used add(Duration(days: 1)), which shifts the absolute
instant by exactly 86400s — documented Dart behavior (sdk#47666), so
crossing a DST transition drifted the wall hour by +-1h permanently
for the rest of the candidate scan (verified: 2026-03-28 07:30
Europe/Madrid + "1 day" = 08:30). The native Calendar engine preserves
wall time, and the single-authority fix made the drifted Dart verdict
win. Candidates now advance by calendar reconstruction (_siguienteDia:
DateTime(y, m, d+1, hora, minuto)), the same wall-clock-preserving
semantics as Calendar.add(DAY_OF_YEAR, 1) plus AOSP DeskClock's
defensive hour/minute re-assertion, keeping both engines in agreement
through any transition.
Instant-valued fields (snoozeHasta/snoozeOrigen/proximaEjecucion/
ultimaEjecucionGestionada/creadaEn/actualizadaEn) serialized as
offset-less local ISO, so re-parsing after a device timezone change
reinterpreted the same wall fields as a different instant. They now
serialize as UTC ("Z"); reads normalize to local, and legacy
offset-less payloads parse identically — no migration. fechaUnica
stays local on purpose: it is a wall-clock date.
One-shot alarms sent fechaUnica's midnight epoch to the native side,
whose boot/travel re-arm derives the calendar day back from it in the
CURRENT zone — a westward shift rolled the date to the previous day.
The channel now anchors the date at local noon, keeping it stable
across real-world zone shifts.
Property tests lock the no-drift guarantee (400 daily / 200 weekday
iterations must all land exactly at hora:minuto — on DST-observing
dev machines this crosses real transitions), plus UTC round-trip,
legacy-payload compatibility, and wall-date preservation tests.
This commit is contained in:
@@ -239,7 +239,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
'minute': alarma.minuto,
|
||||
'scheduleType': alarma.tipoProgramacion.name,
|
||||
'weekdays': alarma.diasSemana,
|
||||
'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch,
|
||||
// Anchored at LOCAL NOON, not midnight: the native side derives the
|
||||
// calendar DAY back from this epoch in whatever timezone the device is
|
||||
// in when it re-arms (boot/travel). A midnight epoch reinterpreted in
|
||||
// a westward zone rolls to the previous day; noon keeps the intended
|
||||
// date stable across any real-world zone shift (+-11h).
|
||||
'oneShotDateMillis':
|
||||
alarma.fechaUnica == null
|
||||
? null
|
||||
: DateTime(
|
||||
alarma.fechaUnica!.year,
|
||||
alarma.fechaUnica!.month,
|
||||
alarma.fechaUnica!.day,
|
||||
12,
|
||||
).millisecondsSinceEpoch,
|
||||
'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch,
|
||||
'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch,
|
||||
'snoozeMinutes': alarma.snoozeMinutos,
|
||||
|
||||
@@ -28,7 +28,7 @@ class ServicioProgramacionAlarmas {
|
||||
? inicio
|
||||
: _sigueSiendoInminente(inicio, desde)
|
||||
? inicio
|
||||
: inicio.add(const Duration(days: 1));
|
||||
: _siguienteDia(inicio, alarma);
|
||||
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica =>
|
||||
@@ -93,7 +93,7 @@ class ServicioProgramacionAlarmas {
|
||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||
return _normalizarInminente(actual, desde);
|
||||
}
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
actual = _siguienteDia(actual, alarma);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -113,11 +113,28 @@ class ServicioProgramacionAlarmas {
|
||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||
return _normalizarInminente(actual, desde);
|
||||
}
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
actual = _siguienteDia(actual, alarma);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Advances a candidate to the SAME wall-clock time on the next calendar
|
||||
/// day. `add(Duration(days: 1))` must never be used for this: Dart
|
||||
/// Duration arithmetic shifts the absolute instant by exactly 86400s, so
|
||||
/// crossing a DST transition drifts the wall hour by +-1h PERMANENTLY for
|
||||
/// the rest of the scan (verified: 2026-03-28 07:30 Europe/Madrid +1d ->
|
||||
/// 08:30). Calendar reconstruction preserves the alarm's wall-clock time
|
||||
/// through any transition — the same semantics as the native engine's
|
||||
/// Calendar.add(DAY_OF_YEAR, 1), keeping both sides in agreement. The
|
||||
/// DateTime constructor normalizes day/month/year overflow.
|
||||
DateTime _siguienteDia(DateTime actual, AlarmaMusical alarma) => DateTime(
|
||||
actual.year,
|
||||
actual.month,
|
||||
actual.day + 1,
|
||||
alarma.hora,
|
||||
alarma.minuto,
|
||||
);
|
||||
|
||||
bool _esValida(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
|
||||
Reference in New Issue
Block a user