Files
pluriwave/openspec/changes/alarm-volume-ramp-restore/tasks.md
T
FreeTLab 66a19525bd
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
fix(alarm): defer the Dart fade-in until the native handoff confirms
The native service and the Flutter player each ran their own 5%-to-
target fade-in, and both could drive audible volume at the handoff,
producing a jump or ramp reset. The Dart ramp now starts exactly once
from the handoff-confirmation path: the player still pre-starts at 5%,
and _confirmarAudioFlutterListo() starts the ramp in a finally block
so it runs whether the native confirmation succeeds or fails — the
alarm can never stay stuck at 5% if the native side is already gone.

Work unit 3/3 of alarm-volume-ramp-restore (fade-in dedup).
2026-07-11 09:57:06 +02:00

244 lines
28 KiB
Markdown

# Tasks: Alarm Volume Ramp & Device-Volume Immunity
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | 260-360 (7 files: manifest, 2 Kotlin, 3 Dart, 1 new Dart interface method pair + 3 test files) |
| 400-line budget risk | Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 (Slice 1 manifest+FGS) -> PR 2 (Slice 2 volume override/restore) -> PR 3 (Slice 3 fade-in dedup) |
| Delivery strategy | ask-on-risk |
| Chain strategy | pending |
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: pending
400-line budget risk: Medium
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | Fix FGS manifest+runtime type mismatch (Slice 1) | PR 1 | Independent, near-zero risk, own rollback; mandatory Android 14+ device gate |
| 2 | Ring-scoped STREAM_MUSIC override + idempotent restore (Slice 2) | PR 2 | Depends on PR 1 landing (FGS must start before override matters); own rollback via guard-disable |
| 3 | Dedup fade-in driver at handoff (Slice 3) | PR 3 | Independent of PR 2; can reorder before PR 2 if preferred; own rollback (revert gate) |
---
## Phase 1: FGS Manifest + Runtime Type Fix (Slice 1 — code-inspection + manual QA)
> **PHASE CANCELLED (orchestrator, 2026-07-11)** — resolution of the BLOCKED note below: the
> target constants are fictional (verified against the local SDK), the existing
> `mediaPlayback|systemExempted` declaration is confirmed correct, and Root Cause B is withdrawn.
> Tasks 1.1-1.7 ship no code. See the correction banner in design.md and the amended Requirement
> in specs/native-alarms/spec.md. Slices 2 and 3 proceed unaffected.
- [ ] 1.1 Edit `android/app/src/main/AndroidManifest.xml:57` — change `PluriWaveAlarmService` `android:foregroundServiceType` from `"mediaPlayback|systemExempted"` to `"mediaPlayback|alarm"`.
- [ ] 1.2 Edit `android/app/src/main/AndroidManifest.xml:6` — replace `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>` with `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>`.
- [ ] 1.3 Edit `PluriWaveAlarmService.kt:118-119` — change `startForeground` type constants from `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` to `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_ALARM`. 1.1-1.3 are ONE atomic unit — a manifest-only or runtime-only edit leaves the API 34+ exception alive; do not split across commits.
> **BLOCKED (sdd-apply, 2026-07-11):** `FOREGROUND_SERVICE_TYPE_ALARM` / `android.permission.FOREGROUND_SERVICE_ALARM` do NOT exist in the Android SDK. Verified via `javap -constants` against `android.content.pm.ServiceInfo` and `android.Manifest$permission` in the local `android-34`/`android-35`/`android-36` platform `android.jar`s, plus a full `FOREGROUND_SERVICE_TYPE_*` field sweep of `api-versions.xml`. The only FGS types that exist through API 36 are: camera, connectedDevice, dataSync, health, location, manifest, mediaPlayback, mediaProcessing, mediaProjection, microphone, phoneCall, remoteMessaging, shortService, specialUse, systemExempted — no `alarm` variant. Edits 1.1-1.3 were applied then reverted (`git checkout --`) to avoid landing an unresolved-Kotlin-reference / invalid-manifest-enum compile break that `flutter analyze` cannot catch (Dart-only) and that this task explicitly forbids validating via `flutter build`/gradle. This reconfirms the identical finding already recorded in `app-quality-and-native-alarms` (T-S1-03/T-S1-04), which used `systemExempted`/`FOREGROUND_SERVICE_SYSTEM_EXEMPTED` for the same reason — see design #2310's own "Open Questions" section, which flagged but did not resolve this before approval. Design #2310 / tasks #2316 need correction before Slice 1 can proceed: either identify a real SDK-backed fix for the API 34+ `ForegroundServiceTypeException`, or confirm `systemExempted` was already correct and the actual Slice 1 defect (if any) lies elsewhere. Working tree is clean — no diff left on either file.
- [ ] 1.4 Static check: `rg 'foregroundServiceType' android/app/src/main/AndroidManifest.xml` shows `alarm`, not `systemExempted`, on the `PluriWaveAlarmService` line.
- [ ] 1.5 Static check: `rg 'FOREGROUND_SERVICE_ALARM|FOREGROUND_SERVICE_SYSTEM_EXEMPTED' android/app/src/main/AndroidManifest.xml` shows `FOREGROUND_SERVICE_ALARM` present and `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` absent.
- [ ] 1.6 Static check: `rg 'FOREGROUND_SERVICE_TYPE_ALARM|FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED' android/.../PluriWaveAlarmService.kt` shows `TYPE_ALARM` present and `TYPE_SYSTEM_EXEMPTED` absent — confirms manifest/runtime match (Requirement: Manifest declares alarm-eligible FGS, Scenario "Manifest declares required FGS type and permission").
- [ ] 1.7 `flutter analyze` (no `flutter build`) — confirm no lint regressions from these edits (none expected; XML/Kotlin only).
## Phase 2: Ring-Scoped Volume Override — Kotlin Channel Methods (Slice 2, code-inspection only)
- [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)
- [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)
- [x] 4.1 (RED) In `test/pantallas/pantalla_alarma_sonando_test.dart` (or a new focused fade-in test file), add a widget test asserting the Dart fade-in ramp (observable via `FakeServicioAudio`/fallback player volume changes) does NOT start before `confirmarAudioFlutter` has been invoked on the android port (i.e., before `_confirmarAudioFlutterListo()` runs) — assert `env.android.detenidas` (which `confirmarAudioFlutter` appends to, per `FakePuertoAlarmasAndroid.confirmarAudioFlutter`) is non-empty before any volume-ramp step is observed. Run `flutter test` — confirm it fails (current code starts the ramp at L66 immediately after `radio.reproducir`, before confirmation).
- [x] 4.2 (RED) Add a test asserting the fade-in DOES start once `_confirmarAudioFlutterListo()` has run (radio path via `estadoStream` emitting `reproduciendo`, or fallback path via `_iniciarFallback`) — the ramp must still function end-to-end after the gate. Run `flutter test` — confirm it fails or is trivially satisfied depending on 4.1's fixture; treat 4.1+4.2 as one RED pair validating gate correctness both ways.
> Implementation note: 4.1+4.2 were implemented as a single RED pair (one `testWidgets`, two
> assertion stages) in `test/pantallas/pantalla_alarma_sonando_test.dart`, radio path. Rather than
> relying on real `estadoStream` subscription timing (existing `_montarPantalla` pre-emits
> `reproduciendo` BEFORE mount in all prior tests, which — traced via code inspection — means
> `_confirmarAudioFlutterListo()` never actually fires in those fixtures: the broadcast stream drops
> the pre-mount event, and the fallback timer's immediate-cancel branch also short-circuits before
> any listener would see it), added a new opt-out param `audioYaReproduciendo` (default `true`,
> preserves all existing tests byte-for-byte) plus a test-only `Completer`-based gate
> (`FakePuertoAlarmasAndroid.puertaConfirmarAudioFlutter`) on `confirmarAudioFlutter`, so the test
> can deterministically observe the pre-confirm state, release the gate, then observe the
> post-confirm state — independent of stream/timer race conditions. Confirmed RED against the
> pre-4.3 code: the gate-pending assertion failed with `Actual: [0.05, 0.85]` (ramp already fired
> before the gate was ever released), proving the ramp was ungated.
>
> Also added a THIRD test beyond the literal 4.1/4.2 wording, covering the explicit edge case in
> this batch's own dispatch instructions: if `confirmarAudioFlutter` FAILS (dead/never-there native
> channel), the Dart ramp must still start — the ring must never stay stuck at
> `_volumenInicialFadeIn` forever, since Dart is the only audible source once native is gone. Added
> `FakePuertoAlarmasAndroid.fallaConfirmarAudioFlutter` to simulate this. Confirmed RED against
> pre-4.3 code too: the fake's `StateError` propagated UNCAUGHT out of `_confirmarAudioFlutterListo()`
> (no try/catch existed), crashing the test — proving the failure path was unhandled before this
> slice.
- [x] 4.3 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, remove the `_iniciarFadeIn()` call at L66 (radio path, currently fires immediately after `radio.reproducir(emisora)`) and the one at L96 (fallback path, currently fires before `_confirmarAudioFlutterListo()` at L97); move the single `_iniciarFadeIn()` invocation INTO `_confirmarAudioFlutterListo()` (L133-139) so it fires exactly once, after the `_audioFlutterConfirmado` guard, for both the radio and fallback paths. Run `flutter test` — confirm 4.1-4.2 pass.
> Implementation note: moved as specified. `_confirmarAudioFlutterListo()` now wraps the native
> channel call in `try { await ... } catch (e) { debugPrint(...); } finally { _iniciarFadeIn(); }` —
> matching the existing `try/catch/finally` idiom already used in `_detener()`/`_posponer()` in the
> same file. The `finally` block is what satisfies the failure-edge case above: whether the native
> confirmation succeeds or throws, the fade-in always starts exactly once (still gated by the
> pre-existing `_audioFlutterConfirmado` guard at the top of the method, unchanged). All 3 new tests
> (4.1+4.2 combined, plus the failure-edge test) pass after this change; `rg '_iniciarFadeIn'
> lib/` confirms exactly one call site remains (inside `_confirmarAudioFlutterListo`) plus the
> function definition itself.
- [x] 4.4 (REFACTOR) Re-run the full `pantalla_alarma_sonando_test.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart` suites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point. (Also included `pantalla_alarma_sonando_scaffold_test.dart` and `servicio_alarmas_android_test.dart` for extra confidence since a shared fake was extended. 22/22 tests green, no regressions — none of the pre-existing tests assert on fade/volume timing, since none of them previously exercised `_confirmarAudioFlutterListo()` at all under the old pre-mount-emit fixture pattern, as traced in the 4.1/4.2 note above.)
- [x] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits. (`flutter analyze`: 0 issues. `dart format .` reformatted the 3 Slice 3 files cleanly; it also touched 8 files unrelated to this change — pre-existing formatter-version drift on `main`, confirmed reproducible and out of scope per this batch's file-scope constraint — reverted via `git checkout --` both times it recurred, see Phase 6 6.3 note.)
## Phase 5: Manual/On-Device QA (mandatory human gate — Android 14+ physical or emulator device)
- [ ] 5.1 Set device media (`STREAM_MUSIC`) volume to 0. Trigger an alarm. Confirm the alarm rings audibly for its full duration, not just the pre-handoff native window (Scenario "Alarm is audible when device media volume is 0").
- [ ] 5.2 Confirm `PluriWaveAlarmService` starts without `ForegroundServiceTypeException` on an Android 14+ (API 34+) device when the alarm fires from a background/killed-app broadcast context (Scenario "Native service starts from broadcast context on Android 14+").
- [ ] 5.3 Note the original device media volume before triggering the alarm; dismiss the alarm; confirm media volume is restored to exactly the pre-alarm value (Scenario "Dismiss restores the original captured volume").
- [ ] 5.4 Repeat 5.3 for snooze instead of dismiss (Scenario "Snooze restores the original captured volume").
- [ ] 5.5 Listen across the native-to-Flutter handoff window; confirm there is no audible double-ramp/volume jump at the moment `confirmarAudioFlutter` fires (Scenario "No double-ramp interleaving at handoff").
- [ ] 5.6 With no alarm ringing, play/pause normal radio repeatedly and confirm device volume controls behave exactly as before this change (no override side effects) — Scenario "Normal radio playback never triggers the override".
- [ ] 5.7 Optional/best-effort: force-kill the app mid-ring and confirm the residual-volume-override gap is no worse than documented (Scenario "App killed mid-ring — best-effort restore only"; known accepted gap, not a blocking QA failure).
- [ ] 5.8 Record QA sign-off (device model, Android version, pass/fail per scenario) before `sdd-verify`/merge.
## Phase 6: Final Static Sweep
- [x] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`).
> **INTERPRETATION FLIP (sdd-apply, 2026-07-11):** this check's original "zero remaining
> references" wording was written under the Phase 1 premise that later proved false. Ran it anyway:
> 3 references found (`AndroidManifest.xml:6` permission, `AndroidManifest.xml:58`
> `foregroundServiceType`, `PluriWaveAlarmService.kt:124` runtime constant) — all `systemExempted`/
> `SYSTEM_EXEMPTED`, none `alarm`. This is the CORRECT and EXPECTED state, not a failure: per the
> Phase 1 cancellation banner (design.md correction, confirmed via `javap -constants` SDK
> inspection), `FOREGROUND_SERVICE_TYPE_ALARM`/`FOREGROUND_SERVICE_ALARM` are fictional and
> `mediaPlayback|systemExempted` is confirmed the correct, intentional declaration — so
> "fully dropped" is no longer the right target; "present and internally consistent between
> manifest and runtime" is. Manifest (`systemExempted` at L6+L58) and Kotlin runtime
> (`FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` at L124) match each other exactly, same as before this
> entire change — Slice 1 shipped no code (cancelled), so this file pair was never touched by any
> batch of this change. Marking complete because the check RAN and its result was interpreted
> correctly against the corrected design, not because the literal original wording's outcome was
> achieved.
- [x] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review.
> Implementation note: the full-suite invocation reproduced the documented intermittent hang (this
> task's own dispatch instructions named `estado_alarmas_ejecuciones_test.dart` and
> `servicio_grabacion_radio_test.dart` as known culprits) — it stalled past `+276` tests for several
> minutes of real time with zero further progress and had to be killed. Fell back exactly as
> instructed: ran all 44 other test files (everything under `test/` except those two) together —
> `01:02 +272: All tests passed!`, zero failures. Then ran the two known-flaky files standalone —
> `+7: All tests passed!`, zero failures, confirming (again) they only misbehave under full-suite
> load, not on their own — same conclusion already recorded in Batch 2's apply-progress for
> `servicio_grabacion_radio_test.dart` (`_fallar must clear activa flag immediately`, timing-
> sensitive). 272 + 7 = 279 tests total across all 46 files in the suite, all green. `flutter
> analyze`: 0 issues.
- [x] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted.
> Implementation note: `dart format .` left the 3 files this batch touched
> (`lib/pantallas/pantalla_alarma_sonando.dart`, `test/helpers/fakes_alarmas.dart`,
> `test/pantallas/pantalla_alarma_sonando_test.dart`) untouched on its second run (already clean
> from the 4.5 pass) — confirmed via `git status --short` showing only those 3 files modified. Both
> times `dart format .` ran in this batch (4.5 and 6.3) it ALSO reformatted 8 files this batch never
> edited (`lib/pantallas/pantalla_ajustes.dart`, `lib/servicios/servicio_ecualizador.dart`, and 6
> test files under `test/estado`, `test/pantallas`, `test/servicios`) — reverted both times via `git
> checkout --` to stay within this batch's file-scope constraint (only
> `pantalla_alarma_sonando.dart` + test files). This is pre-existing formatter-version drift already
> present on `main` before this change started, unrelated to Slice 2 or Slice 3 — worth a
> follow-up `dart format .` cleanup commit on its own, out of scope here.