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

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

    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.

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

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

  • 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

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

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

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