ADR-0072: Prevent notification storm on Android#4381
Conversation
WalkthroughAdds an ADR that defines four defensive mechanisms to prevent Android notification storms: a 30-second push debounce persisted via SharedPreferences; a notification limiter that caps generated notifications at 20 and prioritizes action-required then newest Inbox emails with a selection algorithm; persisting email delivery state during foreground synchronization via FCMCacheManager.storeStateToRefresh; and safe local notification removal wrapped in try/catch. Describes architecture flow updates, integration points in push and email handling code, example Dart implementations, and future improvement options. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
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 |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/adr/0072-prevent-notification-storm-android.md`:
- Line 97: Hyphenate the numeric adjective in the sentence "The background push
handler introduces a 30 second debounce window" by changing "30 second" to
"30-second" so it reads "The background push handler introduces a 30-second
debounce window"; locate the exact sentence in the ADR text and update it
accordingly.
- Line 17: Update every fenced code block in the ADR document that currently
uses bare backticks to include a language identifier (e.g., change ``` to
```text). Specifically, replace the unnamed fences that enclose examples like
the block with "100 push events at T0" and the other unnamed examples throughout
the ADR so they become labeled (e.g., ```text) to satisfy markdownlint MD040 and
improve readability.
- Around line 261-272: The code is saving the email cache state
(StateType.email) then incorrectly reusing newState when storing the delivery
cursor to FCM; update the FCMCacheManager.storeStateToRefresh call to pass the
delivery-state token (e.g., deliveryNewState or deliveryCursor) for
TypeName.emailDelivery rather than newState so the delivery stream and email
stream remain separate; locate the calls to stateCacheManager.saveState and
FCMCacheManager.storeStateToRefresh and replace the second argument value for
the TypeName.emailDelivery call with the correct delivery-state variable
returned/produced by your email-delivery processing logic.
- Around line 106-117: The check-then-set in shouldProcessPush allows a race
where multiple handlers read the old 'last_push_processed_time' before any
write, so two handlers can both proceed; fix it by performing a check, writing
the candidate timestamp, then re-reading to confirm you won the race: read
prefs.getInt('last_push_processed_time'), if within debounce return false; else
compute now, await prefs.setInt('last_push_processed_time', now), then read back
prefs.getInt('last_push_processed_time') and only return true if that stored
value equals now (otherwise return false) so only the first writer proceeds.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9708a519-1aa6-411c-aead-c32446de4d66
📒 Files selected for processing (1)
docs/adr/0072-prevent-notification-storm-android.md
|
|
||
|
|
||
|
|
||
| # 1. Push debounce in background handler (30s window) |
There was a problem hiding this comment.
I propose the following algorithm, not a normal debounce
WHEN receiving FCM call with emailDelivery wait 1 minute, ignore received message during this 1 minute, then and only then fire notifs for the last 1 minute.
There was a problem hiding this comment.
The proposed algorithm is closer to a batch aggregation window rather than a debounce. However, the FCM background handler runs inside a short-lived Flutter isolate and is not designed for long-running tasks. Delaying notification processing for 1 minute would require keeping the isolate alive, which is not guaranteed by the Android system. Therefore the ADR proposes a simpler debounce mechanism that drops repeated push events within a 30s window while still allowing the notification pipeline to run immediately for the first event.
|
|
||
| Rules: | ||
|
|
||
| * maximum **20 notifications** |
|
|
||
|
|
||
|
|
||
| # Action-required Email Detection |
There was a problem hiding this comment.
Good idea but let's not implement this for now IMO
| Selection priority: | ||
|
|
||
| 1. actionable emails (`needs-action`) | ||
| 2. newest emails |
There was a problem hiding this comment.
- Favour INBOX, ignore SPAM / Trash
| } | ||
| ``` | ||
|
|
||
| This ensures notification removal failures do not interrupt the notification pipeline. |
chibenwa
left a comment
There was a problem hiding this comment.
Small adjustments but I mostly agree with this document.
And getting the ADR before writing code is truely nice. THANKS for making the effort.
| ```dart | ||
| Future<void> handleFirebaseBackgroundMessage(RemoteMessage message) async { | ||
|
|
||
| if (!await shouldProcessPush()) { |
There was a problem hiding this comment.
please check the posibility of this in isolated in flutter
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/adr/0072-prevent-notification-storm-android.md`:
- Line 126: Change the "Background execution constraints" heading from level-3
to level-2 (replace "### Background execution constraints" with "## Background
execution constraints") so it directly follows the document's level-1 section
and ensures headings increment by one; also scan nearby headings in the ADR to
confirm no other headings skip a level and adjust them similarly.
- Around line 199-201: The example uses a hard-coded "inbox-id"; update the
isInboxEmail guidance to explain how to derive the real inbox ID for an account
(e.g., inspect the account's mailbox objects and find the mailbox whose
mailboxRole == MailboxRole.inbox or equivalent) and then check that ID against
email.mailboxIds in isInboxEmail (function isInboxEmail, type PresentationEmail,
property mailboxIds); mention that implementers should resolve the inbox mailbox
ID from the account's mailbox list rather than using a literal string.
- Around line 217-251: selectEmailsForNotification currently collects all
actionEmails uncapped and then trims the combined list only at the end, which
can let action emails monopolize the 20 slots; change the logic so actionEmails
is capped to at most maxNotifications (use actionEmails.take(maxNotifications)),
compute remainingSlots = maxNotifications - cappedActionCount, then fill
remainingSlots from newestInboxEmails (constructed from the heap) and return
[...cappedActionEmails, ...newestInboxEmailsTaken] so the total never exceeds
maxNotifications and inbox slots are preserved when actions are abundant; keep
references to actionEmails, heap, newestInboxEmails, and maxNotifications when
making these changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5b2f0032-7c08-4f07-bade-a1851ff36e05
📒 Files selected for processing (1)
docs/adr/0072-prevent-notification-storm-android.md
There was a problem hiding this comment.
🧹 Nitpick comments (1)
docs/adr/0072-prevent-notification-storm-android.md (1)
94-107: Note potential race condition in debounce implementation.The
shouldProcessPush()example uses a check-then-set pattern: readinglastTime(line 97) and writing the new timestamp (line 104) are separate operations. If two FCM background handlers execute concurrently, both may read the old timestamp before either writes, allowing both to proceed and bypass the debounce.This race is low-probability (requires FCM isolates spawning within milliseconds of each other), but the ADR should either:
- Acknowledge this as an acceptable edge case for "best-effort" debouncing, or
- Propose a mitigation (e.g., atomic check-and-set via native code, file locking, or a check-write-verify pattern)
Given that this is an architectural proposal, consider adding a brief note in the ADR clarifying the atomicity guarantees (or lack thereof) of this approach.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/adr/0072-prevent-notification-storm-android.md` around lines 94 - 107, The debounce implementation in shouldProcessPush() has a potential race because it reads and then writes SharedPreferences separately; acknowledge this or propose a mitigation. Update the ADR text near the shouldProcessPush() example to either state that this is a best-effort, non-atomic debounce with possible rare duplicates, or describe a mitigation such as using an atomic check-and-set via native code or file-locking, or implementing a check-write-verify loop that re-reads the stored last_push_processed_time after writing and aborts if another handler has advanced it; reference shouldProcessPush() and the last_push_processed_time key in the note so readers can find the code and understand the atomicity guarantees (or lack thereof).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@docs/adr/0072-prevent-notification-storm-android.md`:
- Around line 94-107: The debounce implementation in shouldProcessPush() has a
potential race because it reads and then writes SharedPreferences separately;
acknowledge this or propose a mitigation. Update the ADR text near the
shouldProcessPush() example to either state that this is a best-effort,
non-atomic debounce with possible rare duplicates, or describe a mitigation such
as using an atomic check-and-set via native code or file-locking, or
implementing a check-write-verify loop that re-reads the stored
last_push_processed_time after writing and aborts if another handler has
advanced it; reference shouldProcessPush() and the last_push_processed_time key
in the note so readers can find the code and understand the atomicity guarantees
(or lack thereof).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d8fdb336-27cf-4d0d-89e4-3e21f0cf6cf8
📒 Files selected for processing (1)
docs/adr/0072-prevent-notification-storm-android.md
Add ADR-0072: Prevent notification storm on Android
Summary
This PR introduces a new Architecture Decision Record (ADR) that defines how the application prevents notification storms on Android when a device reconnects after a long idle period.
The ADR documents the root causes of the issue and proposes several defensive mechanisms to stabilize the push notification system and limit excessive local notifications.
Related Issue
Fixes #4366
Problem
Some Android users experience a large burst of notifications when their device reconnects to the network after being idle (for example overnight).
When reconnection occurs, Firebase Cloud Messaging (FCM) may deliver many push events within a short time window.
Each push currently triggers the full notification pipeline and may generate multiple local notifications.
Example scenario:
This can lead to:
Decision
To mitigate this issue, the ADR proposes four defensive mechanisms.
1. Push debounce in background handler
A debounce window is introduced in the background push handler to prevent repeated push bursts from triggering the notification pipeline multiple times.
When multiple FCM messages arrive within a short period of time:
This prevents push bursts from repeatedly executing the full notification pipeline.
2. Notification limiter
When a push burst is processed, the application limits the number of generated notifications.
Rules:
needs-action) are prioritizedSelection priority:
Emails delivered to other mailboxes (Sent, Drafts, Spam, Trash, etc.) will not generate notifications.
This significantly reduces unnecessary notifications during large synchronization bursts.
3. Foreground delivery state synchronization
The email delivery state is currently stored when push notifications are processed.
To maintain state consistency, the delivery state will also be updated during foreground synchronization when the email state cache is refreshed.
This ensures the latest delivery state is always persisted even if updates originate outside the push pipeline.
4. Fix local notification removal error
The notification cancellation logic will be improved to safely handle failures.
Sentry reports the following error:
When cancellation fails:
The updated logic introduces defensive cancellation handling and safer notification removal.
Expected Benefits
This design provides several improvements:
Scope
This PR adds documentation only.
The ADR defines the architecture and design decisions.
Implementation will be delivered in follow-up issues and pull requests.
Files Added
Summary by CodeRabbit