# 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 forzarVolumenMediaParaAlarma(double fraccion); // -> overrideMediaVolumeForRing Future 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.)