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

18 KiB

Tasks: Pre-notice Live Countdown

Change: pre-notice-live-countdown Spec: sdd/pre-notice-live-countdown/spec Design: sdd/pre-notice-live-countdown/design

Notes on Verification Approach

This is a Kotlin-only change (AlarmScheduler.kt, PluriWaveAlarmReceiver.kt) inside an Android host module with no Kotlin unit test framework configured (confirmed in prior verify reports for this project — JUnit/Robolectric/instrumentation harness absent). Strict TDD Mode applies to this repo's Dart/Flutter code only; it does not apply here because there is no RED step available (no test runner to fail first).

Tasks below therefore follow implement -> manual/code-inspection verify instead of RED-GREEN-REFACTOR:

  • Each implementation task has a paired verification task that is a concrete, checkable inspection (read the diff, trace the call graph, confirm requestCode arithmetic matches, confirm grep counts) — not "looks good".
  • Where a real device/emulator check is feasible (notification updates, Doze jump behavior) it is called out explicitly as manual QA, separate from code inspection.

1. AlarmScheduler.kt — Core Tick Engine (Sequential, single file)

1.1 [x] Add armNextPreNoticeCountdownTick to AlarmScheduler.kt

  • Satisfies: Requirement "Per-Minute Tick Re-Arm", Requirement "Self-Stop at Final Minute"
  • Location: new private/internal function near armNextSnoozeCountdownTick (around L435), in AlarmScheduler.kt
  • Mirror armNextSnoozeCountdownTick signature/shape per design: armNextPreNoticeCountdownTick(id, triggerAtMillis, title, snoozeMinutes, occurrenceAtMillis, remaining)
  • Early-return when remaining <= 1L (no re-arm on final minute — design "Open Questions" + spec "Self-Stop at Final Minute")
  • Compute nextBoundary = triggerAtMillis - (remaining - 1L) * 60_000L
  • Build PendingIntent.getBroadcast with action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE (REUSE, no new action constant) and full extras (EXTRA_ALARM_ID, EXTRA_ALARM_TITLE, EXTRA_SNOOZE_MINUTES, EXTRA_TRIGGER_AT, EXTRA_OCCURRENCE_AT) — same extras schedulePreNotice already sends (L155-162)
  • Use requestCode(id, 9) — slot 9, MUST be in AlarmScheduler.requestCode (31*hash+slot formula, L864) per design's critical gotcha
  • Call alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextBoundary, pending) wrapped in try/catch (SecurityException), mirroring L450-459
  • Function MUST be public (design: receiver calls it directly)
  • Parallel-safe: NO — must land before 1.2 (cancel function needs to exist alongside, both reviewed together) and before 2.x (receiver depends on this signature)

1.2 [x] Add cancelPreNoticeCountdown(id) to AlarmScheduler.kt

  • Satisfies: Requirement "Tick Chain Cancellation" (defines the primitive used by all 5 cancellation sites)
  • Location: new public function near cancelSnoozeCountdown (around L462), in AlarmScheduler.kt
  • Mirror cancelSnoozeCountdown shape: build PendingIntent.getBroadcast with requestCode(id, 9), action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
  • Call cancelPending("preNoticeCountdown", pending) (reuse existing cancelPending helper, same as L471)
  • Function MUST be public (design: receiver's SKIP_NEXT/POSTPONE_NEXT handlers call it directly)
  • Parallel-safe: NO — same file/region as 1.1, sequential

1.3 [x] Switch armNextPreNoticeCountdownTick minute math to reuse existing ceilMinutes()

  • Satisfies: Requirement "Consistent Rounding via ceilMinutes"
  • Verify ceilMinutes() at L551-552 is reachable from the new function (confirmed class-level private in design — no duplication needed, same class)
  • This task is really a checkpoint folded into 1.1's implementation: confirm 1.1 uses ceilMinutes() for any remaining-minutes math it does (the boundary math itself uses raw arithmetic per design; ceilMinutes is invoked at the call site / by the receiver, not inside the arm function — see task 2.1)
  • Parallel-safe: NO — depends on 1.1

1.4 [x] [VERIFY] Code-inspect armNextPreNoticeCountdownTick + cancelPreNoticeCountdown

  • Inspection checklist (no test runner available, must be done by reading the diff):
    • armNextPreNoticeCountdownTick is declared in AlarmScheduler.kt, NOT in PluriWaveAlarmReceiver.kt
    • cancelPreNoticeCountdown is declared in AlarmScheduler.kt, NOT in PluriWaveAlarmReceiver.kt
    • Both use requestCode(id, 9) resolving through AlarmScheduler.requestCode (the 31 * id.hashCode() + slot formula at L864) — NOT PluriWaveAlarmReceiver.requestCode (47 * id.hashCode() + slot)
    • armNextPreNoticeCountdownTick early-returns (no-op) when remaining <= 1L
    • armNextPreNoticeCountdownTick and cancelPreNoticeCountdown are both public (callable from PluriWaveAlarmReceiver)
    • cancelPreNoticeCountdown uses PendingIntent.FLAG_NO_CREATE (cancel-only, does not recreate)
    • No new ACTION_* constant was introduced — both functions reference PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
  • Parallel-safe: NO — gate before proceeding to section 2

2. AlarmScheduler.kt — Wire 3 of the 5 Cancellation Sites (Sequential, same file as section 1)

2.1 [x] Wire cancellation site 1/5: cancelAlarm(id)

  • Satisfies: Requirement "Tick Chain Cancellation" — Scenario "Full alarm cancellation tears down tick chain"
  • Location: AlarmScheduler.kt L554-568, alongside the existing cancelSnoozeCountdown(id) call at L561
  • Add cancelPreNoticeCountdown(id) directly after cancelSnoozeCountdown(id)
  • Parallel-safe: YES (with 2.2, 2.3 — distinct branches in the same file, no shared local state; serialize the actual edit application to avoid diff collisions, but design/review can happen in parallel)

2.2 [x] Wire cancellation site 2/5: scheduleSpec no-next-trigger branch

  • Satisfies: Requirement "Tick Chain Cancellation" — Scenario "No-next-trigger reschedule cancels tick chain"
  • Location: AlarmScheduler.kt L87-93, alongside the existing cancelPending("preNotice", ...) call at L92
  • Add cancelPreNoticeCountdown(spec.id) in this branch (after the existing preNotice single-shot PI cancel)
  • Parallel-safe: YES (with 2.1, 2.3 — distinct branch)

2.3 [x] Wire cancellation site 3/5: schedulePreNotice snooze-transition branch

  • Satisfies: Requirement "Tick Chain Cancellation" — Scenario "Snooze transition cancels pre-notice tick chain"
  • Location: AlarmScheduler.kt L140-144 (the if (spec.snoozeUntilMillis != null) early-return inside schedulePreNotice, which currently only cancels the single-shot preNotice PI)
  • Add cancelPreNoticeCountdown(spec.id) in this branch, alongside the existing cancelPending("preNotice", ...) call
  • Parallel-safe: YES (with 2.1, 2.2 — distinct branch)

2.4 [x] [VERIFY] Code-inspect the 3 in-scheduler cancellation sites

  • Inspection checklist:
    • cancelAlarm(id) (L554-568 region) calls cancelPreNoticeCountdown(id)
    • scheduleSpec no-trigger branch (L87-93 region) calls cancelPreNoticeCountdown(spec.id)
    • schedulePreNotice snooze-transition branch (L140-144 region) calls cancelPreNoticeCountdown(spec.id)
    • grep -n "cancelPreNoticeCountdown" AlarmScheduler.kt returns exactly: 1 declaration + 3 call sites so far (more added in section 3 from the receiver side) — confirms no site was missed or duplicated
  • Parallel-safe: NO — gate before section 3

3. PluriWaveAlarmReceiver.kt — Re-arm on Tick + Remaining 2 Cancellation Sites (Sequential, single file)

3.1 [x] Make ACTION_PRE_NOTICE handler re-arm the next tick after posting

  • Satisfies: Requirement "First Pre-Notice Post", Requirement "Per-Minute Tick Re-Arm", Requirement "Tick updates notification content"
  • Location: PluriWaveAlarmReceiver.kt showPreNoticeNotification (L131-200), called from the ACTION_PRE_NOTICE branch (L47-56)
  • After successfully posting/updating the notification (after the NotificationManagerCompat...notify(...) call at L195), compute remaining via ceilMinutes()-based logic and call AlarmScheduler(context).armNextPreNoticeCountdownTick(alarmId, triggerAtMillis, title, snoozeMinutes, occurrenceAtMillis, remaining)
  • Design's open question: pass remaining computed once (avoid double clock-read causing off-by-one between displayed text and next-boundary math) — compute remaining a single time in showPreNoticeNotification and use that same value both for AlarmNotificationStrings.preNoticeText(...) and for the arm call
  • Parallel-safe: NO — must land before 3.2/3.3 are meaningfully testable together, but see note below

3.2 [x] Replace computeRemainingMinutes() with ceilMinutes() semantics in the receiver

  • Satisfies: Requirement "Consistent Rounding via ceilMinutes"
  • Location: PluriWaveAlarmReceiver.kt L206-207 (computeRemainingMinutes, floor-based: (triggerAtMillis - now) / 60_000L)
  • Replace the floor-based computation with ceilMinutes() semantics (maxOf(1L, (deltaMillis + 59_999L) / 60_000L)), matching AlarmScheduler.ceilMinutes() at L551-552
  • Decide and apply consistently per design: either (a) inline the ceil formula in the receiver (duplication, but receiver and scheduler are different classes — ceilMinutes in AlarmScheduler is class-private per design notes, "directly reusable" refers to scheduler-internal reuse, not cross-class), or (b) expose a small shared helper. Given design explicitly says "class-level private, NOT snooze-private — directly reusable" in the context of AlarmScheduler, the receiver still needs its own copy of the formula since it's a different class — duplicate the one-line ceilMinutes formula in the receiver, matching the scheduler's exactly, OR have the receiver call into the scheduler instance it already constructs (AlarmScheduler(context)) if that's promoted to public. Pick the option that does not require new public surface beyond what's already needed (prefer inlining the formula to avoid scope creep)
  • Parallel-safe: NO — same function area as 3.1 (both touch showPreNoticeNotification / its remaining-minutes computation), sequential

3.3 [x] Wire cancellation site 4/5: ACTION_SKIP_NEXT handler

  • Satisfies: Requirement "Tick Chain Cancellation" — Scenario "Skip-next action cancels tick chain"
  • Location: PluriWaveAlarmReceiver.kt L77-92 (ACTION_SKIP_NEXT branch)
  • Add AlarmScheduler(context).cancelPreNoticeCountdown(alarmId) BEFORE the call to AlarmScheduler(context).skipNext(alarmId) (L79) — design specifies cancel-then-reschedule ordering since skipNext -> scheduleSpec will arm a fresh chain
  • Parallel-safe: YES (with 3.4 — distinct branch in same file; serialize edit application)

3.4 [x] Wire cancellation site 5/5: ACTION_POSTPONE_NEXT handler

  • Satisfies: Requirement "Tick Chain Cancellation" — Scenario "Postpone-next action cancels tick chain"
  • Location: PluriWaveAlarmReceiver.kt L57-76 (ACTION_POSTPONE_NEXT branch)
  • Add AlarmScheduler(context).cancelPreNoticeCountdown(alarmId) BEFORE the call to AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes) (L59) — postpone transitions into snooze, where snooze-countdown takes over per the mutual-exclusivity invariant
  • Parallel-safe: YES (with 3.3 — distinct branch in same file; serialize edit application)

3.5 [x] [VERIFY] Code-inspect receiver changes

  • Inspection checklist:
    • showPreNoticeNotification computes remaining exactly once and reuses that single value for both notification text and the armNextPreNoticeCountdownTick call (no second System.currentTimeMillis() read causing drift)
    • ACTION_PRE_NOTICE branch results in a call to AlarmScheduler(context).armNextPreNoticeCountdownTick(...) after the notification is posted
    • ACTION_SKIP_NEXT branch calls cancelPreNoticeCountdown(alarmId) BEFORE skipNext(alarmId)
    • ACTION_POSTPONE_NEXT branch calls cancelPreNoticeCountdown(alarmId) BEFORE postponeNext(alarmId, snoozeMinutes)
    • Old floor-based computeRemainingMinutes() is no longer used for pre-notice display — replaced in place with ceil-based logic (kept as the single computation function, now ceiling-rounded, still the only call site for pre-notice remaining-minutes)
    • No new ACTION_* constant added to the companion object (L231+)
  • Parallel-safe: NO — gate before section 4

4. Full 5-Site Cancellation Cross-Check (Sequential, spans both files)

4.1 [x] [VERIFY] Enumerate and confirm all 5 cancellation sites are wired

This is the change's single highest-risk item per the design's "CRITICAL GOTCHA" — list each site explicitly and confirm:

  1. AlarmScheduler.cancelAlarm(id) (L554-568 region) — calls cancelPreNoticeCountdown(id) (task 2.1)
  2. AlarmScheduler.scheduleSpec no-next-trigger branch (L87-93 region) — calls cancelPreNoticeCountdown(spec.id) (task 2.2)
  3. AlarmScheduler.schedulePreNotice snooze-transition branch (L140-144 region) — calls cancelPreNoticeCountdown(spec.id) (task 2.3)
  4. PluriWaveAlarmReceiver ACTION_SKIP_NEXT handler (L77-92 region) — calls cancelPreNoticeCountdown(alarmId) (task 3.3)
  5. PluriWaveAlarmReceiver ACTION_POSTPONE_NEXT handler (L57-76 region) — calls cancelPreNoticeCountdown(alarmId) (task 3.4)
  • Confirm via grep -rn "cancelPreNoticeCountdown" android/app/src/main/kotlin/es/freetimelab/pluriwave/ that the count is exactly: 1 declaration (AlarmScheduler.kt) + 5 call sites (3 in AlarmScheduler.kt, 2 in PluriWaveAlarmReceiver.kt) = 6 total matches
  • Confirm both armNextPreNoticeCountdownTick and cancelPreNoticeCountdown resolve requestCode through AlarmScheduler's own requestCode(id, slot) = 31 * id.hashCode() + slot — re-confirm this did NOT silently get called via PluriWaveAlarmReceiver's 47 * id.hashCode() + slot formula anywhere (that would make arm/cancel PendingIntents mismatch and leak the chain)
  • Parallel-safe: NO — single gating checkpoint, blocks section 5

5. Manual / Device QA (Sequential, requires emulator or physical device — no automated harness available)

5.1 [MANUAL QA] Happy-path countdown on real/emulated device

  • Satisfies: Proposal Success Criteria "Pre-notice updates each minute 29->1 with device idle/screen-off"
  • Schedule a test alarm ~3-5 minutes out (shrink the 30-min window for practical testing by temporarily adjusting PRE_NOTICE_MILLIS constant value locally, or schedule far enough out and observe the last few ticks before fire)
  • Confirm notification updates in place (same notification ID, no duplicate entries) each minute boundary
  • Parallel-safe: NO

5.2 [MANUAL QA] Self-stop at final minute, fire alarm takes over

  • Satisfies: Requirement "Self-Stop at Final Minute"
  • Confirm no ACTION_PRE_NOTICE tick fires when remaining <= 1; confirm the real setAlarmClock fire alarm rings on schedule
  • Parallel-safe: YES (with 5.3, 5.4 — independent device sessions, but practically run sequentially on one test device)

5.3 [MANUAL QA] Skip/Postpone/Snooze-transition tear down chain, no orphaned alarm

  • Satisfies: Requirement "Tick Chain Cancellation" (all 5 scenarios), Proposal Success Criteria "Skip-next/postpone-next cancel the tick chain", "Snooze transition cancels pre-notice tick chain; no double-notification"
  • Trigger skip, postpone, and snooze mid-chain on separate test runs; confirm via adb shell dumpsys alarm | grep pluriwave (or logcat alarm.snoozeCountdown / alarm.schedule preNotice tags) that no stale ACTION_PRE_NOTICE slot-9 PendingIntent remains armed after each transition
  • Parallel-safe: YES (with 5.2, 5.4)

5.4 [MANUAL QA] Doze-delayed tick jumps forward, does not crash/loop

  • Satisfies: Requirement "Self-Healing Minute Computation" — Scenario "Missed tick self-heals by jumping, not crashing"
  • Use adb shell dumpsys deviceidle force-idle (or equivalent Doze simulation) to delay a tick; confirm the next tick recomputes remaining minutes from wall clock and displays a forward jump (e.g. 15 -> 12) rather than a stale or negative value
  • Parallel-safe: YES (with 5.2, 5.3)

5.5 [MANUAL QA] Snooze-countdown regression check

  • Satisfies: Proposal Success Criteria "Snooze countdown unchanged (no regression)"
  • Run the existing snooze-countdown flow (snooze an alarm, observe per-minute countdown) and confirm it behaves identically to pre-change behavior — slot 8 / ACTION_SNOOZE_COUNTDOWN path untouched by this change
  • Parallel-safe: YES (with 5.2, 5.3, 5.4)

Dependency Graph

1.1 -> 1.2 -> 1.3 -> 1.4 [VERIFY GATE]
                        |
                        v
        2.1, 2.2, 2.3 (parallel design, serial apply) -> 2.4 [VERIFY GATE]
                        |
                        v
        3.1 -> 3.2 -> 3.3, 3.4 (parallel design, serial apply) -> 3.5 [VERIFY GATE]
                        |
                        v
                4.1 [VERIFY GATE — 5-site cross-check]
                        |
                        v
        5.1 -> 5.2, 5.3, 5.4, 5.5 (parallel manual QA sessions)

No task in this change can run fully independently of the others — both files are small and the two new functions (armNextPreNoticeCountdownTick, cancelPreNoticeCountdown) are shared dependencies for every cancellation-site task and the receiver re-arm task. "Parallel-safe" above means parallel in review/design reasoning; the actual file edits should still be applied serially to avoid diff collisions in two small files.

Review Workload Forecast

  • Files touched: 2 (AlarmScheduler.kt, PluriWaveAlarmReceiver.kt)
  • Estimated changed lines: ~90-130 (2 new functions ~25-35 lines each in AlarmScheduler.kt; 5 small call-site insertions of 1-2 lines each; receiver re-arm wiring + ceilMinutes swap ~15-25 lines)
  • 400-line budget risk: Low — well under threshold, single small PR is appropriate
  • Chained PRs recommended: No
  • Decision needed before apply: No — proceed with delivery_strategy: ask-on-risk as a single PR; no risk threshold triggered
  • Primary review focus: the requestCode formula (slot 9, AlarmScheduler's 31*hash+slot, NOT the receiver's 47*hash+slot) and the 5-site cancellation cross-check (section 4.1) — these are the two failure modes called out explicitly in the design as silent/non-crashing (PendingIntent mismatch leaks a repeating alarm with no visible error)
  • Suggested reviewer pass order: section 1 (engine) first in isolation, then section 4.1's grep-based cross-check as the acceptance gate before merging, manual QA (section 5) can follow merge if device access is constrained at review time but MUST complete before this change is considered done