fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s

Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.

Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
  post-teardown state (companion instance + synchronous stopEverything +
  activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
  snooze, missed, onDestroy, startForeground failure) funnels through one
  stopEverything() covering audio, wakelock, notification, foreground
  state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
  FIRED->MISSED transition with a localized missed-alarm notification;
  repeating alarms keep their native rearm, deleted alarms never produce
  ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
  guard) and boot-time stale cleanup; firing records cleared on every
  refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
  so UI-level dedup can never kill a live ring's audio.

Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
  it; on native-query failure the stop falls back toward silence via the
  id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
  dismiss-by-design on success, but on a verified failure it stays up
  with a persistent force-stop banner (guarded against double-dismiss)
  and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).

550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
This commit is contained in:
Javier Bautista Fernández
2026-07-22 23:52:36 +02:00
parent 0f9a6a1719
commit 29f7d54e85
50 changed files with 2461 additions and 24 deletions
@@ -27,6 +27,10 @@ class EventoAlarmaAndroid {
/// 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;
@@ -108,6 +112,36 @@ class DiagnosticoAlarmasAndroid {
}
}
/// 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,
@@ -137,7 +171,20 @@ abstract class PuertoAlarmasAndroid {
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
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();
@@ -195,6 +242,8 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
'missedTitle': l10n.alarmMissedNotificationTitle,
'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText),
});
} catch (e) {
debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e');
@@ -209,6 +258,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
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;
@@ -286,10 +343,43 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
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<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(