docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Persist the exploration, proposal, spec, design, tasks, and verify/archive reports produced during the multi-device EQ, alarm-countdown, and notification-visual-polish SDD cycles.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Design: Pre-notice Live Countdown
|
||||
|
||||
## Technical Approach
|
||||
|
||||
Mirror the shipped snooze-countdown chain (`scheduleSnoozeCountdown` / `armNextSnoozeCountdownTick` / `handleSnoozeCountdownTick` / `cancelSnoozeCountdown`) for the 30-min pre-notice. Reuse the existing `ACTION_PRE_NOTICE` for both first-post and every tick — no new action constant. `schedulePreNotice()` still arms the first exact alarm at `T-30min` (unchanged). After the receiver posts the pre-notice notification, it calls back into a new `AlarmScheduler.armNextPreNoticeCountdownTick(id, remaining)` to re-arm the next minute boundary. Notification building (skip/postpone) stays in the receiver; AlarmManager primitives stay in the scheduler — preserving the existing separation of concerns. Ticks are self-healing: each computes `ceilMinutes(triggerAtMillis - now)` from the wall clock, never decrementing a stored counter, so Doze coalescing makes the countdown *jump* rather than break.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| Decision | Choice | Alternative rejected | Rationale |
|
||||
|---|---|---|---|
|
||||
| Chain vs shared engine | Parallel impl mirroring snooze | Generalized phase-agnostic engine | Notification builders/teardown semantics diverge; shared engine needs callbacks anyway, risks shipped snooze code. |
|
||||
| Action constant | Reuse `ACTION_PRE_NOTICE` | New `ACTION_PRE_NOTICE_COUNTDOWN` | First-post and tick differ only by "recompute now"; same receiver branch, zero new wiring. |
|
||||
| requestCode slot | Slot **9** in `AlarmScheduler.requestCode` (`31*hash+9`) | Slot 4 | 4 risks future low-slot ambiguity; 9 continues the snooze-tick(8) sequence. Verified free. |
|
||||
| Minute rounding | Reuse existing `ceilMinutes()` (L551) | Receiver's floor-based `computeRemainingMinutes()` | `ceilMinutes` already class-private (not snooze-private), consistent with snooze; no new helper. |
|
||||
| Arm/cancel ownership | Both in `AlarmScheduler` | Build PI in receiver | **Critical**: receiver `requestCode` is `47*hash+slot`, scheduler is `31*hash+slot` — different values. PI cancel only matches if arm+cancel use the SAME function. |
|
||||
|
||||
## Data Flow
|
||||
|
||||
schedulePreNotice (T-30 exact) ─→ receiver ACTION_PRE_NOTICE
|
||||
│ post notification (ceilMinutes)
|
||||
▼
|
||||
AlarmScheduler.armNextPreNoticeCountdownTick(id, remaining)
|
||||
│ setExactAndAllowWhileIdle @ next boundary (slot 9)
|
||||
▼
|
||||
receiver ACTION_PRE_NOTICE (tick) ──┐ self-loop until remaining<=1
|
||||
└─→ fire alarm takes over
|
||||
|
||||
## Interfaces / Contracts
|
||||
|
||||
New in `AlarmScheduler`, signatures mirroring snooze:
|
||||
|
||||
```kotlin
|
||||
fun armNextPreNoticeCountdownTick(id: String, triggerAtMillis: Long, title: String,
|
||||
snoozeMinutes: Int, occurrenceAtMillis: Long, remaining: Long)
|
||||
private fun cancelPreNoticeCountdown(id: String) // slot 9, action ACTION_PRE_NOTICE
|
||||
```
|
||||
|
||||
`armNextPreNoticeCountdownTick` is **public** (receiver calls it). It returns early when `remaining <= 1L` (final minute owned by the real fire alarm), computes `nextBoundary = triggerAtMillis - (remaining-1)*60_000L`, and arms `ACTION_PRE_NOTICE` with the full extras (id/title/snoozeMinutes/triggerAt/occurrenceAt) via `requestCode(id, 9)`. The receiver passes these from the incoming intent. `cancelPreNoticeCountdown` builds an action-only PI with `FLAG_NO_CREATE` at slot 9 and calls `cancelPending`.
|
||||
|
||||
Receiver `ACTION_PRE_NOTICE` handler: after `showPreNoticeNotification(...)`, recompute `remaining` and call `AlarmScheduler(context).armNextPreNoticeCountdownTick(...)`. Switch `computeRemainingMinutes()` to ceil semantics for display consistency (or pass remaining through from scheduler).
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Action | Description |
|
||||
|---|---|---|
|
||||
| `android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt` | Modify | Add `armNextPreNoticeCountdownTick`, `cancelPreNoticeCountdown`; wire cancel into `cancelAlarm` (L560 area), `scheduleSpec` no-trigger branch (L90-92), snooze-transition branch (`schedulePreNotice` L140-144). Reuse `ceilMinutes`. |
|
||||
| `android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt` | Modify | `ACTION_PRE_NOTICE` re-arms next tick after posting; `ACTION_SKIP_NEXT` (L77) and `ACTION_POSTPONE_NEXT` (L57) cancel the tick chain via `AlarmScheduler`. |
|
||||
|
||||
### 5-Site Cancellation Wiring (exact)
|
||||
|
||||
1. `cancelAlarm(id)` L554-568 — add `cancelPreNoticeCountdown(id)` alongside existing `cancelSnoozeCountdown(id)`.
|
||||
2. `scheduleSpec` no-trigger branch L87-93 — add `cancelPreNoticeCountdown(spec.id)` after the existing preNotice cancel.
|
||||
3. `schedulePreNotice` snooze-transition branch L140-144 — add `cancelPreNoticeCountdown(spec.id)` (currently only cancels single-shot preNotice PI).
|
||||
4. Receiver `ACTION_SKIP_NEXT` L77-92 — call `AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)` (must be public or via a thin public wrapper) before/after `skipNext`. `skipNext`→`scheduleSpec` re-arms a fresh chain for the next occurrence, so cancel the *current* chain first.
|
||||
5. Receiver `ACTION_POSTPONE_NEXT` L57-76 — same: cancel current pre-notice tick chain (postpone transitions to snooze, which drives the snooze countdown instead).
|
||||
|
||||
Note: sites 2 already cancels via `scheduleSpec` when postpone/skip route through it; explicit cancel in 4/5 guards the window before re-scheduling and the one-shot path that calls `cancelAlarm`.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | What to Test | Approach |
|
||||
|---|---|---|
|
||||
| Unit | `ceilMinutes` boundary (29→1), `nextBoundary` math, `remaining<=1` stop | Pure-function tests on the math helpers. |
|
||||
| Unit | `cancelPreNoticeCountdown` PI identity (slot 9, `31*hash`) | Verify same requestCode used to arm and cancel. |
|
||||
| Instrumentation | Tick reposts each minute; self-stops; skip/postpone/snooze-transition tear down chain | Robolectric/instrumented receiver with a fake clock. |
|
||||
| Regression | Snooze countdown unchanged | Existing snooze tests must stay green. |
|
||||
|
||||
## Migration / Rollout
|
||||
|
||||
No migration required. Kotlin-only, two files. No schema/ARB/Dart/MainActivity changes — `preNoticeCountdown` ARB key with `{minutes}` placeholder and `setNotificationStrings` plumbing already shipped in the prior `alarm-live-countdown` change. Rollback = revert the two files (single commit, zero data migration); pre-notice falls back to single-shot.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] Receiver cancel calls `AlarmScheduler.cancelPreNoticeCountdown` which is currently `private` — expose a public wrapper or make it public. (Recommendation: public, mirrors how receiver already calls public `cancelSnooze`/`skipNext`.)
|
||||
- [ ] Display rounding: switch receiver `computeRemainingMinutes` to ceil, or pass `remaining` from scheduler. (Recommendation: pass through to avoid double clock reads producing off-by-one between display and next-boundary math.)
|
||||
Reference in New Issue
Block a user