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
+8
View File
@@ -363,6 +363,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
// Must run FIRST, before any early return: the override needs to be in
// effect for the whole ring, including fallback-WAV-only alarms that
// never reach the station-playback branch below (Requirement:
// Ring-scoped device-volume override).
await context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(
1.0,
);
if (!mounted) return;
final emisora = alarma.emisora;
if (emisora == null) return;
final radio = context.read<EstadoRadio>();
@@ -40,6 +40,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
bool _fallbackActivo = false;
bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
bool _volumenMediaRestaurado = false;
@override
void initState() {
@@ -138,6 +139,23 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
);
}
/// Restores the ring-scoped `STREAM_MUSIC` override at most once per
/// screen instance (Requirement: Ring-scoped device-volume override,
/// Scenario "Restore is idempotent across double-exit paths"). Both
/// `_silenciarAudio` (dismiss/snooze) and `dispose` call this; the guard
/// here plus the idempotent Kotlin-side restore together keep any
/// exit-path ordering safe. Failures never propagate — a broken restore
/// must not block dismiss/snooze.
Future<void> _restaurarVolumenMediaUnaVez() async {
if (_volumenMediaRestaurado) return;
_volumenMediaRestaurado = true;
try {
await context.read<EstadoAlarmas>().android.restaurarVolumenMedia();
} catch (e) {
debugPrint('[PluriWave][alarmas] restaurar volumen media fallo: $e');
}
}
/// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart
/// fallback player and fade timer MUST die before the alarm is re-programmed
/// natively, otherwise the local fallback keeps looping after snooze.
@@ -210,6 +228,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) {
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
}
await _restaurarVolumenMediaUnaVez();
}
/// Dismisses the alarm screen safely in both live-app and dead-app states.
@@ -240,6 +259,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fadeInTimer?.cancel();
_estadoSub?.cancel();
_fallbackPlayer.dispose();
unawaited(_restaurarVolumenMediaUnaVez());
super.dispose();
}
@@ -143,6 +143,19 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<void> confirmarAudioFlutter(String alarmaId);
/// Forces `STREAM_MUSIC` to the fixed audible reference level for the
/// duration of an alarm ring so the alarm cannot be silenced by a device
/// media volume of 0 (Requirement: Ring-scoped device-volume override).
/// [fraccion] is reserved for future tuning; the current native
/// implementation always targets the device max regardless of its value.
Future<void> forzarVolumenMediaParaAlarma(double fraccion);
/// Restores `STREAM_MUSIC` to the value captured by
/// [forzarVolumenMediaParaAlarma]. Idempotent: safe to call even when no
/// override is active or it was already restored.
Future<void> restaurarVolumenMedia();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
@@ -281,6 +294,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
Future<void> confirmarAudioFlutter(String alarmaId) =>
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
@override
Future<void> forzarVolumenMediaParaAlarma(double fraccion) =>
_logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion});
@override
Future<void> restaurarVolumenMedia() =>
_logAndInvokeVoid('restoreMediaVolume', {});
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(