From acd903d9a8bd443daab7a2c0ce2e81e83469fc17 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 11 Jul 2026 09:15:37 +0200 Subject: [PATCH] feat(alarm): make the ring immune to device media volume 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). --- .../es/freetimelab/pluriwave/MainActivity.kt | 117 ++++++++++++++++++ .../pluriwave/PluriWaveAlarmService.kt | 52 ++++++++ lib/app.dart | 8 ++ lib/pantallas/pantalla_alarma_sonando.dart | 20 +++ lib/servicios/servicio_alarmas_android.dart | 21 ++++ .../alarm-volume-ramp-restore/tasks.md | 110 ++++++++++++---- test/helpers/fakes_alarmas.dart | 18 +++ ...lla_alarma_sonando_dismiss_guard_test.dart | 87 +++++++++++++ .../servicio_alarmas_android_test.dart | 28 +++++ 9 files changed, 440 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 9bc8240..c6ec656 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -159,6 +159,14 @@ class MainActivity : AudioServiceActivity() { if (id == null) { result.error("INVALID_ALARM", "Missing alarm id", null) } else { + // Mark the handoff BEFORE triggering the native stop so + // PluriWaveAlarmService.stopAlarm() -- reached via the + // identical ACTION_STOP path a real dismiss uses -- can + // tell "Flutter took over the ring" apart from a true + // ring exit and skip its volume-restore backstop + // accordingly (Requirement: Ring-scoped device-volume + // override, restore only when the ring ends). + PluriWaveAlarmService.flutterOwnsRing = true PluriWaveAlarmService.stop(this, id) result.success(null) } @@ -217,6 +225,17 @@ class MainActivity : AudioServiceActivity() { } result.success(null) } + "overrideMediaVolumeForRing" -> { + val fraction = call.argument("fraction")?.toFloat() ?: 1.0f + Log.d(tag, "alarm.channel overrideMediaVolumeForRing fraction=$fraction") + overrideMediaVolumeForRing() + result.success(null) + } + "restoreMediaVolume" -> { + Log.d(tag, "alarm.channel restoreMediaVolume") + restoreMediaVolume() + result.success(null) + } else -> result.notImplemented() } } @@ -288,6 +307,64 @@ class MainActivity : AudioServiceActivity() { ) } + // ------------------------------------------------------------------------- + // Ring-scoped media-volume override (Requirement: Ring-scoped device-volume + // override). Forces STREAM_MUSIC to an audible reference level while an + // alarm rings so the Flutter media-stream player is never silenced by a + // device media volume of 0, then restores the captured value on exit. + // ------------------------------------------------------------------------- + + /** + * Captures the current STREAM_MUSIC volume and raises it to the fixed + * audible reference level (device max) so the alarm cannot be silenced + * by device volume 0. Idempotent: a second call while already overridden + * is a no-op so the originally captured value is never clobbered. + */ + private fun overrideMediaVolumeForRing() { + if (mediaVolumeOverridden) { + Log.d(tag, "alarm.channel overrideMediaVolumeForRing skipped (already overridden)") + return + } + try { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, max, 0) + capturedMediaVolume = current + mediaVolumeOverridden = true + Log.d(tag, "alarm.channel overrideMediaVolumeForRing captured=$current max=$max") + } catch (error: Throwable) { + Log.e(tag, "alarm.channel overrideMediaVolumeForRing failed", error) + } + } + + /** + * Restores STREAM_MUSIC to the value captured by + * [overrideMediaVolumeForRing]. Idempotent: a no-op when no override is + * active, so double-exit paths (e.g. dismiss's `_silenciarAudio` + + * `dispose`, or the native backstop firing after Dart already restored) + * never throw and never re-apply a stale value. + */ + private fun restoreMediaVolume() { + if (!mediaVolumeOverridden) { + Log.d(tag, "alarm.channel restoreMediaVolume skipped (not overridden)") + return + } + val target = capturedMediaVolume + try { + if (target != null) { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) + } + Log.d(tag, "alarm.channel restoreMediaVolume restored=$target") + } catch (error: Throwable) { + Log.e(tag, "alarm.channel restoreMediaVolume failed", error) + } finally { + mediaVolumeOverridden = false + capturedMediaVolume = null + } + } + private fun requestExactAlarmPermission(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager @@ -828,6 +905,18 @@ class MainActivity : AudioServiceActivity() { @Volatile private var activeInstance: MainActivity? = null + /** + * Ring-scoped STREAM_MUSIC override state (Requirement: Ring-scoped + * device-volume override). @Volatile, not persisted: does NOT survive + * process death — documented residual gap, best-effort restore only + * via [restoreMediaVolumeBestEffort]. + */ + @Volatile + private var mediaVolumeOverridden: Boolean = false + + @Volatile + private var capturedMediaVolume: Int? = null + /** * Bridge for components without an activity (PluriWaveAlarmService): * forwards alarm events through the existing alarmFired MethodChannel @@ -844,5 +933,33 @@ class MainActivity : AudioServiceActivity() { activity.alarmMethodChannel?.invokeMethod("alarmFired", payload) } } + + /** + * Best-effort backstop for PluriWaveAlarmService teardown paths + * (stopAlarm/onDestroy): restores the ring-scoped media volume + * override when the Flutter engine/activity is alive. No-op and + * never throws when the activity is dead (Decision: Native backstop). + * + * Callers guard this: PluriWaveAlarmService.stopAlarm()/onDestroy() + * only invoke it when PluriWaveAlarmService.flutterOwnsRing is + * false, since stopAlarm() also runs at the native-to-Flutter + * handoff (confirmFlutterAudio) and this method must never restore + * mid-ring -- see [PluriWaveAlarmService.flutterOwnsRing]. The + * idempotent guard in restoreMediaVolume() remains a secondary + * safety net for legitimate double-calls at a true ring end (e.g. + * stopAlarm() then onDestroy() for the same exit). + */ + fun restoreMediaVolumeBestEffort() { + val activity = activeInstance + if (activity == null) { + Log.d(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort skipped (engine dead)") + return + } + try { + activity.restoreMediaVolume() + } catch (error: Throwable) { + Log.e(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort failed", error) + } + } } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index 20a8b33..309ea20 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -90,6 +90,11 @@ class PluriWaveAlarmService : Service() { return } activeAlarmId = alarmId + // Reset for this new ring: flutterOwnsRing must never carry a stale + // `true` forward from a PREVIOUS ring's confirmFlutterAudio handoff, + // or this ring's own backstop restore would be wrongly suppressed + // (Requirement: Ring-scoped device-volume override). + flutterOwnsRing = false val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave" val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME) @@ -366,6 +371,19 @@ class PluriWaveAlarmService : Service() { player = null activeAlarmId = null releaseWakeLock() + // Best-effort backstop restore (Requirement: Ring-scoped device-volume + // override; Scenario "App killed mid-ring"). Skipped when Flutter has + // taken over the ring (flutterOwnsRing == true): stopAlarm() also runs + // at the native-to-Flutter handoff (confirmFlutterAudio), which is NOT + // a ring exit, and restoring here would silence the Flutter-driven + // remainder of the ring. From handoff onward Dart owns restore via + // _silenciarAudio()/dispose() plus the idempotent restoreMediaVolume() + // guard. Native-only exits (fire-notification STOP, real snooze, + // teardown before any handoff) keep this backstop, since + // flutterOwnsRing is still false for those. + if (!flutterOwnsRing) { + runCatching { MainActivity.restoreMediaVolumeBestEffort() } + } if (alarmId != null) { NotificationManagerCompat.from(this).cancel( PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId) @@ -500,6 +518,16 @@ class PluriWaveAlarmService : Service() { override fun onDestroy() { stopAlarm(activeAlarmId) + // Defensive-in-depth: covers any onDestroy path that could ever + // bypass stopAlarm() directly (e.g. abrupt service teardown). + // Idempotent, so redundant with the call already inside stopAlarm(). + // Same flutterOwnsRing guard as stopAlarm() -- skip when Flutter has + // taken over the ring, so an onDestroy() racing in after handoff + // (e.g. the system reclaiming the now-idle service) cannot restore + // mid-ring either. + if (!flutterOwnsRing) { + runCatching { MainActivity.restoreMediaVolumeBestEffort() } + } super.onDestroy() } @@ -518,6 +546,30 @@ class PluriWaveAlarmService : Service() { private const val FADE_IN_STEP_MILLIS = 250L private const val FADE_IN_START_FRACTION = 0.05f + /** + * Set by [MainActivity]'s `confirmFlutterAudio` handler immediately + * BEFORE it triggers the native stop, to distinguish "Flutter took + * over the ring" (native-to-Flutter handoff, not a ring exit) from a + * true ring exit (dismiss/snooze/service teardown). [stopAlarm] and + * [onDestroy] read this to skip the best-effort volume-restore + * backstop during handoff -- restoring here would silence the + * Flutter-driven remainder of the ring (Requirement: Ring-scoped + * device-volume override, restore only on true ring end). From + * handoff onward Dart owns restore via `_silenciarAudio()`/ + * `dispose()` and the idempotent `restoreMediaVolume()` guard on + * [MainActivity]. Reset to `false` at the start of every new ring in + * [startAlarm] so a stale `true` left over from a PREVIOUS ring can + * never suppress the CURRENT ring's backstop. + * + * Accepted residual gap: if the Flutter process dies AFTER handoff + * (flag already `true`) but BEFORE Dart's own restore runs, no + * restorer fires at all -- same class of accepted gap as the + * pre-handoff process-death residual already documented on + * [MainActivity.restoreMediaVolumeBestEffort]. + */ + @Volatile + var flutterOwnsRing: Boolean = false + fun start(context: Context, source: Intent) { ensureChannel(context) val intent = Intent(context, PluriWaveAlarmService::class.java).apply { diff --git a/lib/app.dart b/lib/app.dart index 5c507dd..ca77a3d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -363,6 +363,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } Future _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().android.forzarVolumenMediaParaAlarma( + 1.0, + ); + if (!mounted) return; final emisora = alarma.emisora; if (emisora == null) return; final radio = context.read(); diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 3c2ad43..786da9a 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -40,6 +40,7 @@ class _PantallaAlarmaSonandoState extends State { bool _fallbackActivo = false; bool _radioIntentada = false; bool _audioFlutterConfirmado = false; + bool _volumenMediaRestaurado = false; @override void initState() { @@ -138,6 +139,23 @@ class _PantallaAlarmaSonandoState extends State { ); } + /// 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 _restaurarVolumenMediaUnaVez() async { + if (_volumenMediaRestaurado) return; + _volumenMediaRestaurado = true; + try { + await context.read().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 { } 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 { _fadeInTimer?.cancel(); _estadoSub?.cancel(); _fallbackPlayer.dispose(); + unawaited(_restaurarVolumenMediaUnaVez()); super.dispose(); } diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index c686727..1ec4cdf 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -143,6 +143,19 @@ abstract class PuertoAlarmasAndroid { Future solicitarPermisoPantallaCompleta(); Future solicitarExencionBateria(); Future 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 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 restaurarVolumenMedia(); + Future diagnostico(); Future obtenerEventoInicial(); Future> obtenerEjecucionesNativasGestionadas(); @@ -281,6 +294,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { Future confirmarAudioFlutter(String alarmaId) => _logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId}); + @override + Future forzarVolumenMediaParaAlarma(double fraccion) => + _logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion}); + + @override + Future restaurarVolumenMedia() => + _logAndInvokeVoid('restoreMediaVolume', {}); + @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( diff --git a/openspec/changes/alarm-volume-ramp-restore/tasks.md b/openspec/changes/alarm-volume-ramp-restore/tasks.md index e62f394..4229819 100644 --- a/openspec/changes/alarm-volume-ramp-restore/tasks.md +++ b/openspec/changes/alarm-volume-ramp-restore/tasks.md @@ -47,30 +47,98 @@ Chain strategy: pending ## Phase 2: Ring-Scoped Volume Override — Kotlin Channel Methods (Slice 2, code-inspection only) -- [ ] 2.1 In `MainActivity.kt`, add `@Volatile` companion-scoped state: `mediaVolumeOverridden: Boolean` and `capturedMediaVolume: Int?` to track ring-scoped override without surviving process death (documented residual gap). -- [ ] 2.2 In `MainActivity.kt`'s `alarm_scheduler` `when (call.method)` block (near L79-218), add `"overrideMediaVolumeForRing"` case: capture current `AudioManager.STREAM_MUSIC` volume into `capturedMediaVolume` (only if not already overridden — idempotent guard), then `setStreamVolume(STREAM_MUSIC, getStreamMaxVolume(STREAM_MUSIC), 0)` (flag `0` = no `FLAG_SHOW_UI`, no slider flash). Set `mediaVolumeOverridden = true`. `fraction` arg accepted but unused (reserved, default `1.0` = max reference level, per design). -- [ ] 2.3 In the same `when` block, add `"restoreMediaVolume"` case: no-op if `mediaVolumeOverridden == false` (idempotent guard); otherwise `setStreamVolume(STREAM_MUSIC, capturedMediaVolume, 0)`, then clear `mediaVolumeOverridden = false` and `capturedMediaVolume = null`. -- [ ] 2.4 Add a public `restoreMediaVolumeBestEffort()` method on `MainActivity` (or companion) that `PluriWaveAlarmService` can call as a backstop when the engine is alive. -- [ ] 2.5 In `PluriWaveAlarmService.kt`'s `stopAlarm()` (L356-381) and `onDestroy()` (L501-504), call the best-effort restore before/alongside existing teardown, guarded so it never throws if the engine/activity is unavailable. -- [ ] 2.6 Static check: `rg 'overrideMediaVolumeForRing|restoreMediaVolume' android/.../MainActivity.kt` shows both channel cases present. -- [ ] 2.7 Static check: `rg 'mediaVolumeOverridden' android/.../MainActivity.kt` shows the guard read in BOTH the override and restore branches (idempotence, Requirement: Ring-scoped device-volume override, Scenario "Restore is idempotent across double-exit paths"). -- [ ] 2.8 Static check: `rg 'restoreMediaVolumeBestEffort' android/.../PluriWaveAlarmService.kt` shows it called from both `stopAlarm` and `onDestroy`. -- [ ] 2.9 `flutter analyze` — confirm no Kotlin/lint regressions. +- [x] 2.1 In `MainActivity.kt`, add `@Volatile` companion-scoped state: `mediaVolumeOverridden: Boolean` and `capturedMediaVolume: Int?` to track ring-scoped override without surviving process death (documented residual gap). +- [x] 2.2 In `MainActivity.kt`'s `alarm_scheduler` `when (call.method)` block (near L79-218), add `"overrideMediaVolumeForRing"` case: capture current `AudioManager.STREAM_MUSIC` volume into `capturedMediaVolume` (only if not already overridden — idempotent guard), then `setStreamVolume(STREAM_MUSIC, getStreamMaxVolume(STREAM_MUSIC), 0)` (flag `0` = no `FLAG_SHOW_UI`, no slider flash). Set `mediaVolumeOverridden = true`. `fraction` arg accepted but unused (reserved, default `1.0` = max reference level, per design). +- [x] 2.3 In the same `when` block, add `"restoreMediaVolume"` case: no-op if `mediaVolumeOverridden == false` (idempotent guard); otherwise `setStreamVolume(STREAM_MUSIC, capturedMediaVolume, 0)`, then clear `mediaVolumeOverridden = false` and `capturedMediaVolume = null`. +- [x] 2.4 Add a public `restoreMediaVolumeBestEffort()` method on `MainActivity` (or companion) that `PluriWaveAlarmService` can call as a backstop when the engine is alive. +- [x] 2.5 In `PluriWaveAlarmService.kt`'s `stopAlarm()` (L356-381) and `onDestroy()` (L501-504), call the best-effort restore before/alongside existing teardown, guarded so it never throws if the engine/activity is unavailable. + + > **RISK NOTE (sdd-apply, 2026-07-11):** `stopAlarm()` also fires at the native-to-Flutter handoff + > (`confirmFlutterAudio` channel case -> `PluriWaveAlarmService.stop()` -> `stopAlarm()`), not only + > at a true dismiss/snooze ring exit — `stopAlarm()`'s caller has no way to distinguish "handoff" + > from "real exit" (both `stopNativeAlarmSound` and `confirmFlutterAudio` call the identical + > `PluriWaveAlarmService.stop(this, id)`). Implemented exactly as specified (design #2310 + this + > task both call for wiring both `stopAlarm()`/`onDestroy()`), but this means the best-effort + > restore backstop COULD fire mid-ring at the handoff moment, restoring the original (possibly + > zero) device volume right as the Flutter player takes over — which would silence the + > Flutter-driven remainder of the ring and contradict Scenario "Alarm is audible when device media + > volume is 0". Not verifiable without an emulator (Kotlin is code-inspection-only, `flutter + > build`/gradle forbidden this phase). Phase 5 QA 5.1 is the exact scenario that will surface this + > if it manifests — treat as the primary manual QA risk for this change, and flag to the + > human/design owner before merge. + + > **FIX (sdd-apply, 2026-07-11):** Risk #1 resolved. Added a `@Volatile` companion flag + > `PluriWaveAlarmService.flutterOwnsRing` (default `false`, declared alongside the service's other + > companion constants). `MainActivity.kt`'s `confirmFlutterAudio` handler sets it to `true` + > immediately BEFORE calling `PluriWaveAlarmService.stop(this, id)` (the handoff trigger) — the + > flag is now visible by the time the resulting `ACTION_STOP` intent reaches `stopAlarm()`. + > `stopAlarm()` and `onDestroy()` now wrap the `restoreMediaVolumeBestEffort()` call in + > `if (!flutterOwnsRing)`, so the backstop no longer fires at the handoff moment — restore + > ownership passes cleanly to Dart's `_silenciarAudio()`/`dispose()` path (already wired, Phase 3) + > for the remainder of the ring. Native-only exits (fire-notification STOP, real snooze, teardown + > before any handoff) are unaffected — `flutterOwnsRing` stays `false` there, so the backstop + > still fires exactly as before. The flag resets to `false` at the top of `startAlarm()` + > (immediately after the `activeAlarmId` re-entrancy guard passes) so a stale `true` left over + > from a PREVIOUS ring's handoff can never suppress the CURRENT ring's backstop. New accepted + > residual, documented inline on the flag: if the Flutter process dies AFTER handoff (flag already + > `true`) but BEFORE Dart's own restore runs, no restorer fires — same class of gap as the + > pre-handoff process-death residual already documented on `restoreMediaVolumeBestEffort()`. + > Verified via `rg 'flutterOwnsRing'` (flag declared in `PluriWaveAlarmService`'s companion; set + > in `MainActivity.kt`'s `confirmFlutterAudio` case; read-guarded in both `stopAlarm()` and + > `onDestroy()`; reset in `startAlarm()`) and `flutter analyze` (0 issues, Dart untouched, no + > `flutter build`/gradle run). Only `PluriWaveAlarmService.kt` and `MainActivity.kt` changed for + > this fix. Phase 5 QA 5.1 remains the recommended on-device confirmation — code inspection cannot + > fully substitute for a real handoff-timing test. + +- [x] 2.6 Static check: `rg 'overrideMediaVolumeForRing|restoreMediaVolume' android/.../MainActivity.kt` shows both channel cases present. +- [x] 2.7 Static check: `rg 'mediaVolumeOverridden' android/.../MainActivity.kt` shows the guard read in BOTH the override and restore branches (idempotence, Requirement: Ring-scoped device-volume override, Scenario "Restore is idempotent across double-exit paths"). +- [x] 2.8 Static check: `rg 'restoreMediaVolumeBestEffort' android/.../PluriWaveAlarmService.kt` shows it called from both `stopAlarm` and `onDestroy`. +- [x] 2.9 `flutter analyze` — confirm no Kotlin/lint regressions. (0 issues.) ## Phase 3: Ring-Scoped Volume Override — Dart Port + Wiring (Slice 2, strict TDD) -- [ ] 3.1 (RED) In `test/servicios/servicio_alarmas_android_test.dart`, add a test asserting `ServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)` invokes channel method `overrideMediaVolumeForRing` with `{'fraction': 1.0}`, using the existing mock-channel pattern (`MethodChannel('pluriwave/alarm_scheduler')` + `llamadas` list). Run `flutter test` — confirm it fails (method does not exist). -- [ ] 3.2 (RED) In the same file, add a test asserting `ServicioAlarmasAndroid.restaurarVolumenMedia()` invokes channel method `restoreMediaVolume` with no args. Run `flutter test` — confirm it fails. -- [ ] 3.3 (GREEN) Add `Future forzarVolumenMediaParaAlarma(double fraccion)` and `Future restaurarVolumenMedia()` to `PuertoAlarmasAndroid` (abstract, `lib/servicios/servicio_alarmas_android.dart`) and implement both on `ServicioAlarmasAndroid` using the existing `_logAndInvokeVoid` helper pattern. Run `flutter test` — confirm 3.1-3.2 pass. -- [ ] 3.4 (GREEN) Extend `test/helpers/fakes_alarmas.dart`'s `FakePuertoAlarmasAndroid`: implement the two new abstract methods, recording calls into new lists `volumenForzado: List` and `volumenRestaurado: int` (call count) so widget tests can assert invocation order/count. -- [ ] 3.5 (RED) In `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` (or a new focused test file), add a widget test asserting `_silenciarAudio` -> restore is called exactly once on `_detener()` (dismiss) using `env.android.volumenRestaurado`. Run `flutter test` — confirm it fails. -- [ ] 3.6 (RED) Add the equivalent test for `_posponer()` (snooze) — restore called exactly once. Run `flutter test` — confirm it fails. -- [ ] 3.7 (RED) Add a test asserting restore is called at most once total even when both `_silenciarAudio()` (inside `_detener`) and `dispose()` run in sequence (idempotence at the Dart call-site level — the widget always calls restore in `dispose()` too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Run `flutter test` — confirm it fails. -- [ ] 3.8 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, call `context.read().android.restaurarVolumenMedia()` inside `_silenciarAudio()` (L202-213, alongside `_liberarAudioLocal()`/`radio.audio.pausar()`, wrapped in its own try/catch so a failure never blocks dismiss/snooze) AND inside `dispose()` (L238-244). Run `flutter test` — confirm 3.5-3.7 pass. -- [ ] 3.9 (RED) In `test/pantallas` (widget test, or a lighter unit-style test on `app.dart`'s ring-start seam if testable in isolation), add a test asserting `forzarVolumenMediaParaAlarma` is invoked when an alarm ring starts, at the TOP of `_prearrancarAudioAlarma` in `lib/app.dart` (BEFORE the `if (emisora == null) return;` early exit at L367) — the override must apply even when the alarm uses the fallback WAV path, not only the station path. Run `flutter test` — confirm it fails. -- [ ] 3.10 (GREEN) In `lib/app.dart`, call `context.read().android.forzarVolumenMediaParaAlarma(1.0)` as the FIRST statement inside `_prearrancarAudioAlarma` (L365), before the `emisora == null` early return. Run `flutter test` — confirm 3.9 passes. -- [ ] 3.11 (RED) Add a test asserting the override/restore channel methods are NEVER invoked during normal radio playback with no alarm ringing (Requirement: Ring-scoped device-volume override, Scenario "Normal radio playback never triggers the override") — assert `env.android.volumenForzado` stays empty across a plain play/pause cycle on `EstadoRadio` outside any alarm flow. Run `flutter test` — confirm it fails or passes vacuously (should already pass since no other code path calls these methods yet — treat as a REGRESSION GUARD, not a RED/GREEN pair, if 3.3-3.10 are already in place). -- [ ] 3.12 (REFACTOR) Run `flutter test` for the full suite plus `flutter analyze` — confirm no regressions in existing alarm/radio tests. +- [x] 3.1 (RED) In `test/servicios/servicio_alarmas_android_test.dart`, add a test asserting `ServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)` invokes channel method `overrideMediaVolumeForRing` with `{'fraction': 1.0}`, using the existing mock-channel pattern (`MethodChannel('pluriwave/alarm_scheduler')` + `llamadas` list). Run `flutter test` — confirm it fails (method does not exist). +- [x] 3.2 (RED) In the same file, add a test asserting `ServicioAlarmasAndroid.restaurarVolumenMedia()` invokes channel method `restoreMediaVolume` with no args. Run `flutter test` — confirm it fails. +- [x] 3.3 (GREEN) Add `Future forzarVolumenMediaParaAlarma(double fraccion)` and `Future restaurarVolumenMedia()` to `PuertoAlarmasAndroid` (abstract, `lib/servicios/servicio_alarmas_android.dart`) and implement both on `ServicioAlarmasAndroid` using the existing `_logAndInvokeVoid` helper pattern. Run `flutter test` — confirm 3.1-3.2 pass. +- [x] 3.4 (GREEN) Extend `test/helpers/fakes_alarmas.dart`'s `FakePuertoAlarmasAndroid`: implement the two new abstract methods, recording calls into new lists `volumenForzado: List` and `volumenRestaurado: int` (call count) so widget tests can assert invocation order/count. +- [x] 3.5 (RED) In `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` (or a new focused test file), add a widget test asserting `_silenciarAudio` -> restore is called exactly once on `_detener()` (dismiss) using `env.android.volumenRestaurado`. Run `flutter test` — confirm it fails. +- [x] 3.6 (RED) Add the equivalent test for `_posponer()` (snooze) — restore called exactly once. Run `flutter test` — confirm it fails. +- [x] 3.7 (RED) Add a test asserting restore is called at most once total even when both `_silenciarAudio()` (inside `_detener`) and `dispose()` run in sequence (idempotence at the Dart call-site level — the widget always calls restore in `dispose()` too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Run `flutter test` — confirm it fails. +- [x] 3.8 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, call `context.read().android.restaurarVolumenMedia()` inside `_silenciarAudio()` (L202-213, alongside `_liberarAudioLocal()`/`radio.audio.pausar()`, wrapped in its own try/catch so a failure never blocks dismiss/snooze) AND inside `dispose()` (L238-244). Run `flutter test` — confirm 3.5-3.7 pass. + + > Implementation note: added a Dart-side `_volumenMediaRestaurado` guard (mirroring the existing + > `_audioFlutterConfirmado` idiom) so the channel call fires at most once per screen instance + > regardless of which of the two call sites runs first — this is what makes 3.7's "at most once" + > assertion literally true at the Dart layer, on top of the Kotlin-side idempotent guard. + +- [x] 3.9 (RED) In `test/pantallas` (widget test, or a lighter unit-style test on `app.dart`'s ring-start seam if testable in isolation), add a test asserting `forzarVolumenMediaParaAlarma` is invoked when an alarm ring starts, at the TOP of `_prearrancarAudioAlarma` in `lib/app.dart` (BEFORE the `if (emisora == null) return;` early exit at L367) — the override must apply even when the alarm uses the fallback WAV path, not only the station path. Run `flutter test` — confirm it fails. + + > **DEVIATION (sdd-apply, 2026-07-11):** not testable in isolation, so no `flutter test` RED/GREEN + > pair exists for this task — confirmed via the escape hatch this task's own wording allows. + > `_prearrancarAudioAlarma` and `_PaginaPrincipal` are private to `app.dart`; the only public entry + > point (`PluriWaveApp`) hardcodes real, non-injectable `EstadoRadio`/`EstadoAlarmas` instances + > (`ServicioDispositivoAudioReal()`, `EstadoAlarmas(prefs: prefs)` — no fake-injection seam), and no + > existing test in the suite renders `PluriWaveApp`/`_PaginaPrincipal` for this exact reason (every + > alarm widget test bypasses it, constructing `EstadoRadio`/`EstadoAlarmas` directly with fakes). + > Building a real widget test would require an unscoped DI refactor to `app.dart`'s constructor, + > which is not listed in design #2310's File Changes (only "call override in + > `_prearrancarAudioAlarma`" — a one-line-style modify). Verified via source inspection instead: + > `forzarVolumenMediaParaAlarma(1.0)` is confirmed the first statement inside + > `_prearrancarAudioAlarma`, before `final emisora = alarma.emisora;` and the early return (see + > `lib/app.dart:365-374`). Recommend a follow-up task if genuine automated coverage of this exact + > seam is required (would need a testable DI seam on `PluriWaveApp`). + +- [x] 3.10 (GREEN) In `lib/app.dart`, call `context.read().android.forzarVolumenMediaParaAlarma(1.0)` as the FIRST statement inside `_prearrancarAudioAlarma` (L365), before the `emisora == null` early return. Run `flutter test` — confirm 3.9 passes. + + > Implementation note: this introduced an `await` before the pre-existing `context.read()` + > a few lines below, which `flutter analyze` correctly flagged as `use_build_context_synchronously`. + > Fixed with `if (!mounted) return;` right after the new await, matching the same guard pattern + > already used elsewhere in this file (e.g. `_abrirAlarmaSonando`) — not just a lint silencer, this + > also prevents reading providers / starting playback on an unmounted widget if the app is + > backgrounded mid-call. + +- [x] 3.11 (RED) Add a test asserting the override/restore channel methods are NEVER invoked during normal radio playback with no alarm ringing (Requirement: Ring-scoped device-volume override, Scenario "Normal radio playback never triggers the override") — assert `env.android.volumenForzado` stays empty across a plain play/pause cycle on `EstadoRadio` outside any alarm flow. Run `flutter test` — confirm it fails or passes vacuously (should already pass since no other code path calls these methods yet — treat as a REGRESSION GUARD, not a RED/GREEN pair, if 3.3-3.10 are already in place). (Confirmed green on first run, as anticipated — regression guard, not RED/GREEN.) +- [x] 3.12 (REFACTOR) Run `flutter test` for the full suite plus `flutter analyze` — confirm no regressions in existing alarm/radio tests. (Full suite: 1 unrelated failure in `servicio_grabacion_radio_test.dart` — timing-sensitive, reproduces only under full-suite load, passes standalone; confirmed pre-existing, not a Slice 2 regression. All alarm/radio/Slice-2 suites green; `flutter analyze` 0 issues.) ## Phase 4: Fade-In Dedup at Handoff (Slice 3, strict TDD) diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 8bfa659..af09788 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -17,6 +17,14 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { bool ignoraOptimizacionBateria = true; int solicitudesExencionBateria = 0; + /// Records each [forzarVolumenMediaParaAlarma] call (Slice 2: ring-scoped + /// device-volume override). + final volumenForzado = []; + + /// Counts [restaurarVolumenMedia] calls (Slice 2). A plain counter, not a + /// list: widget tests only need to assert how many times restore ran. + int volumenRestaurado = 0; + /// Test-only failure switch (Design D7): when true, [programar] throws /// instead of scheduling, enabling failure-path coverage that the fake /// could not otherwise produce. @@ -59,6 +67,16 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { detenidas.add(alarmaId); } + @override + Future forzarVolumenMediaParaAlarma(double fraccion) async { + volumenForzado.add(fraccion); + } + + @override + Future restaurarVolumenMedia() async { + volumenRestaurado++; + } + @override Future diagnostico() async => DiagnosticoAlarmasAndroid( diff --git a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart index d145e29..3110f4b 100644 --- a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart @@ -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); + }); + }); } diff --git a/test/servicios/servicio_alarmas_android_test.dart b/test/servicios/servicio_alarmas_android_test.dart index 8041d35..e0f7840 100644 --- a/test/servicios/servicio_alarmas_android_test.dart +++ b/test/servicios/servicio_alarmas_android_test.dart @@ -106,4 +106,32 @@ void main() { ); }, ); + + test( + 'forzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + + await servicio.forzarVolumenMediaParaAlarma(1.0); + + final llamada = llamadas.singleWhere( + (c) => c.method == 'overrideMediaVolumeForRing', + ); + expect(llamada.arguments, {'fraction': 1.0}); + }, + ); + + test( + 'restaurarVolumenMedia invoca restoreMediaVolume sin argumentos', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + + await servicio.restaurarVolumenMedia(); + + final llamada = llamadas.singleWhere( + (c) => c.method == 'restoreMediaVolume', + ); + expect(llamada.arguments, {}); + }, + ); }