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.
8.5 KiB
Design: Alarm Volume Ramp & Device-Volume Immunity
SLICE 1 CANCELLED — CORRECTION (2026-07-11, apply-stage SDK verification). The first two Architecture Decisions below are built on constants that DO NOT EXIST in the Android SDK:
FOREGROUND_SERVICE_TYPE_ALARMandandroid.permission.FOREGROUND_SERVICE_ALARMare fictional (verified viajavap -constantson the local android-34/35/36 platform jars and a fullapi-versions.xmlsweep — 13 real FGS types, no alarm variant; constants are additive, so absence in 36 rules out 34/35). The existingmediaPlayback|systemExempteddeclaration is the documented correct pattern for an alarm-clock app holding exact-alarm permissions starting an FGS from an AlarmManager broadcast, and the priorapp-quality-and-native-alarmsdeviation (T-S1-03/04) that kept it was right. Slice 1 ships NO code change. Root Cause B from the exploration is withdrawn; the user-visible bug is fully attributed to Root Cause A (media-stream steady state), addressed by Slices 2 and 3, which are independent and unaffected. Evidence trail: engramsdd/alarm-volume-ramp-restore/apply-progress(#2332) and project discoveryandroid/fgs-alarm-type-nonexistent(#2333).
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
// 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).
fractionparam default 1.0 (max) accepted, or expose per-alarm later? (Deferred; not blocking.)