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

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
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 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.)
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 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).
xxblyxx added a commit to xxblyxx/noop that referenced this pull request Aug 21, 2026
The Home Screen widget showed heart rate, battery and the connection dot
but blanked Charge, Effort, Rest, HRV and Resting HR — while Today showed
four of those five as real numbers.

`WidgetSnapshot.publish` resolved ONE recovery-gated anchor day
(`Repository.widgetAnchor`) and read Charge, Effort, HRV and Resting HR off
it, with the `sleep_performance` read nested inside `if let day`. That
anchor returns non-nil only when some row carries a non-null `recovery`, so
a store with nothing scored blanked all five at once. Only bpm/battery/
bonded survived, because they come off `model.live` and never touch the
anchor. Today does not have this problem: it resolves the same five stats
through four independent, mostly recovery-INDEPENDENT selectors, so one
null recovery column blanked five widget fields but only one app field.

Adds `Repository.glanceFields`, a pure injectable resolver that gives each
stat the selector its Today call site uses. It invents no new selector — it
composes four that already exist:

  Charge     widgetAnchor            (the one field the gate belongs on)
  Effort     today's own row         never carries; yesterday's strain
                                     shown as today's is a false statement
  Rest       TodayView.freshRestScore  the shared ryanbr#977 staleness gate that
                                     WatchSessionBridge already adopted;
                                     no longer nested behind the anchor
  HRV / RHR  today-first per-field carry over lastVitalsDay, which is
             explicitly recovery-independent

`todayKey` is pinned to `todayRow?.day ?? logicalKey` (byte-identical to
widgetAnchor's own carriedKey), NOT the 2-arg lastVitalsDay overload's
`max(logicalKey, localKey)` — those diverge in the ryanbr#304 pre-04:00 window.
Unreachable today, but it is the near-miss class that produced ryanbr#911.

Charge stays blank on this store because no row carries a recovery score at
all — the same reason Today's Charge hero is blank. That is a separate
data gap, deliberately not addressed here.

Render side unchanged: NOOPWidget already renders every field honest-null,
and the wire schema is untouched, so no App-Group migration.

`exploreSeries` is now unconditional on the full publish. It was already
paid whenever an anchor existed; `publishLive` still bypasses it, so the
high-frequency HR/battery path is unaffected.

Verification: `xcodegen generate` + `xcodebuild` BUILD SUCCEEDED for both
the macOS `Strand` scheme and the iOS `NOOPiOS` scheme (which compiles the
embedded NOOPiOSWidgets extension) — CI does not build the app targets in
this repo. New `StrandTests/WidgetGlanceFieldsTests` (14 tests) passes
14/14 and `WidgetAnchorTests` still passes 9/9 on `platform=macOS`; the new
suite is pure — no clock, no AppModel, no store, no strap. Not yet
confirmed on device.

Kotlin twin written but NOT compiled locally (no JDK, no Android SDK).
Both Android producers carried the identical coupling and both already had
`resolveTodayRow` in scope; `restPct`/`effortPct` and `widgetRest`/
`widgetEffort` now read today's row while recovery keeps the anchor.
Android's WidgetSnapshot has no HRV or resting-HR field, so `lastVitalsRow`
does not enter the twin. `StrainScorer.effectiveEffort` deliberately left
alone — Android's Today hero uses it and iOS's does not, a pre-existing
divergence out of scope here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfTpUwbM2d7TmwyGneB7mx
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Consume the approved day-aware Home route handoff and align the Strain target marker across platforms.

Refs ryanbr#304
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Refs ryanbr#304

# Conflicts:
#	Strand/Screens/HomeDashboardContent.swift
#	android/app/src/main/java/com/noop/ui/TodayScreen.kt
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Route selected Sleep through the shell path. Token changes are the D1 owner handoff from e2ed08e; Sleep hook lines are from 59eac66.

Refs ryanbr#304
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Use the selected activity window separately from a carried score day, keep recorded unscored sleep honest, and correct the merged Insights source guard. The named source argument follows Plan owner 2dc0456.

Refs ryanbr#304
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
…al label on one line

Previous day skipped the local-calendar day when the ryanbr#304 carve-out surfaced it before 04:00.
Shared homeDayAnchor on Swift and Kotlin with a parity test. Android compact ScoreDial label
now auto-shrinks like iOS lineLimit(1) + minimumScaleFactor.
stammi922 added a commit to stammi922/noop that referenced this pull request Nov 1, 2026
Sessions were keyed by the logical day (04:00 rollover) while Today
reads the Rest credit for repo.today?.day, which carries the ryanbr#304
pre-04:00 carve-out. After a 22:00-02:45 sleep, a 03:15 session was
credited to yesterday while Today showed the wake day, so the credit
never appeared. The key now goes through the same resolveToday, falling
back to the logical day like Today's selectedDayKey.

NSDRSessionDayKeyTests pins the 03:15 case and the fallbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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