Apply-stage SDK verification (javap on android-34/35/36 platform jars plus api-versions.xml) proved FOREGROUND_SERVICE_TYPE_ALARM and the FOREGROUND_SERVICE_ALARM permission are fictional constants. The existing mediaPlayback|systemExempted declaration is the documented correct pattern for an alarm app holding exact-alarm permissions, so slice 1 ships no code and root cause B is withdrawn. Spec, design, and tasks amended with the evidence; volume-override and fade-dedup slices proceed unaffected.
15 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|systemExempteddeclaration 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— changePluriWaveAlarmServiceandroid:foregroundServiceTypefrom"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— changestartForegroundtype constants fromFOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTEDtoFOREGROUND_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_ALARMdo NOT exist in the Android SDK. Verified viajavap -constantsagainstandroid.content.pm.ServiceInfoandandroid.Manifest$permissionin the localandroid-34/android-35/android-36platformandroid.jars, plus a fullFOREGROUND_SERVICE_TYPE_*field sweep ofapi-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 — noalarmvariant. Edits 1.1-1.3 were applied then reverted (git checkout --) to avoid landing an unresolved-Kotlin-reference / invalid-manifest-enum compile break thatflutter analyzecannot catch (Dart-only) and that this task explicitly forbids validating viaflutter build/gradle. This reconfirms the identical finding already recorded inapp-quality-and-native-alarms(T-S1-03/T-S1-04), which usedsystemExempted/FOREGROUND_SERVICE_SYSTEM_EXEMPTEDfor 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 confirmsystemExemptedwas 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.xmlshowsalarm, notsystemExempted, on thePluriWaveAlarmServiceline. - 1.5 Static check:
rg 'FOREGROUND_SERVICE_ALARM|FOREGROUND_SERVICE_SYSTEM_EXEMPTED' android/app/src/main/AndroidManifest.xmlshowsFOREGROUND_SERVICE_ALARMpresent andFOREGROUND_SERVICE_SYSTEM_EXEMPTEDabsent. - 1.6 Static check:
rg 'FOREGROUND_SERVICE_TYPE_ALARM|FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED' android/.../PluriWaveAlarmService.ktshowsTYPE_ALARMpresent andTYPE_SYSTEM_EXEMPTEDabsent — confirms manifest/runtime match (Requirement: Manifest declares alarm-eligible FGS, Scenario "Manifest declares required FGS type and permission"). - 1.7
flutter analyze(noflutter 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@Volatilecompanion-scoped state:mediaVolumeOverridden: BooleanandcapturedMediaVolume: Int?to track ring-scoped override without surviving process death (documented residual gap). - 2.2 In
MainActivity.kt'salarm_schedulerwhen (call.method)block (near L79-218), add"overrideMediaVolumeForRing"case: capture currentAudioManager.STREAM_MUSICvolume intocapturedMediaVolume(only if not already overridden — idempotent guard), thensetStreamVolume(STREAM_MUSIC, getStreamMaxVolume(STREAM_MUSIC), 0)(flag0= noFLAG_SHOW_UI, no slider flash). SetmediaVolumeOverridden = true.fractionarg accepted but unused (reserved, default1.0= max reference level, per design). - 2.3 In the same
whenblock, add"restoreMediaVolume"case: no-op ifmediaVolumeOverridden == false(idempotent guard); otherwisesetStreamVolume(STREAM_MUSIC, capturedMediaVolume, 0), then clearmediaVolumeOverridden = falseandcapturedMediaVolume = null. - 2.4 Add a public
restoreMediaVolumeBestEffort()method onMainActivity(or companion) thatPluriWaveAlarmServicecan call as a backstop when the engine is alive. - 2.5 In
PluriWaveAlarmService.kt'sstopAlarm()(L356-381) andonDestroy()(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.ktshows both channel cases present. - 2.7 Static check:
rg 'mediaVolumeOverridden' android/.../MainActivity.ktshows 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.ktshows it called from bothstopAlarmandonDestroy. - 2.9
flutter analyze— confirm no Kotlin/lint regressions.
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 assertingServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)invokes channel methodoverrideMediaVolumeForRingwith{'fraction': 1.0}, using the existing mock-channel pattern (MethodChannel('pluriwave/alarm_scheduler')+llamadaslist). Runflutter test— confirm it fails (method does not exist). - 3.2 (RED) In the same file, add a test asserting
ServicioAlarmasAndroid.restaurarVolumenMedia()invokes channel methodrestoreMediaVolumewith no args. Runflutter test— confirm it fails. - 3.3 (GREEN) Add
Future<void> forzarVolumenMediaParaAlarma(double fraccion)andFuture<void> restaurarVolumenMedia()toPuertoAlarmasAndroid(abstract,lib/servicios/servicio_alarmas_android.dart) and implement both onServicioAlarmasAndroidusing the existing_logAndInvokeVoidhelper pattern. Runflutter test— confirm 3.1-3.2 pass. - 3.4 (GREEN) Extend
test/helpers/fakes_alarmas.dart'sFakePuertoAlarmasAndroid: implement the two new abstract methods, recording calls into new listsvolumenForzado: List<double>andvolumenRestaurado: 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) usingenv.android.volumenRestaurado. Runflutter test— confirm it fails. - 3.6 (RED) Add the equivalent test for
_posponer()(snooze) — restore called exactly once. Runflutter test— confirm it fails. - 3.7 (RED) Add a test asserting restore is called at most once total even when both
_silenciarAudio()(inside_detener) anddispose()run in sequence (idempotence at the Dart call-site level — the widget always calls restore indispose()too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Runflutter test— confirm it fails. - 3.8 (GREEN) In
lib/pantallas/pantalla_alarma_sonando.dart, callcontext.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 insidedispose()(L238-244). Runflutter test— confirm 3.5-3.7 pass. - 3.9 (RED) In
test/pantallas(widget test, or a lighter unit-style test onapp.dart's ring-start seam if testable in isolation), add a test assertingforzarVolumenMediaParaAlarmais invoked when an alarm ring starts, at the TOP of_prearrancarAudioAlarmainlib/app.dart(BEFORE theif (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. Runflutter test— confirm it fails. - 3.10 (GREEN) In
lib/app.dart, callcontext.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(1.0)as the FIRST statement inside_prearrancarAudioAlarma(L365), before theemisora == nullearly return. Runflutter 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.volumenForzadostays empty across a plain play/pause cycle onEstadoRadiooutside any alarm flow. Runflutter 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 testfor the full suite plusflutter analyze— confirm no regressions in existing alarm/radio tests.
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 viaFakeServicioAudio/fallback player volume changes) does NOT start beforeconfirmarAudioFlutterhas been invoked on the android port (i.e., before_confirmarAudioFlutterListo()runs) — assertenv.android.detenidas(whichconfirmarAudioFlutterappends to, perFakePuertoAlarmasAndroid.confirmarAudioFlutter) is non-empty before any volume-ramp step is observed. Runflutter test— confirm it fails (current code starts the ramp at L66 immediately afterradio.reproducir, before confirmation). - 4.2 (RED) Add a test asserting the fade-in DOES start once
_confirmarAudioFlutterListo()has run (radio path viaestadoStreamemittingreproduciendo, or fallback path via_iniciarFallback) — the ramp must still function end-to-end after the gate. Runflutter 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 afterradio.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_audioFlutterConfirmadoguard, for both the radio and fallback paths. Runflutter test— confirm 4.1-4.2 pass. - 4.4 (REFACTOR) Re-run the full
pantalla_alarma_sonando_test.dartandpantalla_alarma_sonando_dismiss_guard_test.dartsuites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point. - 4.5
flutter analyzeanddart 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
PluriWaveAlarmServicestarts withoutForegroundServiceTypeExceptionon 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
confirmarAudioFlutterfires (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'acrossandroid/app/src/main— confirm zero remaining references (fully dropped, not kept alongsidealarm). - 6.2
flutter test(full suite) andflutter analyze— final clean run before requesting review. - 6.3
dart format .— confirm no formatting diffs remain uncommitted.