From 6e307def420e2e5a4370f23c76a3e0e48ccb8ce4 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 6 May 2026 14:12:32 -0700 Subject: [PATCH 01/21] fix(notifications): replay click event after JS handler is registered fix(ios): capture launchOptions for cold-start notification taps fix(ios): use retainUntilConsumed for cold-start click replay --- OneSignalCapacitorPlugin.podspec | 6 +- Package.swift | 13 +++- .../capacitor/OneSignalCapacitorPlugin.kt | 52 +++++++++------- examples/demo/src/hooks/useOneSignal.ts | 21 +++++++ .../OSCapacitorLaunchOptions.m | 61 +++++++++++++++++++ .../include/OSCapacitorLaunchOptions.h | 32 ++++++++++ .../OneSignalCapacitorPlugin.swift | 45 ++++++++------ src/NotificationsNamespace.ts | 3 + 8 files changed, 193 insertions(+), 40 deletions(-) create mode 100644 ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m create mode 100644 ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h diff --git a/OneSignalCapacitorPlugin.podspec b/OneSignalCapacitorPlugin.podspec index 574e990..c51d718 100644 --- a/OneSignalCapacitorPlugin.podspec +++ b/OneSignalCapacitorPlugin.podspec @@ -10,7 +10,11 @@ Pod::Spec.new do |s| s.homepage = package['homepage'] s.author = 'OneSignal' s.source = { :git => package['repository']['url'], :tag => s.version.to_s } - s.source_files = 'ios/Sources/OneSignalCapacitorPlugin/**/*.swift' + s.source_files = [ + 'ios/Sources/OneSignalCapacitorPlugin/**/*.swift', + 'ios/Sources/OSCapacitorLaunchOptions/**/*.{h,m}' + ] + s.public_header_files = 'ios/Sources/OSCapacitorLaunchOptions/include/*.h' s.ios.deployment_target = '14.0' s.swift_version = '5.9' diff --git a/Package.swift b/Package.swift index 3dd0d73..3bd6043 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,16 @@ let package = Package( .package(url: "https://github.com/OneSignal/OneSignal-XCFramework", from: "5.0.0") ], targets: [ + // Obj-C helper that captures the iOS launchOptions dictionary at + // process start (via +load + UIApplicationDidFinishLaunchingNotification) + // so cold-start notification taps are still available when the JS + // layer initializes the plugin later. SPM cannot mix Swift and Obj-C + // in the same target, so this lives as its own target. + .target( + name: "OSCapacitorLaunchOptions", + path: "ios/Sources/OSCapacitorLaunchOptions", + publicHeadersPath: "include" + ), .target( name: "OnesignalCapacitorPlugin", dependencies: [ @@ -28,7 +38,8 @@ let package = Package( .product(name: "OneSignalFramework", package: "OneSignal-XCFramework"), .product(name: "OneSignalInAppMessages", package: "OneSignal-XCFramework"), .product(name: "OneSignalLocation", package: "OneSignal-XCFramework"), - .product(name: "OneSignalExtension", package: "OneSignal-XCFramework") + .product(name: "OneSignalExtension", package: "OneSignal-XCFramework"), + "OSCapacitorLaunchOptions" ], path: "ios/Sources/OneSignalCapacitorPlugin" ) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index 1e74514..f16ed3d 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -39,7 +39,11 @@ class OneSignalCapacitorPlugin : Plugin(), private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() + // Holds a click event that arrived before the JS-side click listener was + // attached. Replayed once initialize() has run and JS calls + // setNotificationClickHandlerRegistered. private var pendingClickEvent: INotificationClickEvent? = null + private var jsClickHandlerRegistered = false // region Core @@ -99,20 +103,35 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignal.InAppMessages.addLifecycleListener(this) OneSignal.InAppMessages.addClickListener(this) - pendingClickEvent?.let { event -> - val ret = JSObject() - val clickResult = JSObject() - clickResult.put("actionId", event.result.actionId) - clickResult.put("url", event.result.url) - ret.put("result", clickResult) - ret.put("notification", JSObject(event.notification.rawPayload)) - notifyListeners("notificationClick", ret) - pendingClickEvent = null - } + flushPendingClickEventIfReady() call.resolve() } + @PluginMethod + fun setNotificationClickHandlerRegistered(call: PluginCall) { + jsClickHandlerRegistered = true + flushPendingClickEventIfReady() + call.resolve() + } + + private fun flushPendingClickEventIfReady() { + if (bridge == null || !jsClickHandlerRegistered) return + val event = pendingClickEvent ?: return + pendingClickEvent = null + notifyListeners("notificationClick", buildClickEventJson(event)) + } + + private fun buildClickEventJson(event: INotificationClickEvent): JSObject { + val ret = JSObject() + val clickResult = JSObject() + clickResult.put("actionId", event.result.actionId) + clickResult.put("url", event.result.url) + ret.put("result", clickResult) + ret.put("notification", serializeNotification(event.notification)) + return ret + } + @PluginMethod fun login(call: PluginCall) { val externalId = call.getString("externalId") @@ -654,17 +673,8 @@ class OneSignalCapacitorPlugin : Plugin(), } override fun onClick(event: INotificationClickEvent) { - if (bridge != null) { - val ret = JSObject() - val clickResult = JSObject() - clickResult.put("actionId", event.result.actionId) - clickResult.put("url", event.result.url) - ret.put("result", clickResult) - ret.put("notification", serializeNotification(event.notification)) - notifyListeners("notificationClick", ret) - } else { - pendingClickEvent = event - } + pendingClickEvent = event + flushPendingClickEventIfReady() } private fun serializeNotification(notification: INotification): JSObject { diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 945456b..9706798 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -175,6 +175,22 @@ export function useOneSignal(): UseOneSignalReturn { const handleNotificationClick = (e: NotificationClickEvent) => { console.log(`Notification click: ${e.notification.title ?? ''}`); + // Persist to localStorage so cold-start clicks are still inspectable + // after the Safari Web Inspector reattaches to the WKWebView. + try { + const existing = JSON.parse(localStorage.getItem('lastNotificationClicks') ?? '[]'); + existing.push({ + notificationId: e.notification.notificationId, + title: e.notification.title ?? null, + body: e.notification.body ?? null, + actionId: e.result.actionId ?? null, + url: e.result.url ?? null, + receivedAt: new Date().toISOString(), + }); + localStorage.setItem('lastNotificationClicks', JSON.stringify(existing.slice(-20))); + } catch (err) { + console.warn('Failed to persist notification click to localStorage', err); + } }; const handleForegroundWillDisplay = (e: NotificationWillDisplayEvent) => { @@ -249,6 +265,11 @@ export function useOneSignal(): UseOneSignalReturn { console.log(`OneSignal initialized with app ID: ${nextAppId}`); + const stored = localStorage.getItem('lastNotificationClicks'); + if (stored) { + console.log('lastNotificationClicks (from previous launches):', JSON.parse(stored)); + } + const externalId = await OneSignal.User.getExternalId(); const [pushId, pushOptedIn, hasPerm] = await Promise.all([ OneSignal.User.pushSubscription.getIdAsync(), diff --git a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m new file mode 100644 index 0000000..03a3d1e --- /dev/null +++ b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m @@ -0,0 +1,61 @@ +#import "OSCapacitorLaunchOptions.h" +#import +#import +#import + +@implementation OSCapacitorLaunchOptions + +static NSDictionary *_capturedLaunchOptions = nil; +static UNNotificationResponse *_capturedColdStartResponse = nil; + ++ (void)load { + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(applicationDidFinishLaunching:) + name:UIApplicationDidFinishLaunchingNotification + object:nil]; +} + ++ (void)applicationDidFinishLaunching:(NSNotification *)notification { + _capturedLaunchOptions = notification.userInfo; + + [[NSNotificationCenter defaultCenter] + removeObserver:self + name:UIApplicationDidFinishLaunchingNotification + object:nil]; + + // Wrap the UN delegate's didReceiveNotificationResponse so we can hold on + // to the UNNotificationResponse iOS hands us on cold start. The OneSignal + // iOS SDK drops cold-start responses inside processNotificationResponse: + // when no appId is set yet, which is always true on cold start because the + // JS layer has not called OneSignal.initialize yet. The plugin replays the + // captured response after initialize() so the SDK can fire its click + // listeners normally. + id unDelegate = [UNUserNotificationCenter currentNotificationCenter].delegate; + if (!unDelegate) return; + + SEL didReceiveSel = NSSelectorFromString(@"userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"); + Method original = class_getInstanceMethod([unDelegate class], didReceiveSel); + if (!original) return; + + __block IMP originalIMP = method_getImplementation(original); + IMP newIMP = imp_implementationWithBlock(^(id self_, UNUserNotificationCenter *center, UNNotificationResponse *response, void (^completionHandler)(void)) { + _capturedColdStartResponse = response; + ((void(*)(id, SEL, UNUserNotificationCenter*, UNNotificationResponse*, void(^)(void)))originalIMP)(self_, didReceiveSel, center, response, completionHandler); + }); + method_setImplementation(original, newIMP); +} + ++ (NSDictionary *)launchOptions { + return _capturedLaunchOptions; +} + ++ (UNNotificationResponse *)pendingColdStartResponse { + return _capturedColdStartResponse; +} + ++ (void)consumeColdStartResponse { + _capturedColdStartResponse = nil; +} + +@end diff --git a/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h new file mode 100644 index 0000000..007f380 --- /dev/null +++ b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h @@ -0,0 +1,32 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Captures cold-start state from iOS that the OneSignal SDK would otherwise +/// drop because its JS-driven initialize() runs too late. +/// +/// This class subscribes to UIApplicationDidFinishLaunchingNotification from +/// +load (executed by dyld at process start, before main()) so: +/// * launchOptions are captured before any other code can lose them, and +/// * the UNUserNotificationCenter delegate's +/// didReceiveNotificationResponse: is wrapped so we can hold on to the +/// UNNotificationResponse for a cold-start tap. The OneSignal iOS SDK +/// drops it inside processNotificationResponse: when no appId is set +/// yet (OSNotificationsManager.m). We replay it after the JS layer +/// finishes calling OneSignal.initialize. +@interface OSCapacitorLaunchOptions : NSObject + +@property (class, readonly, nullable) NSDictionary *launchOptions; + +/// The UNNotificationResponse delivered by iOS on cold start, if any. +/// Returns nil for warm starts or once the response has been consumed. +@property (class, readonly, nullable) UNNotificationResponse *pendingColdStartResponse; + +/// Mark the captured cold-start response as consumed so it is not replayed +/// twice. Call after handing the response off to the OneSignal iOS SDK. ++ (void)consumeColdStartResponse; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index fc7626b..c89e28b 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -2,6 +2,7 @@ import Foundation import Capacitor import OneSignalFramework import OneSignalLiveActivities +import OSCapacitorLaunchOptions @objc(OneSignalCapacitorPlugin) public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, @@ -73,7 +74,6 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, private var notificationWillDisplayCache = [String: OSNotificationWillDisplayEvent]() private var preventDefaultCache = [String: OSNotificationWillDisplayEvent]() - private var pendingClickEvent: OSNotificationClickEvent? // MARK: - Core @@ -84,7 +84,24 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, } OneSignalWrapper.sdkType = "capacitor" OneSignalWrapper.sdkVersion = "010000" - OneSignal.initialize(appId, withLaunchOptions: nil) + // OSCapacitorLaunchOptions's +load captures the dictionary from + // UIApplicationDidFinishLaunchingNotification at process start (before + // main()), so cold-start notification taps that arrive via launchOptions + // are still available to the OneSignal iOS SDK when the JS layer + // initializes us. + OneSignal.initialize(appId, withLaunchOptions: OSCapacitorLaunchOptions.launchOptions) + + // The OneSignal iOS SDK drops cold-start UNNotificationResponse objects + // inside processNotificationResponse: when no appId is set yet, which + // is always true on cold start because iOS delivers the response before + // OneSignal.initialize() runs from JS. OSCapacitorLaunchOptions's + // delegate wrap captures the response so we can replay it here, after + // initialize has set the appId. + if let pending = OSCapacitorLaunchOptions.pendingColdStartResponse { + OSNotificationsManager.processNotificationResponse(pending) + OSCapacitorLaunchOptions.consumeColdStartResponse() + } + OneSignal.Notifications.addPermissionObserver(self) OneSignal.Notifications.addForegroundLifecycleListener(self) OneSignal.Notifications.addClickListener(self) @@ -93,10 +110,6 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, OneSignal.InAppMessages.addLifecycleListener(self) OneSignal.InAppMessages.addClickListener(self) - if let pending = pendingClickEvent { - sendNotificationClickEvent(pending) - pendingClickEvent = nil - } call.resolve() } @@ -569,18 +582,16 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, } public func onClick(event: OSNotificationClickEvent) { - if bridge != nil { - sendNotificationClickEvent(event) - } else { - pendingClickEvent = event - } - } - - private func sendNotificationClickEvent(_ event: OSNotificationClickEvent) { - if let data = event.stringify().data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - notifyListeners("notificationClick", data: json) + // retainUntilConsumed lets Capacitor hold this event until a JS click + // listener attaches. On cold start the plugin's initialize() replays + // the OneSignal click before JS has had a chance to call + // addEventListener('click', ...), so without this a cold-start tap + // would fire before any JS listener exists and be lost. + guard let data = event.stringify().data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return } + notifyListeners("notificationClick", data: json, retainUntilConsumed: true) } @objc(onWillDisplayInAppMessage:) diff --git a/src/NotificationsNamespace.ts b/src/NotificationsNamespace.ts index 9e8919f..bcc991f 100644 --- a/src/NotificationsNamespace.ts +++ b/src/NotificationsNamespace.ts @@ -109,6 +109,9 @@ export default class Notifications implements OneSignalNotificationsAPI { this._notificationClickedListeners.push(listener as (event: NotificationClickEvent) => void); if (!this._hasRegisteredClickListener) { this._hasRegisteredClickListener = true; + // The native plugin emits notificationClick with retainUntilConsumed + // so any click delivered before this addListener call (e.g. a cold + // start from a notification tap) is held until we attach here. void this._plugin.addListener('notificationClick', (json: NotificationClickEvent) => { this._processFunctionList(this._notificationClickedListeners, json); }); From 6cd408dc776b6192b92d1974e9633e9c709e0e1d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 6 May 2026 16:22:22 -0700 Subject: [PATCH 02/21] fix(android): use retainUntilConsumed for click events --- .../capacitor/OneSignalCapacitorPlugin.kt | 28 ++++--------------- examples/demo/capacitor.config.ts | 1 + 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index f16ed3d..a640888 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -39,11 +39,6 @@ class OneSignalCapacitorPlugin : Plugin(), private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() - // Holds a click event that arrived before the JS-side click listener was - // attached. Replayed once initialize() has run and JS calls - // setNotificationClickHandlerRegistered. - private var pendingClickEvent: INotificationClickEvent? = null - private var jsClickHandlerRegistered = false // region Core @@ -103,25 +98,9 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignal.InAppMessages.addLifecycleListener(this) OneSignal.InAppMessages.addClickListener(this) - flushPendingClickEventIfReady() - call.resolve() } - @PluginMethod - fun setNotificationClickHandlerRegistered(call: PluginCall) { - jsClickHandlerRegistered = true - flushPendingClickEventIfReady() - call.resolve() - } - - private fun flushPendingClickEventIfReady() { - if (bridge == null || !jsClickHandlerRegistered) return - val event = pendingClickEvent ?: return - pendingClickEvent = null - notifyListeners("notificationClick", buildClickEventJson(event)) - } - private fun buildClickEventJson(event: INotificationClickEvent): JSObject { val ret = JSObject() val clickResult = JSObject() @@ -673,8 +652,11 @@ class OneSignalCapacitorPlugin : Plugin(), } override fun onClick(event: INotificationClickEvent) { - pendingClickEvent = event - flushPendingClickEventIfReady() + // retainUntilConsumed lets Capacitor hold this event until the JS-side + // click listener attaches. On Android the OneSignal SDK can deliver a + // cold-start click before the WebView has finished booting and the JS + // layer has called addEventListener('click', ...). + notifyListeners("notificationClick", buildClickEventJson(event), true) } private fun serializeNotification(notification: INotification): JSObject { diff --git a/examples/demo/capacitor.config.ts b/examples/demo/capacitor.config.ts index bc4c7a0..19186cb 100644 --- a/examples/demo/capacitor.config.ts +++ b/examples/demo/capacitor.config.ts @@ -4,6 +4,7 @@ const config: CapacitorConfig = { appId: 'com.onesignal.example', appName: 'OneSignal Demo', webDir: 'dist', + loggingBehavior: 'debug', ios: { handleApplicationNotifications: false, // Force WKWebView.isInspectable = true so Appium's XCUITest driver can From ac2a844d0523abfa2094530b324e99c65a34f3f2 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 6 May 2026 16:26:21 -0700 Subject: [PATCH 03/21] chore(demo): remove localStorage notification click logging --- examples/demo/src/hooks/useOneSignal.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 9706798..70fab27 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -265,11 +265,6 @@ export function useOneSignal(): UseOneSignalReturn { console.log(`OneSignal initialized with app ID: ${nextAppId}`); - const stored = localStorage.getItem('lastNotificationClicks'); - if (stored) { - console.log('lastNotificationClicks (from previous launches):', JSON.parse(stored)); - } - const externalId = await OneSignal.User.getExternalId(); const [pushId, pushOptedIn, hasPerm] = await Promise.all([ OneSignal.User.pushSubscription.getIdAsync(), From aebb4d006627c5a569cd97523cd97bbc6ce54275 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 6 May 2026 18:20:44 -0700 Subject: [PATCH 04/21] chore(debug): add temporary agent logging --- .../capacitor/OneSignalCapacitorPlugin.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index a640888..5db47ea 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -40,7 +40,21 @@ class OneSignalCapacitorPlugin : Plugin(), private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() - // region Core + override fun handleOnDestroy() { + // Detach this dead plugin instance from the OneSignal SDK singleton so a + // new plugin instance (created on the next activity launch) can receive + // events. Without this, a notification click delivered between activity + // destroy and re-launch would fire on this stale instance whose WebView + // is gone, and the SDK's `unprocessedOpenedNotifs` replay queue would be + // skipped because `extOpenedCallback.hasSubscribers` would still be true. + runCatching { + OneSignal.Notifications.removeClickListener(this) + OneSignal.Notifications.removeForegroundLifecycleListener(this) + OneSignal.InAppMessages.removeLifecycleListener(this) + OneSignal.InAppMessages.removeClickListener(this) + } + super.handleOnDestroy() + } @PluginMethod fun initialize(call: PluginCall) { From d315070b02a28e5ecb70caf261ea877124e96ce7 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 12:12:18 -0700 Subject: [PATCH 05/21] chore(demo): replace IonRouterOutlet with Switch to test useEffect cleanup --- examples/demo/src/App.tsx | 8 ++++---- examples/demo/src/hooks/useOneSignal.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/demo/src/App.tsx b/examples/demo/src/App.tsx index 71cb36f..bd76837 100644 --- a/examples/demo/src/App.tsx +++ b/examples/demo/src/App.tsx @@ -1,7 +1,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; -import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'; +import { IonApp, setupIonicReact } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; -import { Redirect, Route } from 'react-router-dom'; +import { Redirect, Route, Switch } from 'react-router-dom'; import HomeScreen from './pages/HomeScreen'; import Secondary from './pages/Secondary'; @@ -31,7 +31,7 @@ setupIonicReact(); const App: React.FC = () => ( - + @@ -41,7 +41,7 @@ const App: React.FC = () => ( - + ); diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index 70fab27..bf6d1d9 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -294,7 +294,9 @@ export function useOneSignal(): UseOneSignalReturn { setIsLoading(false); }); + console.log('Loaded OneSignal'); return () => { + console.log('Cleaning up OneSignal listeners'); OneSignal.InAppMessages.removeEventListener('willDisplay', handleIamWillDisplay); OneSignal.InAppMessages.removeEventListener('didDisplay', handleIamDidDisplay); OneSignal.InAppMessages.removeEventListener('willDismiss', handleIamWillDismiss); From 4d2b2430968c52c63ac2a6e0f0d413253ecca093 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 12:42:56 -0700 Subject: [PATCH 06/21] fix(android): guard listener registration against duplicate calls --- .../capacitor/OneSignalCapacitorPlugin.kt | 97 +++++++++++-------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index 5db47ea..c659390 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -39,6 +39,7 @@ class OneSignalCapacitorPlugin : Plugin(), private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() + private var listenersRegistered = false override fun handleOnDestroy() { // Detach this dead plugin instance from the OneSignal SDK singleton so a @@ -68,49 +69,59 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignalWrapper.sdkVersion = "010000" OneSignal.initWithContext(context, appId) - OneSignal.Notifications.addPermissionObserver(object : IPermissionObserver { - override fun onNotificationPermissionChange(permission: Boolean) { - val ret = JSObject() - ret.put("permission", permission) - notifyListeners("permissionChange", ret) - } - }) - - OneSignal.Notifications.addForegroundLifecycleListener(this) - OneSignal.Notifications.addClickListener(this) - - OneSignal.User.pushSubscription.addObserver(object : IPushSubscriptionObserver { - override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) { - val ret = JSObject() - val prev = JSObject() - prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL }) - prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL }) - prev.put("optedIn", state.previous.optedIn) - ret.put("previous", prev) - - val curr = JSObject() - curr.put("id", state.current.id.ifEmpty { JSONObject.NULL }) - curr.put("token", state.current.token.ifEmpty { JSONObject.NULL }) - curr.put("optedIn", state.current.optedIn) - ret.put("current", curr) - - notifyListeners("pushSubscriptionChange", ret) - } - }) - - OneSignal.User.addObserver(object : IUserStateObserver { - override fun onUserStateChange(state: UserChangedState) { - val ret = JSObject() - val curr = JSObject() - curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL }) - curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL }) - ret.put("current", curr) - notifyListeners("userStateChange", ret) - } - }) - - OneSignal.InAppMessages.addLifecycleListener(this) - OneSignal.InAppMessages.addClickListener(this) + // The JS layer can call initialize() multiple times within a single + // Activity lifetime (e.g. on every React mount). The OneSignal SDK's + // EventProducer does not de-duplicate subscribers, so re-subscribing + // `this` would cause `onWillDisplay` / `onClick` to fire N times for a + // single notification. Register the listeners once per plugin instance; + // they're cleaned up in handleOnDestroy when the activity dies. + if (!listenersRegistered) { + listenersRegistered = true + + OneSignal.Notifications.addPermissionObserver(object : IPermissionObserver { + override fun onNotificationPermissionChange(permission: Boolean) { + val ret = JSObject() + ret.put("permission", permission) + notifyListeners("permissionChange", ret) + } + }) + + OneSignal.Notifications.addForegroundLifecycleListener(this) + OneSignal.Notifications.addClickListener(this) + + OneSignal.User.pushSubscription.addObserver(object : IPushSubscriptionObserver { + override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) { + val ret = JSObject() + val prev = JSObject() + prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL }) + prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL }) + prev.put("optedIn", state.previous.optedIn) + ret.put("previous", prev) + + val curr = JSObject() + curr.put("id", state.current.id.ifEmpty { JSONObject.NULL }) + curr.put("token", state.current.token.ifEmpty { JSONObject.NULL }) + curr.put("optedIn", state.current.optedIn) + ret.put("current", curr) + + notifyListeners("pushSubscriptionChange", ret) + } + }) + + OneSignal.User.addObserver(object : IUserStateObserver { + override fun onUserStateChange(state: UserChangedState) { + val ret = JSObject() + val curr = JSObject() + curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL }) + curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL }) + ret.put("current", curr) + notifyListeners("userStateChange", ret) + } + }) + + OneSignal.InAppMessages.addLifecycleListener(this) + OneSignal.InAppMessages.addClickListener(this) + } call.resolve() } From eb8d5b508ba86598997bc80e3f084bd7135ebad5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 12:56:20 -0700 Subject: [PATCH 07/21] refactor(android): promote observers to named fields --- .../capacitor/OneSignalCapacitorPlugin.kt | 116 +++++++++--------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index c659390..c35ecec 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -39,7 +39,45 @@ class OneSignalCapacitorPlugin : Plugin(), private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() - private var listenersRegistered = false + private var initialized = false + + private val permissionObserver = object : IPermissionObserver { + override fun onNotificationPermissionChange(permission: Boolean) { + val ret = JSObject() + ret.put("permission", permission) + notifyListeners("permissionChange", ret) + } + } + + private val pushSubscriptionObserver = object : IPushSubscriptionObserver { + override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) { + val ret = JSObject() + val prev = JSObject() + prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL }) + prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL }) + prev.put("optedIn", state.previous.optedIn) + ret.put("previous", prev) + + val curr = JSObject() + curr.put("id", state.current.id.ifEmpty { JSONObject.NULL }) + curr.put("token", state.current.token.ifEmpty { JSONObject.NULL }) + curr.put("optedIn", state.current.optedIn) + ret.put("current", curr) + + notifyListeners("pushSubscriptionChange", ret) + } + } + + private val userStateObserver = object : IUserStateObserver { + override fun onUserStateChange(state: UserChangedState) { + val ret = JSObject() + val curr = JSObject() + curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL }) + curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL }) + ret.put("current", curr) + notifyListeners("userStateChange", ret) + } + } override fun handleOnDestroy() { // Detach this dead plugin instance from the OneSignal SDK singleton so a @@ -49,8 +87,11 @@ class OneSignalCapacitorPlugin : Plugin(), // is gone, and the SDK's `unprocessedOpenedNotifs` replay queue would be // skipped because `extOpenedCallback.hasSubscribers` would still be true. runCatching { - OneSignal.Notifications.removeClickListener(this) + OneSignal.Notifications.removePermissionObserver(permissionObserver) OneSignal.Notifications.removeForegroundLifecycleListener(this) + OneSignal.Notifications.removeClickListener(this) + OneSignal.User.pushSubscription.removeObserver(pushSubscriptionObserver) + OneSignal.User.removeObserver(userStateObserver) OneSignal.InAppMessages.removeLifecycleListener(this) OneSignal.InAppMessages.removeClickListener(this) } @@ -65,63 +106,28 @@ class OneSignalCapacitorPlugin : Plugin(), return } + // initialize() is idempotent at the plugin layer. The JS side can call + // it multiple times within one activity (React effect re-runs on + // remount, hot reload, etc.); subsequent calls are a no-op. Listeners + // are detached in handleOnDestroy when the activity dies, and the next + // plugin instance gets a fresh `initialized = false`. + if (initialized) { + call.resolve() + return + } + initialized = true + OneSignalWrapper.sdkType = "capacitor" OneSignalWrapper.sdkVersion = "010000" OneSignal.initWithContext(context, appId) - // The JS layer can call initialize() multiple times within a single - // Activity lifetime (e.g. on every React mount). The OneSignal SDK's - // EventProducer does not de-duplicate subscribers, so re-subscribing - // `this` would cause `onWillDisplay` / `onClick` to fire N times for a - // single notification. Register the listeners once per plugin instance; - // they're cleaned up in handleOnDestroy when the activity dies. - if (!listenersRegistered) { - listenersRegistered = true - - OneSignal.Notifications.addPermissionObserver(object : IPermissionObserver { - override fun onNotificationPermissionChange(permission: Boolean) { - val ret = JSObject() - ret.put("permission", permission) - notifyListeners("permissionChange", ret) - } - }) - - OneSignal.Notifications.addForegroundLifecycleListener(this) - OneSignal.Notifications.addClickListener(this) - - OneSignal.User.pushSubscription.addObserver(object : IPushSubscriptionObserver { - override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) { - val ret = JSObject() - val prev = JSObject() - prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL }) - prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL }) - prev.put("optedIn", state.previous.optedIn) - ret.put("previous", prev) - - val curr = JSObject() - curr.put("id", state.current.id.ifEmpty { JSONObject.NULL }) - curr.put("token", state.current.token.ifEmpty { JSONObject.NULL }) - curr.put("optedIn", state.current.optedIn) - ret.put("current", curr) - - notifyListeners("pushSubscriptionChange", ret) - } - }) - - OneSignal.User.addObserver(object : IUserStateObserver { - override fun onUserStateChange(state: UserChangedState) { - val ret = JSObject() - val curr = JSObject() - curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL }) - curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL }) - ret.put("current", curr) - notifyListeners("userStateChange", ret) - } - }) - - OneSignal.InAppMessages.addLifecycleListener(this) - OneSignal.InAppMessages.addClickListener(this) - } + OneSignal.Notifications.addPermissionObserver(permissionObserver) + OneSignal.Notifications.addForegroundLifecycleListener(this) + OneSignal.Notifications.addClickListener(this) + OneSignal.User.pushSubscription.addObserver(pushSubscriptionObserver) + OneSignal.User.addObserver(userStateObserver) + OneSignal.InAppMessages.addLifecycleListener(this) + OneSignal.InAppMessages.addClickListener(this) call.resolve() } From 56878a8e0e3d37062c436d013cdcd8d07966afd9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 13:09:54 -0700 Subject: [PATCH 08/21] fix(android): scope coroutines and fix cache leaks --- .../capacitor/OneSignalCapacitorPlugin.kt | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index c35ecec..f8f285a 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -24,8 +24,8 @@ import com.onesignal.user.state.IUserStateObserver import com.onesignal.user.state.UserChangedState import com.onesignal.user.subscriptions.IPushSubscriptionObserver import com.onesignal.user.subscriptions.PushSubscriptionChangedState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject @@ -37,10 +37,25 @@ class OneSignalCapacitorPlugin : Plugin(), IInAppMessageLifecycleListener, IInAppMessageClickListener { + companion object { + // Mirror of iOS UNAuthorizationStatus values so the JS layer can use a + // single permissionNative() return shape across platforms. Android only + // distinguishes denied/authorized; the iOS-specific notDetermined (0), + // provisional (3), and ephemeral (4) states do not apply here. + private const val PERMISSION_DENIED = 1 + private const val PERMISSION_AUTHORIZED = 2 + } + private val notificationWillDisplayCache = mutableMapOf() private val preventDefaultCache = mutableSetOf() private var initialized = false + // Class-scoped scope so launched permission/location coroutines are tied + // to the plugin instance lifetime. Cancelled in handleOnDestroy so a + // pending permission dialog that resolves after the activity dies cannot + // call into the dead Capacitor bridge. + private val pluginScope = MainScope() + private val permissionObserver = object : IPermissionObserver { override fun onNotificationPermissionChange(permission: Boolean) { val ret = JSObject() @@ -95,6 +110,12 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignal.InAppMessages.removeLifecycleListener(this) OneSignal.InAppMessages.removeClickListener(this) } + pluginScope.cancel() + // notificationWillDisplayCache and preventDefaultCache are not cleared + // here — once the listener removals above run, nothing references this + // plugin instance and GC reclaims the caches along with it. The real + // bound on cache size is the consume-on-read in proceedWithWillDisplay + // / displayNotification. super.handleOnDestroy() } @@ -173,8 +194,6 @@ class OneSignalCapacitorPlugin : Plugin(), call.resolve() } - // endregion - // region Debug @PluginMethod @@ -416,14 +435,15 @@ class OneSignalCapacitorPlugin : Plugin(), @PluginMethod fun permissionNative(call: PluginCall) { val ret = JSObject() - ret.put("permission", if (OneSignal.Notifications.permission) 2 else 1) + val status = if (OneSignal.Notifications.permission) PERMISSION_AUTHORIZED else PERMISSION_DENIED + ret.put("permission", status) call.resolve(ret) } @PluginMethod fun requestPermission(call: PluginCall) { val fallback = call.getBoolean("fallbackToSettings") ?: false - CoroutineScope(Dispatchers.Main).launch { + pluginScope.launch { val accepted = OneSignal.Notifications.requestPermission(fallback) val ret = JSObject() ret.put("permission", accepted) @@ -440,8 +460,12 @@ class OneSignalCapacitorPlugin : Plugin(), @PluginMethod fun registerForProvisionalAuthorization(call: PluginCall) { + // Provisional authorization is an iOS-only concept (UNUserNotification + // .provisional). Android has no equivalent quiet-delivery permission + // tier, so report `accepted = false` rather than misleading the JS + // layer into thinking a quiet permission was granted. val ret = JSObject() - ret.put("accepted", true) + ret.put("accepted", false) call.resolve(ret) } @@ -496,12 +520,15 @@ class OneSignalCapacitorPlugin : Plugin(), call.reject("notificationId is required") return } - val event = notificationWillDisplayCache[notificationId] + // Consume the cached event: foreground will-display is single-shot, so + // remove from the cache once the JS side has decided what to do with + // it. Without this both caches would grow for the activity's lifetime. + val event = notificationWillDisplayCache.remove(notificationId) if (event == null) { call.reject("Could not find notification will display event") return } - if (!preventDefaultCache.contains(notificationId)) { + if (!preventDefaultCache.remove(notificationId)) { event.notification.display() } call.resolve() @@ -514,11 +541,12 @@ class OneSignalCapacitorPlugin : Plugin(), call.reject("notificationId is required") return } - val event = notificationWillDisplayCache[notificationId] + val event = notificationWillDisplayCache.remove(notificationId) if (event == null) { call.reject("Could not find notification will display event") return } + preventDefaultCache.remove(notificationId) event.notification.display() call.resolve() } @@ -617,7 +645,7 @@ class OneSignalCapacitorPlugin : Plugin(), @PluginMethod fun requestLocationPermission(call: PluginCall) { - CoroutineScope(Dispatchers.Main).launch { + pluginScope.launch { OneSignal.Location.requestPermission() call.resolve() } @@ -639,7 +667,13 @@ class OneSignalCapacitorPlugin : Plugin(), // endregion - // region Live Activities (no-op on Android) + // region Live Activities + // + // Live Activities are an iOS-only feature backed by ActivityKit. All + // methods below intentionally resolve as a no-op on Android (any args + // passed from JS are ignored) so cross-platform JS code can call them + // unconditionally. Do not log warnings here — JS callers expect silent + // success on the unsupported platform. @PluginMethod fun enterLiveActivity(call: PluginCall) { @@ -676,6 +710,10 @@ class OneSignalCapacitorPlugin : Plugin(), // region Observer Callbacks override fun onWillDisplay(event: INotificationWillDisplayEvent) { + // No retainUntilConsumed needed: foreground will-display only fires + // while the app is foregrounded, so the JS layer's listener is + // already attached. Contrast with onClick() below, which can fire + // before the WebView finishes booting on a cold-start tap. val notificationId = event.notification.notificationId ?: return notificationWillDisplayCache[notificationId] = event event.preventDefault() From da52b664f1f0c67a540bc87e9542c1e16d579c5b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 13:58:53 -0700 Subject: [PATCH 09/21] fix(android): nudge SDK foreground state on init --- .../capacitor/OneSignalCapacitorPlugin.kt | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt index f8f285a..7458a0a 100644 --- a/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt +++ b/android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt @@ -1,5 +1,6 @@ package com.onesignal.capacitor +import android.app.Application import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall @@ -7,6 +8,7 @@ import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.onesignal.OneSignal import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.application.IApplicationService import com.onesignal.inAppMessages.IInAppMessageClickEvent import com.onesignal.inAppMessages.IInAppMessageClickListener import com.onesignal.inAppMessages.IInAppMessageDidDismissEvent @@ -95,12 +97,9 @@ class OneSignalCapacitorPlugin : Plugin(), } override fun handleOnDestroy() { - // Detach this dead plugin instance from the OneSignal SDK singleton so a - // new plugin instance (created on the next activity launch) can receive - // events. Without this, a notification click delivered between activity - // destroy and re-launch would fire on this stale instance whose WebView - // is gone, and the SDK's `unprocessedOpenedNotifs` replay queue would be - // skipped because `extOpenedCallback.hasSubscribers` would still be true. + // Detach this dead plugin instance so the next plugin instance can + // receive events; otherwise the SDK keeps firing on this stale + // instance and skips its unprocessed-click replay queue. runCatching { OneSignal.Notifications.removePermissionObserver(permissionObserver) OneSignal.Notifications.removeForegroundLifecycleListener(this) @@ -111,11 +110,9 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignal.InAppMessages.removeClickListener(this) } pluginScope.cancel() - // notificationWillDisplayCache and preventDefaultCache are not cleared - // here — once the listener removals above run, nothing references this - // plugin instance and GC reclaims the caches along with it. The real - // bound on cache size is the consume-on-read in proceedWithWillDisplay - // / displayNotification. + // Caches aren't explicitly cleared: GC reclaims them with this + // instance once the listener removals above run, and runtime size is + // bounded by consume-on-read in proceedWithWillDisplay/displayNotification. super.handleOnDestroy() } @@ -127,11 +124,9 @@ class OneSignalCapacitorPlugin : Plugin(), return } - // initialize() is idempotent at the plugin layer. The JS side can call - // it multiple times within one activity (React effect re-runs on - // remount, hot reload, etc.); subsequent calls are a no-op. Listeners - // are detached in handleOnDestroy when the activity dies, and the next - // plugin instance gets a fresh `initialized = false`. + // initialize() is idempotent: JS may call it multiple times per + // activity (effect re-runs, hot reload). Listeners are detached in + // handleOnDestroy and the next plugin instance starts fresh. if (initialized) { call.resolve() return @@ -142,6 +137,11 @@ class OneSignalCapacitorPlugin : Plugin(), OneSignalWrapper.sdkVersion = "010000" OneSignal.initWithContext(context, appId) + // If the SDK was initialized from a non-Activity context (FCM/work + // managers) before this call, its ALC missed MainActivity.onResume + // and isInForeground stays false. Forward the missed events now. + nudgeApplicationServiceForeground() + OneSignal.Notifications.addPermissionObserver(permissionObserver) OneSignal.Notifications.addForegroundLifecycleListener(this) OneSignal.Notifications.addClickListener(this) @@ -153,6 +153,17 @@ class OneSignalCapacitorPlugin : Plugin(), call.resolve() } + /** Forward the missed activity-resume to the SDK so isInForeground is + * correct on cold start. No-op if the SDK already saw the resume. */ + private fun nudgeApplicationServiceForeground() { + val activity = activity ?: return + val appSvc = runCatching { OneSignal.getServiceOrNull() }.getOrNull() ?: return + if (appSvc.isInForeground && appSvc.current === activity) return + val callbacks = appSvc as? Application.ActivityLifecycleCallbacks ?: return + callbacks.onActivityStarted(activity) + callbacks.onActivityResumed(activity) + } + private fun buildClickEventJson(event: INotificationClickEvent): JSObject { val ret = JSObject() val clickResult = JSObject() @@ -520,12 +531,12 @@ class OneSignalCapacitorPlugin : Plugin(), call.reject("notificationId is required") return } - // Consume the cached event: foreground will-display is single-shot, so - // remove from the cache once the JS side has decided what to do with - // it. Without this both caches would grow for the activity's lifetime. + // JS always dispatches this after the listener loop, even when a + // listener already called display(). Missing entry = already handled. val event = notificationWillDisplayCache.remove(notificationId) if (event == null) { - call.reject("Could not find notification will display event") + preventDefaultCache.remove(notificationId) + call.resolve() return } if (!preventDefaultCache.remove(notificationId)) { @@ -543,7 +554,8 @@ class OneSignalCapacitorPlugin : Plugin(), } val event = notificationWillDisplayCache.remove(notificationId) if (event == null) { - call.reject("Could not find notification will display event") + preventDefaultCache.remove(notificationId) + call.resolve() return } preventDefaultCache.remove(notificationId) @@ -668,12 +680,8 @@ class OneSignalCapacitorPlugin : Plugin(), // endregion // region Live Activities - // - // Live Activities are an iOS-only feature backed by ActivityKit. All - // methods below intentionally resolve as a no-op on Android (any args - // passed from JS are ignored) so cross-platform JS code can call them - // unconditionally. Do not log warnings here — JS callers expect silent - // success on the unsupported platform. + // iOS-only feature; methods below are no-ops on Android so cross-platform + // JS code can call them unconditionally. No warnings — silent success. @PluginMethod fun enterLiveActivity(call: PluginCall) { From a5e15dc4c674d5b7b6915dcbce4113d66d562709 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 14:36:14 -0700 Subject: [PATCH 10/21] feat(demo): add live-reload dev scripts for iOS/Android --- examples/demo/package.json | 5 ++ examples/dev-android.sh | 90 +++++++++++++++++++++++++++ examples/dev-ios.sh | 121 +++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100755 examples/dev-android.sh create mode 100755 examples/dev-ios.sh diff --git a/examples/demo/package.json b/examples/demo/package.json index 85a0670..1606e50 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -8,8 +8,13 @@ "build": "vp build", "clean:android": "rm -rf android/app/build android/app/.cxx android/build", "clean:ios": "rm -rf ios/App/build ios/DerivedData ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm", + "dev": "vp dev --host --port 5173", + "dev:android": "bash ../dev-android.sh", + "dev:ios": "bash ../dev-ios.sh", "ios": "bash ../run-ios.sh", "preandroid": "vp run setup", + "predev:android": "vp run setup", + "predev:ios": "vp run setup", "preios": "vp run setup", "setup": "../setup.sh" }, diff --git a/examples/dev-android.sh b/examples/dev-android.sh new file mode 100755 index 0000000..d50b385 --- /dev/null +++ b/examples/dev-android.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS +# Live-reload runner. Spawns Vite if no dev server is already on $DEV_PORT, +# then installs the app and points its WebView at the dev server. Vite is +# torn down on exit (Ctrl+C, error, etc.) only if this script started it. +set -e + +PROJECT_DIR="${INIT_CWD:-$PWD}" +DEV_PORT="${DEV_PORT:-5173}" +VITE_PID="" + +cleanup() { + if [ -n "$VITE_PID" ]; then + echo + echo "Stopping dev server (pid=$VITE_PID)..." + kill "$VITE_PID" 2>/dev/null || true + wait "$VITE_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +serials=$(adb devices | awk '/\tdevice$/{print $1}') + +if [ -z "$serials" ]; then + echo "No Android devices connected. Start an emulator and try again." + exit 1 +fi + +labels=() +devices=() +while IFS= read -r serial; do + avd=$(adb -s "$serial" emu avd name 2>/dev/null | head -1 | tr -d '\r') + label="${avd:-$serial} ($serial)" + labels+=("$label") + devices+=("$serial") +done <<< "$serials" + +if [ ${#devices[@]} -eq 1 ]; then + selected="${devices[0]}" + echo "Using device: ${labels[0]}" +else + echo "Select a device:" + for i in "${!labels[@]}"; do + echo " $((i+1))) ${labels[$i]}" + done + printf "Choice [1]: " + read -r choice + choice=${choice:-1} + idx=$((choice - 1)) + if [ "$idx" -lt 0 ] || [ "$idx" -ge ${#devices[@]} ]; then + echo "Invalid choice." + exit 1 + fi + selected="${devices[$idx]}" +fi + +cd "$PROJECT_DIR" + +# Reuse an existing dev server if one's already on $DEV_PORT (e.g. user is +# running `vp run dev` separately for cleaner logs); otherwise spawn one. +if curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then + echo "Reusing existing dev server on http://localhost:$DEV_PORT" +else + echo "Starting dev server on http://localhost:$DEV_PORT..." + vp dev --port "$DEV_PORT" & + VITE_PID=$! + + for _ in $(seq 1 30); do + if curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then + break + fi + sleep 1 + done + + if ! curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then + echo "Dev server didn't come up in 30s. Aborting." + exit 1 + fi +fi + +# --forwardPorts wires up `adb reverse` so the device's localhost hits the +# host machine — no LAN IP / firewall hops, works on emulators and physical +# devices alike. Sync runs inside cap (not skipped) so the live-reload +# server.url + cleartext exemption land in the native project. +npx cap run android \ + --target "$selected" \ + --live-reload \ + --host localhost \ + --port "$DEV_PORT" \ + --forwardPorts "$DEV_PORT:$DEV_PORT" diff --git a/examples/dev-ios.sh b/examples/dev-ios.sh new file mode 100755 index 0000000..d8ff9f9 --- /dev/null +++ b/examples/dev-ios.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS +# Live-reload runner. Spawns Vite if no dev server is already on $DEV_PORT, +# then installs the app and points its WebView at the dev server. Vite is +# torn down on exit (Ctrl+C, error, etc.) only if this script started it. +set -e + +PROJECT_DIR="${INIT_CWD:-$PWD}" +DEV_PORT="${DEV_PORT:-5173}" +VITE_PID="" + +cleanup() { + if [ -n "$VITE_PID" ]; then + echo + echo "Stopping dev server (pid=$VITE_PID)..." + kill "$VITE_PID" 2>/dev/null || true + wait "$VITE_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +booted=$(xcrun simctl list devices booted -j | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +devices_by_runtime = data.get("devices", {}) +booted = [] +for runtime_devices in devices_by_runtime.values(): + for device in runtime_devices: + if device.get("state") == "Booted": + name = device.get("name", "") + udid = device.get("udid", "") + if udid: + booted.append((name, udid)) + +for name, udid in booted: + print(f"{name}|{udid}") +') + +count=0 +selected="" +while IFS= read -r line; do + [ -z "$line" ] && continue + count=$((count + 1)) + if [ "$count" -eq 1 ]; then + selected="$line" + fi +done < Date: Thu, 7 May 2026 15:40:16 -0700 Subject: [PATCH 11/21] fix(ios): guard init and fix cache leaks --- .../OneSignalCapacitorPlugin.swift | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index c89e28b..6f4e7db 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -73,7 +73,20 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, ] private var notificationWillDisplayCache = [String: OSNotificationWillDisplayEvent]() - private var preventDefaultCache = [String: OSNotificationWillDisplayEvent]() + private var preventDefaultCache = Set() + private var initialized = false + + deinit { + // Detach observers so a stale plugin instance doesn't keep firing + // into a dead JS bridge if the WebView VC is ever released. + OneSignal.Notifications.removePermissionObserver(self) + OneSignal.Notifications.removeForegroundLifecycleListener(self) + OneSignal.Notifications.removeClickListener(self) + OneSignal.User.pushSubscription.removeObserver(self) + OneSignal.User.removeObserver(self) + OneSignal.InAppMessages.removeLifecycleListener(self) + OneSignal.InAppMessages.removeClickListener(self) + } // MARK: - Core @@ -82,6 +95,17 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, call.reject("appId is required") return } + + // initialize() is idempotent: JS may call it multiple times per + // plugin instance (effect re-runs, hot reload). The iOS SDK's + // listener arrays don't dedupe, so unguarded re-entry would + // double-fire foreground/click events. + if initialized { + call.resolve() + return + } + initialized = true + OneSignalWrapper.sdkType = "capacitor" OneSignalWrapper.sdkVersion = "010000" // OSCapacitorLaunchOptions's +load captures the dictionary from @@ -333,7 +357,7 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, return } event.preventDefault() - preventDefaultCache[notificationId] = event + preventDefaultCache.insert(notificationId) call.resolve() } @@ -342,11 +366,15 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, call.reject("notificationId is required") return } - guard let event = notificationWillDisplayCache[notificationId] else { - call.reject("Could not find notification will display event") + // JS always dispatches this after the listener loop, even when a + // listener already called display(). Missing entry = already handled. + guard let event = notificationWillDisplayCache.removeValue(forKey: notificationId) else { + preventDefaultCache.remove(notificationId) + call.resolve() return } - if preventDefaultCache[notificationId] == nil { + let wasPrevented = preventDefaultCache.remove(notificationId) != nil + if !wasPrevented { event.notification.display() } call.resolve() @@ -357,10 +385,12 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, call.reject("notificationId is required") return } - guard let event = notificationWillDisplayCache[notificationId] else { - call.reject("Could not find notification will display event") + guard let event = notificationWillDisplayCache.removeValue(forKey: notificationId) else { + preventDefaultCache.remove(notificationId) + call.resolve() return } + preventDefaultCache.remove(notificationId) event.notification.display() call.resolve() } From 450dc914d12fb96da5849425b3352c3a8f180540 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 15:56:52 -0700 Subject: [PATCH 12/21] fix(js): dedupe native bridge listeners --- src/InAppMessagesNamespace.test.ts | 39 +++++++++++++ src/InAppMessagesNamespace.ts | 81 +++++++++++++++++---------- src/PushSubscriptionNamespace.test.ts | 8 +++ src/PushSubscriptionNamespace.ts | 21 ++++--- src/UserNamespace.test.ts | 8 +++ src/UserNamespace.ts | 15 +++-- 6 files changed, 129 insertions(+), 43 deletions(-) diff --git a/src/InAppMessagesNamespace.test.ts b/src/InAppMessagesNamespace.test.ts index 9ac2bb5..fd2dbc5 100644 --- a/src/InAppMessagesNamespace.test.ts +++ b/src/InAppMessagesNamespace.test.ts @@ -67,6 +67,45 @@ describe('InAppMessages', () => { expect(mockPlugin.addListener).not.toHaveBeenCalled(); }); + + test.each([ + ['click'], + ['willDisplay'], + ['didDisplay'], + ['willDismiss'], + ['didDismiss'], + ] as const)( + 'should register the bridge %s listener only once across multiple subscriptions', + (eventType) => { + inAppMessages.addEventListener(eventType, vi.fn()); + inAppMessages.addEventListener(eventType, vi.fn()); + inAppMessages.addEventListener(eventType, vi.fn()); + + expect(mockPlugin.addListener).toHaveBeenCalledTimes(1); + }, + ); + + test.each([ + ['click', 'inAppMessageClick', messageData.click], + ['willDisplay', 'inAppMessageWillDisplay', messageData.willDisplay], + ['didDisplay', 'inAppMessageDidDisplay', messageData.didDisplay], + ['willDismiss', 'inAppMessageWillDismiss', messageData.willDismiss], + ['didDismiss', 'inAppMessageDidDismiss', messageData.didDismiss], + ] as const)( + 'should fan a single native %s event into all currently-subscribed listeners', + (eventType, _listenerName, data) => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + inAppMessages.addEventListener(eventType, listener1); + inAppMessages.addEventListener(eventType, listener2); + + const callback = mockPlugin.addListener.mock.calls[0][1]; + callback(data); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }, + ); }); describe('removeEventListener', () => { diff --git a/src/InAppMessagesNamespace.ts b/src/InAppMessagesNamespace.ts index 24149eb..d551a58 100644 --- a/src/InAppMessagesNamespace.ts +++ b/src/InAppMessagesNamespace.ts @@ -18,6 +18,11 @@ export default class InAppMessages implements OneSignalInAppMessagesAPI { private _didDisplayInAppMessageListeners: ((event: InAppMessageDidDisplayEvent) => void)[] = []; private _willDismissInAppMessageListeners: ((event: InAppMessageWillDismissEvent) => void)[] = []; private _didDismissInAppMessageListeners: ((event: InAppMessageDidDismissEvent) => void)[] = []; + private _hasRegisteredClickListener = false; + private _hasRegisteredWillDisplayListener = false; + private _hasRegisteredDidDisplayListener = false; + private _hasRegisteredWillDismissListener = false; + private _hasRegisteredDidDismissListener = false; constructor(plugin: OneSignalCapacitorPlugin) { this._plugin = plugin; @@ -31,9 +36,10 @@ export default class InAppMessages implements OneSignalInAppMessagesAPI { /** * Add event listeners for In-App Message click and/or lifecycle events. - * @param event - * @param listener - * @returns + * Each native event channel is bridged once per namespace instance; subsequent + * subscribers are appended to the local list. Without this guard, hot-reload + * cycles and effect re-runs leak orphaned bridge subscriptions that fan a + * single native event into N JS callbacks. */ addEventListener( event: K, @@ -41,49 +47,64 @@ export default class InAppMessages implements OneSignalInAppMessagesAPI { ): void { if (event === 'click') { this._inAppMessageClickListeners.push(listener as (event: InAppMessageClickEvent) => void); - void this._plugin.addListener('inAppMessageClick', (json: InAppMessageClickEvent) => { - this._processFunctionList(this._inAppMessageClickListeners, json); - }); + if (!this._hasRegisteredClickListener) { + this._hasRegisteredClickListener = true; + void this._plugin.addListener('inAppMessageClick', (json: InAppMessageClickEvent) => { + this._processFunctionList(this._inAppMessageClickListeners, json); + }); + } } else if (event === 'willDisplay') { this._willDisplayInAppMessageListeners.push( listener as (event: InAppMessageWillDisplayEvent) => void, ); - void this._plugin.addListener( - 'inAppMessageWillDisplay', - (event: InAppMessageWillDisplayEvent) => { - this._processFunctionList(this._willDisplayInAppMessageListeners, event); - }, - ); + if (!this._hasRegisteredWillDisplayListener) { + this._hasRegisteredWillDisplayListener = true; + void this._plugin.addListener( + 'inAppMessageWillDisplay', + (event: InAppMessageWillDisplayEvent) => { + this._processFunctionList(this._willDisplayInAppMessageListeners, event); + }, + ); + } } else if (event === 'didDisplay') { this._didDisplayInAppMessageListeners.push( listener as (event: InAppMessageDidDisplayEvent) => void, ); - void this._plugin.addListener( - 'inAppMessageDidDisplay', - (event: InAppMessageDidDisplayEvent) => { - this._processFunctionList(this._didDisplayInAppMessageListeners, event); - }, - ); + if (!this._hasRegisteredDidDisplayListener) { + this._hasRegisteredDidDisplayListener = true; + void this._plugin.addListener( + 'inAppMessageDidDisplay', + (event: InAppMessageDidDisplayEvent) => { + this._processFunctionList(this._didDisplayInAppMessageListeners, event); + }, + ); + } } else if (event === 'willDismiss') { this._willDismissInAppMessageListeners.push( listener as (event: InAppMessageWillDismissEvent) => void, ); - void this._plugin.addListener( - 'inAppMessageWillDismiss', - (event: InAppMessageWillDismissEvent) => { - this._processFunctionList(this._willDismissInAppMessageListeners, event); - }, - ); + if (!this._hasRegisteredWillDismissListener) { + this._hasRegisteredWillDismissListener = true; + void this._plugin.addListener( + 'inAppMessageWillDismiss', + (event: InAppMessageWillDismissEvent) => { + this._processFunctionList(this._willDismissInAppMessageListeners, event); + }, + ); + } } else if (event === 'didDismiss') { this._didDismissInAppMessageListeners.push( listener as (event: InAppMessageDidDismissEvent) => void, ); - void this._plugin.addListener( - 'inAppMessageDidDismiss', - (event: InAppMessageDidDismissEvent) => { - this._processFunctionList(this._didDismissInAppMessageListeners, event); - }, - ); + if (!this._hasRegisteredDidDismissListener) { + this._hasRegisteredDidDismissListener = true; + void this._plugin.addListener( + 'inAppMessageDidDismiss', + (event: InAppMessageDidDismissEvent) => { + this._processFunctionList(this._didDismissInAppMessageListeners, event); + }, + ); + } } } diff --git a/src/PushSubscriptionNamespace.test.ts b/src/PushSubscriptionNamespace.test.ts index dc039e4..035b220 100644 --- a/src/PushSubscriptionNamespace.test.ts +++ b/src/PushSubscriptionNamespace.test.ts @@ -114,6 +114,14 @@ describe('PushSubscription', () => { expect(mockListener).toHaveBeenCalledWith(SUB_CHANGED_STATE); expect(mockListener2).toHaveBeenCalledWith(SUB_CHANGED_STATE); }); + + test('should register the bridge change listener only once across multiple subscriptions', () => { + pushSubscription.addEventListener('change', vi.fn()); + pushSubscription.addEventListener('change', vi.fn()); + pushSubscription.addEventListener('change', vi.fn()); + + expect(mockPlugin.addListener).toHaveBeenCalledTimes(1); + }); }); describe('removeEventListener', () => { diff --git a/src/PushSubscriptionNamespace.ts b/src/PushSubscriptionNamespace.ts index 506a004..832705e 100644 --- a/src/PushSubscriptionNamespace.ts +++ b/src/PushSubscriptionNamespace.ts @@ -17,6 +17,7 @@ export default class PushSubscription implements OneSignalPushSubscriptionAPI { private _plugin: OneSignalCapacitorPlugin; private _subscriptionObserverList: ((event: PushSubscriptionChangedState) => void)[] = []; + private _hasRegisteredChangeListener = false; constructor(plugin: OneSignalCapacitorPlugin) { this._plugin = plugin; @@ -63,17 +64,21 @@ export default class PushSubscription implements OneSignalPushSubscriptionAPI { /** * Add a callback that fires when the OneSignal push subscription state changes. - * @param {(event: PushSubscriptionChangedState)=>void} listener - * @returns void + * The bridge subscription is registered once per namespace instance; subsequent + * subscribers append to the local list to avoid orphaned bridge handlers + * across hot-reload cycles. */ addEventListener(_event: 'change', listener: (event: PushSubscriptionChangedState) => void) { this._subscriptionObserverList.push(listener); - void this._plugin.addListener( - 'pushSubscriptionChange', - (state: PushSubscriptionChangedState) => { - this._processFunctionList(this._subscriptionObserverList, state); - }, - ); + if (!this._hasRegisteredChangeListener) { + this._hasRegisteredChangeListener = true; + void this._plugin.addListener( + 'pushSubscriptionChange', + (state: PushSubscriptionChangedState) => { + this._processFunctionList(this._subscriptionObserverList, state); + }, + ); + } } /** diff --git a/src/UserNamespace.test.ts b/src/UserNamespace.test.ts index bd81efa..7abb3b3 100644 --- a/src/UserNamespace.test.ts +++ b/src/UserNamespace.test.ts @@ -229,6 +229,14 @@ describe('User', () => { expect(mockListener2).toHaveBeenCalledWith(USER_CHANGED_STATE); expect(mockListener3).toHaveBeenCalledWith(USER_CHANGED_STATE); }); + + test('should register the bridge change listener only once across multiple subscriptions', () => { + user.addEventListener('change', vi.fn()); + user.addEventListener('change', vi.fn()); + user.addEventListener('change', vi.fn()); + + expect(mockPlugin.addListener).toHaveBeenCalledTimes(1); + }); }); describe('removeEventListener', () => { diff --git a/src/UserNamespace.ts b/src/UserNamespace.ts index e26b1e6..2b458da 100644 --- a/src/UserNamespace.ts +++ b/src/UserNamespace.ts @@ -17,6 +17,7 @@ export default class User implements OneSignalUserAPI { private _plugin: OneSignalCapacitorPlugin; private _userStateObserverList: ((event: UserChangedState) => void)[] = []; + private _hasRegisteredChangeListener = false; constructor(plugin: OneSignalCapacitorPlugin) { this._plugin = plugin; @@ -170,14 +171,18 @@ export default class User implements OneSignalUserAPI { /** * Add a callback that fires when the OneSignal User state changes. - * @param {(event: UserChangedState)=>void} listener - * @returns void + * The bridge subscription is registered once per namespace instance; subsequent + * subscribers append to the local list to avoid orphaned bridge handlers + * across hot-reload cycles. */ addEventListener(_event: 'change', listener: (event: UserChangedState) => void) { this._userStateObserverList.push(listener); - void this._plugin.addListener('userStateChange', (state: UserChangedState) => { - this._processFunctionList(this._userStateObserverList, state); - }); + if (!this._hasRegisteredChangeListener) { + this._hasRegisteredChangeListener = true; + void this._plugin.addListener('userStateChange', (state: UserChangedState) => { + this._processFunctionList(this._userStateObserverList, state); + }); + } } /** From db03a91382a184762e22de5840fd6ea6bae964c1 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 16:10:48 -0700 Subject: [PATCH 13/21] fix(demo): bust Vite cache after SDK reinstall --- examples/setup.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/examples/setup.sh b/examples/setup.sh index e330dc7..bac2884 100755 --- a/examples/setup.sh +++ b/examples/setup.sh @@ -40,6 +40,27 @@ else echo "$SDK_SRC_HASH" > "$SDK_STAMP" fi +# ── Vite prebundle staleness check ─────────────────────────────────────────── +# Vite prebundles deps into node_modules/.vite/deps once at startup and keys +# the cache on lockfileHash. file: deps don't always trip that hash, and a +# long-running Vite (via dev-android.sh's "reuse existing dev server" path) +# never re-bundles mid-session. Always check: if the prebundle predates the +# installed dist, drop it and kill any Vite still on $DEV_PORT so the next +# dev:* run re-bundles cleanly. Self-healing; runs regardless of whether the +# SDK rebuild branch above fired. +DEV_PORT="${DEV_PORT:-5173}" +PREBUNDLE="$ORIGINAL_DIR/node_modules/.vite/deps/@onesignal_capacitor-plugin.js" +INSTALLED_DIST="$INSTALLED_DIR/dist/index.js" +if [[ -f "$INSTALLED_DIST" ]] && [[ -f "$PREBUNDLE" ]] && [[ "$INSTALLED_DIST" -nt "$PREBUNDLE" ]]; then + info "Vite prebundle is stale (installed dist is newer); invalidating..." + rm -rf "$ORIGINAL_DIR/node_modules/.vite" + if lsof -ti:"$DEV_PORT" >/dev/null 2>&1; then + info "Killing stale Vite on :$DEV_PORT so the rebuild takes effect..." + lsof -ti:"$DEV_PORT" | xargs kill 2>/dev/null || true + sleep 1 + fi +fi + # ── Web bundle ─────────────────────────────────────────────────────────────── info "Building web bundle (vite)..." # Same reasoning as the SDK build above: invoke `vp build` directly to skip From ff45c065cf5b2c373b4888e99d2fd4ce6c3736a5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 16:40:12 -0700 Subject: [PATCH 14/21] docs(demo): add README with run instructions --- examples/demo/README.md | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 examples/demo/README.md diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..80edb7e --- /dev/null +++ b/examples/demo/README.md @@ -0,0 +1,101 @@ +# OneSignal Capacitor Demo + +Reference app for the `@onesignal/capacitor-plugin`. Use it to exercise the SDK on a real device or simulator and to reproduce/debug issues against the local plugin sources at `../../`. + +--- + +## Prerequisites + +- Bun, Xcode (with a booted simulator), and Android Studio (with an emulator or attached device) +- `vp` (Vite+) on `PATH` — the demo uses it instead of running `bun`/`vite` directly. Install with: + + ```bash + # macOS / Linux + curl -fsSL https://vite.plus | bash + + # Windows (PowerShell) + irm https://vite.plus/ps1 | iex + ``` + + See the [Vite+ install guide](https://viteplus.dev/guide/#install-vp) for other platforms. + +- A OneSignal app id + +Copy `.env.example` to `.env` and fill in your app id (and REST API key if you plan to send notifications from inside the demo): + +```bash +cp .env.example .env +``` + +```env +VITE_ONESIGNAL_APP_ID= +VITE_ONESIGNAL_API_KEY= +VITE_ONESIGNAL_ANDROID_CHANNEL_ID= +VITE_E2E_MODE=false +``` + +--- + +## Run modes + +There are two ways to run the demo. Both rebuild the local plugin from `../../` first (via `setup.sh`), so any change you make to the plugin sources is picked up automatically. + +| Script | What it does | When to use | +| --------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `vp run android` / `vp run ios` | Bundles the web app to `dist/`, syncs into the native project, installs and launches | You only changed native (Kotlin/Swift) code, or you want a "clean" install that mirrors a release build | +| `vp run dev:android` / `vp run dev:ios` | Same install, but the WebView loads from a Vite dev server with HMR + React Fast Refresh | You're iterating on TypeScript/React. Save a file and the WebView updates without a reinstall | + +Both modes prompt you to pick a device/simulator if more than one is available. + +### Standard (non-dev) + +```bash +vp run android +# or +vp run ios +``` + +The webview loads the prebuilt bundle from inside the app. JS edits require re-running the script. Native code changes always require this path (or a fresh `dev:*` run, which performs a sync). + +### Dev (live reload) + +```bash +vp run dev:android +# or +vp run dev:ios +``` + +What happens: + +1. `setup.sh` rebuilds the plugin and installs it. +2. The script starts a Vite dev server on `http://localhost:5173` (or reuses one that's already listening — useful if you'd rather run `vp run dev` in a separate terminal for cleaner logs). +3. Capacitor installs the app with `--live-reload --host localhost --port 5173`. On Android it also calls `adb reverse` so the device's `localhost` resolves to your machine. +4. Save a `.ts`/`.tsx` file → HMR pushes it into the WebView. + +`Ctrl+C` tears down the Vite server only if the script started it. If you started Vite yourself, that one keeps running. + +Notes: + +- **Native changes still need a reinstall.** HMR only covers JS/CSS. Editing Kotlin/Swift means re-running `dev:*` (or `android` / `ios`). +- **JS plugin changes require restarting `dev:*`.** `setup.sh` rebuilds and reinstalls the plugin tarball, and also invalidates Vite's prebundle cache + kills any stale Vite on `:5173` so the new code is picked up. The next `dev:*` run starts Vite fresh. +- **Override the port** with `DEV_PORT=5174 vp run dev:android` if `5173` is taken. + +--- + +## Other scripts + +| Script | Purpose | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vp run setup` | Build the plugin, pack it, install into the demo, `vp build`, `cap sync`. Runs automatically before `android` / `ios` / `dev:android` / `dev:ios`. | +| `vp build` | Build the web bundle into `dist/` only. | +| `vp run clean:android` | Wipe Android build outputs (`android/app/build`, `.cxx`, `android/build`). | +| `vp run clean:ios` | Wipe iOS build outputs and SPM checkouts. | + +--- + +## Troubleshooting + +- **App stuck on splash (`dev:*`)** — the Vite dev server isn't reachable from the device. Confirm `http://localhost:5173` works in your host browser, then re-run `dev:*`. On a physical Android device, `adb reverse` (handled via `--forwardPorts`) requires `adb` to see the device as `device`, not `offline`. +- **`Invalid target ID` / device shows as `offline`** — `adb kill-server && adb start-server`, then re-run. +- **Plugin changes not showing up in the WebView** — kill the script with `Ctrl+C` and re-run `dev:*`. `setup.sh` will rebuild the plugin, drop `node_modules/.vite/`, and start Vite clean. +- **iOS WebView won't connect to Safari Web Inspector** — make sure the simulator is booted before running `dev:ios`. `webContentsDebuggingEnabled: true` is already set in `capacitor.config.ts`. From afed1d4db90ff008653f32c19a78fc11519454e8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 16:56:34 -0700 Subject: [PATCH 15/21] fix(demo): split SDK build and install stamps --- .gitignore | 1 + examples/demo_pods/package.json | 22 +++++++++---------- examples/demo_pods/src/hooks/useOneSignal.ts | 2 ++ examples/setup.sh | 23 +++++++++++++++----- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 9957d7c..647eb20 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules dist/ coverage/ .capacitor-sdk-source.stamp +.capacitor-sdk-installed.stamp .cap-sync.stamp # macOS diff --git a/examples/demo_pods/package.json b/examples/demo_pods/package.json index 9886455..5b67516 100644 --- a/examples/demo_pods/package.json +++ b/examples/demo_pods/package.json @@ -8,10 +8,16 @@ "build": "vp build", "clean:android": "rm -rf android/app/build android/app/.cxx android/build", "clean:ios": "rm -rf ios/App/Pods ios/App/build", + "dev": "vp dev --host --port 5173", + "dev:android": "bash ../dev-android.sh", + "dev:ios": "bash ../dev-ios.sh", "ios": "bash ../run-ios.sh", - "preandroid": "bun run setup", - "preios": "bun run setup", - "setup": "../setup.sh" + "preandroid": "vp run setup", + "predev:android": "vp run setup", + "predev:ios": "vp run setup", + "preios": "vp run setup", + "setup": "../setup.sh", + "update:pods": "(cd ios/App && pod update OneSignalXCFramework --no-repo-update)" }, "dependencies": { "@capacitor/android": "^8.3.0", @@ -36,13 +42,7 @@ "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^6.0.1", - "typescript": "^6.0.2", - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "0.1.17" + "typescript": "^6.0.3" }, - "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "npm:@voidzero-dev/vite-plus-test@latest" - }, - "packageManager": "bun@1.3.11" + "packageManager": "bun@1.3.13" } diff --git a/examples/demo_pods/src/hooks/useOneSignal.ts b/examples/demo_pods/src/hooks/useOneSignal.ts index 945456b..72c2b4d 100644 --- a/examples/demo_pods/src/hooks/useOneSignal.ts +++ b/examples/demo_pods/src/hooks/useOneSignal.ts @@ -278,7 +278,9 @@ export function useOneSignal(): UseOneSignalReturn { setIsLoading(false); }); + console.log('Loaded OneSignal'); return () => { + console.log('Cleaning up OneSignal listeners'); OneSignal.InAppMessages.removeEventListener('willDisplay', handleIamWillDisplay); OneSignal.InAppMessages.removeEventListener('didDisplay', handleIamDidDisplay); OneSignal.InAppMessages.removeEventListener('willDismiss', handleIamWillDismiss); diff --git a/examples/setup.sh b/examples/setup.sh index bac2884..0661300 100755 --- a/examples/setup.sh +++ b/examples/setup.sh @@ -8,9 +8,16 @@ SDK_ROOT=$(cd "$ORIGINAL_DIR/../.." && pwd) info() { echo -e "\033[0;32m[setup]\033[0m $*"; } # ── Plugin tarball cache ───────────────────────────────────────────────────── -# Skip rebuild/repack/`vp add` when plugin sources haven't changed. -SDK_STAMP="$SDK_ROOT/.capacitor-sdk-source.stamp" +# Two stamps: the SDK build is shared across demos (one tarball serves all), +# but the `vp add` install is per-demo. Without the split, running setup in +# `demo` first would leave the shared stamp at the new hash, and a follow-up +# `demo_pods` setup would short-circuit the install and keep stale sources in +# `demo_pods/node_modules/@onesignal/capacitor-plugin/` — which is what +# CocoaPods path-references, so the native pod stays pre-fix. +SDK_BUILD_STAMP="$SDK_ROOT/.capacitor-sdk-source.stamp" +INSTALLED_STAMP="$ORIGINAL_DIR/.capacitor-sdk-installed.stamp" INSTALLED_DIR="$ORIGINAL_DIR/node_modules/@onesignal/capacitor-plugin" +TARBALL="$SDK_ROOT/onesignal-capacitor-plugin.tgz" SDK_SRC_HASH=$(find "$SDK_ROOT/src" "$SDK_ROOT/android" "$SDK_ROOT/ios" \ "$SDK_ROOT/package.json" \ @@ -23,21 +30,25 @@ SDK_SRC_HASH=$(find "$SDK_ROOT/src" "$SDK_ROOT/android" "$SDK_ROOT/ios" \ | shasum \ | awk '{print $1}') -if [[ -d "$INSTALLED_DIR" ]] && [[ -f "$SDK_STAMP" ]] && [[ "$(cat "$SDK_STAMP")" == "$SDK_SRC_HASH" ]]; then - info "Capacitor SDK source unchanged, skipping rebuild + repack" +if [[ -f "$TARBALL" ]] && [[ -f "$SDK_BUILD_STAMP" ]] && [[ "$(cat "$SDK_BUILD_STAMP")" == "$SDK_SRC_HASH" ]]; then + info "Capacitor SDK tarball is up-to-date, skipping rebuild + repack" else info "Building Capacitor plugin & packing tarball..." (cd "$SDK_ROOT" && vp run build) (cd "$SDK_ROOT" && rm -f onesignal-capacitor-plugin*.tgz && vp pm pack && mv onesignal-capacitor-plugin-*.tgz onesignal-capacitor-plugin.tgz) + echo "$SDK_SRC_HASH" > "$SDK_BUILD_STAMP" +fi +if [[ -d "$INSTALLED_DIR" ]] && [[ -f "$INSTALLED_STAMP" ]] && [[ "$(cat "$INSTALLED_STAMP")" == "$SDK_SRC_HASH" ]]; then + info "Plugin already installed at current SDK hash, skipping vp add" +else # Remove before add so bun.lock's integrity hash refreshes against the new # tarball; otherwise `vp add` hits a dependency-loop error under bun 1.3+. # Keep the relative `file:../../...` path to match package.json's spec. info "Registering tarball with vp (refreshes bun.lock integrity hash)..." vp remove @onesignal/capacitor-plugin 2>/dev/null || true vp add file:../../onesignal-capacitor-plugin.tgz - - echo "$SDK_SRC_HASH" > "$SDK_STAMP" + echo "$SDK_SRC_HASH" > "$INSTALLED_STAMP" fi # ── Vite prebundle staleness check ─────────────────────────────────────────── From ce2dddbb300b9a72f88a3ee7336240b54f1c093d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 17:12:13 -0700 Subject: [PATCH 16/21] fix(demo): persist notification clicks to localStorage --- examples/demo_pods/src/hooks/useOneSignal.ts | 18 +++++++++++++++++- .../OneSignalCapacitorPlugin.swift | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/examples/demo_pods/src/hooks/useOneSignal.ts b/examples/demo_pods/src/hooks/useOneSignal.ts index 72c2b4d..d93cd9a 100644 --- a/examples/demo_pods/src/hooks/useOneSignal.ts +++ b/examples/demo_pods/src/hooks/useOneSignal.ts @@ -175,6 +175,22 @@ export function useOneSignal(): UseOneSignalReturn { const handleNotificationClick = (e: NotificationClickEvent) => { console.log(`Notification click: ${e.notification.title ?? ''}`); + // Persist to localStorage so cold-start clicks are still inspectable + // after the Safari Web Inspector reattaches to the WKWebView. + try { + const existing = JSON.parse(localStorage.getItem('lastNotificationClicks') ?? '[]'); + existing.push({ + notificationId: e.notification.notificationId, + title: e.notification.title ?? null, + body: e.notification.body ?? null, + actionId: e.result.actionId ?? null, + url: e.result.url ?? null, + receivedAt: new Date().toISOString(), + }); + localStorage.setItem('lastNotificationClicks', JSON.stringify(existing.slice(-20))); + } catch (err) { + console.warn('Failed to persist notification click to localStorage', err); + } }; const handleForegroundWillDisplay = (e: NotificationWillDisplayEvent) => { @@ -280,7 +296,7 @@ export function useOneSignal(): UseOneSignalReturn { console.log('Loaded OneSignal'); return () => { - console.log('Cleaning up OneSignal listeners'); + console.log('Cleaning up OneSignal listenerszzz'); OneSignal.InAppMessages.removeEventListener('willDisplay', handleIamWillDisplay); OneSignal.InAppMessages.removeEventListener('didDisplay', handleIamDidDisplay); OneSignal.InAppMessages.removeEventListener('willDismiss', handleIamWillDismiss); diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index 6f4e7db..f8b532e 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -2,7 +2,9 @@ import Foundation import Capacitor import OneSignalFramework import OneSignalLiveActivities +#if SWIFT_PACKAGE import OSCapacitorLaunchOptions +#endif @objc(OneSignalCapacitorPlugin) public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, From f1dbf55983ef8f423a2f30d6430e38c36ecd223b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 17:26:19 -0700 Subject: [PATCH 17/21] fix(ios): break retain cycle via listener proxy --- .../OneSignalCapacitorPlugin.swift | 116 ++++++++++++++---- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index f8b532e..ad40c91 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -7,14 +7,7 @@ import OSCapacitorLaunchOptions #endif @objc(OneSignalCapacitorPlugin) -public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, - OSNotificationPermissionObserver, - OSNotificationLifecycleListener, - OSNotificationClickListener, - OSPushSubscriptionObserver, - OSInAppMessageLifecycleListener, - OSInAppMessageClickListener, - OSUserStateObserver { +public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "OneSignalCapacitorPlugin" public let jsName = "OneSignalCapacitor" @@ -78,16 +71,24 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, private var preventDefaultCache = Set() private var initialized = false + // Observer/listener forwarder. The iOS SDK strong-retains conformers of + // the lifecycle/click protocols (NSMutableArray-backed), so registering + // self directly creates a retain cycle that makes deinit unreachable. + // The proxy holds a weak ref back to us, so dropping the last external + // ref to the plugin lets deinit run and clean up the proxy registration. + private var listenerProxy: OneSignalListenerProxy? + deinit { - // Detach observers so a stale plugin instance doesn't keep firing - // into a dead JS bridge if the WebView VC is ever released. - OneSignal.Notifications.removePermissionObserver(self) - OneSignal.Notifications.removeForegroundLifecycleListener(self) - OneSignal.Notifications.removeClickListener(self) - OneSignal.User.pushSubscription.removeObserver(self) - OneSignal.User.removeObserver(self) - OneSignal.InAppMessages.removeLifecycleListener(self) - OneSignal.InAppMessages.removeClickListener(self) + // Only present after initialize() ran. Removes are idempotent on the + // SDK side; we still gate to avoid touching an uninitialized SDK. + guard let listenerProxy = listenerProxy else { return } + OneSignal.Notifications.removePermissionObserver(listenerProxy) + OneSignal.Notifications.removeForegroundLifecycleListener(listenerProxy) + OneSignal.Notifications.removeClickListener(listenerProxy) + OneSignal.User.pushSubscription.removeObserver(listenerProxy) + OneSignal.User.removeObserver(listenerProxy) + OneSignal.InAppMessages.removeLifecycleListener(listenerProxy) + OneSignal.InAppMessages.removeClickListener(listenerProxy) } // MARK: - Core @@ -128,13 +129,17 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, OSCapacitorLaunchOptions.consumeColdStartResponse() } - OneSignal.Notifications.addPermissionObserver(self) - OneSignal.Notifications.addForegroundLifecycleListener(self) - OneSignal.Notifications.addClickListener(self) - OneSignal.User.pushSubscription.addObserver(self) - OneSignal.User.addObserver(self) - OneSignal.InAppMessages.addLifecycleListener(self) - OneSignal.InAppMessages.addClickListener(self) + let proxy = OneSignalListenerProxy() + proxy.owner = self + listenerProxy = proxy + + OneSignal.Notifications.addPermissionObserver(proxy) + OneSignal.Notifications.addForegroundLifecycleListener(proxy) + OneSignal.Notifications.addClickListener(proxy) + OneSignal.User.pushSubscription.addObserver(proxy) + OneSignal.User.addObserver(proxy) + OneSignal.InAppMessages.addLifecycleListener(proxy) + OneSignal.InAppMessages.addClickListener(proxy) call.resolve() } @@ -680,3 +685,66 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, ]) } } + +// Forwarding proxy for the OneSignal iOS SDK observer/listener APIs. +// Registered with the SDK in place of the plugin so the SDK's strong +// retention of click/lifecycle listeners (NSMutableArray-backed) does not +// pin the plugin and make its deinit unreachable. The proxy holds a weak +// ref back to the plugin; once external holders drop the plugin its +// deinit runs, removes the proxy from the SDK, and the proxy is then +// released too. +final class OneSignalListenerProxy: NSObject, + OSNotificationPermissionObserver, + OSNotificationLifecycleListener, + OSNotificationClickListener, + OSPushSubscriptionObserver, + OSInAppMessageLifecycleListener, + OSInAppMessageClickListener, + OSUserStateObserver { + + weak var owner: OneSignalCapacitorPlugin? + + public func onNotificationPermissionDidChange(_ permission: Bool) { + owner?.onNotificationPermissionDidChange(permission) + } + + public func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { + owner?.onPushSubscriptionDidChange(state: state) + } + + public func onUserStateDidChange(state: OSUserChangedState) { + owner?.onUserStateDidChange(state: state) + } + + public func onWillDisplay(event: OSNotificationWillDisplayEvent) { + owner?.onWillDisplay(event: event) + } + + public func onClick(event: OSNotificationClickEvent) { + owner?.onClick(event: event) + } + + @objc(onWillDisplayInAppMessage:) + public func onWillDisplay(event: OSInAppMessageWillDisplayEvent) { + owner?.onWillDisplay(event: event) + } + + @objc(onDidDisplayInAppMessage:) + public func onDidDisplay(event: OSInAppMessageDidDisplayEvent) { + owner?.onDidDisplay(event: event) + } + + @objc(onWillDismissInAppMessage:) + public func onWillDismiss(event: OSInAppMessageWillDismissEvent) { + owner?.onWillDismiss(event: event) + } + + @objc(onDidDismissInAppMessage:) + public func onDidDismiss(event: OSInAppMessageDidDismissEvent) { + owner?.onDidDismiss(event: event) + } + + public func onClick(event: OSInAppMessageClickEvent) { + owner?.onClick(event: event) + } +} From 0a9afe78290024a79cd80843ca6da5ffdf111a3b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 17:43:50 -0700 Subject: [PATCH 18/21] fix(ios): avoid swizzling inherited methods on parent class --- .../OSCapacitorLaunchOptions.m | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m index 03a3d1e..07ef7a9 100644 --- a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m +++ b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m @@ -35,7 +35,8 @@ + (void)applicationDidFinishLaunching:(NSNotification *)notification { if (!unDelegate) return; SEL didReceiveSel = NSSelectorFromString(@"userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"); - Method original = class_getInstanceMethod([unDelegate class], didReceiveSel); + Class delegateClass = [unDelegate class]; + Method original = class_getInstanceMethod(delegateClass, didReceiveSel); if (!original) return; __block IMP originalIMP = method_getImplementation(original); @@ -43,7 +44,15 @@ + (void)applicationDidFinishLaunching:(NSNotification *)notification { _capturedColdStartResponse = response; ((void(*)(id, SEL, UNUserNotificationCenter*, UNNotificationResponse*, void(^)(void)))originalIMP)(self_, didReceiveSel, center, response, completionHandler); }); - method_setImplementation(original, newIMP); + + // Try to add the method to the delegate's exact class first. If it succeeds, + // the IMP came from a parent class (inherited) and we've safely shadowed it + // on this subclass only, leaving sibling subclasses untouched. If it fails, + // the method is owned by this exact class and method_setImplementation is + // safe — it won't leak the wrap up the inheritance chain. + if (!class_addMethod(delegateClass, didReceiveSel, newIMP, method_getTypeEncoding(original))) { + method_setImplementation(original, newIMP); + } } + (NSDictionary *)launchOptions { From 0aeab8df29439fee52a2d8b46932c192c27a5427 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 18:46:05 -0700 Subject: [PATCH 19/21] fix(ios): queue multiple cold-start notification responses --- .../OSCapacitorLaunchOptions.m | 19 +++++++++++++------ .../include/OSCapacitorLaunchOptions.h | 15 +++++++++------ .../OneSignalCapacitorPlugin.swift | 15 ++++++++++----- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m index 07ef7a9..551fa22 100644 --- a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m +++ b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m @@ -6,9 +6,10 @@ @implementation OSCapacitorLaunchOptions static NSDictionary *_capturedLaunchOptions = nil; -static UNNotificationResponse *_capturedColdStartResponse = nil; +static NSMutableArray *_capturedColdStartResponses = nil; + (void)load { + _capturedColdStartResponses = [NSMutableArray array]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidFinishLaunching:) @@ -41,7 +42,13 @@ + (void)applicationDidFinishLaunching:(NSNotification *)notification { __block IMP originalIMP = method_getImplementation(original); IMP newIMP = imp_implementationWithBlock(^(id self_, UNUserNotificationCenter *center, UNNotificationResponse *response, void (^completionHandler)(void)) { - _capturedColdStartResponse = response; + // Queue every response that arrives before the JS layer drains us. + // The JS bundle can take multiple seconds to load on cold start (worse + // in dev builds), and the user can tap a second notification from the + // shade in that window. Overwriting would silently lose the earlier + // tap. consumeColdStartResponses clears the queue once initialize() + // has handed every response to OSNotificationsManager. + [_capturedColdStartResponses addObject:response]; ((void(*)(id, SEL, UNUserNotificationCenter*, UNNotificationResponse*, void(^)(void)))originalIMP)(self_, didReceiveSel, center, response, completionHandler); }); @@ -59,12 +66,12 @@ + (NSDictionary *)launchOptions { return _capturedLaunchOptions; } -+ (UNNotificationResponse *)pendingColdStartResponse { - return _capturedColdStartResponse; ++ (NSArray *)pendingColdStartResponses { + return [_capturedColdStartResponses copy]; } -+ (void)consumeColdStartResponse { - _capturedColdStartResponse = nil; ++ (void)consumeColdStartResponses { + [_capturedColdStartResponses removeAllObjects]; } @end diff --git a/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h index 007f380..f24ecd0 100644 --- a/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h +++ b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h @@ -19,13 +19,16 @@ NS_ASSUME_NONNULL_BEGIN @property (class, readonly, nullable) NSDictionary *launchOptions; -/// The UNNotificationResponse delivered by iOS on cold start, if any. -/// Returns nil for warm starts or once the response has been consumed. -@property (class, readonly, nullable) UNNotificationResponse *pendingColdStartResponse; +/// UNNotificationResponses delivered by iOS while the JS layer was still +/// booting (i.e. before OneSignal.initialize ran and we drained the queue). +/// Empty for warm starts and after consumeColdStartResponses has been called. +/// Multiple entries are possible: the user can tap a second notification from +/// the shade while the JS bundle is still loading, especially in dev builds. +@property (class, readonly, nonnull) NSArray *pendingColdStartResponses; -/// Mark the captured cold-start response as consumed so it is not replayed -/// twice. Call after handing the response off to the OneSignal iOS SDK. -+ (void)consumeColdStartResponse; +/// Mark the captured cold-start responses as consumed so they are not replayed +/// twice. Call after handing each response off to the OneSignal iOS SDK. ++ (void)consumeColdStartResponses; @end diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index ad40c91..c92f303 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -122,11 +122,16 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { // inside processNotificationResponse: when no appId is set yet, which // is always true on cold start because iOS delivers the response before // OneSignal.initialize() runs from JS. OSCapacitorLaunchOptions's - // delegate wrap captures the response so we can replay it here, after - // initialize has set the appId. - if let pending = OSCapacitorLaunchOptions.pendingColdStartResponse { - OSNotificationsManager.processNotificationResponse(pending) - OSCapacitorLaunchOptions.consumeColdStartResponse() + // delegate wrap queues every response that arrives in that window so we + // can replay them here, in arrival order, after initialize has set the + // appId. The queue can hold more than one entry if the user tapped a + // second notification from the shade while the JS bundle was loading. + let pending = OSCapacitorLaunchOptions.pendingColdStartResponses + for response in pending { + OSNotificationsManager.processNotificationResponse(response) + } + if !pending.isEmpty { + OSCapacitorLaunchOptions.consumeColdStartResponses() } let proxy = OneSignalListenerProxy() From 228ee68d53103007d8f4bcaca20a1beae099d7b0 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 19:30:09 -0700 Subject: [PATCH 20/21] fix(examples): always own vite on a free port --- examples/dev-android.sh | 39 ++++++++++++++++++++++++++++++++------- examples/dev-ios.sh | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/examples/dev-android.sh b/examples/dev-android.sh index d50b385..bd877f1 100755 --- a/examples/dev-android.sh +++ b/examples/dev-android.sh @@ -1,12 +1,14 @@ #!/bin/bash # DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS -# Live-reload runner. Spawns Vite if no dev server is already on $DEV_PORT, -# then installs the app and points its WebView at the dev server. Vite is -# torn down on exit (Ctrl+C, error, etc.) only if this script started it. +# Live-reload runner. Always spawns its own Vite on a free port (starting at +# $DEV_PORT, falling back to the next free port if it's in use) and tears it +# down on exit. Set REUSE_DEV_SERVER=1 to opt into the legacy two-terminal +# workflow where a separately-managed `vp dev` on $DEV_PORT is reused as-is. set -e PROJECT_DIR="${INIT_CWD:-$PWD}" DEV_PORT="${DEV_PORT:-5173}" +REUSE_DEV_SERVER="${REUSE_DEV_SERVER:-0}" VITE_PID="" cleanup() { @@ -19,6 +21,25 @@ cleanup() { } trap cleanup EXIT INT TERM +# Returns the first port at or after $1 that has nothing LISTENing, scanning +# up to $1 + 20. Exits non-zero if the range is exhausted. Owning vite on a +# guaranteed-free port avoids the failure mode where a previous dev-android +# run left a vite on $DEV_PORT that this run reuses, then dies under us when +# that earlier run's trap fires. +find_free_port() { + local p="$1" + local max="$((p + 20))" + while [ "$p" -lt "$max" ]; do + if ! lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1; then + echo "$p" + return 0 + fi + p=$((p + 1)) + done + echo "No free port in range $1-$((max - 1))." >&2 + return 1 +} + serials=$(adb devices | awk '/\tdevice$/{print $1}') if [ -z "$serials" ]; then @@ -56,11 +77,15 @@ fi cd "$PROJECT_DIR" -# Reuse an existing dev server if one's already on $DEV_PORT (e.g. user is -# running `vp run dev` separately for cleaner logs); otherwise spawn one. -if curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then - echo "Reusing existing dev server on http://localhost:$DEV_PORT" +if [ "$REUSE_DEV_SERVER" = "1" ] \ + && curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then + echo "Reusing existing dev server on http://localhost:$DEV_PORT (REUSE_DEV_SERVER=1)" else + if [ "$REUSE_DEV_SERVER" = "1" ]; then + echo "REUSE_DEV_SERVER=1 set but nothing on http://localhost:$DEV_PORT; spawning a new one." + fi + + DEV_PORT="$(find_free_port "$DEV_PORT")" || exit 1 echo "Starting dev server on http://localhost:$DEV_PORT..." vp dev --port "$DEV_PORT" & VITE_PID=$! diff --git a/examples/dev-ios.sh b/examples/dev-ios.sh index d8ff9f9..f9610bc 100755 --- a/examples/dev-ios.sh +++ b/examples/dev-ios.sh @@ -1,12 +1,14 @@ #!/bin/bash # DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS -# Live-reload runner. Spawns Vite if no dev server is already on $DEV_PORT, -# then installs the app and points its WebView at the dev server. Vite is -# torn down on exit (Ctrl+C, error, etc.) only if this script started it. +# Live-reload runner. Always spawns its own Vite on a free port (starting at +# $DEV_PORT, falling back to the next free port if it's in use) and tears it +# down on exit. Set REUSE_DEV_SERVER=1 to opt into the legacy two-terminal +# workflow where a separately-managed `vp dev` on $DEV_PORT is reused as-is. set -e PROJECT_DIR="${INIT_CWD:-$PWD}" DEV_PORT="${DEV_PORT:-5173}" +REUSE_DEV_SERVER="${REUSE_DEV_SERVER:-0}" VITE_PID="" cleanup() { @@ -19,6 +21,25 @@ cleanup() { } trap cleanup EXIT INT TERM +# Returns the first port at or after $1 that has nothing LISTENing, scanning +# up to $1 + 20. Exits non-zero if the range is exhausted. Owning vite on a +# guaranteed-free port avoids the failure mode where a previous dev-ios run +# left a vite on $DEV_PORT that this run reuses, then dies under us when +# that earlier run's trap fires. +find_free_port() { + local p="$1" + local max="$((p + 20))" + while [ "$p" -lt "$max" ]; do + if ! lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1; then + echo "$p" + return 0 + fi + p=$((p + 1)) + done + echo "No free port in range $1-$((max - 1))." >&2 + return 1 +} + booted=$(xcrun simctl list devices booted -j | python3 -c ' import json import sys @@ -89,12 +110,16 @@ echo "Using simulator: $name ($udid)" cd "$PROJECT_DIR" -# Reuse an existing dev server if one's already on $DEV_PORT (e.g. user is -# running `vp run dev` separately for cleaner logs); otherwise spawn one. # iOS simulators share the host's loopback so localhost just works. -if curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then - echo "Reusing existing dev server on http://localhost:$DEV_PORT" +if [ "$REUSE_DEV_SERVER" = "1" ] \ + && curl -sSf -o /dev/null --max-time 1 "http://localhost:$DEV_PORT"; then + echo "Reusing existing dev server on http://localhost:$DEV_PORT (REUSE_DEV_SERVER=1)" else + if [ "$REUSE_DEV_SERVER" = "1" ]; then + echo "REUSE_DEV_SERVER=1 set but nothing on http://localhost:$DEV_PORT; spawning a new one." + fi + + DEV_PORT="$(find_free_port "$DEV_PORT")" || exit 1 echo "Starting dev server on http://localhost:$DEV_PORT..." vp dev --port "$DEV_PORT" & VITE_PID=$! From 9baef26272c1197bcdc5e66598b13596af63f499 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 7 May 2026 19:57:35 -0700 Subject: [PATCH 21/21] fix(ios): stop capturing taps after cold-start drain --- examples/demo_pods/src/hooks/useOneSignal.ts | 2 +- .../OSCapacitorLaunchOptions.m | 18 +++++++++++++++--- .../OneSignalCapacitorPlugin.swift | 12 +++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/examples/demo_pods/src/hooks/useOneSignal.ts b/examples/demo_pods/src/hooks/useOneSignal.ts index d93cd9a..bf6d1d9 100644 --- a/examples/demo_pods/src/hooks/useOneSignal.ts +++ b/examples/demo_pods/src/hooks/useOneSignal.ts @@ -296,7 +296,7 @@ export function useOneSignal(): UseOneSignalReturn { console.log('Loaded OneSignal'); return () => { - console.log('Cleaning up OneSignal listenerszzz'); + console.log('Cleaning up OneSignal listeners'); OneSignal.InAppMessages.removeEventListener('willDisplay', handleIamWillDisplay); OneSignal.InAppMessages.removeEventListener('didDisplay', handleIamDidDisplay); OneSignal.InAppMessages.removeEventListener('willDismiss', handleIamWillDismiss); diff --git a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m index 551fa22..8d8d50f 100644 --- a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m +++ b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m @@ -7,6 +7,13 @@ @implementation OSCapacitorLaunchOptions static NSDictionary *_capturedLaunchOptions = nil; static NSMutableArray *_capturedColdStartResponses = nil; +// Flips to YES the first time consumeColdStartResponses runs (i.e. after the JS +// layer has called OneSignal.initialize and drained whatever was queued). The +// swizzle stays installed for the process lifetime, so without this flag every +// subsequent warm/background tap would be retained in the array forever and +// _capturedColdStartResponses would grow monotonically. Once consumed, new +// taps fall through to the host delegate without being captured. +static BOOL _coldStartResponsesConsumed = NO; + (void)load { _capturedColdStartResponses = [NSMutableArray array]; @@ -46,9 +53,13 @@ + (void)applicationDidFinishLaunching:(NSNotification *)notification { // The JS bundle can take multiple seconds to load on cold start (worse // in dev builds), and the user can tap a second notification from the // shade in that window. Overwriting would silently lose the earlier - // tap. consumeColdStartResponses clears the queue once initialize() - // has handed every response to OSNotificationsManager. - [_capturedColdStartResponses addObject:response]; + // tap. consumeColdStartResponses clears the queue and flips the + // _coldStartResponsesConsumed flag once initialize() has handed every + // response to OSNotificationsManager; from that point on, taps fall + // straight through to the host delegate so the array can't grow. + if (!_coldStartResponsesConsumed) { + [_capturedColdStartResponses addObject:response]; + } ((void(*)(id, SEL, UNUserNotificationCenter*, UNNotificationResponse*, void(^)(void)))originalIMP)(self_, didReceiveSel, center, response, completionHandler); }); @@ -71,6 +82,7 @@ + (NSDictionary *)launchOptions { } + (void)consumeColdStartResponses { + _coldStartResponsesConsumed = YES; [_capturedColdStartResponses removeAllObjects]; } diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index c92f303..38a6b1f 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -126,13 +126,15 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { // can replay them here, in arrival order, after initialize has set the // appId. The queue can hold more than one entry if the user tapped a // second notification from the shade while the JS bundle was loading. - let pending = OSCapacitorLaunchOptions.pendingColdStartResponses - for response in pending { + for response in OSCapacitorLaunchOptions.pendingColdStartResponses { OSNotificationsManager.processNotificationResponse(response) } - if !pending.isEmpty { - OSCapacitorLaunchOptions.consumeColdStartResponses() - } + // Always consume, even if the queue was empty. consumeColdStartResponses + // also flips a one-way flag that tells the swizzle to stop capturing + // future taps; without this unconditional call, sessions that cold-start + // without a notification tap would never flip the flag and any later + // warm/background taps would accumulate in the static array forever. + OSCapacitorLaunchOptions.consumeColdStartResponses() let proxy = OneSignalListenerProxy() proxy.owner = self