docs(openspec): add SDD artifact trails for bt-device-identity and alarm-volume-ramp-restore
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s

In-progress artifact sets from the current SDD cycles: exploration,
proposal, spec, design, tasks, and verify reports as produced so far.
Also drops a leftover working copy of eq-device-disconnect-revert
whose contents were already committed under changes/archive/.
This commit is contained in:
2026-07-11 00:56:22 +02:00
parent 0b18540935
commit 747738d20a
11 changed files with 1100 additions and 0 deletions
@@ -0,0 +1,70 @@
# Design: Alarm Volume Ramp & Device-Volume Immunity
## Technical Approach
Three independent, rollback-isolated slices realizing proposal #2302. Kotlin owns the manifest fix and a new ring-scoped `STREAM_MUSIC` override (no Flutter volume plugin exists; `MainActivity` already owns the audio channels). Dart drives lifecycle: it invokes override at ring start and restore from the already-centralized exit points. The existing 5%->`alarma.volumen` Dart player ramp is kept; only the audible fade-in *driver* is deduped at handoff. Normal radio playback and `ServicioAudioSession` ducking (S3-R1) are never touched — the override fires only while a ring is active. Kotlin is code-inspection + mandatory on-device QA (flutter build forbidden); Dart follows strict TDD.
## Architecture Decisions
| Decision | Choice | Rejected | Rationale |
|---|---|---|---|
| Manifest FGS combo | `mediaPlayback\|alarm` + add `FOREGROUND_SERVICE_ALARM`; drop `systemExempted`/`FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | Keep `...\|alarm\|systemExempted` | `alarm` is the correct type for an AlarmManager-broadcast-started FGS on API 34+. `systemExempted` is reserved (Play-policy narrow use) and adds nothing once `alarm` is present; matches already-approved D1.1/S1-R1. |
| Runtime `startForeground` type | Change `PluriWaveAlarmService.kt:114-120` to `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_ALARM` | Manifest-only edit | **Load-bearing**: the runtime type is hard-coded and must match the manifest, else `ForegroundServiceTypeException` persists. This is the real fix; the manifest alone would still crash. |
| Override ownership & state | New channel methods `overrideMediaVolumeForRing(fraction)` / `restoreMediaVolume()` on `pluriwave/alarm_scheduler`; captured original volume in a `@Volatile` field on `MainActivity` (companion) | Kotlin object singleton; SharedPreferences | Survives across method calls, not process death (documented residual). `MainActivity` already holds the channel + `AudioManager`. |
| Stream reference level | Force `STREAM_MUSIC` to a FIXED audible reference = `getStreamMaxVolume(STREAM_MUSIC)`; `alarma.volumen` stays the *player* volume via existing Dart ramp | Map `alarma.volumen` to stream volume | Player ramp already governs perceived loudness (5%->target). Stream must only guarantee audibility at volume 0; max is the safe immunity floor. `fraction` arg reserved for future tuning, default 1.0. |
| `setStreamVolume` flags | flag `0` (no `FLAG_SHOW_UI`) | `FLAG_SHOW_UI` | No volume-slider flash during a ring. |
| Restore idempotence | `@Volatile var mediaVolumeOverridden` + saved level; restore is guarded no-op when not overridden; both override and restore are once-guards | Unconditional restore | Prevents clobbering user volume on double-restore; safe from any exit path. |
| Restore call sites (Dart) | Invoke restore inside `_silenciarAudio()` (covers dismiss `_detener` + snooze `_posponer`, both already funnel here) and again in `dispose()` | New per-path calls | Reuses the existing single teardown seam; idempotent guard tolerates the double call. |
| Native backstop | `PluriWaveAlarmService.stopAlarm()` / `onDestroy()` call `MainActivity.restoreMediaVolumeBestEffort()` when engine alive | No backstop | Best-effort recovery if app is killed mid-ring; still leaves the documented process-death gap. |
| Override trigger point | Dart calls override in `_prearrancarAudioAlarma()` (app.dart) — the earliest point the Flutter player starts, before the ring screen pushes | Ring-screen `initState` | Override must precede the media-player becoming audible to avoid a volume-0 gap at handoff. |
| Fade-in dedup gate | Native ramp owns audio until handoff; Dart player pre-starts at 0.05 but its ramp START defers to the existing `confirmarAudioFlutter` success (`_confirmarAudioFlutterListo`, already fired on `reproduciendo`/fallback) | Kill native ramp early; shared timer | Exactly one audible ramp at any instant, reusing the existing handoff protocol seam. No new IPC. |
## Data Flow
ring fires -> PluriWaveAlarmService (USAGE_ALARM, native ramp) --immune--> audible
| |
app.dart _prearrancarAudioAlarma ---> overrideMediaVolumeForRing() [capture+max STREAM_MUSIC]
| |
PantallaAlarmaSonando: player pre-starts @0.05, Dart ramp START gated ------- v
| reproduciendo
└── _confirmarAudioFlutterListo -> confirmFlutterAudio -> service.stop() (native ramp ends)
-> Dart ramp begins 0.05->alarma.volumen (sole driver)
exit (dismiss/snooze/dispose) -> _silenciarAudio + dispose -> restoreMediaVolume() [idempotent]
(backstop) service.stopAlarm/onDestroy -> restoreMediaVolumeBestEffort()
## File Changes
| File | Action | Description |
|---|---|---|
| `android/.../AndroidManifest.xml` | Modify | L57 FGS type -> `mediaPlayback\|alarm`; add `FOREGROUND_SERVICE_ALARM` at L3-16; drop `systemExempted` type + `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` perm |
| `android/.../PluriWaveAlarmService.kt` | Modify | L114-120 runtime type -> `MEDIA_PLAYBACK or ALARM`; add best-effort restore in `stopAlarm`/`onDestroy` |
| `android/.../MainActivity.kt` | Modify | Add `overrideMediaVolumeForRing`/`restoreMediaVolume` channel cases; `@Volatile` capture state; `AudioManager` `STREAM_MUSIC` capture/set(max, flag 0)/restore |
| `lib/servicios/servicio_alarmas_android.dart` | Modify | Add `forzarVolumenMediaParaAlarma(double)` + `restaurarVolumenMedia()` to `PuertoAlarmasAndroid` + impl |
| `lib/app.dart` | Modify | Call override in `_prearrancarAudioAlarma` |
| `lib/pantallas/pantalla_alarma_sonando.dart` | Modify | Call restore in `_silenciarAudio`+`dispose`; gate Dart ramp START on `_confirmarAudioFlutterListo` |
## Interfaces / Contracts
```dart
// PuertoAlarmasAndroid additions
Future<void> forzarVolumenMediaParaAlarma(double fraccion); // -> overrideMediaVolumeForRing
Future<void> restaurarVolumenMedia(); // -> restoreMediaVolume (idempotent)
```
## Testing Strategy
| Layer | What | Approach |
|---|---|---|
| Unit (Dart, TDD) | New wrapper methods emit correct channel calls/args | `servicio_alarmas_android_test.dart` mock-channel pattern |
| Widget (Dart, TDD) | Restore invoked on dismiss/snooze/dispose exactly once; Dart ramp START deferred until confirm | Extend `FakePuertoAlarmasAndroid` (record override/restore call lists); reuse `dismiss_guard_test` harness |
| Kotlin | Manifest+runtime type match; capture/set/restore; idempotence | Code inspection only |
| Manual QA (mandatory human gate) | media vol 0 -> alarm audible; vol restored after dismiss/snooze; Android 14+ service starts | On-device checklist (CC-R1/R2) |
## Migration / Rollout
No data migration. Per-slice independent rollback: revert manifest+runtime type together; disable the override call in `_prearrancarAudioAlarma` to neutralize Slice 2 (restore guard makes it no-worse-than-today); fade-in gate reverts alone.
## Open Questions
- [ ] Confirm with user whether app-quality-and-native-alarms Slice 1 manifest was ever verified on a real Android 14+ device (apply-progress Batch 1 deviation note missing).
- [ ] `fraction` param default 1.0 (max) accepted, or expose per-alarm later? (Deferred; not blocking.)
@@ -0,0 +1,40 @@
# Exploration: alarm-volume-ramp-restore
## Current State — dual-track handoff architecture (by design)
Alarm audio is a deliberate handoff (documented in `app-quality-and-native-alarms/design.md` Decision 1.5, `docs/alarmas-pantalla-apagada.md`):
1. **Native track**: `PluriWaveAlarmReceiver(ACTION_FIRE)``PluriWaveAlarmService.kt` plays via `MediaPlayer` with `AudioAttributes.USAGE_ALARM` (L350-354) — Android ALARM stream, immune to media-volume-0 by OS design. Has a working fade-in (`startFadeIn`, `FADE_IN_START_FRACTION = 0.05f`, 250ms steps).
2. **Flutter track**: `app.dart` `_prearrancarAudioAlarma()` starts the regular radio player (`PluriWaveAudioHandler`/just_audio) at volume 0.05; `pantalla_alarma_sonando.dart` `_iniciarFadeIn()` ramps it 0.05 → `alarma.volumen`. Uses `AudioSessionConfiguration.music()` — normal media session, fully subject to device media volume.
3. **Handoff**: when the Flutter player reaches `reproduciendo`, `confirmarAudioFlutter``MainActivity``PluriWaveAlarmService.stop()`. Native audio torn down; Flutter media-stream player is the sole source for the rest of the ring.
Grep-verified: **zero occurrences of `setStreamVolume`/AudioManager volume-override anywhere in the codebase, ever** — the app has never programmatically overridden device stream volume. The "used to ignore device volume" impression comes from the native track's brief immune window, not a regressed capability.
## Root Causes
**A — architectural**: steady-state alarm audio is the Flutter media-stream player within 1-3s of firing. Media volume 0 → silent alarm (symptom 2).
**B — manifest bug (verified)**: `AndroidManifest.xml:55-58` declares `foregroundServiceType="mediaPlayback|systemExempted"` and NO `FOREGROUND_SERVICE_ALARM` permission — contradicting the approved design (`app-quality-and-native-alarms` design.md Decision 1.1, spec S1-R1 CRITICAL, which mandates `mediaPlayback|alarm` + permission to prevent `ForegroundServiceTypeException` on Android 14+ when starting from a broadcast receiver). `apply-progress.md` marks T-S1-03/04 done with "DEVIATION (see below)" but no Batch-1 deviation section exists. On Android 14+ this plausibly kills the native service start silently — removing even the brief volume-immune window.
**C — double fade-in**: native Kotlin ramp and Dart ramp run independently and can interleave at handoff → audible jump/reset (likely the real cause of "feels broken", symptom 1).
## Recommendation (sequenced, one change)
1. **Manifest fix**: restore `mediaPlayback|alarm` + `FOREGROUND_SERVICE_ALARM` per the already-approved design. Near-zero risk.
2. **Ring-scoped volume immunity (option c)**: at ring start, capture current media-stream volume; force it to an audible reference; ramp PLAYER volume 5% → `alarma.volumen`; restore original stream volume on stop/snooze/dismiss. Scoped strictly to the ring — must not touch normal radio listening.
3. **Fade-in dedup**: gate so only one ramp drives audible volume across the handoff.
Deferred (flagged as follow-up, not bundled): option (b) — native-only audio for the whole ring (Flutter screen as pure UI). Architecturally purest, but a large rewrite across `pantalla_alarma_sonando.dart`, `PluriWaveAlarmService.kt`, and the MethodChannel surface.
## Affected Areas
- `AndroidManifest.xml:56-58` — FGS type + missing permission (CRITICAL on API 34+)
- `PluriWaveAlarmService.kt` — native fade-in OK for its scope; handoff teardown
- `lib/servicios/servicio_audio.dart` — steady-state alarm source, no alarm-stream routing
- `lib/pantallas/pantalla_alarma_sonando.dart` — Dart ramp; double-ramp at handoff
- `lib/app.dart``_prearrancarAudioAlarma()` shrinks the immune window
- `app-quality-and-native-alarms/apply-progress.md` — missing Batch-1 deviation note (doc gap)
## Risks
- Kotlin/manifest edits cannot be compiled by agents (`flutter build` forbidden) — mandatory on-device verification by the user (this exact gap is how the manifest regression slipped through)
- Any audio-session/volume change must be provably scoped to the ring and reverted — must not regress phone-call ducking (`ServicioAudioSession`, S3-R1) or normal listening
- Native and Dart fade-ins duplicate the same algorithm/constants — flag for single-sourcing to prevent drift
@@ -0,0 +1,66 @@
# Proposal: Alarm Volume Ramp & Device-Volume Immunity
## Intent
An alarm must ring regardless of device media volume. Today the alarm hands off within 1-3s from the volume-immune native `USAGE_ALARM` player to the Flutter media-stream player, which is fully governed by media volume — so **media volume 0 = silent alarm** for nearly the whole ring. A manifest bug (`systemExempted` instead of `alarm`, no `FOREGROUND_SERVICE_ALARM`) can also silently kill the native start on Android 14+. Device-volume immunity for the full ring was never actually built; it must be built now.
## Scope
### In Scope
- **Manifest fix**: `PluriWaveAlarmService``foregroundServiceType="mediaPlayback|alarm"` + declare `FOREGROUND_SERVICE_ALARM`. Restores already-approved design (`app-quality-and-native-alarms` D1.1 / S1-R1).
- **Ring-scoped media-volume override (Kotlin-owned)**: on ring start capture `STREAM_MUSIC` volume, force an audible reference level; player ramps 5% → `alarma.volumen` (existing Dart ramp); restore original volume on EVERY exit (dismiss, snooze, dispose, service teardown, best-effort on kill).
- **Fade-in dedup**: gate native + Dart ramps so only one drives audible volume across handoff.
### Out of Scope
- Full native-only audio for the whole ring (exploration option b) — **future follow-up**.
- Single-sourcing the fade-in constant/curve shared by Kotlin+Dart — noted as debt.
## Capabilities
### New Capabilities
- None.
### Modified Capabilities
- `native-alarms`: alarm ring MUST be immune to device media volume for its full duration (not just the pre-handoff window); FGS type/permission corrected; fade-in single-driver across handoff.
## Approach
Override lives in **Kotlin** (`MainActivity`/`PluriWaveAlarmService` via a new method on the existing `pluriwave/alarm_scheduler` MethodChannel) because `AudioManager.setStreamVolume` has no Flutter plugin here and MainActivity already owns audio channels. **Dart drives the ring lifecycle**; `pantalla_alarma_sonando.dart` calls `capture+override` at ring start and `restore` from the already-centralized exit points (`_silenciarAudio` → dismiss/snooze; `dispose`). Restore must be **idempotent** and provably scoped: normal radio listening and phone-call ducking (`ServicioAudioSession`, S3-R1) stay untouched — the override only fires while a ring is active. Existing 5%→target Dart ramp is kept; the native ramp is gated to avoid a second audible driver at handoff.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `android/.../AndroidManifest.xml:57` | Modified | FGS type `mediaPlayback\|alarm` + `FOREGROUND_SERVICE_ALARM` permission |
| `android/.../MainActivity.kt` | Modified | New `alarm_scheduler` methods: capture/override/restore `STREAM_MUSIC` |
| `android/.../PluriWaveAlarmService.kt` | Modified | Restore-on-teardown safety net; gate native fade-in vs Dart |
| `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | Invoke override at start; restore in `_silenciarAudio`/`dispose` |
| `lib/servicios/servicio_alarmas_android.dart` | Modified | Dart wrapper for the new channel methods |
| `lib/app.dart` | Modified | Coordinate override with early `_prearrancarAudioAlarma` |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Kotlin/manifest not compilable by agent | High | On-device verify on Android 14+ is a MANDATORY human gate (CC-R1/R2) |
| App killed mid-ring leaves volume overridden | Med | Best-effort restore in service teardown + `onDestroy`; document residual gap |
| Override leaks into normal radio playback | Med | Scope strictly to ring; idempotent restore; assert ducking path untouched |
| Double fade-in still audible at handoff | Low | Single-driver gate; test the handoff window |
## Rollback Plan
Per-slice and independent. Revert the manifest line to restore prior FGS type. Behind a guard so `restore` always runs even if `override` failed (no worse than today). If regressions appear in normal playback, disable the override call in `pantalla_alarma_sonando.dart` — manifest fix stands alone.
## Dependencies
- Android 14+ device for the mandatory on-device verification of the manifest fix.
- Confirm from user whether `app-quality-and-native-alarms` Slice 1 (manifest) was ever verified on a real device (apply-progress deviation note is missing).
## Success Criteria
- [ ] Manifest declares `mediaPlayback|alarm` + `FOREGROUND_SERVICE_ALARM`; native service starts on Android 14+.
- [ ] With device media volume at 0, the alarm still rings and ramps 5% → `alarma.volumen`.
- [ ] Original media volume is restored after dismiss, snooze, and dispose.
- [ ] Normal radio listening and phone-call ducking are unaffected.
- [ ] Only one fade-in driver is audible across the native→Flutter handoff.
- [ ] No new user-visible strings (no l10n across 13 locales required).
@@ -0,0 +1,91 @@
# Delta for native-alarms
## ADDED Requirements
### Requirement: Manifest declares alarm-eligible foreground service
`PluriWaveAlarmService` in `AndroidManifest.xml` MUST use `foregroundServiceType="mediaPlayback|alarm"` and the app MUST declare `FOREGROUND_SERVICE_ALARM`, so the service is eligible to start on Android 14+ (API 34+) from a broadcast receiver.
#### Scenario: Manifest declares required FGS type and permission
**Testability**: static/grep-verifiable
- GIVEN the built `AndroidManifest.xml`
- WHEN the `PluriWaveAlarmService` `<service>` element is inspected
- THEN `android:foregroundServiceType` MUST contain `alarm`
- AND a `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>` MUST exist
#### Scenario: Native service starts from broadcast context on Android 14+
**Testability**: manual on-device QA (Android 14+; agent cannot install/verify)
- GIVEN an alarm is scheduled and the app is not foregrounded
- WHEN `PluriWaveAlarmReceiver.onReceive(ACTION_FIRE)` starts `PluriWaveAlarmService`
- THEN it MUST start without `ForegroundServiceTypeException`
- AND the alarm MUST be audible via the native `USAGE_ALARM` player
### Requirement: Ring-scoped device-volume override
While an alarm is ringing, the system MUST override `STREAM_MUSIC` to an audible reference level so the alarm is not silenced by device volume 0, and MUST restore the original captured volume when the ring ends through any exit path.
#### Scenario: Alarm is audible when device media volume is 0
**Testability**: manual on-device QA (native volume APIs)
- GIVEN device `STREAM_MUSIC` volume is 0
- WHEN an alarm fires and the override captures/raises `STREAM_MUSIC` to an audible level
- THEN the alarm MUST be audible for the full ring duration, not only the pre-handoff window
#### Scenario: Player fade-in ramps within the overridden level
**Testability**: flutter test with fakes
- GIVEN the override has set an audible `STREAM_MUSIC` reference level
- WHEN the ring's fade-in timer runs
- THEN player volume MUST ramp 5% -> `alarma.volumen` over `alarma.fadeInSegundos`, unchanged from today
#### Scenario: Dismiss restores the original captured volume
**Testability**: flutter test with fakes (Dart->channel call) + manual on-device QA (native restore)
- GIVEN the override captured `STREAM_MUSIC` at volume `V`
- WHEN the user dismisses the alarm (`_detener()` -> `_silenciarAudio()`)
- THEN `STREAM_MUSIC` MUST be restored to exactly `V`
#### Scenario: Snooze restores the original captured volume
**Testability**: flutter test with fakes (Dart->channel call) + manual on-device QA (native restore)
- GIVEN the override captured `STREAM_MUSIC` at volume `V`
- WHEN the user snoozes the alarm (`_posponer()` -> `_silenciarAudio()`)
- THEN `STREAM_MUSIC` MUST be restored to exactly `V`
#### Scenario: Restore is idempotent across double-exit paths
**Testability**: flutter test with fakes (single/no-op restore call assertion) + manual on-device QA
- GIVEN restore already ran once (`_silenciarAudio()` inside `_detener()`)
- WHEN a second exit path also runs restore (e.g. `dispose()` firing after)
- THEN the second call MUST NOT throw, MUST NOT re-apply a stale value, and MUST leave `STREAM_MUSIC` unchanged
#### Scenario: Normal radio playback never triggers the override
**Testability**: flutter test with fakes (channel never invoked outside a ring) + manual on-device QA
- GIVEN the user is listening to radio with no alarm ringing
- WHEN playback starts, plays, or stops normally
- THEN the override MUST NOT be invoked; `STREAM_MUSIC` stays fully governed by device controls
#### Scenario: App killed mid-ring — best-effort restore only
**Testability**: manual on-device QA; accepted residual gap, not required in automated coverage
- GIVEN an alarm is ringing and the override is active
- WHEN the app process is killed before any exit path runs
- THEN the system SHOULD best-effort restore from service teardown/`onDestroy`, but a residual overridden-volume state MAY occur and is an accepted known gap
### Requirement: Single fade-in driver across native-to-Flutter handoff
During handoff from the native `USAGE_ALARM` player to the Flutter media-stream player, only one fade-in ramp MUST drive audible volume at any instant.
#### Scenario: No double-ramp interleaving at handoff
**Testability**: manual on-device QA (timing-sensitive, cross-process); flutter test with fakes can assert the Dart ramp only runs when native ownership is inactive, but cannot observe native `MediaPlayer.setVolume()` timing
- GIVEN the native fade-in is ramping the `USAGE_ALARM` player
- WHEN the Flutter player becomes ready and handoff occurs (`confirmarAudioFlutter`)
- THEN the native fade-in MUST stop driving audible volume once Flutter takes over; both ramps MUST NOT drive audible volume simultaneously
## Non-Functional Notes
- No new user-visible strings; no l10n work required across the 13 supported locales.
@@ -0,0 +1,89 @@
# 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)
- [ ] 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.
- [ ] 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.
- [ ] 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.
## Phase 3: Ring-Scoped Volume Override — Dart Port + Wiring (Slice 2, strict TDD)
- [ ] 3.1 (RED) In `test/servicios/servicio_alarmas_android_test.dart`, add a test asserting `ServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)` invokes channel method `overrideMediaVolumeForRing` with `{'fraction': 1.0}`, using the existing mock-channel pattern (`MethodChannel('pluriwave/alarm_scheduler')` + `llamadas` list). Run `flutter test` — confirm it fails (method does not exist).
- [ ] 3.2 (RED) In the same file, add a test asserting `ServicioAlarmasAndroid.restaurarVolumenMedia()` invokes channel method `restoreMediaVolume` with no args. Run `flutter test` — confirm it fails.
- [ ] 3.3 (GREEN) Add `Future<void> forzarVolumenMediaParaAlarma(double fraccion)` and `Future<void> restaurarVolumenMedia()` to `PuertoAlarmasAndroid` (abstract, `lib/servicios/servicio_alarmas_android.dart`) and implement both on `ServicioAlarmasAndroid` using the existing `_logAndInvokeVoid` helper pattern. Run `flutter test` — confirm 3.1-3.2 pass.
- [ ] 3.4 (GREEN) Extend `test/helpers/fakes_alarmas.dart`'s `FakePuertoAlarmasAndroid`: implement the two new abstract methods, recording calls into new lists `volumenForzado: List<double>` and `volumenRestaurado: int` (call count) so widget tests can assert invocation order/count.
- [ ] 3.5 (RED) In `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` (or a new focused test file), add a widget test asserting `_silenciarAudio` -> restore is called exactly once on `_detener()` (dismiss) using `env.android.volumenRestaurado`. Run `flutter test` — confirm it fails.
- [ ] 3.6 (RED) Add the equivalent test for `_posponer()` (snooze) — restore called exactly once. Run `flutter test` — confirm it fails.
- [ ] 3.7 (RED) Add a test asserting restore is called at most once total even when both `_silenciarAudio()` (inside `_detener`) and `dispose()` run in sequence (idempotence at the Dart call-site level — the widget always calls restore in `dispose()` too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Run `flutter test` — confirm it fails.
- [ ] 3.8 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, call `context.read<EstadoAlarmas>().android.restaurarVolumenMedia()` inside `_silenciarAudio()` (L202-213, alongside `_liberarAudioLocal()`/`radio.audio.pausar()`, wrapped in its own try/catch so a failure never blocks dismiss/snooze) AND inside `dispose()` (L238-244). Run `flutter test` — confirm 3.5-3.7 pass.
- [ ] 3.9 (RED) In `test/pantallas` (widget test, or a lighter unit-style test on `app.dart`'s ring-start seam if testable in isolation), add a test asserting `forzarVolumenMediaParaAlarma` is invoked when an alarm ring starts, at the TOP of `_prearrancarAudioAlarma` in `lib/app.dart` (BEFORE the `if (emisora == null) return;` early exit at L367) — the override must apply even when the alarm uses the fallback WAV path, not only the station path. Run `flutter test` — confirm it fails.
- [ ] 3.10 (GREEN) In `lib/app.dart`, call `context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(1.0)` as the FIRST statement inside `_prearrancarAudioAlarma` (L365), before the `emisora == null` early return. Run `flutter test` — confirm 3.9 passes.
- [ ] 3.11 (RED) Add a test asserting the override/restore channel methods are NEVER invoked during normal radio playback with no alarm ringing (Requirement: Ring-scoped device-volume override, Scenario "Normal radio playback never triggers the override") — assert `env.android.volumenForzado` stays empty across a plain play/pause cycle on `EstadoRadio` outside any alarm flow. Run `flutter test` — confirm it fails or passes vacuously (should already pass since no other code path calls these methods yet — treat as a REGRESSION GUARD, not a RED/GREEN pair, if 3.3-3.10 are already in place).
- [ ] 3.12 (REFACTOR) Run `flutter test` for the full suite plus `flutter analyze` — confirm no regressions in existing alarm/radio tests.
## 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.