feat(alarm): make the ring immune to device media volume
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s

The alarm's steady-state audio runs on the Flutter media-stream
player after the native handoff, so device volume 0 silenced it
entirely. The ring now forces STREAM_MUSIC to an audible reference:
Dart requests the override before pre-starting alarm audio (fallback
WAV included), Kotlin captures the current volume once and restores
it idempotently on every exit path (dismiss, snooze, dispose), with
a native best-effort backstop in service teardown.

The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing:
confirmFlutterAudio marks the handoff before triggering the native
stop, so the backstop cannot restore the volume mid-ring right as the
Flutter player takes over (that would re-silence the alarm at volume
0). The flag resets at every ring start; Flutter process death after
handoff remains a documented best-effort gap.

The alarm's perceived loudness keeps ramping 5% to the configured
volume through the player as before; normal radio playback and call
ducking never touch the override.

Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
This commit is contained in:
2026-07-11 09:15:37 +02:00
parent 251d3fd3cd
commit acd903d9a8
9 changed files with 440 additions and 21 deletions
@@ -356,4 +356,91 @@ void main() {
},
);
});
group('PantallaAlarmaSonando media-volume override restore (Slice 2)', () {
testWidgets('detener: restaura el volumen de medios exactamente una vez', (
tester,
) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(env.android.volumenRestaurado, 1);
});
testWidgets('posponer: restaura el volumen de medios exactamente una vez', (
tester,
) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
await tester.pumpAndSettle();
expect(env.android.volumenRestaurado, 1);
});
testWidgets(
'detener: _silenciarAudio y dispose en secuencia no duplican la '
'restauracion (idempotencia en el call-site Dart)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
// Proves dispose() really ran too (both call sites fired) — the
// idempotence guard must still cap the counter at 1.
expect(find.byType(PantallaAlarmaSonando), findsNothing);
expect(
env.android.volumenRestaurado,
1,
reason:
'restaurarVolumenMedia debe invocarse a lo sumo una vez por '
'pantalla, aunque _silenciarAudio (dentro de _detener) y '
'dispose() ambos lo llamen',
);
},
);
});
group('EstadoRadio reproduccion normal nunca dispara el override de volumen '
'(Slice 2, guardia de regresion)', () {
test('ciclo de reproducir/pausar fuera de una alarma no toca el canal '
'de volumen de medios', () async {
final env = await _buildEnv();
addTearDown(env.dispose);
final emisora = env.estadoAlarmas.alarmas.single.emisora!;
await env.radio.reproducir(emisora);
await env.radio.audio.pausar();
expect(env.android.volumenForzado, isEmpty);
expect(env.android.volumenRestaurado, 0);
});
});
}