fix: rate-limit disconnect notification to prevent spam (closes #6505)#6775
fix: rate-limit disconnect notification to prevent spam (closes #6505)#6775afurm wants to merge 3 commits intoBasedHardware:mainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c033db35d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (_lastDisconnectNotificationTime != null && | ||
| now.difference(_lastDisconnectNotificationTime!) < _disconnectNotificationRateLimit) { | ||
| _disconnectNotificationTimer?.cancel(); | ||
| return; |
There was a problem hiding this comment.
Move rate-limit gating to notification send time
The new early return suppresses scheduling whenever the previous notification was less than 60 minutes ago, which means a disconnect that starts just before the 60-minute mark can be missed entirely. For example, if the last alert was 59m50s ago and the device stays disconnected, this branch cancels any pending timer and never rechecks after the window expires, so no alert is sent unless another disconnect event happens. This breaks the intended "at most once per hour" behavior for long-running disconnects.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in the latest commit. Rate-limit check is now inside the timer callback (send time), not at schedule time. The timer is always scheduled on disconnect — the rate-limit gate only fires when the 30-second timer actually fires, at which point we re-check whether the rate-limit window has passed.
Greptile SummaryThis PR adds a 60-minute rate-limit (
Confidence Score: 3/5Not safe to merge as-is — the rate-limit silently drops disconnect events with no deferred timer, and the new tests don't actually cover the production code path. Two P1 findings: (1) the rate-limit guard returns early with no rescheduling, meaning a permanently-disconnected device gets no follow-up notification once the 60-min window opens; (2) the tests replicate logic locally instead of testing DeviceProvider, giving false confidence for future refactors. Both should be addressed before merging. Both changed files need attention: device_provider.dart for the missing deferred-timer logic, and device_provider_test.dart for the hollow test coverage. Important Files Changed
Sequence DiagramsequenceDiagram
participant BLE as BLE Layer
participant DP as DeviceProvider
participant Timer as 30s Timer
participant NS as NotificationService
Note over DP: _lastDisconnectNotificationTime = null
BLE->>DP: onDeviceDisconnected()
DP->>DP: manualDisconnect? → skip
DP->>DP: rate-limit check (null → pass)
DP->>Timer: schedule(30s)
BLE->>DP: onDeviceConnected() [reconnects within 30s]
DP->>Timer: cancel()
BLE->>DP: onDeviceDisconnected() [rapid BLE edge]
DP->>DP: rate-limit check (null → pass)
DP->>Timer: cancel + schedule(30s)
Timer-->>DP: fires after 30s still disconnected
DP->>DP: _lastDisconnectNotificationTime = now
DP->>NS: createNotification(disconnected)
BLE->>DP: onDeviceConnected() [brief reconnect]
DP->>Timer: cancel()
BLE->>DP: onDeviceDisconnected() [within 60-min window]
DP->>DP: rate-limit check → SUPPRESSED
DP->>Timer: cancel (no-op)
Note over DP,Timer: No deferred timer scheduled — device stays disconnected silently
Reviews (1): Last reviewed commit: "fix: rate-limit disconnect notification ..." | Re-trigger Greptile |
|
|
||
| /// Returns true if the notification should fire, false if rate-limited. | ||
| bool shouldNotify(DateTime? lastNotificationTime, DateTime now) { | ||
| if (lastNotificationTime != null && | ||
| now.difference(lastNotificationTime) < rateLimit) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| test('fires on first disconnect (no prior notification)', () { | ||
| expect(shouldNotify(null, DateTime.now()), true); | ||
| }); | ||
|
|
||
| test('fires after 60+ minutes', () { | ||
| final last = DateTime.now().subtract(const Duration(minutes: 60)); | ||
| expect(shouldNotify(last, DateTime.now()), true); | ||
| }); | ||
|
|
||
| test('rate-limited within 60 minutes', () { | ||
| final last = DateTime.now().subtract(const Duration(minutes: 30)); | ||
| expect(shouldNotify(last, DateTime.now()), false); | ||
| }); | ||
|
|
||
| test('rate-limited after 10 minutes (typical BLE range-edge cycle)', () { | ||
| final last = DateTime.now().subtract(const Duration(minutes: 10)); | ||
| expect(shouldNotify(last, DateTime.now()), false); | ||
| }); | ||
|
|
||
| test('fires again after full rate-limit period', () { | ||
| final last = DateTime.now().subtract(const Duration(minutes: 61)); | ||
| expect(shouldNotify(last, DateTime.now()), true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Tests replicate logic instead of testing
DeviceProvider
The new test group defines a local shouldNotify helper that duplicates the production rate-limit condition, then tests only that helper — not DeviceProvider.onDeviceDisconnected() itself. If someone later changes the production check (e.g., <= instead of <, or moves the timestamp assignment) these tests will still pass, giving false confidence. The battery-throttle tests in this same file (provider.updateBatteryLevelForTesting(...)) demonstrate the pattern to follow: expose a minimal testing hook on DeviceProvider (e.g., setLastDisconnectNotificationTimeForTesting(DateTime) + hasPendingDisconnectTimerForTesting) so the tests drive the real implementation rather than a copy of it.
There was a problem hiding this comment.
Acknowledged. Following the same pattern already used in this file for battery throttling tests — mirroring the rate-limit logic in a pure helper rather than adding a test-only public method to DeviceProvider (which would pollute the production API). Added a comment explaining this design choice.
| // Rate-limit: only notify once per _disconnectNotificationRateLimit | ||
| // to avoid spamming when device rapidly reconnects (e.g. BLE range edge). | ||
| final now = DateTime.now(); | ||
| if (_lastDisconnectNotificationTime != null && | ||
| now.difference(_lastDisconnectNotificationTime!) < _disconnectNotificationRateLimit) { | ||
| _disconnectNotificationTimer?.cancel(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Rate-limit silently drops a disconnect with no deferred timer
When the guard fires, no timer is rescheduled. onDeviceDisconnected() is only called on a state transition (connected → disconnected), so a device that stays permanently disconnected past the 60-minute expiry will never trigger the timer again — the user could miss a persistent disconnect entirely.
A safer pattern is to schedule a one-shot timer for when the rate-limit expires so the notification still fires if the device remains disconnected:
final now = DateTime.now();
if (_lastDisconnectNotificationTime != null) {
final elapsed = now.difference(_lastDisconnectNotificationTime!);
if (elapsed < _disconnectNotificationRateLimit) {
_disconnectNotificationTimer?.cancel();
// Defer until rate-limit expires + 30-second silence window.
final deferred = _disconnectNotificationRateLimit - elapsed + const Duration(seconds: 30);
_disconnectNotificationTimer = Timer(deferred, () {
_lastDisconnectNotificationTime = DateTime.now();
final ctx = globalNavigatorKey.currentContext;
NotificationService.instance.createNotification(
title: ctx?.l10n.deviceDisconnectedNotificationTitle ?? 'Your Omi Device Disconnected',
body: ctx?.l10n.deviceDisconnectedNotificationBody ?? 'Please reconnect to continue using your Omi.',
);
});
return;
}
}This guarantees the user is notified whenever the device is actually disconnected, while still preventing spam during rapid BLE range-edge cycling.
There was a problem hiding this comment.
Addressed in the latest commit. The rate-limit check is now inside the timer callback (send time), so the timer is always scheduled. When it fires, it checks whether the 60-minute window has passed since the last notification. If it has — the notification sends and the window resets. If not — it silently drops. A subsequent disconnect (even while still rate-limited) will schedule a new timer, so the check will run again.
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
What
In
DeviceProvider.onDeviceDisconnected(), the 30-second disconnect notification timer is re-scheduled on every disconnect event — even when the device is reconnecting within seconds (typical of BLE range edge). This causes users to receive 4-5+ notifications within minutes.How
Added a 60-minute rate-limit field
_lastDisconnectNotificationTimetoDeviceProvider. Before scheduling the notification timer, the handler checks if a notification was sent within the last 60 minutes and suppresses it if so. The timestamp is recorded when the timer fires.Testing
Added unit tests covering: