Skip to content

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

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

chore: [SDK-5083] add an inert-by-default notification service extension to the demo#2726
nan-li wants to merge 4 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

image

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.

@nan-li nan-li changed the title feat: [SDK-5083] add an inert-by-default notification service extension to the demo chore: [SDK-5083] add an inert-by-default notification service extension to the demo Aug 28, 2026
@nan-li

nan-li commented Aug 28, 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?

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.

We have the NSE in our ios demo ( granted it is more crucial for ios for core features like received receipts), and it used to be included in the prev Android example (see old example) that never ported over to the new sample. I would argue this keeps parity with previous version of the sample app and our current iOS sample rather than an extra feature that warrants to be in a separate example. But if you still feel strongly I can make a separate demo, it's just when you start making more demos people are rarely going to run the other ones in their day-to-day, compared to other demos we have that exclude location or don't use pods. Those are demos that rarely run and seem to be for build verification.

The shared demo is meant to look almost the same across every wrapper, so a
section with six switches and a fold row was more surface than this earns.
Enable Extension is the only control now, and the section reads like the
In-App Messaging card next to it.

The five behavior switches are unchanged and still wired end to end. They
just have no UI: flip the defaults in
SharedPreferenceUtil.getNotificationExtensionOptions and rebuild when
reproducing something.
@nan-li

nan-li commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Removed all the option toggles from the UI per JonF's feedback today, since the NSE is quite customizable, we don't want to lead users into only considering limited options, or taking our options as best practices.

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

yeah we definitely shouldnt have another demo app.
and we if we do add a new field/feature in one SDK, all SDK's need to be updated accordingly including their appium tests.

onInfoClick = { showTooltipDialog = "sendPushNotification" }
)

NotificationExtensionSection(

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.

Maybe render this section last as to not interfere with appium tests.
Potentially could also move to secondary screen.

@fadi-george

Copy link
Copy Markdown
Contributor

Otherwise maybe we can gate rendering the whole section by a gradle property e.g. onesignal.showNSESection ?

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.

3 participants