Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7f4cb66
chore: add demo_fm example reproducing #1138 firebase_messaging coexi…
fadi-george May 28, 2026
d820d66
fix(android): [SDK-4407] deliver notification events when firebase_me…
fadi-george May 28, 2026
b0f07c3
fix(android): defer click listener on engine swap
fadi-george May 28, 2026
f36c941
chore(android): remove ISSUE-1138 debug logs
fadi-george May 28, 2026
42cf004
docs(demo_fm): replace placeholder README
fadi-george May 28, 2026
cb79e30
chore(demo_fm): remove issue-1138 debug listeners
fadi-george May 28, 2026
8fcb5ab
docs(demo_fm): expand README, drop ISSUE_1138_REPRO.md
fadi-george May 28, 2026
5d72c7d
revert(android): remove FCM manifest workaround
fadi-george May 28, 2026
b518cbc
fix(android): extend channel-rebind fix to all singletons & guard bg …
fadi-george May 28, 2026
a7c2d1e
fix(demo): use message.messageId in IAM click listener
fadi-george May 28, 2026
e81be6c
refactor(android): dedupe channel bind/rebind into base helpers
fadi-george May 28, 2026
6aa8f9b
chore(demo): improve push subscription debug logging
fadi-george May 28, 2026
5e4791b
chore(demo_fm): add direct-FCM send script
fadi-george May 28, 2026
e9f9dc6
fix(ios): drop re-entrant willDisplay events
fadi-george May 29, 2026
dc0accc
chore(demo_fm): add 'both' mode and iOS FCM fixes
fadi-george May 29, 2026
2133c51
docs(demo_fm): document iOS FCM testing and send_fcm modes
fadi-george May 29, 2026
f2c3f25
fix(android): register incoming-call handler on every engine messenger
fadi-george May 29, 2026
ad72c38
fix(android): reset clickListenerRequested on new-engine attach
fadi-george May 29, 2026
98d8c40
fix(notifications): buffer clicks before listener registers
fadi-george May 29, 2026
62fd8d7
fix(notifications): bound pending-click buffer to pre-registration wi…
fadi-george May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ dlcov.log
**/Package.resolved
**/.lock
**/workspace-state.json

# Per-developer Firebase config for issue #1138 repro scaffolding
# (only needed because firebase_messaging is wired into examples/demo;
# OneSignal itself does NOT need this file).
Comment thread
fadi-george marked this conversation as resolved.
**/google-services.json
**/GoogleService-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
private final HashMap<String, INotificationWillDisplayEvent> notificationOnWillDisplayEventCache = new HashMap<>();
private final HashMap<String, INotificationWillDisplayEvent> preventedDefaultCache = new HashMap<>();

// Tracks whether Dart has asked us to be subscribed to click events. We
// toggle the native-SDK subscription on/off across Flutter engine + host
// activity lifecycles so that, while the channel is detached, click events
// get queued by the native SDK instead of dispatched into a dead JNI.
// Issue #1138 — Android `addClickListener` silently dropped on
// background notification tap when host activity has been destroyed
// (e.g. while running alongside firebase_messaging which can perturb
// the Flutter engine lifecycle).
private boolean clickListenerRequested = false;

public static OneSignalNotifications getSharedInstance() {
if (sharedInstance == null) {
sharedInstance = new OneSignalNotifications();
Expand Down Expand Up @@ -73,6 +83,18 @@

static void registerWith(BinaryMessenger messenger) {
OneSignalNotifications controller = getSharedInstance();
// Issue #1138: OneSignalNotifications is a process-global singleton, but
// registerWith is invoked once per Flutter engine. When firebase_messaging
// is present, FlutterFire spins up a headless background FlutterEngine and
// GeneratedPluginRegistrant registers us against it too — which would
// otherwise REBIND the shared channel to the background isolate (that never
// ran main() and has no listeners), so native click/willDisplay callbacks
// were silently routed there and dropped. Only bind on the first engine;
// the authoritative binding is (re)asserted from onAttachedToActivity so
// the channel always points at the engine that actually hosts the UI.
if (controller.channel != null) {
return;
}
controller.messenger = messenger;
controller.channel = new MethodChannel(messenger, "OneSignal#notifications");
controller.channel.setMethodCallHandler(controller);
Expand Down Expand Up @@ -239,6 +261,68 @@
OneSignal.getNotifications().removeClickListener(this);
}

/**
* Called from {@link OneSignalPlugin#onDetachedFromActivity()} to extend the
* defensive unsubscribe to the case where the Flutter engine survives but the
* host activity is destroyed (e.g. user back-pressed out of MainActivity but
* the engine is cached). Without this, clicks delivered to
* {@link com.onesignal.notifications.activities.NotificationOpenedActivity}
* before the new MainActivity attaches go to a detached JNI and are dropped.
*/
void onDetachedFromActivity() {
// Same guard as onDetachedFromEngine (#1149): activity can be torn down
// before OneSignal.initialize() has been called from Dart.
if (!OneSignal.isInitialized()) {
return;

Check failure on line 276 in android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java

View check run for this annotation

Claude / Claude Code Review

Background-engine detach removes click listener globally without re-add

Background-engine detach removes the click listener globally. When `firebase_messaging` spawns a headless background `FlutterEngine` and it's later destroyed, the bg-engine's `OneSignalPlugin` instance fires `onDetachedFromEngine` → `OneSignalNotifications.onDetachedFromEngine`, which unconditionally calls `removeClickListener` on the process-global singleton — even though the main engine, activity, and Dart click listener are all still alive. With `singleTop` MainActivity (as in `demo_fm`), a s
Comment thread
claude[bot] marked this conversation as resolved.
}
OneSignal.getNotifications().removeClickListener(this);
}

/**
* Called from {@link OneSignalPlugin#onAttachedToActivity} after a (re)attach.
* If Dart had previously asked us to listen for clicks (via the
* `OneSignal#addNativeClickListener` MethodChannel call), re-register the
* native listener so any clicks queued by the native SDK while we were
* detached get drained now that the channel is live again.
*/
void onAttachedToActivity(BinaryMessenger activityMessenger) {
// Issue #1138: the engine that owns the host activity is the one whose
// Dart isolate ran main() and registered the user's listeners. Re-bind the
// shared channel to THIS engine's messenger so native click/willDisplay
// callbacks are dispatched to the UI isolate — even if a background engine
// (FlutterFire) attached after us and we kept the original binding.
boolean engineChanged = activityMessenger != null && activityMessenger != this.messenger;
if (engineChanged) {
this.messenger = activityMessenger;
this.channel = new MethodChannel(activityMessenger, "OneSignal#notifications");
this.channel.setMethodCallHandler(this);
// The new engine's Dart isolate has NOT re-run main()/registerClickListener
// yet, so its MethodChannel handler is not registered. Re-adding the native
// click listener now would make the native SDK replay any queued click
// immediately — into a channel whose Dart end isn't listening — and the
// click would be dropped (see #1138 back-button-then-tap repro). Dart will
// call OneSignal#addNativeClickListener once it re-initializes, which drains
// the native queue at the point the isolate is actually ready to receive.
return;
}
// Same engine: the activity reattached while the Flutter engine and its Dart
// isolate stayed alive (config change, or cached-engine resume). The Dart
// handler is still live, so re-adding now safely drains any clicks the native
// SDK queued while we were detached, without losing them to a dead isolate.
if (!clickListenerRequested) {
return;
}
// Same guard as onDetachedFromEngine (#1149): the first attach happens
// before Dart has had a chance to call OneSignal.initialize(), but in
// that case clickListenerRequested is also false, so we're already
// skipped above. Keep the guard anyway as defense-in-depth.
if (!OneSignal.isInitialized()) {
return;
}
OneSignal.getNotifications().removeClickListener(this);
OneSignal.getNotifications().addClickListener(this);
}
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
fadi-george marked this conversation as resolved.

private void lifecycleInit(Result result) {
OneSignal.getNotifications().removeForegroundLifecycleListener(this);
OneSignal.getNotifications().addForegroundLifecycleListener(this);
Expand All @@ -250,6 +334,7 @@
}

private void registerClickListener() {
clickListenerRequested = true;
OneSignal.getNotifications().removeClickListener(this);
OneSignal.getNotifications().addClickListener(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,34 @@
}

@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
this.context = binding.getActivity();
// Issue #1138: re-register native click listener now that the host
// activity (and therefore the MethodChannel) is live again, so any
// clicks the native SDK queued while we were detached get drained.
// Pass this engine's messenger so the shared channel is (re)bound to the
// UI isolate rather than a FlutterFire background engine.
OneSignalNotifications.getSharedInstance().onAttachedToActivity(this.messenger);
}

@Override
public void onDetachedFromActivity() {}
public void onDetachedFromActivity() {
// Issue #1138: unregister click listener while we have no host activity,
// so the native SDK queues clicks (rather than dispatching them into a
// MethodChannel whose JNI is detached) until a new MainActivity attaches.
OneSignalNotifications.getSharedInstance().onDetachedFromActivity();
}

@Override
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {}
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
this.context = binding.getActivity();
OneSignalNotifications.getSharedInstance().onAttachedToActivity(this.messenger);
}

Check failure on line 78 in android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java

View check run for this annotation

Claude / Claude Code Review

Channel-rebind fix not applied to sibling singletons (User/PushSubscription/InAppMessages)

The PR fixes the channel-rebind anti-pattern in `OneSignalNotifications` (early-return guard in `registerWith` + authoritative rebind in `onAttachedToActivity`) but leaves the identical anti-pattern in the three sibling singletons registered alongside it at lines 35-37: `OneSignalUser.registerWith` (OneSignalUser.java:28-33), `OneSignalPushSubscription.registerWith` (OneSignalPushSubscription.java:27-32), and `OneSignalInAppMessages.registerWith` (OneSignalInAppMessages.java:34-40). When Flutter
Comment thread
claude[bot] marked this conversation as resolved.

@Override
public void onDetachedFromActivityForConfigChanges() {}
public void onDetachedFromActivityForConfigChanges() {
OneSignalNotifications.getSharedInstance().onDetachedFromActivity();
}

@Override
public void onMethodCall(MethodCall call, Result result) {
Expand Down
6 changes: 6 additions & 0 deletions examples/demo_fm/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your-onesignal-api-key

# Optional: Android Notification Channel ID for the WITH SOUND test notification.
# Create one in your OneSignal dashboard under Settings > Android Notification Categories.
ONESIGNAL_ANDROID_CHANNEL_ID=
48 changes: 48 additions & 0 deletions examples/demo_fm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/

# IntelliJ related
*.iml
*.ipr
*.iws
.idea/

# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/

# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/

# Symbolication related
app.*.symbols

# Obfuscation related
app.*.map.json

# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

# Environment
.env
45 changes: 45 additions & 0 deletions examples/demo_fm/.metadata
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.

version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"

project_type: app

# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: ios
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: linux
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: macos
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: web
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: windows
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407

# User provided section

# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
137 changes: 137 additions & 0 deletions examples/demo_fm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# demo_fm — OneSignal + Firebase Messaging coexistence demo

This is a variant of [`examples/demo`](../demo) that additionally wires in
**Firebase Cloud Messaging** (`firebase_core` + `firebase_messaging`)
alongside the OneSignal Flutter SDK.

It exists to reproduce and validate fixes for
[issue #1138](https://github.com/OneSignal/OneSignal-Flutter-SDK/issues/1138):
on Android, `OneSignal.Notifications.addClickListener` could stop firing on
background/killed notification taps when `firebase_messaging` is present in
the same app. FCM in the app perturbs the Flutter engine / host activity
lifecycle, which surfaced a channel-binding bug in the plugin.

> Looking for the plain OneSignal sample? Use [`examples/demo`](../demo).
> Use this project only when you need the Firebase coexistence scenario.

## How `demo_fm` differs from `demo`

| Area | `examples/demo` | `examples/demo_fm` |
| --- | --- | --- |
| Dependencies | `onesignal_flutter` (in-tree, `path: ../../`) | same **plus** `firebase_core` + `firebase_messaging` |
| `lib/main.dart` | OneSignal init only | same OneSignal listeners **plus** `Firebase.initializeApp()`, a background FCM handler, `onMessage` / `onMessageOpenedApp` listeners, FCM token logging, and a `getInitialMessage()` check |
| Android Gradle | no Firebase plugin | applies `com.google.gms.google-services` in `android/settings.gradle.kts` + `android/app/build.gradle.kts` |
| Firebase config | not needed | **requires** `android/app/google-services.json` (per-developer, not committed — the build fails without it) |
| Purpose | general SDK sample | repro harness for #1138 (OneSignal + FCM coexistence) |

The actual SDK fix lives in the shared in-tree plugin
(`android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java` and
`OneSignalPlugin.java`), so **both** demos consume it via the `path: ../../`
dependency. `demo_fm` is what lets you exercise the Firebase-specific failure
path that originally hid the bug.

## Setup

This mirrors `demo`'s setup, with two extra Firebase requirements.

1. Add your own Firebase project's `google-services.json` to
`examples/demo_fm/android/app/google-services.json`. The build will fail
without it (the `com.google.gms.google-services` plugin is wired in). It is
not committed because it is per-developer.

2. Make sure the Firebase project's package name matches the Android
`applicationId` (`com.onesignal.example` by default).

3. Put your OneSignal credentials in `examples/demo_fm/.env`
(see `.env.example`):

```
ONESIGNAL_APP_ID=<your-app-id>
ONESIGNAL_API_KEY=<your-rest-api-key>
```

4. Run on a real Android device (or emulator with Google Play services):

```
cd examples/demo_fm
flutter pub get
flutter run -d <android-device>
```

## Reproducing / verifying issue #1138

Send a push from the OneSignal dashboard and tap it in each app state
(foreground / background / killed). The OneSignal `addClickListener` should
fire every time — watch for `Notification clicked:` in the logs.

Quick check — watch for the click callback (and the other listeners) after
tapping a notification:

```
adb logcat -c && adb logcat | rg -i 'Notification (clicked|foreground)|IAM |\[FCM'
```

## Testing with non-OneSignal (direct FCM) notifications

Pushes sent from the **OneSignal dashboard** are rendered and handled by the
OneSignal SDK (you'll see `Notification clicked:` / `Notification foreground
will display:`). They are delivered as FCM **data** messages, so FlutterFire's
`onMessageOpenedApp` never fires for them.

To exercise the **FlutterFire** path instead, send a message **directly through
FCM**, bypassing OneSignal. This is the right way to verify the two pipelines
coexist without interfering.

### 1. Get the device FCM token

`main.dart` logs it on startup:

```
adb logcat -c && adb logcat | rg '\[FCM token\]'
```

### 2. Send directly via the FCM HTTP v1 API

`<PROJECT_ID>` is in `android/app/google-services.json` (`project_info.project_id`).
Get an access token from a service account JSON (Firebase console → Project
settings → Service accounts):

```bash
ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
PROJECT_ID=<your-firebase-project-id>
TOKEN=<device-fcm-token>
```

**Notification message** — shows a FlutterFire tray notification; tapping it
from the background fires `onMessageOpenedApp`, from the killed state it
arrives via `getInitialMessage()`:

```bash
curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"message":{"token":"'"$TOKEN"'","notification":{"title":"FCM direct","body":"non-OneSignal push"}}}'
```

**Data-only message** — fires `onMessage` (foreground) / `onBackgroundMessage`
(background/killed):

```bash
curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"message":{"token":"'"$TOKEN"'","android":{"priority":"high"},"data":{"alert":"data only","source":"fcm-direct"}}}'
```

(Or use Firebase console → Messaging → "Send test message" and paste the token
for a quick notification-message test.)

### 3. Expected log lines for a direct FCM push

| App state | Notification message | Data-only message |
| --- | --- | --- |
| Foreground | `[FCM fg] received` | `[FCM fg] received` |
| Background | tray shown; tap → `[FCM open] tapped` | `[FCM bg] received` |
| Killed | tray shown; tap → `[FCM initial] launched from tap` | `[FCM bg] received` |

Note `onMessageOpenedApp` (`[FCM open]`) only fires for a **background** tap of
a notification message; a **killed**-state tap surfaces via `getInitialMessage()`
(`[FCM initial]`) instead.
Loading
Loading