Files
pluriwave/openspec/changes/alarm-volume-ramp-restore/tasks.md
T
FreeTLab acd903d9a8
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s
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).
2026-07-11 09:15:37 +02:00

21 KiB

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.jars, 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)

  • 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.

    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.

  • 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. (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.

    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.

  • 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).

  • 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.

  • 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.)

  • 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)

  • 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).
  • 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.
  • 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.
  • 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.
  • 4.5 flutter analyze and dart format . — confirm clean formatting/lint state for all Slice 3 edits.

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

  • 6.1 Full-repo rg 'systemExempted|SYSTEM_EXEMPTED' across android/app/src/main — confirm zero remaining references (fully dropped, not kept alongside alarm).
  • 6.2 flutter test (full suite) and flutter analyze — final clean run before requesting review.
  • 6.3 dart format . — confirm no formatting diffs remain uncommitted.