Skip to content

fix: [SDK-5011] detect and correctly suppress restored notifications - #2723

Open
nan-li wants to merge 11 commits into
mainfrom
nan/sdk-5011
Open

fix: [SDK-5011] detect and correctly suppress restored notifications#2723
nan-li wants to merge 11 commits into
mainfrom
nan/sdk-5011

Conversation

@nan-li

@nan-li nan-li commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

(API Addition)
Let apps detect restored notifications, and stop restored ones from re-alerting or reappearing after they are suppressed.

Details

Motivation

When Android clears the shade (reboot, app update, force-stop) the SDK re-posts saved notifications and re-fires onNotificationReceived. Three problems fell out of that:

  1. Apps could not tell a restore from a new push, so once-only work (analytics, network calls, local writes) ran again on every restore. v3 exposed a restoring flag; it was dropped in the v4 refactor.
  2. preventDefault() during a restore did not stick. markNotificationAsDismissed bailed out unless isNotificationToDisplay was set, which never happens when display is suppressed, so the row was never marked dismissed and the notification returned on every later restore.
  3. An extender calling setChannelId moved a restored notification off the silent restored_OS_notifications channel. The channel governs alerting on Android O+, so removeNotifyOptions() could not keep it quiet and it arrived as a heads-up banner.

Scope

  • INotificationReceivedEvent gains a read-only restoring: Boolean, defaulted so existing implementations still compile. True for both a shade restore and a group collapsing to one member.
  • The restore channel is re-asserted after the extender runs, including on group collapse, so neither path re-alerts. Extenders still control the channel for a first-time notification.
  • Shade restore + preventDefault(true) marks the row dismissed and leaves the shade. Group collapse + preventDefault is a no-op; the remaining child stays, including its summary.
  • Drops NotificationGenerationJob.orgFlags, written after every extender and read nowhere. Its sibling orgSound is kept, still needed by the pre-Android 7.0 grouped-sound workaround.

Testing

Unit testing

NotificationDisplayerTests covers the restore channel, an extender trying to override it, an extender keeping its channel on a normal notification, and the payload-vs-extender sound split that orgSound exists for.
NotificationGenerationProcessorTests covers restoring for both reasons, shade preventDefault(true), no-arg shade preventDefault(), and regroup not dismissing.
NotificationRepositoryTests covers markAsDismissedWithoutCancel leaving the shade.
NotificationGenerationWorkManagerTests covers readRestoreReason for the enum, the legacy boolean, and an unknown name.

JaCoCo does not attribute Robolectric-executed lines, so this PR uses Skip Coverage Check. The processor and work-manager cases are plain JVM and do count.

Manual testing

Confirmed behavior before and after with force kill

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing — every change is in the restored-notification path
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible — see Manual testing

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

nan-li and others added 4 commits August 21, 2026 09:54
…efault

markNotificationAsDismissed returned early whenever isNotificationToDisplay
was false, which is always the case on the suppress path, so a restored
notification the app rejected was never marked dismissed and came back on
every later restore.

Co-authored-by: Cursor <cursoragent@cursor.com>
Apps had no way to tell a restored notification apart from a new one inside
their notification service extension, so work meant to run once ran again on
every restore. v3 exposed this flag and it was dropped in the v4 refactor.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nnel

An extender calling setChannelId moved a restored notification off the silent
Restored channel, and since the channel governs alerting on Android O+,
removeNotifyOptions could not stop it from showing as a heads-up banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
…around

orgFlags was written after every extender ran and never read. Its sibling
orgSound is still needed by the pre-Android 7.0 grouped-sound workaround, whose
minSdk 21 version guard and stale comment are cleaned up here instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nan-li
nan-li requested a review from a team as a code owner August 21, 2026 16:57
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • INotificationReceivedEvent.kt: 0/1 touched executable lines (0.0%) (13 touched lines in diff)
    • 1 uncovered touched lines in this file
  • NotificationReceivedEvent.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • NotificationBundleProcessor.kt: 0/3 touched executable lines (0.0%) (4 touched lines in diff)
    • 3 uncovered touched lines in this file
  • NotificationRestoreReason.kt: 2/2 touched executable lines (100.0%) (13 touched lines in diff)
  • NotificationRepository.kt: 0/8 touched executable lines (0.0%) (19 touched lines in diff)
    • 8 uncovered touched lines in this file
  • NotificationDisplayBuilder.kt: 0/2 touched executable lines (0.0%) (4 touched lines in diff)
    • 2 uncovered touched lines in this file
  • NotificationDisplayer.kt: 0/3 touched executable lines (0.0%) (5 touched lines in diff)
    • 3 uncovered touched lines in this file
  • SummaryNotificationDisplayer.kt: 0/3 touched executable lines (0.0%) (4 touched lines in diff)
    • 3 uncovered touched lines in this file
  • NotificationGenerationProcessor.kt: 11/11 touched executable lines (100.0%) (25 touched lines in diff)
  • NotificationGenerationWorkManager.kt: 0/2 touched executable lines (0.0%) (20 touched lines in diff)
    • 2 uncovered touched lines in this file
  • NotificationRestoreProcessor.kt: 0/5 touched executable lines (0.0%) (8 touched lines in diff)
    • 5 uncovered touched lines in this file
  • NotificationSummaryManager.kt: 0/3 touched executable lines (0.0%) (5 touched lines in diff)
    • 3 uncovered touched lines in this file

Overall (aggregate gate)

14/44 touched executable lines covered (31.8% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • INotificationReceivedEvent.kt: 0.0% (1 uncovered touched lines)

  • NotificationBundleProcessor.kt: 0.0% (3 uncovered touched lines)

  • NotificationRepository.kt: 0.0% (8 uncovered touched lines)

  • NotificationDisplayBuilder.kt: 0.0% (2 uncovered touched lines)

  • NotificationDisplayer.kt: 0.0% (3 uncovered touched lines)

  • SummaryNotificationDisplayer.kt: 0.0% (3 uncovered touched lines)

  • NotificationGenerationWorkManager.kt: 0.0% (2 uncovered touched lines)

  • NotificationRestoreProcessor.kt: 0.0% (5 uncovered touched lines)

  • NotificationSummaryManager.kt: 0.0% (3 uncovered touched lines)

❌ Coverage Check Failed

Aggregate coverage on touched lines is 31.8% (minimum 80%).

📥 View workflow run

@nan-li
nan-li marked this pull request as draft August 21, 2026 17:58
nan-li and others added 6 commits August 21, 2026 11:43
… restore

isRestoring meant two different things. NotificationSummaryManager.restoreSummary
re-enters generation with isRestoring = true when a group drops to one member,
which happens on an ordinary swipe or open. Dismissing on the suppress path
therefore took down the surviving sibling the user never touched, for any app
whose extension calls preventDefault(true) on a received event.

Thread a NotificationRestoreReason through enqueue so the two are distinguishable.
Only SHADE_RESTORE records the dismissal; GROUP_REGROUP keeps its previous
behavior of leaving the notification and its summary in place.

Record that dismissal without cancelling the shade. The restore pass includes
notifications that are still showing on API 21 and 22, and whenever
getActiveNotifications swallows a throwable and reports an empty shade.

Skip the dependent summary rebuild as well. The restore pass already walks every
outstanding notification, so rebuilding from here enqueues generation a second
time for a sibling that is about to be restored anyway.

Co-authored-by: Cursor <cursoragent@cursor.com>
The previous wording said restoring only meant Android cleared the shade, which
is not what the SDK reports. It is also true when a group collapses to a single
notification that has to be rebuilt.

Point restore suppression at preventDefault(true). The no-argument overload parks
on the display waiter for the full 30 second timeout, which is the wrong tool for
dropping a restore and would hold a worker per notification.

Warn that setExtender still has to run when restoring is true, since the rebuilt
notification is what the app's customizations get applied to.

Give the property a default so implementations outside this SDK keep compiling.

Co-authored-by: Cursor <cursoragent@cursor.com>
An app that calls preventDefault() and never calls display() reaches the same
suppress path, just after the callback timeout rather than immediately. Pin that
it dismisses the record without cancelling the shade.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the source-compat note and the API-level walkthroughs. Keep when
restoring is true, which preventDefault to call, and why regroup must
not cancel the shade.

Co-authored-by: Cursor <cursoragent@cursor.com>
A group-collapse rebuild is left in place. Saying "drop it" for every
restoring event would surprise an app that still sees the leftover
child and summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
Processor tests mock the repository, so they never showed that
markAsDismissedWithoutCancel leaves the shade. readRestoreReason was
untested for the enum, the legacy boolean, and an unknown name.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nan-li nan-li added the Skip Coverage Check To skip coverage check in the run label Aug 21, 2026
@nan-li
nan-li marked this pull request as ready for review August 21, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Skip Coverage Check To skip coverage check in the run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant