Skip to content

fix(notify): enforce fire-once dedupe for insight notifications#137

Merged
abdulsaheel merged 4 commits into
OpenStrap:mainfrom
dannymcc:fix/notification-dedupe
Jul 23, 2026
Merged

fix(notify): enforce fire-once dedupe for insight notifications#137
abdulsaheel merged 4 commits into
OpenStrap:mainfrom
dannymcc:fix/notification-dedupe

Conversation

@dannymcc

@dannymcc dannymcc commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The bug (#136)

A user gets the "Irregular heart rhythm" notification repeatedly/constantly.

Insight and health notifications are emitted with a dedupeKey like '$date:irregular', and the call sites promise the event "fires at most once/day" — but nothing enforced it:

  • NotificationCenter.emit gated only on NotificationPrefs.shouldFireOs (category-enabled + quiet-hours). It never consulted dedupeKey.
  • The osId derived from the key makes a re-post replace the shade entry, but it still re-alerts (sound/buzz), and there was no persistent "already fired this key" record anywhere.
  • Derivation re-runs on every BLE sync, so emit('irregular', …) re-fired all day while today's irregular_rhythm_flag was set. The same latent bug hit the illness / anomaly / temperature / readiness / HR-shift alerts (all via the local emit closure in derivation_engine.dart), plus recovery-ready, step-goal and auto-workout.

The fix

Enforce the dedupe with a persistent, bounded fired-key guard:

  • New FiredKeyStore (lib/notify/fired_keys.dart), backed by shared_preferences like the rest of the app.
  • NotificationCenter.emit now: gate → skip if the dedupeKey already fired → present → record the key only after a real present. A permission-denied no-op therefore doesn't consume the key.
  • NotificationService.presentEvent now returns bool (true = actually shown) so emit can record on success only.
  • The store is a capped FIFO of the most-recent 200 keys (oldest evicted). Since a day only mints a handful of distinct keys, a same-day key can never be evicted and re-fire, and a new day's '2026-07-24:irregular' is a fresh key that fires once — so the guard resets itself per day with no manual reset. A repeated hit is a pure no-op (doesn't re-fire, doesn't refresh recency).

This also stops the other insight alerts (illness, anomaly, temperature, readiness, resting-HR shift, recovery-ready, step-goal, auto-workout) from repeating on re-derive.

Also folded in: the stress-screen high-stress alert

stress_screen.dart called NotificationService.presentEvent directly for its "High Stress Detected" alert ('$date:high_stress'), bypassing both the prefs gate and the new dedupe guard — so it could re-alert on repeat screen visits and ignore quiet hours. It now routes through NotificationCenter.emit like every other insight, inheriting the same gating + fire-once behaviour. Event fields are unchanged (health category, normal priority); the call stays fire-and-forget.

What is deliberately left alone

  • Device battery/charging alerts go through NotificationService.showDevice with their own edge-triggered arm/re-arm hysteresis — they never route through emit, so they're unaffected.
  • Scheduled reminders (water / wind-down / weekly recap / journal / AI briefings) are zonedScheduled, not emited — untouched.
  • Every emit caller was audited: all carry one-shot-per-key dedupeKeys (date-prefixed insights, per-firing alarm_fired:$firedAt, per-2h-bucket move nudge, and the ≥48h-cooldown sync_stale whose date prefix always differs between fires). None rely on same-key repetition, so none regress.

Tests

test/notification_dedupe_test.dart (injects SharedPreferences.setMockInitialValues + a fake present sink, no device):

  • same dedupeKey emitted repeatedly → OS present happens exactly once
  • a different (next-day) key → fires
  • the record survives via SharedPreferences (restart-safe)
  • a permission-denied present does not consume the key
  • category-disabled and quiet-hours gating still respected (critical still overrides quiet, and still only once)
  • the stress-screen high_stress event shape dedupes and now respects quiet hours
  • FiredKeyStore caps at 200, evicts oldest, and a repeated record neither duplicates nor refreshes recency

The stress-screen change itself is verified by inspection (routing presentEventemit); the event-level test above exercises the exact event shape through emit. A full widget test would need to mock the repository + the local-notifications plugin, which isn't worth the brittleness here.

Verification note

No Flutter/Dart SDK or device was available in my environment, so this is verified by inspection + the new unit tests. On-device confirmation of the end-to-end fix is still wanted.

Closes #136

Summary by CodeRabbit

  • New Features

    • Added persistent “fire once” notification deduplication with bounded history, preventing repeat displays across app restarts.
    • Routed high-stress alerts through the centralized notification flow so they benefit from the same dedupe and quiet-hours rules.
  • Bug Fixes

    • Deduplication keys are now stored only after the notification is actually shown, not on permission denial or presentation failure.
    • Prevented race conditions so overlapping sends with the same key result in a single OS presentation (while different keys are handled independently).
  • Tests

    • Added coverage for dedupe persistence, permission-failure behavior, gating/quiet-hours, and concurrency.

Also fixes the repeated "Low readiness today" alert (same root cause).
Closes #138

Insight/health notifications carry a dedupeKey ("$date:$kind") and their
call sites promise "fires at most once/day", but nothing enforced it. The
OS notification id derived from the key only REPLACES a prior post in the
shade — it still re-alerts — and derivation re-runs on every BLE sync, so
any insight whose condition holds all day (e.g. today's irregular-rhythm
flag) re-fired the notification over and over (OpenStrap#136). Illness, anomaly,
temperature, readiness, HR-shift, recovery-ready, step-goal and
auto-workout alerts all shared this latent bug.

Add a persistent, bounded fired-key guard (FiredKeyStore, backed by
shared_preferences): NotificationCenter.emit now skips a dedupeKey that
has already fired and records it only after a real present, so a
permission-denied no-op doesn't consume the key. The guard resets itself
per new day via the date-prefixed keys and is capped to the most-recent
200 keys (oldest evicted) so it stays bounded without ever letting a
same-day key re-fire.

Paths that SHOULD repeat are untouched: device battery/charging alerts
(showDevice, edge-triggered arm/re-arm) and scheduled water/wind-down
reminders (zonedSchedule) never route through emit.

Closes OpenStrap#136
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@dannymcc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c921dabd-f66b-4999-83d8-781da95eaa7a

📥 Commits

Reviewing files that changed from the base of the PR and between 2f58df1 and e0aae4e.

📒 Files selected for processing (1)
  • test/notification_dedupe_test.dart
📝 Walkthrough

Walkthrough

Adds persistent notification deduplication using bounded shared-preference storage. Notifications are recorded only after successful OS presentation, while permission failures, category restrictions, quiet hours, and concurrent emits are handled without consuming eligible keys.

Changes

Notification deduplication

Layer / File(s) Summary
Presentation results and fired-key storage
lib/notify/notification_service.dart, lib/notify/fired_keys.dart
presentEvent reports presentation success, and FiredKeyStore persists, deduplicates, and bounds fired keys.
Serialized notification emission
lib/notify/notification_center.dart
emit applies gating, serializes dedupe checks with presentation, and records keys only after successful presentation.
Stress-alert integration
lib/ui/stress/stress_screen.dart
High-stress alerts now route through NotificationCenter.emit.
Behavioral validation
test/notification_dedupe_test.dart
Tests persistence, retry behavior, gating, concurrency, stress alerts, and key eviction.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NotificationCenter
  participant NotificationPrefs
  participant FiredKeyStore
  participant NotificationService
  NotificationCenter->>NotificationPrefs: check shouldFireOs(event)
  NotificationPrefs-->>NotificationCenter: allow or suppress
  NotificationCenter->>FiredKeyStore: check dedupeKey
  FiredKeyStore-->>NotificationCenter: fired or available
  NotificationCenter->>NotificationService: attempt presentation
  NotificationService-->>NotificationCenter: return success status
  NotificationCenter->>FiredKeyStore: record successful dedupeKey
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: enforcing fire-once deduplication for notifications.
Linked Issues check ✅ Passed The PR addresses #136 by persisting dedupe keys and skipping repeated heart rhythm notifications once fired.
Out of Scope Changes check ✅ Passed The added stress-screen routing and tests support the dedupe fix and do not introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/notify/notification_center.dart`:
- Around line 60-65: Serialize the dedupe transaction in NotificationCenter.emit
so each key is guarded from hasFired through presentation and recordFired,
preventing overlapping same-key emissions. In lib/notify/fired_keys.dart lines
42-49, serialize preference read-modify-write mutations so concurrent distinct
keys cannot lose updates. In test/notification_dedupe_test.dart lines 63-73,
replace sequential awaits with overlapping emit calls and add coverage for
concurrent distinct keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a619843-d590-41f8-8922-8456672f280c

📥 Commits

Reviewing files that changed from the base of the PR and between bae8f23 and 78d3ace.

📒 Files selected for processing (4)
  • lib/notify/fired_keys.dart
  • lib/notify/notification_center.dart
  • lib/notify/notification_service.dart
  • test/notification_dedupe_test.dart

Comment thread lib/notify/notification_center.dart Outdated
dannymcc added 2 commits July 23, 2026 14:14
The stress screen called NotificationService.presentEvent directly for its
"High Stress Detected" alert, bypassing both the prefs gate (category /
quiet-hours) and the new fire-once dedupe guard — so it could re-alert on
repeat screen visits and ignore the user's quiet hours. Route it through
NotificationCenter.emit like every other insight, so its
"$date:high_stress" key inherits the same gating and fires at most once.

Event fields are unchanged (health category, normal priority, no route);
the call stays fire-and-forget (nothing read its return value).
Two overlapping emit() calls could both pass FiredKeyStore.hasFired before
either recorded the key (→ both present the same alert), and their
read-modify-write of the shared_preferences fired-key list could clobber
each other (losing a key). This is more likely now the UI-thread
stress-screen alert can race the background derive loop.

Guard the hasFired → present → recordFired section with a small in-memory
chained-Future lock so overlapping emits run one at a time; recordFired
re-reads the latest list inside the serialised section, so no stale
read-modify-write. Dart's single isolate makes this sufficient — no deps.

Adds concurrency tests: overlapping emits of the same key present exactly
once; overlapping emits of different keys both record (neither clobbered).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/ui/stress/stress_screen.dart (1)

80-88: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the deduplication sequence atomic and test that concurrency path.

NotificationCenter.emit can perform presentation twice when overlapping calls both observe an absent key before either records it.

  • lib/ui/stress/stress_screen.dart#L80-L88: protect this routed alert with per-key in-flight serialization while preserving record-after-success behavior.
  • test/notification_dedupe_test.dart#L62-L117: add a concurrent, barrier-controlled regression test so this race cannot return unnoticed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/stress/stress_screen.dart` around lines 80 - 88, The routed alert at
lib/ui/stress/stress_screen.dart:80-88 must use per-key in-flight serialization
through NotificationCenter.emit, ensuring overlapping emissions cannot present
the same dedupeKey twice while retaining record-after-success behavior. Add a
concurrent barrier-controlled regression test at
test/notification_dedupe_test.dart:62-117 that overlaps duplicate emissions,
verifies only one presentation occurs, and confirms the key is recorded only
after successful presentation.
🧹 Nitpick comments (1)
test/notification_dedupe_test.dart (1)

168-202: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Exercise StressScreen itself, not only NotificationCenter.emit.

These tests call center.emit(highStress()) directly, so they would pass even if lib/ui/stress/stress_screen.dart reverted to NotificationService.presentEvent. Add a widget test that mounts StressScreen, supplies a stress score above 70, and verifies the sink receives the gated/deduplicated event.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/notification_dedupe_test.dart` around lines 168 - 202, Add a widget test
that mounts StressScreen with a stress score above 70 and injects a fake
presentation sink, then trigger the screen behavior that emits the high-stress
notification and verify the sink receives the event through the gated,
deduplicated path. Keep the existing NotificationCenter.emit unit tests, but
ensure the new test would fail if StressScreen reverted to calling
NotificationService.presentEvent directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/ui/stress/stress_screen.dart`:
- Around line 80-88: The routed alert at lib/ui/stress/stress_screen.dart:80-88
must use per-key in-flight serialization through NotificationCenter.emit,
ensuring overlapping emissions cannot present the same dedupeKey twice while
retaining record-after-success behavior. Add a concurrent barrier-controlled
regression test at test/notification_dedupe_test.dart:62-117 that overlaps
duplicate emissions, verifies only one presentation occurs, and confirms the key
is recorded only after successful presentation.

---

Nitpick comments:
In `@test/notification_dedupe_test.dart`:
- Around line 168-202: Add a widget test that mounts StressScreen with a stress
score above 70 and injects a fake presentation sink, then trigger the screen
behavior that emits the high-stress notification and verify the sink receives
the event through the gated, deduplicated path. Keep the existing
NotificationCenter.emit unit tests, but ensure the new test would fail if
StressScreen reverted to calling NotificationService.presentEvent directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 55bdb12b-7823-4382-bbbd-87764d9cf5fa

📥 Commits

Reviewing files that changed from the base of the PR and between 78d3ace and 076a648.

📒 Files selected for processing (2)
  • lib/ui/stress/stress_screen.dart
  • test/notification_dedupe_test.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/notification_dedupe_test.dart`:
- Around line 37-42: Make the test sink’s entry point deterministic by exposing
an entered future from the sink implementation around call, and complete it when
call is first reached. In both concurrency tests, replace
Future<void>.delayed(Duration.zero) with await sink.entered before asserting or
releasing the gate, while preserving the existing serialization assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c2beed17-9163-4de7-879c-8bda7301795a

📥 Commits

Reviewing files that changed from the base of the PR and between 076a648 and 2f58df1.

📒 Files selected for processing (2)
  • lib/notify/notification_center.dart
  • test/notification_dedupe_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/notify/notification_center.dart

Comment thread test/notification_dedupe_test.dart
The concurrent-emit tests used Future.delayed(Duration.zero) to assume the
first emit had reached the fake sink before releasing the gate — a
scheduler-dependent barrier that could flake. The gated sink now completes
an `entered` Completer the moment the first emit reaches the parked point,
and the tests await that signal instead, so the overlap is guaranteed by
ordering rather than timing. Assertions are unchanged.
@abdulsaheel
abdulsaheel merged commit ecfce4b into OpenStrap:main Jul 23, 2026
1 check passed
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.

Repeated Low readiness today notification on Android Repeatedly receiving heart rhythm notification

2 participants