Files
pluriwave/openspec/changes/alarm-volume-ramp-restore/design.md
T
FreeTLab 43f61d7c21
Build & Deploy PluriWave / Análisis de código (push) Successful in 34s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
docs(openspec): cancel alarm FGS slice, the alarm service type never existed
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.
2026-07-11 01:22:18 +02:00

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_ALARM and android.permission.FOREGROUND_SERVICE_ALARM are fictional (verified via javap -constants on the local android-34/35/36 platform jars and a full api-versions.xml sweep — 13 real FGS types, no alarm variant; constants are additive, so absence in 36 rules out 34/35). The existing mediaPlayback|systemExempted declaration is the documented correct pattern for an alarm-clock app holding exact-alarm permissions starting an FGS from an AlarmManager broadcast, and the prior app-quality-and-native-alarms deviation (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: engram sdd/alarm-volume-ramp-restore/apply-progress (#2332) and project discovery android/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).
  • fraction param default 1.0 (max) accepted, or expose per-alarm later? (Deferred; not blocking.)