Close the SDD cycle for the ring architecture replacement: verified with one critical (channel silence by omission) fixed and re-checked before archive, delta merged into the main native-alarms spec (2 requirements removed, 5 added), artifacts archived byte-for-byte. Phase 3 on-device QA (9 items) remains the mandatory human gate.
324 lines
23 KiB
Markdown
324 lines
23 KiB
Markdown
# Design: Native Alarm Ring
|
||
|
||
Reads: `proposal.md` (#2388), `explore.md` (#2387). This is the HOW at the architectural
|
||
level. Task breakdown lives in `tasks.md` (next phase).
|
||
|
||
## 1. Context and constraints
|
||
|
||
The ring failed 5 on-device iterations under a split-ownership design (Dart ramp → hand off to
|
||
native → override `STREAM_MUSIC` → restore). Root cause: two audio owners (Dart `just_audio`
|
||
on `STREAM_MUSIC` and the native `MediaPlayer` on `STREAM_ALARM`) fighting the alarm FGS.
|
||
|
||
The rebuild collapses ownership to ONE: `PluriWaveAlarmService` is the sole ring-audio owner
|
||
for the entire ring. Flutter becomes display-only. This is AOSP DeskClock's `AsyncRingtonePlayer`
|
||
shape. The current native service is ~80% there; the change mostly DELETES accidental complexity.
|
||
|
||
Hard constraints (from exploration, unchanged by this design):
|
||
- Scheduling stack, receiver `ACTION_FIRE` ordering, wake-lock/FGS-before-audio ordering,
|
||
snooze truth-ownership (5 entry points), pre-notice + snooze-countdown chains, id-scoped stop.
|
||
- No system stream volume is ever written (`setStreamVolume` forbidden on every stream).
|
||
- Kotlin is not agent-compilable → on-device QA is the acceptance gate.
|
||
|
||
Verified facts (read in full this phase):
|
||
- Ring channel `pluriwave_alarm_fire_v2` is referenced ONLY by `PluriWaveAlarmService`
|
||
(`buildNotification` L425, companion L554, `ensureChannel` L632). Pre-notice + snooze-countdown
|
||
use a SEPARATE channel `pluriwave_alarm_pre_notice` (`PluriWaveAlarmReceiver.CHANNEL_ID`, built
|
||
in `AlarmScheduler.ensurePreNoticeChannel`). The channel bump is fully scoped to the ring.
|
||
- `PluriWaveBootReceiver` only calls `reschedulePersistedAlarms()` — no FGS from boot (A15+ safe).
|
||
- The screen-exit stop path is intact: `finalizarEjecucion(id)` → `ocultarNotificacionAlarma(id)`
|
||
→ channel `dismissAlarmNotification` → `PluriWaveAlarmService.stop(ctx,id)` + `dismissFireNotification(id)`.
|
||
In `stopAlarm`, `id == activeAlarmId` takes the FULL-teardown branch; a different id only cancels
|
||
that id's notification. Id-scoped stop for the ACTIVE id is CONFIRMED working.
|
||
- `minSdk = flutter.minSdkVersion` → the pre-O audio-focus branch is potentially reachable; the
|
||
focus API must be version-guarded.
|
||
|
||
## 2. Architecture approach
|
||
|
||
Single-owner native audio, thin Dart UI, no cross-boundary audio protocol.
|
||
|
||
```
|
||
ACTION_FIRE ─→ PluriWaveAlarmReceiver ─→ PluriWaveAlarmService.startAlarm
|
||
│ wake lock → startForeground(silent v3) BEFORE audio
|
||
│ request AUDIOFOCUS_GAIN_TRANSIENT (USAGE_ALARM)
|
||
│ capture fade anchor (elapsedRealtime)
|
||
▼
|
||
startAudio ─→ 3-stage fallback (station → fallback station → WAV)
|
||
│ each source: setVolume(computeFadeVolume(...)) BEFORE start
|
||
│ ONE ring-anchored 50 ms fade Handler loop (dB curve)
|
||
▼
|
||
plays until ACTION_STOP / ACTION_SNOOZE / onDestroy
|
||
│ cancel fade → player.stop/release → abandon focus
|
||
│ → release wake lock → cancel notif → stopForeground
|
||
|
||
MainActivity ─ alarmFired ─→ EstadoAlarmas ─→ app.dart pushes PantallaAlarmaSonando (DISPLAY ONLY)
|
||
│ Stop / Snooze / back
|
||
▼
|
||
finalizarEjecucion / posponerAlarma ─→ ocultarNotificacionAlarma
|
||
▼
|
||
PluriWaveAlarmService.stop(activeId) (native stop = the ONLY audio stop)
|
||
```
|
||
|
||
Layering / boundaries:
|
||
- Native (Kotlin): owns ALL ring audio, fade, focus, notification. No Dart audio dependency.
|
||
- Bridge (MethodChannel `pluriwave/alarm_scheduler`): loses `confirmFlutterAudio`,
|
||
`overrideMediaVolumeForRing`, `restoreMediaVolume`. Keeps scheduling / dismiss / snooze / diagnostics.
|
||
- Dart port (`PuertoAlarmasAndroid`): loses the 3 handoff/override methods; screen is pure UI.
|
||
- Audio-focus mechanism (not app code) handles the user's normal radio: `AUDIOFOCUS_GAIN_TRANSIENT`
|
||
transiently pauses `STREAM_MUSIC` playback; abandoning on stop lets it resume. The screen never
|
||
touches `EstadoRadio` — the OS focus policy replaces the old manual pause/restore.
|
||
|
||
## 3. Component map and data flow
|
||
|
||
| Component | Change | Detail |
|
||
|-----------|--------|--------|
|
||
| `PluriWaveAlarmService.kt` | Rebuild | dB-curve fade (pure fn + single 50 ms loop, ring-anchored), manual focus, silent v3 channel, delete `flutterOwnsRing` + both backstop sites; keep wake lock / FGS-before-audio / 3-stage fallback / id-scoped stop verbatim |
|
||
| `MainActivity.kt` | Delete surface | remove `confirmFlutterAudio` / `overrideMediaVolumeForRing` / `restoreMediaVolume` handlers, private `overrideMediaVolumeForRing` / `restoreMediaVolume`, companion `mediaVolumeOverridden` / `capturedMediaVolume` / `restoreMediaVolumeBestEffort` |
|
||
| `pantalla_alarma_sonando.dart` | Rewrite → pure UI | delete all audio orchestration; keep single-exit guard, Stop/Snooze/back → EstadoAlarmas, PopScope, `_dismissScreen`, snooze options, layout |
|
||
| `app.dart` | Delete pre-start | remove `_prearrancarAudioAlarma` + call, `audioPrearrancado` arg, `_volumenInicialFadeInAlarmas`; keep SKIP/POSTPONE/PRE_NOTICE routing + duplicate-delivery guard |
|
||
| `servicio_alarmas_android.dart` | Reduce port | drop `confirmarAudioFlutter` / `forzarVolumenMediaParaAlarma` / `restaurarVolumenMedia` from interface + impl |
|
||
| `test/helpers/fakes_alarmas.dart` | Reduce fake | drop the 3 overrides + `volumenForzado` / `volumenRestaurado` / `puertaConfirmarAudioFlutter` / `fallaConfirmarAudioFlutter` |
|
||
| 4 test files | Rewrite/trim | see §7 |
|
||
|
||
## 4. Decisions (ADR)
|
||
|
||
### D1 — Fade as a pure function + one ring-anchored 50 ms Handler loop
|
||
**Decision.** Extract the DeskClock curve to a pure, side-effect-free function so the math is
|
||
inspectable and JVM-testable later:
|
||
```
|
||
private const val FADE_TICK_MILLIS = 50L
|
||
private const val FADE_RANGE_DB = 40.0f // DeskClock: -40 dB → 0 dB ≈ 1% → 100% amplitude
|
||
|
||
fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
|
||
if (fadeMs <= 0L) return ceiling.coerceIn(0f, 1f)
|
||
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
|
||
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB // -40..0 dB
|
||
val curve = Math.pow(10.0, (gainDb / 20.0)).toFloat() // 0.01..1.0
|
||
return (ceiling * curve).coerceIn(0f, 1f)
|
||
}
|
||
```
|
||
A SINGLE `Handler` loop (50 ms tick) drives the whole ring, reading the current `player` field
|
||
each tick so it survives the 3-stage source swap. `ceiling = volume` (the per-alarm scalar, already
|
||
wired via `EXTRA_VOLUME`). At `fraction=0` → `ceiling*0.01`; at `fraction=1` → `ceiling`.
|
||
**Rejected.** (a) Per-source fade restarts (current shape) — a source swap would restart the
|
||
crescendo from 1% after the user already waited. (b) Linear-amplitude ramp (current `startFadeIn`)
|
||
— inaudible for the first half, the exact defect being fixed. (c) 250 ms tick — audibly stepped at
|
||
low gain where the ear is most sensitive; 50 ms is the DeskClock cadence.
|
||
**Replaces.** `startFadeIn`, `initialVolume`, `FADE_IN_STEP_MILLIS`, `FADE_IN_START_FRACTION`.
|
||
|
||
### D2 — Anchor the fade at RING start, not at audio start
|
||
**Decision.** Capture `fadeAnchorElapsedMs = SystemClock.elapsedRealtime()` once in `startAlarm`
|
||
(before audio). Every tick and every pre-start `setVolume` computes `elapsed = now - anchor`.
|
||
**Why.** Matches the on-device-proven behavior just shipped (commit 2e64740, "anchor the fade at
|
||
alarm time"): a slow-buffering station or a 15 s stage timeout must NOT freeze/restart the ring at
|
||
1%. When a source finally prepares, it joins the ramp at the elapsed level (a fresh source starting
|
||
at, e.g., 50% via `setVolume` before `start()` is click-free, not a pop).
|
||
**Rejected.** Pure DeskClock "anchor at crescendo/audio start" — reintroduces the long-buffer
|
||
freeze the app already fixed; the 3-stage fallback makes per-source anchoring user-hostile.
|
||
**Trade-off.** After a 15 s station timeout the WAV enters at a higher fade level; acceptable and
|
||
preferable to silence — the user has already waited in silence.
|
||
|
||
### D3 — Manual audio focus: `AUDIOFOCUS_GAIN_TRANSIENT`, version-guarded, no ducking listener
|
||
**Decision.** Request focus ONCE in `startAudio` (before the fallback chain, so it covers whichever
|
||
source plays); abandon in `stopAlarm`'s full-teardown branch (next to `releaseWakeLock`, NOT in the
|
||
mismatched-id early return). Use the modern API on O+ and the deprecated stream API below:
|
||
```
|
||
// O+ : AudioFocusRequest.Builder(AUDIOFOCUS_GAIN_TRANSIENT)
|
||
// .setAudioAttributes(alarmAudioAttributes()) // USAGE_ALARM
|
||
// .setOnAudioFocusChangeListener { } // no-op: an alarm does not duck/stop
|
||
// .build() → store to abandon the exact request
|
||
// < O : requestAudioFocus(noopListener, AudioManager.STREAM_ALARM, AUDIOFOCUS_GAIN_TRANSIENT)
|
||
```
|
||
**Why.** ExoPlayer's auto-focus throws for non-media usages; DeskClock requests focus manually.
|
||
`TRANSIENT` (not `GAIN`) so the user's music/radio auto-resumes when we abandon. The listener is a
|
||
no-op because an alarm must keep ringing through focus changes.
|
||
**Rejected.** (a) No focus request — the user's `STREAM_MUSIC` radio keeps playing under the alarm
|
||
(the exact double-audio symptom, now solved by the OS instead of manual Dart pause). (b) Permanent
|
||
`GAIN` — music would not resume after dismiss.
|
||
**Note.** If `minSdk >= 26` the pre-O branch is dead code and may be dropped; the guard is kept
|
||
for correctness regardless.
|
||
|
||
### D4 — Silent channel `pluriwave_alarm_fire_v3` + single `channels_migrated_v3` migration
|
||
**Decision.** New channel id `pluriwave_alarm_fire_v3`, `IMPORTANCE_HIGH` (kept — required for FSI),
|
||
`setSound(null, null)` (silent: the player is the only audio). Replace the `channels_migrated_v2`
|
||
guard with ONE `channels_migrated_v3` guard that deletes all three obsolete ids
|
||
(`pluriwave_alarm_native`, `pluriwave_alarm_fire`, `pluriwave_alarm_fire_v2`) — `deleteNotificationChannel`
|
||
is a safe no-op when a channel is absent, so this is correct on both fresh installs and v2 devices.
|
||
Update the `CHANNEL_ID` constant and its single use in `buildNotification`.
|
||
**Why.** Android locks channel sound at creation; editing `_v2` (which set `DEFAULT_ALARM_ALERT_URI`)
|
||
is a no-op. A new id is the only way to make the fire notification silent. The v2 migration already
|
||
proved this pattern.
|
||
**Rejected.** Keeping both `_v2` and `_v3` migration guards — redundant; a folded `_v3` guard is
|
||
simpler and equally safe.
|
||
**Scope.** Only the ring channel resets user-visible settings; the pre-notice channel is untouched.
|
||
|
||
### D5 — Keep channel vibration ON (minimal change) rather than an explicit Vibrator
|
||
**Decision.** `enableVibration(true)` on `_v3`, as `_v2` had.
|
||
**Why.** DeskClock vibrates via a separate `Vibrator`, but adding one here expands surface for no
|
||
functional gain; the channel-level vibration already works. Minimal change wins.
|
||
**Rejected.** Explicit `Vibrator` with a pattern — more code, new permission/coordination surface,
|
||
out of proportion to the ring-audio goal. Can be a follow-up if per-alarm vibration control is wanted.
|
||
|
||
### D6 — Keep MediaPlayer; reject ExoPlayer/Media3 now
|
||
**Decision.** MediaPlayer stays for all three sources.
|
||
**Why.** It is already pop-free and already streams stations successfully. ExoPlayer has an
|
||
unresolved start-pop (#2752, ~200 ms at full gain) and would need muted-preroll mitigation.
|
||
**Rejected.** ExoPlayer/Media3 for ICY metadata + reconnect — real benefits, but not worth the pop
|
||
risk for the ring; revisit only if station reconnect proves insufficient on device.
|
||
|
||
### D7 — Kill the handoff surface entirely
|
||
**Decision.** Delete `flutterOwnsRing` (field, reset in `startAlarm`, both guard reads in
|
||
`stopAlarm`/`onDestroy`, and the companion doc); delete `MainActivity.restoreMediaVolumeBestEffort`
|
||
and its two call sites; delete the `confirmFlutterAudio`/`overrideMediaVolumeForRing`/`restoreMediaVolume`
|
||
handlers + private methods + companion state. On the Dart side delete the matching port methods,
|
||
service impls, and fake members.
|
||
**Why.** With one owner there is no handoff to signal and no stream to restore — "restore" is a
|
||
no-op by construction because no system volume is ever written. Every guard that existed to keep the
|
||
two owners from silencing each other becomes dead.
|
||
**Verification (this phase).** Grepped every reference; the only callers are the sites listed above.
|
||
Nothing else in the codebase reads/writes these symbols.
|
||
|
||
### D8 — Ring-state visibility: NO new channel; status from static alarm config
|
||
**Decision.** Do NOT add a native→Dart ring-state event. The screen's status line (currently the
|
||
tri-state `_fallbackActivo`/`_radioIntentada` text) is re-sourced from `widget.alarma` static config
|
||
(e.g. the configured station name when `alarma.emisora != null`, else a neutral ringing label). The
|
||
`Text` widget stays in place so the visual layout is unchanged.
|
||
**Why.** In native-only, Dart cannot know which source is playing without a new channel — which
|
||
would contradict "kill the handoff, minimal surface." The system notification already shows the
|
||
station name for "what's playing." Evidence: the only screen state that depended on live playback
|
||
was that one status line; everything else (time, name, buttons) is already static.
|
||
**Rejected.** (a) New ring-state EventChannel — reintroduces a cross-boundary protocol for a cosmetic
|
||
label. (b) Removing the line entirely — would alter the layout the proposal says to keep.
|
||
**Consequence.** l10n keys `alarmRingingFallbackActive` / `alarmRingingTryingStation` /
|
||
`alarmRingingPreparingFallback` become unused; leaving them is harmless (arb cleanup out of scope).
|
||
|
||
### D9 — Screen exit path unchanged; native stop is the only audio stop
|
||
**Decision.** Keep `_detener` → `finalizarEjecucion(id)` and `_posponer` → `posponerAlarma`, both of
|
||
which already route the native stop via `ocultarNotificacionAlarma`. `_detener`/`_posponer` no longer
|
||
touch `EstadoRadio` (no Flutter audio to stop; focus abandon handles the user's radio). Keep the
|
||
single-exit `_salidaEnCurso` guard, `PopScope(canPop:false)` back=Stop, `_dismissScreen`
|
||
(canPop→pop / else SystemNavigator.pop), snooze options, failure SnackBar (with the pre-captured
|
||
`ScaffoldMessenger`).
|
||
**Verification (this phase).** Traced `finalizarEjecucion`/`posponerAlarma` → `ocultarNotificacionAlarma`
|
||
→ `dismissAlarmNotification` → `PluriWaveAlarmService.stop(activeId)` → full-teardown branch. Confirmed
|
||
the active id stops; a different id only cancels its own notification.
|
||
|
||
### D10 — Keep `USAGE_ALARM + CONTENT_TYPE_MUSIC`
|
||
**Decision.** Leave `alarmAudioAttributes()` unchanged and reuse it for the focus request.
|
||
**Why.** `USAGE_ALARM` is the load-bearing part — it routes to `STREAM_ALARM`, making the ring
|
||
audible at media-volume 0 and uninterruptible. `CONTENT_TYPE` is secondary; MUSIC suits a station
|
||
and already works. Changing to `SONIFICATION` is optional and not worth the churn.
|
||
|
||
### D11 — Defer receiver wake-lock + OEM hardening to `oem-reliability-guidance`
|
||
**Decision.** Do NOT add a receiver-level `PARTIAL_WAKE_LOCK` or OEM autostart guidance in this
|
||
change. Verified `PluriWaveBootReceiver` already reschedules only (no boot FGS), so nothing to change
|
||
there.
|
||
**Why.** The proposal scopes this change to the AUDIO rebuild; delivery/OEM hardening is an explicit
|
||
downstream follow-up. Keeping them separate keeps this diff focused and revertible.
|
||
|
||
## 5. Integration points and do-not-touch verification
|
||
|
||
- Notification chains are INDEPENDENT: pre-notice + snooze-countdown live on `pluriwave_alarm_pre_notice`
|
||
via `AlarmScheduler.ensurePreNoticeChannel`; `dismissFireNotification` only cancels the fire
|
||
notification by id; `cancelAlarm` cancels pending intents + both notifications but never touches the
|
||
ring player. The `_v2→_v3` bump cannot affect them.
|
||
- Snooze truth-ownership unchanged: native `ACTION_SNOOZE` still re-arms via `AlarmScheduler.snooze`
|
||
and reports back through `notifyAlarmEvent`; `EstadoAlarmas._alRecibirEventoNativo` still records it.
|
||
Killing the handoff does not touch any of these paths.
|
||
- Wake-lock / `startForeground`-before-audio ordering, 3-stage fallback with 15 s timeouts, and the
|
||
id-scoped `stopAlarm` mismatch branch are preserved verbatim.
|
||
|
||
## 6. Data flow — teardown detail
|
||
|
||
```
|
||
Stop / Snooze / system-back ─→ _detener / _posponer (single-exit guard)
|
||
│ finalizarEjecucion(id) | posponerAlarma(alarma, min)
|
||
▼
|
||
EstadoAlarmas ─→ android.ocultarNotificacionAlarma(id) ─ MethodChannel dismissAlarmNotification
|
||
▼
|
||
PluriWaveAlarmService.stop(id) ─→ stopAlarm(id==active) ─→ cancel fade loop → player.stop()/release()
|
||
→ abandon audio focus → release wake lock → cancel fire notif → stopForeground → stopSelf
|
||
```
|
||
No stream restore, no Dart player teardown — there is nothing to undo.
|
||
|
||
## 7. Test design
|
||
|
||
Dart `flutter test` compiles the WHOLE test library as one unit: any file referencing a removed
|
||
symbol breaks the entire suite. This drives both the rewrite list and the work-unit split (§8).
|
||
|
||
Dies (compile-forced by port/param removal):
|
||
- `servicio_alarmas_android_test.dart`: DELETE the two tests `forzarVolumenMediaParaAlarma invoca
|
||
overrideMediaVolumeForRing` (L110-122) and `restaurarVolumenMedia invoca restoreMediaVolume`
|
||
(L124-136). Keep the scheduleAlarm-payload and battery tests.
|
||
- `pantalla_alarma_sonando_test.dart`: DELETE groups `rampa anclada y override diferido al primer
|
||
audio` (L172-222) and `handoff con audio prearrancado ya reproduciendo (regresion)` (L224-246);
|
||
DELETE `salir a mitad de rampa restaura la ganancia del reproductor` (L273-291) and `restore de
|
||
volumen de medios con dispose como unico llamador` (L294-308). Remove the `audioPrearrancado` arg
|
||
and the `audioYaReproduciendo` gate from `_montarPantalla`.
|
||
- `pantalla_alarma_sonando_dismiss_guard_test.dart`: DELETE group `media-volume override restore
|
||
(Slice 2)` (L360-429). Remove `audioPrearrancado:true` from both mount helpers.
|
||
|
||
Rewritten as pure-UI (buttons → EstadoAlarmas, navigation, SnackBar; NO audio asserts):
|
||
- `pantalla_alarma_sonando_test.dart` KEEPS: snooze-options rendering (L126-148); `posponer 5 min`
|
||
(L150-170) trimmed to assert `snoozeHasta` + `ocultadas contains id` + `programadas.last.snoozeHasta`
|
||
+ screen dismissed (drop `audio.pausas`); system-back = Stop (L248-271) trimmed to assert dismissed
|
||
+ `ocultadas contains id` (drop `audio.pausas`, drop `volumenForzado`).
|
||
- `pantalla_alarma_sonando_dismiss_guard_test.dart` KEEPS: dismiss-guard canPop/SystemNavigator group
|
||
(L183-297) and snooze-failure SnackBar group (L299-358), unchanged except mount-helper signature.
|
||
- `pantalla_alarma_sonando_scaffold_test.dart`: unchanged asserts; only drop the `audioPrearrancado`
|
||
arg. Both scaffold/animation tests survive.
|
||
|
||
Decoupling proof (reframed, KEEP): the `EstadoRadio ... nunca dispara el override de volumen`
|
||
regression (L431-445) can no longer assert `volumenForzado`/`volumenRestaurado` (removed). Reframe it
|
||
as: a normal `reproducir`/`pausar` cycle leaves the alarm port untouched (`programadas` / `detenidas`
|
||
/ `ocultadas` all empty). The strongest decoupling guarantee is now COMPILE-TIME: the fake no longer
|
||
HAS override methods, so no screen code can call them.
|
||
|
||
Fade math: `computeFadeVolume` is documented as a pure Kotlin function for inspection. No JVM harness
|
||
is added (Kotlin has none today). On-device QA checklist is the acceptance gate:
|
||
1. Audible exponential curve (not linear/late) at a configured fade of 15–30 s.
|
||
2. Volume ceiling correct: "50%" ≈ 50% of the device ALARM knob at full ramp.
|
||
3. No start pop on station, fallback station, and WAV.
|
||
4. No second audio source (native only; Dart plays nothing).
|
||
5. Dismiss / snooze / system-back all stop the ring and abandon focus (user's music resumes).
|
||
6. Media volume 0 still rings (USAGE_ALARM); ALARM stream 0 is silent (accepted).
|
||
7. Screen-off FSI delivery + ring after HyperOS Autostart enabled (POCO X7 Pro).
|
||
|
||
## 8. Work-unit split — 2 units, Dart-first (stacked-to-main)
|
||
|
||
The proposal suggested 3 units (Kotlin / Dart / tests). This design consolidates to **2**, and
|
||
reverses the order, with justification.
|
||
|
||
- **WU1 — Dart + fakes + tests (ONE unit).** `pantalla_alarma_sonando.dart`, `app.dart`,
|
||
`servicio_alarmas_android.dart` (port+impl), `fakes_alarmas.dart`, and all 4 test files.
|
||
*Why atomic:* removing `audioPrearrancado` and the 3 port methods breaks compilation of every test
|
||
still referencing them; Dart test compilation is whole-suite, so the surviving tests cannot even
|
||
run until the tests are rewritten in the SAME unit. Splitting "Dart lib" from "tests" is impossible
|
||
for a green PR.
|
||
- **WU2 — Kotlin.** `PluriWaveAlarmService.kt` rebuild (dB curve, manual focus, silent v3 channel,
|
||
delete `flutterOwnsRing` + backstops) and `MainActivity.kt` surface deletion. On-device QA gate.
|
||
|
||
**Order = Dart-first, and it matters.** Each stacked PR must be shippable:
|
||
- Dart-first intermediate (new pure-UI Dart + OLD native): the OLD native already plays the full ring
|
||
via its 3-stage fallback until `stopAlarm`; new Dart calls none of the deleted handlers, so there is
|
||
NO double audio and NO `notImplemented` exception. The ring works (old linear fade + the pre-existing
|
||
channel-sound overlap; native on `STREAM_ALARM` so media-0 still rings). This is not worse than what
|
||
is shipped at 2e64740 — it is better (the failed handoff is gone). SHIPPABLE.
|
||
- Kotlin-first intermediate (OLD Dart + new native) would be BROKEN: WU2 deletes the `MainActivity`
|
||
handlers while old Dart still calls `confirmFlutterAudio`/override/restore → `confirmFlutterAudio`
|
||
no longer stops the native, and old Dart simultaneously starts its `_fallbackPlayer`/radio ramp →
|
||
DOUBLE AUDIO on device. Rejected.
|
||
|
||
Therefore: ship WU1 (Dart) first, WU2 (Kotlin) second. After WU2, no Dart code references the deleted
|
||
native handlers, so their removal is clean.
|
||
|
||
## 9. Risks and assumptions
|
||
|
||
| Risk / assumption | Severity | Mitigation |
|
||
|-------------------|----------|------------|
|
||
| Kotlin not agent-compilable | High | On-device QA checklist (§7) is the gate; fade math isolated in a pure fn for inspection |
|
||
| Focus request pauses user's radio but resume UX differs per OEM | Medium | `TRANSIENT` is the standard; verify resume on device (checklist #5) |
|
||
| `_v3` channel bump resets ring-channel settings for users | Low (expected) | Release note; pre-notice channel untouched; revert recreates `_v2` harmlessly |
|
||
| Dart-first intermediate shows old fade / channel-sound overlap briefly | Low | It is ≤ current shipped behavior; only exists between WU1 and WU2 |
|
||
| Anchor-at-ring-start makes WAV enter loud after a 15 s station timeout | Low | Intended (§D2); preferable to silence |
|
||
| Assumption: OS focus policy replaces manual radio pause on all target devices | Medium | Validate on POCO X7 Pro; fallback is a follow-up, not a blocker |
|