Skip to content

fix(android): give every notifier a distinct notification id (#297) - #304

Merged
ryanbr merged 1 commit into
mainfrom
fix-notif-id-collisions
Jul 12, 2026
Merged

fix(android): give every notifier a distinct notification id (#297)#304
ryanbr merged 1 commit into
mainfrom
fix-notif-id-collisions

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Closes #297. Reimplements #289 with the collision corrected.

The bug

notify() is called tagless, so notifiers sharing an id silently replace each other's undismissed notifications. On main:

  • 4203 — InactivityNotifier, BatteryAlertNotifier.NOTIF_ID_LOW, ScheduledReportNotifier.MORNING (3-way)
  • 4204 — SmartAlarmNotifier, BatteryAlertNotifier.NOTIF_ID_FULL, ScheduledReportNotifier.WORKOUT (3-way)

A low-battery warning could wipe the morning recap, a move reminder wipe the low-battery warning, etc.

Fix

Fully-distinct map: 4201 connection · 4202 illness · 4203 inactivity · 4204 smart alarm · 4205/4206/4207 battery (runtime/low/full) · 4208/4209 scheduled report (morning/workout). Verified all nine ids are unique.

Why not #289

#289 moved NOTIF_ID_LOW to 4205 — but NOTIF_ID_RUNTIME in the same file is already 4205, so it traded one collision for another (battery has three posters, not two). It also bundled an unrelated 200-line AUDIT.md. This drops both problems.

Android-only; compileFullDebugKotlin clean; ids verified distinct by grep.

notify() is tagless, so notifiers sharing an id silently replace each other's
undismissed notifications. On main, id 4203 was posted by InactivityNotifier,
BatteryAlertNotifier.NOTIF_ID_LOW and ScheduledReportNotifier.MORNING; id 4204 by
SmartAlarmNotifier, BatteryAlertNotifier.NOTIF_ID_FULL and .WORKOUT — so e.g. a
low-battery warning wiped the morning recap.

Reassign to a clean, fully-distinct map: 4201 connection, 4202 illness, 4203
inactivity, 4204 smart alarm, 4205/4206/4207 battery (runtime/low/full),
4208/4209 scheduled report (morning/workout).

(Corrects #289, which moved NOTIF_ID_LOW to 4205 — already taken by the battery
NOTIF_ID_RUNTIME in the same file — and bundled an unrelated AUDIT.md. Battery
needs THREE ids, not two.)
@ryanbr
ryanbr merged commit d0b3c1a into main Jul 12, 2026
ryanbr added a commit that referenced this pull request Jul 18, 2026
…he calendar day (#567) (#589)

The morning recap re-fired at midnight for anyone up late. ScheduledReportNotifier.onMorning
deduped on LocalDate.now() (the phone's CALENDAR day, rolls at midnight), but the recap reports
a banked NIGHT and _today resolves via the logical day (rolls at 04:00, #304 carve-out). For a
late-nighter crossing midnight without sleeping, the new calendar day has no banked night, so
resolveTodayRow falls back to LAST night's row (totalSleepMin != null) — while the dedup key had
already advanced to the new calendar day, so `lastNotifiedDay != today` went true again and the
recap re-fired with last night's data ("sent at the start of a new day").

Fix: dedup on the REPORTED NIGHT's day (the resolved today-row's `day`), not the calendar day.
- onMorning gains a reportDay param; AppViewModel passes todayRow.day.
- Policy param renamed today -> reportDay with a doc note (prevents a regression back to
  LocalDate.now()). Logic unchanged: enabled && present && lastNotifiedDay != reportDay.
At midnight the row still resolves to last night (reportDay unchanged) → already notified →
no re-fire. The real morning's new-night row fires normally.

No pref migration: reportMorningDay stays an ISO yyyy-MM-dd string; a calendar day and a row day
compare the same, so an upgrading user simply fires normally the next morning.

Android-only (the morning recap is an Android feature; no iOS twin). Strap-agnostic (day-key logic).

Tested: ./gradlew compileFullDebugKotlin + testFullDebugUnitTest --tests ScheduledReportPolicyTest
-> 16/16 (added a midnight-rollover regression: reportDay = last night's day → suppressed).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
) (ryanbr#304)

notify() is tagless, so notifiers sharing an id silently replace each other's
undismissed notifications. On main, id 4203 was posted by InactivityNotifier,
BatteryAlertNotifier.NOTIF_ID_LOW and ScheduledReportNotifier.MORNING; id 4204 by
SmartAlarmNotifier, BatteryAlertNotifier.NOTIF_ID_FULL and .WORKOUT — so e.g. a
low-battery warning wiped the morning recap.

Reassign to a clean, fully-distinct map: 4201 connection, 4202 illness, 4203
inactivity, 4204 smart alarm, 4205/4206/4207 battery (runtime/low/full),
4208/4209 scheduled report (morning/workout).

(Corrects ryanbr#289, which moved NOTIF_ID_LOW to 4205 — already taken by the battery
NOTIF_ID_RUNTIME in the same file — and bundled an unrelated AUDIT.md. Battery
needs THREE ids, not two.)
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…he calendar day (ryanbr#567) (ryanbr#589)

The morning recap re-fired at midnight for anyone up late. ScheduledReportNotifier.onMorning
deduped on LocalDate.now() (the phone's CALENDAR day, rolls at midnight), but the recap reports
a banked NIGHT and _today resolves via the logical day (rolls at 04:00, ryanbr#304 carve-out). For a
late-nighter crossing midnight without sleeping, the new calendar day has no banked night, so
resolveTodayRow falls back to LAST night's row (totalSleepMin != null) — while the dedup key had
already advanced to the new calendar day, so `lastNotifiedDay != today` went true again and the
recap re-fired with last night's data ("sent at the start of a new day").

Fix: dedup on the REPORTED NIGHT's day (the resolved today-row's `day`), not the calendar day.
- onMorning gains a reportDay param; AppViewModel passes todayRow.day.
- Policy param renamed today -> reportDay with a doc note (prevents a regression back to
  LocalDate.now()). Logic unchanged: enabled && present && lastNotifiedDay != reportDay.
At midnight the row still resolves to last night (reportDay unchanged) → already notified →
no re-fire. The real morning's new-night row fires normally.

No pref migration: reportMorningDay stays an ISO yyyy-MM-dd string; a calendar day and a row day
compare the same, so an upgrading user simply fires normally the next morning.

Android-only (the morning recap is an Android feature; no iOS twin). Strap-agnostic (day-key logic).

Tested: ./gradlew compileFullDebugKotlin + testFullDebugUnitTest --tests ScheduledReportPolicyTest
-> 16/16 (added a midnight-rollover regression: reportDay = last night's day → suppressed).
DX23876 added a commit to DX23876/noop that referenced this pull request Aug 1, 2026
…dditively

Three correctness follow-ups to the reviewed revision.

Day keying. applyingEditedSleepSessions grouped its sessions with a single
current-zone offset applied across all history, while userEditedDays — which
decides where the debt correction lands — resolves the offset at each session's
own end instant. The two could therefore disagree about which day a night
belongs to across a DST boundary, leaving the rebuilt sleep fields on one day
and the debt correction on another. The keyer existed in three copies, one of
them wrong; it is now Repository.sleepEndDayKey and userEditedDays, mergeSleep
and the edited-day overlay all go through it. This is the ryanbr#406 (Swift) / ryanbr#304
(Android) local wake-day rule that MergeSleepLocalDayTest pins.

Owner probe order. sleepOwnerIds was computedReadIds + importedReadIds, putting
the CANONICAL computed namespace ahead of the ACTIVE imported one. That demotes
the active imported source relative to the previous computed-then-imported
chain and lets a coincidental same-detectedStartTs row capture an edit — the
failure the single fallback was written to avoid. The order is now strictly
additive over the pre-union behaviour: the active strap's two namespaces keep
their exact previous order, and the canonical pair is appended as a fallback
that was not probed at all before, so no case that worked before can regress.
The doc comment also claimed computed rows outrank imported ones, which
SleepMerge.merge documents the other way round; corrected.

Android edited-day set. It tried a UTC key first and fell back to UTC, which
re-opens ryanbr#304 for a UTC+ user who wakes after local midnight but before UTC
midnight. It now uses localDayString, the helper this same file already uses
eight lines above to group sleeps by local day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DX23876 added a commit to DX23876/noop that referenced this pull request Aug 1, 2026
…dditively

Three correctness follow-ups to the reviewed revision.

Day keying. applyingEditedSleepSessions grouped its sessions with a single
current-zone offset applied across all history, while userEditedDays — which
decides where the debt correction lands — resolves the offset at each session's
own end instant. The two could therefore disagree about which day a night
belongs to across a DST boundary, leaving the rebuilt sleep fields on one day
and the debt correction on another. The keyer existed in three copies, one of
them wrong; it is now Repository.sleepEndDayKey and userEditedDays, mergeSleep
and the edited-day overlay all go through it. This is the ryanbr#406 (Swift) / ryanbr#304
(Android) local wake-day rule that MergeSleepLocalDayTest pins.

Owner probe order. sleepOwnerIds was computedReadIds + importedReadIds, putting
the CANONICAL computed namespace ahead of the ACTIVE imported one. That demotes
the active imported source relative to the previous computed-then-imported
chain and lets a coincidental same-detectedStartTs row capture an edit — the
failure the single fallback was written to avoid. The order is now strictly
additive over the pre-union behaviour: the active strap's two namespaces keep
their exact previous order, and the canonical pair is appended as a fallback
that was not probed at all before, so no case that worked before can regress.
The doc comment also claimed computed rows outrank imported ones, which
SleepMerge.merge documents the other way round; corrected.

Android edited-day set. It tried a UTC key first and fell back to UTC, which
re-opens ryanbr#304 for a UTC+ user who wakes after local midnight but before UTC
midnight. It now uses localDayString, the helper this same file already uses
eight lines above to group sleeps by local day.
@ryanbr
ryanbr deleted the fix-notif-id-collisions branch August 2, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show recently selected activities at the top of the workout list

1 participant