Files
pluriwave/lib/servicios/servicio_alarmas_android.dart
FreeTLab f2528c930b 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.
2026-07-31 23:27:45 +02:00

607 lines
22 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';
/// Action reported when a fired alarm auto-silenced unattended after the
/// 10-minute bound (Decision 3), never a user-initiated stop.
static const accionMissed = 'missed';
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,
);
}
}
/// Fail-safe stop result (Decision 1). `detenido` reports whether a
/// non-no-op teardown was dispatched (never silently swallowed); `alarmaId`
/// is the id that was actually ringing, for Dart-side reconciliation.
class ResultadoDetencion {
const ResultadoDetencion({
required this.detenido,
required this.estabaSonando,
this.alarmaId,
});
final bool detenido;
final bool estabaSonando;
final String? alarmaId;
/// A thrown channel error or a missing native response is treated as a
/// failure, never as an implicit success.
static const fallo = ResultadoDetencion(
detenido: false,
estabaSonando: false,
);
factory ResultadoDetencion.fromMap(Map<Object?, Object?> map) {
return ResultadoDetencion(
detenido: map['stopped'] as bool? ?? false,
estabaSonando: map['wasRinging'] as bool? ?? false,
alarmaId: map['activeAlarmId'] as String?,
);
}
}
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,
),
);
}
}
/// 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<Object?, Object?> 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<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);
/// 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".
///
/// 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<List<FalloProgramacionNativo>> fallosNativosProgramacion();
Future<void> ocultarNotificacionAlarma(String alarmaId);
/// Notification-only dismissal (RES-1): hides the fire notification for
/// [alarmaId] WITHOUT stopping native ring audio for any alarm. Used when a
/// genuinely different alarm rings while another one is still active.
Future<void> ocultarSoloNotificacion(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
/// Synchronous companion snapshot (Decision 1): the id of the alarm
/// currently ringing natively, or null if none is.
Future<String?> alarmaSonandoId();
/// Id-agnostic fail-safe stop: silences whatever is ringing regardless of
/// which alarm the caller thinks is active, and reports a verified result.
Future<ResultadoDetencion> detenerSonidoActivo();
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
/// Opens the system's per-app notification settings screen directly
/// (`ACTION_APP_NOTIFICATION_SETTINGS`), as opposed to
/// [solicitarPermisoNotificaciones]'s runtime permission popup. Used from
/// the reliability diagnostics screen: once a user is troubleshooting an
/// alarm that already failed, sending them straight to Settings is more
/// robust than a runtime dialog the OS may refuse to show again after a
/// prior denial.
Future<bool> abrirConfiguracionNotificaciones();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> 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<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
}
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,
// Title of the storage root that RecordingsDocumentsProvider publishes
// to the system file manager, so the recordings folder is browsable
// without leaving app-private storage.
'recordingsRootTitle': l10n.recordingsFolderTitle,
'missedTitle': l10n.alarmMissedNotificationTitle,
'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText),
});
} 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}');
}
/// Same sentinel-swap approach as [_plantillaMinutos], but for a
/// `{String} -> String` message: swaps a unique sentinel token back for the
/// literal `{name}` placeholder Kotlin fills in at fire time.
static String _plantillaNombre(String Function(Object) traducir) {
const sentinel = 'PLURIWAVE_NAME_SENTINEL';
return traducir(sentinel).replaceFirst(sentinel, '{name}');
}
@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> ocultarSoloNotificacion(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotificationOnly', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<String?> alarmaSonandoId() async {
try {
return await _channel.invokeMethod<String>('getActiveRingingAlarmId');
} catch (e) {
// Fail-toward-silence (Finding 2): a query failure must NOT be
// mistaken for "nothing is ringing" by callers like
// EstadoAlarmas._detenerSiEstaSonando, which would otherwise skip the
// stop entirely on a genuinely ringing alarm. Rethrow so the caller can
// fall back to the id-scoped legacy stop instead.
debugPrint('[PluriWave][alarmas] alarmaSonandoId ERROR $e');
rethrow;
}
}
@override
Future<ResultadoDetencion> detenerSonidoActivo() async {
try {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'stopActiveAlarm',
);
if (raw == null) return ResultadoDetencion.fallo;
return ResultadoDetencion.fromMap(raw);
} catch (e) {
debugPrint('[PluriWave][alarmas] detenerSonidoActivo ERROR $e');
return ResultadoDetencion.fallo;
}
}
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
try {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.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
// of the native side simply has no such channel method.
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
return const [];
}
}
@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<bool> abrirConfiguracionNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'openNotificationSettings',
);
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();
}
@override
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
.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);
}
}
});
}
}