fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
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:
@@ -289,6 +289,40 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'detener: root-launch (canPop false) + fallo de detencion verificado NO '
|
||||
'llama a SystemNavigator.pop y deja visible el banner de retry '
|
||||
'(Finding A, escenario canonico de FSI con app muerta)',
|
||||
(tester) async {
|
||||
final env = await _buildEnv();
|
||||
addTearDown(env.dispose);
|
||||
env.android.fallaDetener = true;
|
||||
final spy = _SystemNavigatorSpy()..install();
|
||||
addTearDown(spy.uninstall);
|
||||
|
||||
await _montarComoRaiz(
|
||||
tester,
|
||||
android: env.android,
|
||||
estadoAlarmas: env.estadoAlarmas,
|
||||
radio: env.radio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget);
|
||||
expect(
|
||||
spy.popCalls,
|
||||
0,
|
||||
reason:
|
||||
'SystemNavigator.pop must not fire while the alarm is still '
|
||||
'ringing after a verified stop failure — the root-launch '
|
||||
'scenario is the one where a lost affordance is unrecoverable',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('PantallaAlarmaSonando snooze failure feedback (Phase 4)', () {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -92,7 +93,12 @@ Future<_Entorno> _montarPantalla(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SizedBox.shrink(),
|
||||
// A real Scaffold (not a bare SizedBox) is required: the
|
||||
// ScaffoldMessenger only displays a SnackBar through a currently
|
||||
// registered ScaffoldState, and the ringing screen's own Scaffold
|
||||
// pops off the tree by the time the SS-3a force-stop SnackBar shows
|
||||
// (mirrors pantalla_alarma_sonando_dismiss_guard_test.dart).
|
||||
home: const Scaffold(body: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -163,6 +169,102 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
group('detener y el force-stop de fallback', () {
|
||||
testWidgets(
|
||||
'detener fallido NO cierra la pantalla y muestra el banner de forzar '
|
||||
'detencion; forzar detencion con exito si la cierra (SS-3a/SS-3b)',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
entorno.android.fallaDetener = true;
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verified stop failure (Finding A): the alarm is still ringing, so
|
||||
// the ringing screen must stay up — dismissing here would hide the
|
||||
// only retry affordance while the native ring keeps sounding.
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget);
|
||||
expect(find.text(l10n.alarmForceStopAction), findsOneWidget);
|
||||
|
||||
// Retry via the banner's own action succeeds this time (SS-3b).
|
||||
entorno.android.fallaDetener = false;
|
||||
await tester.tap(find.text(l10n.alarmForceStopAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'detener confirmado no muestra el banner de fallo (SS-3c)',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
expect(find.text(l10n.alarmStopFailedMessage), findsNothing);
|
||||
expect(entorno.android.detencionesActivas, isNotEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'forzar detencion: invocacion superpuesta es no-op y tras un fallo '
|
||||
'el boton sigue funcionando (RES-2)',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
entorno.android.fallaDetener = true;
|
||||
await tester.tap(find.text(l10n.stopAlarmAction));
|
||||
await tester.pumpAndSettle();
|
||||
final llamadasPrevias = entorno.android.detencionesActivas.length;
|
||||
|
||||
entorno.android.detenerActivoGate = Completer<void>();
|
||||
await tester.tap(find.text(l10n.alarmForceStopAction));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(l10n.alarmForceStopAction));
|
||||
await tester.pump();
|
||||
expect(
|
||||
entorno.android.detencionesActivas.length,
|
||||
llamadasPrevias + 1,
|
||||
reason: 'la segunda invocacion superpuesta debe ser no-op',
|
||||
);
|
||||
entorno.android.detenerActivoGate!.complete();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fallo confirmado: el banner sigue y el guard debe haberse
|
||||
// reseteado para permitir un reintento.
|
||||
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
||||
entorno.android.fallaDetener = false;
|
||||
await tester.tap(find.text(l10n.alarmForceStopAction));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('reconciliacion de fin de ring externo (RES-1)', () {
|
||||
testWidgets(
|
||||
'si la alarma se registra como perdida externamente, la pantalla se '
|
||||
'auto-cierra',
|
||||
(tester) async {
|
||||
final entorno = await _montarPantalla(tester);
|
||||
|
||||
entorno.android.emitirEvento(
|
||||
EventoAlarmaAndroid(
|
||||
alarmaId: 'ring1',
|
||||
titulo: 'Despertar',
|
||||
accion: EventoAlarmaAndroid.accionMissed,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('salidas del ring fuera de los botones', () {
|
||||
testWidgets(
|
||||
'el boton atras del sistema se comporta como Detener: finaliza la '
|
||||
|
||||
Reference in New Issue
Block a user