Skip to content

feat: [SDK-5083] add an inert-by-default notification service extension to the demo - #2726

Open
nan-li wants to merge 3 commits into
mainfrom
nan/sdk-5083
Open

feat: [SDK-5083] add an inert-by-default notification service extension to the demo#2726
nan-li wants to merge 3 commits into
mainfrom
nan/sdk-5083

Conversation

@nan-li

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

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Adds a notification service extension to the Android demo that stays inert until you switch it on.

Details

Motivation

Nothing in this repo implements INotificationServiceExtension. Reproducing an extension bug means writing one from scratch every time. And because the SDK discovers the class by reflection from manifest meta-data, a breaking change to INotificationServiceExtension or INotificationReceivedEvent still compiles and only fails at runtime. A demo implementation compiling in CI closes both gaps.

Scope

examples/demo only. No SDK source changes and no public API changes.

Everything is off by default. An always-on extension would change the baseline for every notification the demo sends, so anyone chasing a grouping or channel bug would be debugging demo code without realizing it. With every switch off the demo behaves exactly as it did before.

DemoNotificationServiceExtension is registered from the demo manifest under com.onesignal.NotificationServiceExtension. Its switches live in a new section, folded behind a Show options row so the section stays two rows tall while the extension is off.

Toggle testTag Behavior
Enable Extension nse_enabled_toggle Master switch. Off means onNotificationReceived returns immediately.
Show / Hide options nse_options_toggle Folds the five switches below. Not a setting.
Log Details nse_log_toggle Logs id, sent time, and the resolved channel.
Apply Extender nse_extender_toggle Prefixes the title through a NotificationCompat.Extender.
Force High Importance Channel nse_high_importance_toggle Moves the notification onto an app-owned IMPORTANCE_HIGH channel.
Delay Display nse_delay_toggle preventDefault(), then display() five seconds later.
Discard nse_discard_toggle preventDefault(true). Takes precedence over the switches above.

Switches persist through SharedPreferenceUtil. The extension reads them from there rather than MainViewModel, since it runs whether or not the app is open.

Design notes

The extender is set conditionally, only when Log Details, Apply Extender, or Force High Importance Channel is on, rather than installed as a no-op whenever the master switch is on. That is about not doing work nothing asked for. It does not change what displays. NotificationGenerationProcessor.shouldDisplayNotification does read hasExtender(), but processHandlerResponse gates on canDisplay, a non-empty notification body, before it gets there, so an extender cannot rescue a bodyless push.

The channel readout happens inside the extender. INotification exposes no channel, so the demo reads it back with NotificationCompat.getChannelId(builder.build()) from inside extend(builder), where the SDK has already applied the channel it resolved. Reading chnl off rawPayload would report what the payload asked for rather than what the SDK chose, and the two diverge on a restore. NotificationChannelManager.createNotificationChannel returns restored_OS_notifications before it ever looks at chnl, while the stored payload is identical to the first delivery.

event.restoring is not on INotificationReceivedEvent yet, so the log line omits it. #2723 adds it, and a TODO in the class marks the spot.

Log tags

Second commit, separable from the first. Demo output and forwarded SDK output sat side by side in logcat under tags that gave no hint which was which. DemoLog stamps both the tag and the message.

D/[Demo]MainViewModel: [Demo] Sending notification: Simple

Callers pass the plain class name and DemoLog adds the prefix, so [Demo] is defined in one place. All 127 demo call sites go through it. The five forwarding calls in MainApplication keep android.util.Log and stay unmarked, since those lines belong to the SDK and marking them would bury the demo's own output whenever you grep [Demo].

Testing

Unit testing

None added. This is demo code with no test target, and the compile is the guard. ci.yml's demo-build job builds :app from inside OneSignalSDK/, where settings.gradle substitutes the published dependency with local source, so a breaking change to either interface now fails the build.

The release build also exercises the -keep class ** implements com.onesignal.notifications.INotificationServiceExtension rule in onesignal/notifications/consumer-rules.pro end to end for the first time. I confirmed the class survives R8 mapped to itself and that the meta-data lands in the merged release manifest.

Manual testing

Pixel 7 emulator, API 34, Google Play image, gms debug build against local SDK source.

Switches Result
all off Posts on fcm_fallback_notification_channel, title unchanged, nothing logged
enabled + log Logs id, sent time and title on receipt, then the resolved channel at build time
+ extender Title becomes [NSE] Simple Notification
+ force channel Posts on demo_nse_high_importance, mImportance=4
delay only preventDefault(false) at 13:10:10.183, display at 13:10:15.188
discard only preventDefault(true), nothing posted
master off, other switches still on Nothing logged, notification posts normally

Killing the app process with adb shell am kill and then sending a push showed the extension firing in a fresh pid, reading its switches from SharedPreferences with no MainViewModel alive.

After adb reboot the same notifications came back through the extension logging channel=restored_OS_notifications at importance=2, against fcm_fallback_notification_channel at importance=3 on first delivery. The payload is identical in both cases, which is why the channel is read from the builder.

spotlessCheck, detekt, assembleGmsRelease, and compileHuaweiDebugKotlin all pass.

Affected code checklist

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

Ticked because the demo extension exercises those paths. No SDK behavior changes.

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • 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

Final pass

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

nan-li added 2 commits August 25, 2026 15:22
…on to the demo

Nothing in the repo implemented INotificationServiceExtension, so reproducing
an NSE bug meant writing one from scratch and no compiled sample guarded the
interface against a breaking change. Building the demo inside OneSignalSDK's
:app project now turns that into a CI failure, and the release build exercises
the -keep rule in onesignal/notifications/consumer-rules.pro end to end.

Six switches drive it, all off, folded behind a Show options row. It reads them
from SharedPreferences rather than MainViewModel because it runs whether or not
the app is open, and it sets an extender only when a switch needs one, since an
extender makes the SDK display a data-only push carrying no alert.

The channel readout uses NotificationCompat.getChannelId inside the extender,
the only place an extension sees the SDK's choice. A restored notification
lands on restored_OS_notifications whatever the payload asked for. Logging
restoring next to it waits on SDK-5011.
Demo output and forwarded SDK output sat side by side in logcat under tags
that gave no hint which was which, which made reading a notification repro
slower than it needed to be.

DemoLog stamps both the tag and the message, so `logcat -s` still filters on
the tag and a line stays recognizable when only the message column is in view.
Callers pass the plain class name and DemoLog adds the prefix, keeping [Demo]
in one place. All 127 demo call sites go through it.

The five forwarding calls in MainApplication keep using android.util.Log and
stay unmarked. Those lines are the SDK's, and marking them would bury the
demo's own output whenever you grep [Demo].
@nan-li
nan-li requested a review from a team as a code owner August 25, 2026 22:24
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

✓ Coverage check passed (no source files changed)

📥 View workflow run

@cursor cursor 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.

Multi-model review (Opus 5 / GPT 5.6 Sol / Grok 4.6) of the demo NSE work. Demo-only; no SDK source issues.

Act on

  1. Delay Display can drop the notification. Raw Thread + Thread.sleep with no try/finally. If the thread is interrupted or dies, display() never runs and the SDK’s 30s waiter treats it as wantsToDisplay = false. MainApplication’s existing delay path already catches InterruptedException and still calls display().
  2. Documented extender / data-only-push behavior is wrong for 5.x. processHandlerResponse checks canDisplay (nonempty alert) before shouldDisplayNotification. An extender cannot make a bodyless push display. Conditional extender is still reasonable; the rationale in the class and build.md is not.

Consider

  • Foreground Delay Display stacks with MainApplication’s 2s lifecycle delay (~7s observed).
  • [Demo]OneSignalRepository is 25 chars; tags over 23 throw on API 21–23 (minSdk 21).
  • Hidden Discard/Delay stay live after collapse/rotation with no visible indicator.

Noted / dismissed

  • Preference backup restoring NSE switches, title-prefix clobbering the app-name default, leftover high-importance channel, and build.md stale Log.* mentions: lone-model or low-impact demo nits.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

The comment claimed an extender makes the SDK display a data-only push, so
installing a no-op one would not be inert. That is not how 5.x behaves.
processHandlerResponse gates on canDisplay, a non-empty notification body,
before it reaches shouldDisplayNotification, so hasExtender() is never read
for a bodyless push and an extender cannot rescue one.

The code stays as it is. Setting an extender only when a switch needs one is
still right, just for the duller reason that nothing asked for it otherwise.
Left uncorrected, a customer reading the demo could design around SDK
behavior that does not exist.
@fadi-george

Copy link
Copy Markdown
Contributor

I would check this doesnt break the e2e tests suit, you can test with sdk-shared run-local

@fadi-george fadi-george left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ideally we dont modify the current demo and just maybe make another example e.g. examples/demo-nse

@nan-li

nan-li commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Ideally we dont modify the current demo and just maybe make another example e.g. examples/demo-nse

The NSE is disabled by default, do you think we really need another demo app just for the NSE?

image image

@nan-li

nan-li commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

I would check this doesnt break the e2e tests suit, you can test with sdk-shared run-local

Done locally, all passed. Note that I don't think the e2e workflow itself added in #2652 has ever ran

@nan-li
nan-li requested a review from fadi-george August 26, 2026 00:21
@fadi-george

fadi-george commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Ideally we dont modify the current demo and just maybe make another example e.g. examples/demo-nse
The NSE is disabled by default, do you think we really need another demo app just for the NSE?

The demo app is supposed to almost the same for all the wrappers. Ideally we don't add stuff to it willy-nilly. I still think this could be a separate example i.e. examples/demo-nse it can just have the section you've made and maybe some test notification if it needs it.

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.

2 participants