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.
6.9 KiB
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:
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)
cancelAlarm(id)L554-568 — addcancelPreNoticeCountdown(id)alongside existingcancelSnoozeCountdown(id).scheduleSpecno-trigger branch L87-93 — addcancelPreNoticeCountdown(spec.id)after the existing preNotice cancel.schedulePreNoticesnooze-transition branch L140-144 — addcancelPreNoticeCountdown(spec.id)(currently only cancels single-shot preNotice PI).- Receiver
ACTION_SKIP_NEXTL77-92 — callAlarmScheduler(context).cancelPreNoticeCountdown(alarmId)(must be public or via a thin public wrapper) before/afterskipNext.skipNext→scheduleSpecre-arms a fresh chain for the next occurrence, so cancel the current chain first. - Receiver
ACTION_POSTPONE_NEXTL57-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.cancelPreNoticeCountdownwhich is currentlyprivate— expose a public wrapper or make it public. (Recommendation: public, mirrors how receiver already calls publiccancelSnooze/skipNext.) - Display rounding: switch receiver
computeRemainingMinutesto ceil, or passremainingfrom scheduler. (Recommendation: pass through to avoid double clock reads producing off-by-one between display and next-boundary math.)