Stop replayed chargingOn events re-notifying (#179) - #180
Conversation
The strap buffers its event log in flash, dumps it on connect, and re-sends
events it has already delivered. Nothing on the charging path checked WHEN an
event happened, so a chargingOn from hours earlier was processed as "the puck
just went on", and the false->true edge state lived only in RAM — which on
Android is reset constantly (EdgeApplication pre-warms an engine on every
process create; KeepAliveWorker and the START_STICKY FGS recreate the process
routinely). Each restart re-armed the alert and the next replay buzzed again.
Both halves are measurable in a real user export (6,980 events): 410 arrived
more than an hour after they occurred, worst case 8h26m — an 8-hour backlog
dumped in ~90s — and 140 distinct (event_id, ts) pairs were delivered more than
once, up to 4x, each with a different frame seq so nothing upstream de-dupes
them. db.dart's insertEvent already notes "the band re-sends events", and
GestureDispatcher already carries a recency window for the same reason
("older than this = a drained/historical tap"); the charging alert never got it.
- ChargeAlertPolicy (pure, unit-tested): a recency window rejects the stale
backlog dump, and a persisted identity high-water rejects re-sends and
restart-driven re-arming. Neither is sufficient alone — a re-send is recent,
and a first-delivery stale event has no prior identity to match.
- DeviceState.chargingTs carries the event's own strap timestamp. The flag
itself still means "latest known charging state" and the UI should keep
showing it; only consumers treating the transition as live gate on the age.
- DeviceAlerts persists its edge state, so "once per real event" survives a
process restart. Same defect applied to the low-battery hysteresis, which
re-armed on every restart; fixed with it.
- chargingOff now cancels the Charging card. It is a state claim, so leaving it
in the tray after the puck comes off was wrong anyway.
- onlyAlertOnce on the device channel, explicitly NOT load-bearing: it only
suppresses sound while the card is still showing, so dismissing it re-arms.
Deliberate trade-off: a genuine plug-in first delivered more than 15 min late no
longer notifies. Measured real first-delivery lags were 0s, 105s and 226s, so
the window clears them with room to spare.
No kAlgoVersion bump: notification layer only, no analytics output changes.
|
Warning Review limit reached
Next review available in: 18 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe PR propagates strap charging timestamps into device state, adds timestamp-aware alert policy evaluation, persists alert edge state, isolates notification and storage failures, cancels alerts on charger removal, and prevents repeated Android notification sounds. ChangesCharging alert lifecycle
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant BleEngine
participant AppState
participant DeviceAlerts
participant ChargeAlertPolicy
participant DeviceAlertStore
participant DeviceAlertSink
BleEngine->>AppState: charging state and chargingTs
AppState->>DeviceAlerts: onDeviceState(chargingTs)
DeviceAlerts->>DeviceAlertStore: restore persisted state
DeviceAlerts->>ChargeAlertPolicy: evaluate event timestamps
ChargeAlertPolicy-->>DeviceAlerts: alert verdict
DeviceAlerts->>DeviceAlertSink: show or cancel alert
DeviceAlerts->>DeviceAlertStore: persist alert state
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Reviewer Guide 🔍(Review updated until commit 04dd2bf)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 04dd2bf Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit a32e92f
Suggestions up to commit 5710526
Suggestions up to commit f094b79
|
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 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/charge_alert_policy.dart`:
- Around line 79-81: Update ChargeAlertPolicy.timestampUsable() to use the
shared kMinPlausibleUnix constant from sync_policy.dart instead of defining
plausibleUnixFloor locally; import or relocate the constant so both charge-alert
and sync clock policies reference one shared value.
In `@lib/notify/device_alerts.dart`:
- Around line 41-54: Update _NotificationServiceSink.show to route device alerts
through NotificationCenter.emit using a NotificationEvent with
NotifCategory.device, preserving the existing device-alert test seam. Ensure the
production path receives the emitter’s quiet-hour guard, fire-once claim/release
behavior, presentation-chain locking, and history key, while leaving
ChargeAlertPolicy responsible only for charge-specific replay and low-water
replay suppression.
- Around line 169-171: Update the cancellation guard around
_notes.cancel(NotificationService.idCharging) to validate the charging-off event
via DeviceState.chargingTs, using the same recency check as the show path.
Cancel only for a usable recent timestamp, or for an unusable timestamp when no
announced charging session remains live; ignore replayed historical events while
preserving the existing charging transition check.
In `@test/device_alerts_test.dart`:
- Around line 107-110: Add coverage in the existing backlog replay tests for an
event age greater than implausibleAgeSec (86400 seconds), asserting the
documented untimed announce verdict when no prior announcement exists. Also add
a regression test covering a replayed chargingOff event during an active
session, verifying it does not cancel the Charging card; use the existing
verdict/test helpers and device-alert behavior symbols in
test/device_alerts_test.dart and lib/notify/device_alerts.dart.
🪄 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: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: cbf205bc-16a3-4490-a2ff-b14b1f7f81dc
📒 Files selected for processing (7)
lib/ble/ble_engine.dartlib/data/models.dartlib/notify/charge_alert_policy.dartlib/notify/device_alerts.dartlib/notify/notification_service.dartlib/state/app_state.darttest/device_alerts_test.dart
| class _NotificationServiceSink implements DeviceAlertSink { | ||
| const _NotificationServiceSink(); | ||
|
|
||
| @override | ||
| Future<void> show({ | ||
| required int id, | ||
| required String title, | ||
| required String body, | ||
| }) => | ||
| NotificationService.instance.showDevice(id: id, title: title, body: body); | ||
|
|
||
| @override | ||
| Future<void> cancel(int id) => NotificationService.instance.cancel(id); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Compare the emitter contract with the direct showDevice bypass.
set -euo pipefail
echo "== NotificationCenter.emit surface =="
ast-grep outline lib/notify/notification_center.dart --items all
echo "== callers of showDevice =="
rg -nP -C4 '\bshowDevice\s*\(' --glob 'lib/**/*.dart'
echo "== preference gating inside the emitter =="
rg -nP -C4 'NotificationPrefs|quietStartMin|quietEndMin|categoryEnabled' --glob 'lib/notify/notification_center.dart'Repository: OpenStrap/edge
Length of output: 268
🏁 Script executed:
#!/bin/bash
set -eo pipefail
echo "== repository notifications files =="
git ls-files 'lib/notify/**/*.dart'
echo "== device_alerts outline =="
ast-grep outline lib/notify/device_alerts.dart --items all || true
echo "== notification_center outline =="
ast-grep outline lib/notify/notification_center.dart --items all || true
echo "== notification_service outline =="
ast-grep outline lib/notify/notification_service.dart --items all || true
echo "== search notify identifiers =="
rg -n -C3 'DeviceAlertSink|_NotificationServiceSink|NotificationCenter|emit|showDevice|presentEvent|dedupe|preference|quiet|ChargeAlertPolicy' --glob 'lib/notify/**/*.dart' || trueRepository: OpenStrap/edge
Length of output: 463
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== lib files under git =="
git ls-files lib | sed -n '1,200p'
echo "== locate relevant files by name =="
fd -a 'device_alerts|notification_center|notification_service|notification_pref|charge_alert' . | sed -n '1,120p'
echo "== current branch status =="
git status --shortRepository: OpenStrap/edge
Length of output: 5981
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== device_alerts.dart =="
sed -n '1,240p' lib/notify/device_alerts.dart
echo "== notification_center.dart =="
sed -n '1,320p' lib/notify/notification_center.dart
echo "== notification_service.dart =="
sed -n '1,260p' lib/notify/notification_service.dart
echo "== notification_prefs.dart =="
sed -n '1,240p' lib/notify/notification_prefs.dart
echo "== charge_alert_policy.dart =="
sed -n '1,220p' lib/notify/charge_alert_policy.dart
echo "== identifiers =="
rg -n -C3 'DeviceAlertSink|_NotificationServiceSink|showDevice|presentEvent|NotificationCenter\.emit|emit\(|dedupe|persistent|locking|lock|race|charging|low-battery|low battery|ChargeAlertPolicy|show charging|charging alert|battery|battery alert' --glob 'lib/**/*.dart' --glob 'test/**/*.dart' || trueRepository: OpenStrap/edge
Length of output: 46173
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== rest of notification_service.dart =="
sed -n '240,540p' lib/notify/notification_service.dart
echo "== notification_ids.dart =="
sed -n '1,240p' lib/notify/notification_ids.dart
echo "== notification_event.dart =="
sed -n '1,240p' lib/notify/notification_event.dart
echo "== test references =="
sed -n '1,320p' test/device_alerts_test.dart
echo "== =="
sed -n '1,360p' test/notification_center_test.dartRepository: OpenStrap/edge
Length of output: 34446
Route device alerts through NotificationCenter.emit.
DeviceAlerts._apply calls _notes.show(...), and _NotificationServiceSink.show() calls NotificationService.instance.showDevice directly. That bypasses NotificationCenter.emit’s category quiet-hour guard, fire-once dedupe claim, release-on-unpresent behavior, and the presentation-chain lock, and these alerts miss the notification history key that belongs with the emitter. Keep the device alert test seam, but make the production sink go through NotificationCenter.emit with a NotificationEvent using NotifCategory.device; keep ChargeAlertPolicy responsible only for charge-specific replay/low-water replay suppression.
🤖 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/notify/device_alerts.dart` around lines 41 - 54, Update
_NotificationServiceSink.show to route device alerts through
NotificationCenter.emit using a NotificationEvent with NotifCategory.device,
preserving the existing device-alert test seam. Ensure the production path
receives the emitter’s quiet-hour guard, fire-once claim/release behavior,
presentation-chain locking, and history key, while leaving ChargeAlertPolicy
responsible only for charge-specific replay and low-water replay suppression.
Source: Coding guidelines
Bot review follow-up. Four of seven findings were real; verified each against code before touching anything. - Reuse kMinPlausibleUnix from sync_policy.dart instead of a second copy of the 1700000000 floor (CodeRabbit). ble_state.dart already imports from there. - Remove the 24h usability cap. It was modelled on GestureDispatcher's _plausibleAgeCapSec, which is right for a tap (bounded backlog) and wrong here: the strap banks days, so a two-day-old chargingOn is a real old event, not a broken clock — and the cap routed it to the untimed path, where it ANNOUNCED. That reintroduced the exact #179 bug past 24h. An unset RTC reads near zero and is still caught by the floor. Prompted by CodeRabbit asking for a test pinning the old behaviour; the behaviour was wrong, so the test pins the fix instead. - Don't let a replayed chargingOff clear a live Charging card (CodeRabbit). Writing this exposed a second defect in my own first attempt: the cancel was keyed off the charging TRANSITION, but a stale chargingOff still has to update _wasCharging (it is the latest known state), which consumed the transition and left the REAL removal with nothing to act on — the card would strand in the tray. Cancel now keys off a separate "already cancelled for this off-state" latch. - Decide-then-act in _apply, so every latch is committed before the first await (PR Agent, citing AGENTS.md 4.3 — a real repo convention, verified). A throwing sink previously left _lowArmed armed and retried the alert on every update, and a throwing store could re-announce the same charge session; the queue's catchError made both silent. Restore now assigns the identity pair atomically too. Declined, with reasons: - Routing device alerts through NotificationCenter.emit (CodeRabbit). Its only added gate for this category is quiet hours — notification_prefs.dart has `NotifCategory.device => true, // device alerts aren't user-gated here`, so there is no category toggle being bypassed. It would silence a confirmation of an action the user just performed, during exactly the window people put a band on the charger. Its fire-once guard is set-membership on a dedupeKey and cannot express the `<=` identity rule this needs. - Persist-before-announce and untimed-cooldown durability gaps (PR Agent): both require SharedPreferences writes to fail, where the recency window still holds. The in-memory latches now cover the same window regardless. Tests 1127 -> 1133. Each new test verified to fail against the pre-fix code.
|
Reviewed both bots against the code. 4 of 7 findings were real and are fixed in 5710526; 3 declined with reasons below. Fixed1. Duplicate plausible-unix floor (CodeRabbit) — real. 2. The 24 h usability cap was itself a bug (surfaced by CodeRabbit asking for a test at 3. A replayed 4. Latch ordering (PR Agent, citing AGENTS.md §4.3) — real, and the citation checks out: §4.3 "Sticky boolean latches never reset on the failure path" is a documented recurring class in this repo. DeclinedRoute device alerts through Persist-before-announce durability gap and untimed cooldown with no prior wall record (PR Agent). Both require a SharedPreferences write to fail. In that window the recency check still rejects anything stale, and after this change the in-memory latches cover the same session regardless of whether persistence succeeded. Narrow enough that I'd rather not add code for it. One correction to a PR Agent suggestion I did partly take: it argued "the real risk is the inverse —
|
|
Persistent review updated to latest commit 5710526 |
Second review round. One of four findings real. A session announced with no usable strap clock has no identity of its own, but the previous high-water was left in place — so it went on to judge a session it knew nothing about, and the next usable timestamp falling at or below it was suppressed. That direction costs the user a real "on the charger" confirmation, which is worse than the duplicate this PR set out to kill. Cleared in memory and persisted as 0 (every real strap timestamp is far above it), so the untimed path is guarded only by the wall-clock cooldown, which is what ChargeAlertPolicy's untimed branch already assumes. Declined: - "Low-armed not persisted" / "untimed cooldown not restart-safe" — both require SharedPreferences writes to fail. Not a regression: before this PR the latch was RAM-only and re-armed on EVERY process start, so a dead store degrades to exactly the old behaviour and a working one is strictly better. There is no way to remember a latch without somewhere to remember it. Noted the asymmetry in a comment: charging keeps the recency window as a second line of defence, low battery has no timestamp to reason about. - "Remove _cancelledForOff, it blocks real card cancellation" — the stated mechanism does not hold: a stale chargingOff cannot set the latch, because isStale() gates it first, so it cannot block the real removal that follows. The latch exists because charging == false persists for the rest of the process once seen, and _onEngineState fires ~1 Hz on live HR — without it we would issue a platform cancel() every second forever. Its failure mode after a restart is one REDUNDANT cancel (a no-op), never a missed one; "charging off clears the card even on a fresh process" already covers that. Tests 1133 -> 1134; the new one verified to fail without the fix.
|
Second round reviewed. 1 of 4 real, fixed in a32e92f. FixedUntimed announcement left a stale identity high-water (PR Agent) — real, and the failure direction is the bad one. A session announced with no usable strap clock has no identity of its own, but the previous high-water stayed in place and went on to judge a session it knew nothing about: the next usable timestamp falling at or below it got suppressed, costing the user a real "on the charger" confirmation. That's worse than the duplicate this PR exists to kill. Now cleared in memory and persisted as Declined"Low-armed not persisted on restore" and "untimed cooldown not restart-safe" — both scenarios require SharedPreferences writes to fail. Not a regression: before this PR "Remove On the underlying question — should the latch be persisted? No: dropping it means calling
|
|
Persistent review updated to latest commit a32e92f |
Third review round. A throwing sink aborted _apply before the writes below it, so a session could be shown but not recorded and would re-announce after a restart. Reordering is not the fix — persist-then-show just swaps which failure is fatal, letting a dead store swallow the alert entirely. Neither step reads the other's result, so each is now attempted independently and absorbs its own failure. Also pinned the subtle two-latch interaction the reviewer flagged as fragile rather than broken (it analysed it and concluded "no actual bug here", correctly): a stale chargingOn re-opens the cancel latch, and the stale chargingOff that follows must still not clear a card. That held, but nothing asserted it. Tests 1134 -> 1135; both new assertions verified to fail without the change.
|
Third round. 1 of 2 real, fixed in 04dd2bf. Presentation could take persistence down with it (PR Agent) — real. A throwing Worth noting the suggested reorder wouldn't have fixed it — persist-then-show just swaps which failure is fatal, letting a dead store swallow the alert entirely. Since neither step reads the other's result, each is now attempted independently and absorbs its own failure. "Low-battery re-arm on stale chargingOn" — the analysis walks through it and reaches "No actual bug here", which matches what I get: a stale
Heads-up on the check status: CodeRabbit's green tick on the last two pushes reads "Review rate limited" — it never analysed those commits, so its pass carries no signal here. Every finding actioned in rounds 2 and 3 came from PR Agent. Worth a re-run before merge if you want a second opinion on the latest diff. |
|
Persistent review updated to latest commit 04dd2bf |
|
Fourth round — no action; this re-raises a finding the same reviewer resolved last round, and the new mechanism doesn't hold. The claim is that a stale static bool isStale(int? tsEpoch, {required int wallNowSec}) =>
timestampUsable(tsEpoch, wallNowSec: wallNowSec) &&
wallNowSec - tsEpoch! > liveWindowSec;The behaviour is asserted, not argued — The second half asks for coverage of the low-battery re-arm side, which already exists as Calling the automated review done here: rounds 2–4 have converged on re-litigating settled ground, and CodeRabbit has been rate-limited (not actually analysing) for the last three pushes. Six real findings were fixed across the three rounds; the rest are documented above with reasons. Ready for human review. |
User description
Fixes #179 — repeated "Your band is on the charger" long after the charger was removed (Android, 0.9.21).
Root cause
The charging alert fires from a band event, and nothing on that path checked when the event happened.
ble_engine.dart_absorbStatesetsstate.chargingfrom a decoded event and callsonState. The decoded event carriests_epoch, and it was discarded.app_state.dart_onEngineStatefeeds everyonStateintoDeviceAlerts.device_alerts.dartfired on anycharging == true && _wasCharging != true, with_wasChargingheld only in RAM.notification_service.dartre-posts id 1002 with noonlyAlertOnce, so each re-post re-buzzes.The strap buffers its event log in flash, dumps it on connect, and re-sends events it has already delivered — so step 1 hands step 3 a
chargingOnfrom hours ago and it is treated as a live plug-in. And because the edge state is RAM-only, every Android process restart re-arms it:EdgeApplicationpre-warms a Dart engine on every process create, andKeepAliveWorker+ theSTART_STICKYforeground service recreate the process routinely.Evidence
Queried a real user export (
openstrap_export_*.db, 6,980 events):(event_id, ts)deliveriesseqeach time, so nothing upstream de-dupes themThe codebase already knew both facts:
db.dart'sinsertEventcomments "the band re-sends events" (hence itsConflictAlgorithm.ignore), andGestureDispatchercarries a recency window plus a debounce for exactly this — "older than this = a drained/historical tap". Double-tap got that treatment; charging never did.The fix
ChargeAlertPolicy(new, pure, unit-tested) — two guards, because neither is sufficient alone:chargingOndescribing a plug-in from hours ago is history, not news. Catches first-time delivery of a backlogged event, which has no prior identity to match against.Supporting changes:
DeviceState.chargingTscarries the event's own strap timestamp. The flag itself still means "latest known charging state" and the UI should keep showing it — only consumers that treat the transition as live gate on the age.DeviceAlertspersists its edge state, so "once per real event" survives a process restart. The low-battery hysteresis had the identical defect (re-armed on every restart) and is fixed with it.chargingOffcancels the Charging card. It is a state claim, so leaving it in the tray after the puck comes off was wrong independently of this bug.onlyAlertOnceon the device channel — explicitly not load-bearing. It only suppresses sound while a card with that id is still showing, so the user dismissing it re-arms the buzz. It just stops a redundant update from making noise; the real de-dupe is the policy.Trade-off worth flagging
A genuine plug-in whose event is first delivered more than 15 min late no longer notifies. Real first-delivery lags in the export were 0 s, 105 s and 226 s (the app reconnecting minutes after the puck went on), so the window clears them with room to spare. A tighter window would have silenced those real alerts and fixed nothing, since re-sends are recent by definition.
A more precise alternative — debounce the charging edge until the event burst goes quiet, then act on the resolved final state — would preserve even a very-late first notification while still dropping a replayed on/off pair. It needs a timer and has its own failure mode (an
offstill queued for a later connection), so I left it out of a bug fix. Noted as a possible follow-up.Not changed, deliberately
state.chargingstill applies old events. Applying the replayed log in order converges to the correct current state; gating it would make the UI go blank on a legitimate plug-in the app learned about late, since there is no live charging source to fall back on.control.dart:295setsHelloInfo.charging;_absorbStatereads onlybatteryPct/wristOnfrom it). A request/response bit would be a genuinely live signal, but the HELLO battery-block offsets are heuristic and unverified on hardware — not something to wire up in a bug fix. Worth a look separately.Testing
test/device_alerts_test.dart— 23 new tests: policy table, the exact #179 loop (announce → remove → 4 re-sends → 5 process restarts ⇒ still one notification), the real measured lags still announcing, a genuinely new session after a restart still announcing, unset-RTC fallback, identity expiry so a backwards strap clock can't silence alerts forever, and low-battery hysteresis surviving a restart.Each was checked to fail against the old behaviour — temporarily restoring the original rule fails 7 of them, including both #179 regression tests, and removing the restore guard fails the broken-store test.
flutter analyze— cleanflutter test --concurrency=1— 1127 passed (1104 + 23)kAlgoVersionbump: notification layer only, no analytics output change.Not verified on hardware — the policy is fully covered by unit tests, but
onlyAlertOnceand the card-cancel are OS behaviours that need a device check: plug in → buzz; re-post while showing → silent update; unplug → card clears; plug in again → buzzes.PR Type
Bug fix, Tests
Description
Fixes repeated "band is on the charger" notifications from replayed stale BLE events (Android App: Repeated notification "Your band is on the charger" well after the charger has been removed #179)
Adds
ChargeAlertPolicywith recency + identity guards to filter backlog replaysPersists charging/low-battery edge state to
SharedPreferencesso Android process restarts don't re-arm alertsAdds 377-line test suite covering all policy paths and restart scenarios
Diagram Walkthrough
File Walkthrough
5 files
Carry strap event timestamp into DeviceState.chargingTsNew pure policy: recency and identity guards for charging alertsPersist edge state, serialize async queue, apply ChargeAlertPolicyAdd onlyAlertOnce for device notification category on AndroidPass chargingTs through to DeviceAlerts.onDeviceState1 files
Add chargingTs field to DeviceState1 files
Full test suite for charging and low-battery alert de-dupeSummary by CodeRabbit
New Features
Bug Fixes