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).
This commit is contained in:
@@ -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<void> forzarVolumenMediaParaAlarma(double fraccion)` and `Future<void> 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<double>` 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<EstadoAlarmas>().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<EstadoAlarmas>().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<void> forzarVolumenMediaParaAlarma(double fraccion)` and `Future<void> 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<double>` 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<EstadoAlarmas>().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<EstadoAlarmas>().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<EstadoRadio>()`
|
||||
> 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user