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.
85 lines
8.5 KiB
Markdown
85 lines
8.5 KiB
Markdown
# 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
|
|
|
|
```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.)
|