Files
pluriwave/lib/servicios/servicio_alarmas_android.dart
T
FreeTLab d8e67a5204
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m46s
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.
2026-07-12 23:32:32 +02:00

410 lines
14 KiB
Dart

import 'dart:async';
import 'dart:ui' show Locale;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
class EventoAlarmaAndroid {
const EventoAlarmaAndroid({
required this.alarmaId,
required this.titulo,
required this.accion,
this.triggerAtMillis = 0,
this.occurrenceAtMillis = 0,
this.snoozeMinutes = 5,
this.snoozeUntilMillis = 0,
});
/// Action reported when the native service snoozed an alarm by itself
/// (notification "Posponer" while the app may be backgrounded/killed).
static const accionSnoozed = 'snoozed';
/// Action reported when a pending snooze was cancelled natively from the
/// countdown notification ("Detener" while the app may be killed).
static const accionSnoozeCancelled = 'snoozeCancelled';
final String alarmaId;
final String titulo;
final String accion;
final int triggerAtMillis;
final int occurrenceAtMillis;
final int snoozeMinutes;
final int snoozeUntilMillis;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid(
alarmaId: map['alarmId'] as String? ?? '',
titulo: map['alarmTitle'] as String? ?? 'PluriWave',
accion: map['alarmAction'] as String? ?? '',
triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0,
occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0,
snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5,
snoozeUntilMillis: (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
);
}
}
/// Active native snooze persisted by `AlarmScheduler` (Kotlin). Used on cold
/// start so Flutter (single source of truth) can import snoozes performed
/// while the engine was dead.
class EstadoSnoozeNativo {
const EstadoSnoozeNativo({
required this.alarmaId,
required this.snoozeHasta,
required this.snoozeOrigen,
});
final String alarmaId;
final DateTime snoozeHasta;
final DateTime snoozeOrigen;
factory EstadoSnoozeNativo.fromMap(Map<Object?, Object?> map) {
return EstadoSnoozeNativo(
alarmaId: map['alarmId'] as String? ?? '',
snoozeHasta: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
),
snoozeOrigen: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeOriginMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
class DiagnosticoAlarmasAndroid {
const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas,
required this.notificacionesPermitidas,
required this.puedeUsarPantallaCompleta,
required this.ignoraOptimizacionBateria,
required this.alarmasNativasPendientes,
required this.fabricante,
required this.versionSdk,
});
final bool puedeProgramarExactas;
final bool notificacionesPermitidas;
final bool puedeUsarPantallaCompleta;
final bool ignoraOptimizacionBateria;
final int alarmasNativasPendientes;
final String fabricante;
final int versionSdk;
factory DiagnosticoAlarmasAndroid.fromMap(Map<Object?, Object?> map) {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true,
ignoraOptimizacionBateria:
map['isIgnoringBatteryOptimizations'] as bool? ?? true,
alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0,
fabricante: map['manufacturer'] as String? ?? 'Android',
versionSdk: map['sdkInt'] as int? ?? 0,
);
}
}
class EjecucionAlarmaNativa {
const EjecucionAlarmaNativa({
required this.alarmaId,
required this.gestionadaEn,
});
final String alarmaId;
final DateTime gestionadaEn;
factory EjecucionAlarmaNativa.fromMap(Map<Object?, Object?> map) {
return EjecucionAlarmaNativa(
alarmaId: map['alarmId'] as String? ?? '',
gestionadaEn: DateTime.fromMillisecondsSinceEpoch(
(map['handledAtMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
/// Provides the UI localizations used to localize the alarm/station names
/// sent to the native scheduler.
void configurarLocalizaciones(AppLocalizations l10n);
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> ocultarNotificacionAlarma(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel {
_instalarHandler();
}
final MethodChannel _channel;
// Instance state (S3-R2): each bridge owns its controller and l10n so
// independent instances never share events through globals.
final _eventosController = StreamController<EventoAlarmaAndroid>.broadcast();
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
@override
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
// Push every localized notification/channel/chooser string to the native
// side so it can build localized notifications even when the Flutter engine
// is dead (alarm fired from a killed app). Fire-and-forget; runs once per
// locale change (callers guard against per-rebuild churn).
unawaited(_enviarTextosNotificacion(l10n));
}
Future<void> _enviarTextosNotificacion(AppLocalizations l10n) async {
try {
await _channel.invokeMethod<void>('setNotificationStrings', {
'ringTitle': l10n.alarmRingingNotificationTitle,
'snoozeLabel': l10n.snoozeAction,
'stopLabel': l10n.stopAlarmAction,
'skipLabel': l10n.skipNextAction,
'snoozeAgainLabel': l10n.snoozeAgainAction,
'fireChannelName': l10n.alarmFireChannelName,
'fireChannelDescription': l10n.alarmFireChannelDescription,
'preNoticeChannelName': l10n.alarmPreNoticeChannelName,
'preNoticeChannelDescription': l10n.alarmPreNoticeChannelDescription,
'preNoticeTemplate': _plantillaMinutos(l10n.preNoticeCountdown),
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
});
} catch (e) {
debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e');
}
}
/// Turns a localized `{int} -> String` message into a template with a literal
/// `{minutes}` placeholder for Kotlin to fill at fire time: it calls the
/// message with a unique sentinel and swaps the sentinel back for `{minutes}`.
static String _plantillaMinutos(String Function(int) traducir) {
const sentinel = 42424242;
return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}');
}
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaProgramable;
if (proxima == null || !alarma.activa) {
debugPrint(
'[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima',
);
await cancelar(alarma.id);
return;
}
debugPrint(
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
);
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
'id': alarma.id,
'title': localizedAlarmName(_textos, alarma.nombre),
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
alarma.snoozeHasta == null
? proxima
.subtract(const Duration(minutes: 30))
.millisecondsSinceEpoch
: 0,
'hour': alarma.hora,
'minute': alarma.minuto,
'scheduleType': alarma.tipoProgramacion.name,
'weekdays': alarma.diasSemana,
// 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,
'lastHandledAtMillis':
alarma.ultimaEjecucionGestionada?.millisecondsSinceEpoch,
'soundOnVacation': alarma.sonarEnVacaciones,
'stationName':
alarma.emisora == null
? null
: localizedStationName(_textos, alarma.emisora!.nombre),
'stationUrl': alarma.emisora?.url,
'fallbackStationName':
alarma.emisoraFallback == null
? null
: localizedStationName(_textos, alarma.emisoraFallback!.nombre),
'fallbackStationUrl': alarma.emisoraFallback?.url,
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
'fadeInSegundos': alarma.fadeInSegundos,
});
if (programada != true) {
throw StateError(_textos.androidExactAlarmScheduleError);
}
}
@override
Future<void> cancelar(String alarmaId) =>
_logAndInvokeVoid('cancelAlarm', {'id': alarmaId});
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'requestPostNotificationsPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoPantallaCompleta() async {
final abierto = await _channel.invokeMethod<bool>(
'requestFullScreenIntentPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarExencionBateria() async {
final abierto = await _channel.invokeMethod<bool>(
'requestIgnoreBatteryOptimizations',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'diagnostics',
);
final diag = DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
debugPrint(
'[PluriWave][alarmas] diagnostico exactas=${diag.puedeProgramarExactas} notificaciones=${diag.notificacionesPermitidas} sdk=${diag.versionSdk} fabricante=${diag.fabricante}',
);
return diag;
}
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'getInitialAlarmIntent',
);
if (raw == null || raw.isEmpty) return null;
final evento = EventoAlarmaAndroid.fromMap(raw);
debugPrint(
'[PluriWave][alarmas] evento inicial id=${evento.alarmaId} accion=${evento.accion}',
);
return evento.alarmaId.isEmpty ? null : evento;
}
@override
Future<List<EjecucionAlarmaNativa>>
obtenerEjecucionesNativasGestionadas() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getHandledAlarmOccurrences',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EjecucionAlarmaNativa.fromMap)
.where(
(evento) =>
evento.alarmaId.isNotEmpty &&
evento.gestionadaEn.millisecondsSinceEpoch > 0,
)
.toList();
}
@override
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSnoozeState',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EstadoSnoozeNativo.fromMap)
.where(
(estado) =>
estado.alarmaId.isNotEmpty &&
estado.snoozeHasta.millisecondsSinceEpoch > 0,
)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
}
// Installed once per instance from the constructor. Creating a second
// instance over the SAME channel re-binds the platform handler to the
// newest instance (production has exactly one instance per channel).
void _instalarHandler() {
_channel.setMethodCallHandler((call) async {
if (call.method != 'alarmFired') return;
final args = call.arguments;
if (args is Map) {
final evento = EventoAlarmaAndroid.fromMap(args);
if (evento.alarmaId.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] evento nativo id=${evento.alarmaId} accion=${evento.accion}',
);
_eventosController.add(evento);
}
}
});
}
}