fix(alarm): scope native stop to the ringing id and intercept system back
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s

Two exit-path holes found by adversarial review before the next build:

PluriWaveAlarmService.stopAlarm() never compared the requested id to
activeAlarmId, so any stop request for a DIFFERENT alarm tore down
whichever ring was active: with two alarms firing close together, the
second one's routine hide-notification call (via dismissAlarmNotification
-> ACTION_STOP) killed the first alarm mid-ring and prematurely restored
the device volume override. A mismatched id now only cancels that id's
notification and returns; null keeps full-teardown semantics for
internal/onDestroy callers.

The ringing screen never intercepted the system back gesture: a plain
route pop ran only dispose(), leaving the shared radio player ringing
with no alarm UI left anywhere to stop it. Back now routes through
PopScope into the same _detener() flow as the Stop button, guarded by a
single-exit flag so a back-press racing a button tap cannot run the
teardown twice and pop the route underneath.

Also resets the shared handler gain to 1.0 on ring exit: the fade-in
mutates the radio player's persistent volume, and exiting mid-ramp used
to leave every later radio play at the partial ramp level.
This commit is contained in:
2026-07-12 00:04:53 +02:00
parent e84a41acd4
commit 86225cbc68
3 changed files with 105 additions and 0 deletions
@@ -31,6 +31,7 @@ class _Entorno {
Future<_Entorno> _montarPantalla(
WidgetTester tester, {
int snoozeMinutos = 5,
int fadeInSegundos = 0,
// Slice 3 (fade-in dedup): existing callers rely on the radio already
// being "reproduciendo" by mount time, which cancels the fallback timer
// synchronously and leaves nothing to observe mid-handoff. Fade-in-gate
@@ -75,6 +76,7 @@ Future<_Entorno> _montarPantalla(
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: snoozeMinutos,
fadeInSegundos: fadeInSegundos,
emisora: const Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
@@ -255,6 +257,52 @@ void main() {
});
});
group('salidas del ring fuera de los botones', () {
testWidgets('el boton atras del sistema se comporta como Detener: para la '
'radio, finaliza y cierra', (tester) async {
final entorno = await _montarPantalla(tester);
// Simulate the SYSTEM back (routes through PopScope via the binding),
// not a direct Navigator.pop: a plain pop only runs dispose(), which
// leaves the shared radio player ringing with no alarm UI left to
// stop it.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(PantallaAlarmaSonando), findsNothing);
expect(
entorno.audio.pausas,
greaterThanOrEqualTo(1),
reason: 'atras debe pausar la radio como lo hace Detener',
);
expect(
entorno.android.ocultadas,
contains('ring1'),
reason: 'atras debe finalizar la ejecucion (mismo camino que Detener)',
);
});
testWidgets('salir a mitad de rampa restaura la ganancia del reproductor '
'a 1.0 para la radio normal', (tester) async {
final entorno = await _montarPantalla(tester, fadeInSegundos: 30);
// Let the ramp advance a few steps (250ms per step) so the shared
// handler gain sits at a partial value well below 1.0.
await tester.pump(const Duration(seconds: 2));
expect(entorno.audio.volumenesAplicados.last, lessThan(0.2));
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(
entorno.audio.volumenesAplicados.last,
1.0,
reason: 'la salida del ring no debe dejar la radio normal al nivel '
'parcial de la rampa',
);
});
});
group('restore de volumen de medios con dispose como unico llamador', () {
testWidgets('desmontar la pantalla sin detener ni posponer restaura el '
'volumen exactamente una vez', (tester) async {