Skip to content
Open
Show file tree
Hide file tree
Changes from all 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_fm;
# 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 @@ -5,13 +5,55 @@
import android.os.Looper;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import java.util.HashMap;

abstract class FlutterMessengerResponder {
Context context;
protected MethodChannel channel;
BinaryMessenger messenger;

/**
* #1138: bind the outgoing shared channel only on the first engine. These
* responders are process-global singletons but {@code registerWith} runs once
* per Flutter engine; FlutterFire's headless background engine would otherwise
* rebind the channel to an isolate with no listeners and drop native callbacks.
*
* <p>The incoming-call handler is still registered on every engine's messenger
* so Dart->Native calls work from any isolate (e.g. an FCM background handler),
* matching the pre-#1138 behavior; only the outgoing Native->Dart channel stays
* pinned to the first engine.
*
* @return true if this call performed the initial bind.
*/
boolean bindChannelIfUnbound(BinaryMessenger messenger, String channelName, MethodCallHandler handler) {
MethodChannel channel = new MethodChannel(messenger, channelName);
channel.setMethodCallHandler(handler);
if (this.channel != null) {
return false;
}
this.messenger = messenger;
this.channel = channel;
return true;
}

/**
* #1138: reassert the channel binding to the engine that hosts the activity (the
* UI isolate), in case a background engine attached after us. No-op if the
* messenger is unchanged.
*
* @return true if the channel was rebound to a different engine.
*/
boolean rebindChannelToEngine(BinaryMessenger activityMessenger, String channelName, MethodCallHandler handler) {
if (activityMessenger == null || activityMessenger == this.messenger) {
return false;
}
this.messenger = activityMessenger;
this.channel = new MethodChannel(activityMessenger, channelName);
this.channel.setMethodCallHandler(handler);
return true;
}

/**
* MethodChannel class is home to success() method used by Result class
* It has the @UiThread annotation and must be run on UI thread, otherwise a RuntimeException will be thrown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import com.onesignal.inAppMessages.IInAppMessageWillDisplayEvent;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.Collection;
Expand All @@ -33,10 +32,11 @@ private OneSignalInAppMessages() {}

static void registerWith(BinaryMessenger messenger) {
OneSignalInAppMessages controller = getSharedInstance();
controller.bindChannelIfUnbound(messenger, "OneSignal#inappmessages", controller);
}

controller.messenger = messenger;
controller.channel = new MethodChannel(messenger, "OneSignal#inappmessages");
controller.channel.setMethodCallHandler(controller);
void onAttachedToActivity(BinaryMessenger activityMessenger) {
rebindChannelToEngine(activityMessenger, "OneSignal#inappmessages", this);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public class OneSignalNotifications extends FlutterMessengerResponder
private final HashMap<String, INotificationWillDisplayEvent> notificationOnWillDisplayEventCache = new HashMap<>();
private final HashMap<String, INotificationWillDisplayEvent> preventedDefaultCache = new HashMap<>();

// #1138: tracks if Dart requested clicks, so we can queue (not drop) them
// while the channel is detached across engine/activity lifecycles.
private boolean clickListenerRequested = false;

public static OneSignalNotifications getSharedInstance() {
if (sharedInstance == null) {
sharedInstance = new OneSignalNotifications();
Expand Down Expand Up @@ -73,9 +77,7 @@ public void resumeWith(@NonNull Object o) {

static void registerWith(BinaryMessenger messenger) {
OneSignalNotifications controller = getSharedInstance();
controller.messenger = messenger;
controller.channel = new MethodChannel(messenger, "OneSignal#notifications");
controller.channel.setMethodCallHandler(controller);
controller.bindChannelIfUnbound(messenger, "OneSignal#notifications", controller);
}

@Override
Expand Down Expand Up @@ -227,18 +229,53 @@ public void onNotificationPermissionChange(boolean permission) {
invokeMethodOnUiThread("OneSignal#onNotificationPermissionDidChange", hash);
}

void onDetachedFromEngine() {
// The Flutter engine can be torn down before OneSignal.initialize() has been
// called from Dart (cold start, fast finish, etc.). Calling getNotifications()
// in that state throws IllegalStateException from the native SDK. See #1149.
void onDetachedFromEngine(BinaryMessenger detachingMessenger) {
// #1138: ignore a FlutterFire background engine detaching — removing the
// listener bound to the live UI engine would drop the next click (the UI
// engine fires no activity event, so nothing re-adds it).
if (detachingMessenger != null && detachingMessenger != this.messenger) {
return;
}
// #1149: engine can be torn down before Dart calls initialize().
if (!OneSignal.isInitialized()) {
return;
}
// Unsubscribe so clicks while the engine is dead get queued by the native SDK
// instead of dispatched on a detached channel.
// Unsubscribe so clicks get queued by the native SDK, not dropped.
OneSignal.getNotifications().removeClickListener(this);
}

/**
* Same as {@link #onDetachedFromEngine} but for when the engine survives and
* only the host activity is destroyed (e.g. back-pressed out of MainActivity).
*/
void onDetachedFromActivity() {
if (!OneSignal.isInitialized()) {
return;
Comment thread
claude[bot] marked this conversation as resolved.
}
OneSignal.getNotifications().removeClickListener(this);
}

/**
* #1138: rebind the shared channel to the UI engine on (re)attach and drain
* any clicks the native SDK queued while detached.
*/
void onAttachedToActivity(BinaryMessenger activityMessenger) {
// Rebind the shared channel so callbacks hit the now-foreground engine.
rebindChannelToEngine(activityMessenger, "OneSignal#notifications", this);
// Re-add the listener so the native SDK drains any clicks queued while
// detached. Works for fresh, FCM-background, and pre-warmed cached engines
// alike: a pre-warmed engine's Dart already ran main() and won't re-call
// OneSignal#addNativeClickListener, so the rebind alone wouldn't restore it.
// Draining before this engine's Dart listeners exist is safe — the Dart
// bridge buffers clicks that arrive with no listeners and flushes them once
// addClickListener runs.
if (!clickListenerRequested || !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 +287,7 @@ private void lifecycleInit(Result result) {
}

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 @@ -45,26 +45,45 @@ public void onAttachedToEngine(@NonNull FlutterPlugin.FlutterPluginBinding flutt

@Override
public void onDetachedFromEngine(@NonNull FlutterPlugin.FlutterPluginBinding binding) {
onDetachedFromEngine();
}

private void onDetachedFromEngine() {
OneSignalNotifications.getSharedInstance().onDetachedFromEngine();
// #1138: pass the detaching engine's messenger so a background (FlutterFire)
// engine detaching doesn't tear down the listener bound to the UI engine.
OneSignalNotifications.getSharedInstance().onDetachedFromEngine(binding.getBinaryMessenger());
}

@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
this.context = binding.getActivity();
rebindChannelsToActivityEngine();
}

@Override
public void onDetachedFromActivity() {}
public void onDetachedFromActivity() {
// #1138: unregister so the native SDK queues clicks until a new activity attaches.
OneSignalNotifications.getSharedInstance().onDetachedFromActivity();
}

@Override
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {}
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
this.context = binding.getActivity();
rebindChannelsToActivityEngine();
}

/**
* #1138: (re)bind the process-global singleton channels to the engine that
* hosts the activity (the UI isolate), so native callbacks aren't routed to a
* FlutterFire background engine that has no listeners.
*/
private void rebindChannelsToActivityEngine() {
OneSignalNotifications.getSharedInstance().onAttachedToActivity(this.messenger);
OneSignalUser.getSharedInstance().onAttachedToActivity(this.messenger);
OneSignalPushSubscription.getSharedInstance().onAttachedToActivity(this.messenger);
OneSignalInAppMessages.getSharedInstance().onAttachedToActivity(this.messenger);
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.onesignal.user.subscriptions.PushSubscriptionChangedState;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import org.json.JSONException;
Expand All @@ -26,9 +25,11 @@ private OneSignalPushSubscription() {}

static void registerWith(BinaryMessenger messenger) {
OneSignalPushSubscription controller = getSharedInstance();
controller.messenger = messenger;
controller.channel = new MethodChannel(messenger, "OneSignal#pushsubscription");
controller.channel.setMethodCallHandler(controller);
controller.bindChannelIfUnbound(messenger, "OneSignal#pushsubscription", controller);
}

void onAttachedToActivity(BinaryMessenger activityMessenger) {
rebindChannelToEngine(activityMessenger, "OneSignal#pushsubscription", this);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.onesignal.user.state.UserChangedState;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.List;
Expand All @@ -27,9 +26,11 @@ private OneSignalUser() {}

static void registerWith(BinaryMessenger messenger) {
OneSignalUser controller = getSharedInstance();
controller.messenger = messenger;
controller.channel = new MethodChannel(messenger, "OneSignal#user");
controller.channel.setMethodCallHandler(controller);
controller.bindChannelIfUnbound(messenger, "OneSignal#user", controller);
}

void onAttachedToActivity(BinaryMessenger activityMessenger) {
rebindChannelToEngine(activityMessenger, "OneSignal#user", this);
}

@Override
Expand Down
2 changes: 1 addition & 1 deletion examples/demo/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Future<void> main() async {
debugPrint('IAM did dismiss: ${event.message.messageId}');
});
OneSignal.InAppMessages.addClickListener((event) {
debugPrint('IAM clicked: ${event.result.actionId}');
debugPrint('IAM clicked: ${event.message.messageId}');
});

// Register notification listeners
Expand Down
10 changes: 9 additions & 1 deletion examples/demo/lib/viewmodels/app_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,16 @@ class AppViewModel extends ChangeNotifier {
OneSignal.User.pushSubscription.addObserver((state) {
_pushSubscriptionId = state.current.id;
_pushEnabled = state.current.optedIn;
String fmtToken(String? t) {
if (t == null || t.isEmpty) return 'null';
return t.length > 8 ? '${t.substring(0, 8)}…' : t;
}

debugPrint(
'Push subscription changed: id=${state.current.id}, optedIn=${state.current.optedIn}',
'Push subscription changed: '
'id=${state.previous.id ?? 'null'} → ${state.current.id ?? 'null'}, '
'optedIn=${state.previous.optedIn} → ${state.current.optedIn}, '
'token=${fmtToken(state.previous.token)} → ${fmtToken(state.current.token)}',
);
notifyListeners();
});
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=
51 changes: 51 additions & 0 deletions examples/demo_fm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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

# FCM service account key for tools/send_fcm.sh (never commit)
tools/service-account.json
Loading
Loading