Files
FreeTLab bccc5c48b8
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
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.
2026-07-04 12:42:11 +02:00

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)

  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. skipNextscheduleSpec 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.)