Skip to content

Commit ad68687

Browse files
authored
fix: [SDK-4481] dedupe listeners and harden plugin lifecycle (#13)
1 parent a54b572 commit ad68687

24 files changed

Lines changed: 1032 additions & 180 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ node_modules
55
dist/
66
coverage/
77
.capacitor-sdk-source.stamp
8+
.capacitor-sdk-installed.stamp
89
.cap-sync.stamp
910

1011
# macOS

OneSignalCapacitorPlugin.podspec

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ Pod::Spec.new do |s|
1010
s.homepage = package['homepage']
1111
s.author = 'OneSignal'
1212
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13-
s.source_files = 'ios/Sources/OneSignalCapacitorPlugin/**/*.swift'
13+
s.source_files = [
14+
'ios/Sources/OneSignalCapacitorPlugin/**/*.swift',
15+
'ios/Sources/OSCapacitorLaunchOptions/**/*.{h,m}'
16+
]
17+
s.public_header_files = 'ios/Sources/OSCapacitorLaunchOptions/include/*.h'
1418

1519
s.ios.deployment_target = '14.0'
1620
s.swift_version = '5.9'

Package.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ let package = Package(
1616
.package(url: "https://github.com/OneSignal/OneSignal-XCFramework", from: "5.0.0")
1717
],
1818
targets: [
19+
// Obj-C helper that captures the iOS launchOptions dictionary at
20+
// process start (via +load + UIApplicationDidFinishLaunchingNotification)
21+
// so cold-start notification taps are still available when the JS
22+
// layer initializes the plugin later. SPM cannot mix Swift and Obj-C
23+
// in the same target, so this lives as its own target.
24+
.target(
25+
name: "OSCapacitorLaunchOptions",
26+
path: "ios/Sources/OSCapacitorLaunchOptions",
27+
publicHeadersPath: "include"
28+
),
1929
.target(
2030
name: "OnesignalCapacitorPlugin",
2131
dependencies: [
@@ -28,7 +38,8 @@ let package = Package(
2838
.product(name: "OneSignalFramework", package: "OneSignal-XCFramework"),
2939
.product(name: "OneSignalInAppMessages", package: "OneSignal-XCFramework"),
3040
.product(name: "OneSignalLocation", package: "OneSignal-XCFramework"),
31-
.product(name: "OneSignalExtension", package: "OneSignal-XCFramework")
41+
.product(name: "OneSignalExtension", package: "OneSignal-XCFramework"),
42+
"OSCapacitorLaunchOptions"
3243
],
3344
path: "ios/Sources/OneSignalCapacitorPlugin"
3445
)

android/src/main/kotlin/com/onesignal/capacitor/OneSignalCapacitorPlugin.kt

Lines changed: 145 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.onesignal.capacitor
22

3+
import android.app.Application
34
import com.getcapacitor.JSObject
45
import com.getcapacitor.Plugin
56
import com.getcapacitor.PluginCall
67
import com.getcapacitor.PluginMethod
78
import com.getcapacitor.annotation.CapacitorPlugin
89
import com.onesignal.OneSignal
910
import com.onesignal.common.OneSignalWrapper
11+
import com.onesignal.core.internal.application.IApplicationService
1012
import com.onesignal.inAppMessages.IInAppMessageClickEvent
1113
import com.onesignal.inAppMessages.IInAppMessageClickListener
1214
import com.onesignal.inAppMessages.IInAppMessageDidDismissEvent
@@ -24,8 +26,8 @@ import com.onesignal.user.state.IUserStateObserver
2426
import com.onesignal.user.state.UserChangedState
2527
import com.onesignal.user.subscriptions.IPushSubscriptionObserver
2628
import com.onesignal.user.subscriptions.PushSubscriptionChangedState
27-
import kotlinx.coroutines.CoroutineScope
28-
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.MainScope
30+
import kotlinx.coroutines.cancel
2931
import kotlinx.coroutines.launch
3032
import org.json.JSONArray
3133
import org.json.JSONObject
@@ -37,11 +39,82 @@ class OneSignalCapacitorPlugin : Plugin(),
3739
IInAppMessageLifecycleListener,
3840
IInAppMessageClickListener {
3941

42+
companion object {
43+
// Mirror of iOS UNAuthorizationStatus values so the JS layer can use a
44+
// single permissionNative() return shape across platforms. Android only
45+
// distinguishes denied/authorized; the iOS-specific notDetermined (0),
46+
// provisional (3), and ephemeral (4) states do not apply here.
47+
private const val PERMISSION_DENIED = 1
48+
private const val PERMISSION_AUTHORIZED = 2
49+
}
50+
4051
private val notificationWillDisplayCache = mutableMapOf<String, INotificationWillDisplayEvent>()
4152
private val preventDefaultCache = mutableSetOf<String>()
42-
private var pendingClickEvent: INotificationClickEvent? = null
53+
private var initialized = false
54+
55+
// Class-scoped scope so launched permission/location coroutines are tied
56+
// to the plugin instance lifetime. Cancelled in handleOnDestroy so a
57+
// pending permission dialog that resolves after the activity dies cannot
58+
// call into the dead Capacitor bridge.
59+
private val pluginScope = MainScope()
60+
61+
private val permissionObserver = object : IPermissionObserver {
62+
override fun onNotificationPermissionChange(permission: Boolean) {
63+
val ret = JSObject()
64+
ret.put("permission", permission)
65+
notifyListeners("permissionChange", ret)
66+
}
67+
}
68+
69+
private val pushSubscriptionObserver = object : IPushSubscriptionObserver {
70+
override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) {
71+
val ret = JSObject()
72+
val prev = JSObject()
73+
prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL })
74+
prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL })
75+
prev.put("optedIn", state.previous.optedIn)
76+
ret.put("previous", prev)
77+
78+
val curr = JSObject()
79+
curr.put("id", state.current.id.ifEmpty { JSONObject.NULL })
80+
curr.put("token", state.current.token.ifEmpty { JSONObject.NULL })
81+
curr.put("optedIn", state.current.optedIn)
82+
ret.put("current", curr)
83+
84+
notifyListeners("pushSubscriptionChange", ret)
85+
}
86+
}
87+
88+
private val userStateObserver = object : IUserStateObserver {
89+
override fun onUserStateChange(state: UserChangedState) {
90+
val ret = JSObject()
91+
val curr = JSObject()
92+
curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL })
93+
curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL })
94+
ret.put("current", curr)
95+
notifyListeners("userStateChange", ret)
96+
}
97+
}
4398

44-
// region Core
99+
override fun handleOnDestroy() {
100+
// Detach this dead plugin instance so the next plugin instance can
101+
// receive events; otherwise the SDK keeps firing on this stale
102+
// instance and skips its unprocessed-click replay queue.
103+
runCatching {
104+
OneSignal.Notifications.removePermissionObserver(permissionObserver)
105+
OneSignal.Notifications.removeForegroundLifecycleListener(this)
106+
OneSignal.Notifications.removeClickListener(this)
107+
OneSignal.User.pushSubscription.removeObserver(pushSubscriptionObserver)
108+
OneSignal.User.removeObserver(userStateObserver)
109+
OneSignal.InAppMessages.removeLifecycleListener(this)
110+
OneSignal.InAppMessages.removeClickListener(this)
111+
}
112+
pluginScope.cancel()
113+
// Caches aren't explicitly cleared: GC reclaims them with this
114+
// instance once the listener removals above run, and runtime size is
115+
// bounded by consume-on-read in proceedWithWillDisplay/displayNotification.
116+
super.handleOnDestroy()
117+
}
45118

46119
@PluginMethod
47120
fun initialize(call: PluginCall) {
@@ -51,68 +124,56 @@ class OneSignalCapacitorPlugin : Plugin(),
51124
return
52125
}
53126

127+
// initialize() is idempotent: JS may call it multiple times per
128+
// activity (effect re-runs, hot reload). Listeners are detached in
129+
// handleOnDestroy and the next plugin instance starts fresh.
130+
if (initialized) {
131+
call.resolve()
132+
return
133+
}
134+
initialized = true
135+
54136
OneSignalWrapper.sdkType = "capacitor"
55137
OneSignalWrapper.sdkVersion = "010000"
56138
OneSignal.initWithContext(context, appId)
57139

58-
OneSignal.Notifications.addPermissionObserver(object : IPermissionObserver {
59-
override fun onNotificationPermissionChange(permission: Boolean) {
60-
val ret = JSObject()
61-
ret.put("permission", permission)
62-
notifyListeners("permissionChange", ret)
63-
}
64-
})
140+
// If the SDK was initialized from a non-Activity context (FCM/work
141+
// managers) before this call, its ALC missed MainActivity.onResume
142+
// and isInForeground stays false. Forward the missed events now.
143+
nudgeApplicationServiceForeground()
65144

145+
OneSignal.Notifications.addPermissionObserver(permissionObserver)
66146
OneSignal.Notifications.addForegroundLifecycleListener(this)
67147
OneSignal.Notifications.addClickListener(this)
68-
69-
OneSignal.User.pushSubscription.addObserver(object : IPushSubscriptionObserver {
70-
override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) {
71-
val ret = JSObject()
72-
val prev = JSObject()
73-
prev.put("id", state.previous.id.ifEmpty { JSONObject.NULL })
74-
prev.put("token", state.previous.token.ifEmpty { JSONObject.NULL })
75-
prev.put("optedIn", state.previous.optedIn)
76-
ret.put("previous", prev)
77-
78-
val curr = JSObject()
79-
curr.put("id", state.current.id.ifEmpty { JSONObject.NULL })
80-
curr.put("token", state.current.token.ifEmpty { JSONObject.NULL })
81-
curr.put("optedIn", state.current.optedIn)
82-
ret.put("current", curr)
83-
84-
notifyListeners("pushSubscriptionChange", ret)
85-
}
86-
})
87-
88-
OneSignal.User.addObserver(object : IUserStateObserver {
89-
override fun onUserStateChange(state: UserChangedState) {
90-
val ret = JSObject()
91-
val curr = JSObject()
92-
curr.put("onesignalId", state.current.onesignalId.ifEmpty { JSONObject.NULL })
93-
curr.put("externalId", state.current.externalId.ifEmpty { JSONObject.NULL })
94-
ret.put("current", curr)
95-
notifyListeners("userStateChange", ret)
96-
}
97-
})
98-
148+
OneSignal.User.pushSubscription.addObserver(pushSubscriptionObserver)
149+
OneSignal.User.addObserver(userStateObserver)
99150
OneSignal.InAppMessages.addLifecycleListener(this)
100151
OneSignal.InAppMessages.addClickListener(this)
101152

102-
pendingClickEvent?.let { event ->
103-
val ret = JSObject()
104-
val clickResult = JSObject()
105-
clickResult.put("actionId", event.result.actionId)
106-
clickResult.put("url", event.result.url)
107-
ret.put("result", clickResult)
108-
ret.put("notification", JSObject(event.notification.rawPayload))
109-
notifyListeners("notificationClick", ret)
110-
pendingClickEvent = null
111-
}
112-
113153
call.resolve()
114154
}
115155

156+
/** Forward the missed activity-resume to the SDK so isInForeground is
157+
* correct on cold start. No-op if the SDK already saw the resume. */
158+
private fun nudgeApplicationServiceForeground() {
159+
val activity = activity ?: return
160+
val appSvc = runCatching { OneSignal.getServiceOrNull<IApplicationService>() }.getOrNull() ?: return
161+
if (appSvc.isInForeground && appSvc.current === activity) return
162+
val callbacks = appSvc as? Application.ActivityLifecycleCallbacks ?: return
163+
callbacks.onActivityStarted(activity)
164+
callbacks.onActivityResumed(activity)
165+
}
166+
167+
private fun buildClickEventJson(event: INotificationClickEvent): JSObject {
168+
val ret = JSObject()
169+
val clickResult = JSObject()
170+
clickResult.put("actionId", event.result.actionId)
171+
clickResult.put("url", event.result.url)
172+
ret.put("result", clickResult)
173+
ret.put("notification", serializeNotification(event.notification))
174+
return ret
175+
}
176+
116177
@PluginMethod
117178
fun login(call: PluginCall) {
118179
val externalId = call.getString("externalId")
@@ -144,8 +205,6 @@ class OneSignalCapacitorPlugin : Plugin(),
144205
call.resolve()
145206
}
146207

147-
// endregion
148-
149208
// region Debug
150209

151210
@PluginMethod
@@ -387,14 +446,15 @@ class OneSignalCapacitorPlugin : Plugin(),
387446
@PluginMethod
388447
fun permissionNative(call: PluginCall) {
389448
val ret = JSObject()
390-
ret.put("permission", if (OneSignal.Notifications.permission) 2 else 1)
449+
val status = if (OneSignal.Notifications.permission) PERMISSION_AUTHORIZED else PERMISSION_DENIED
450+
ret.put("permission", status)
391451
call.resolve(ret)
392452
}
393453

394454
@PluginMethod
395455
fun requestPermission(call: PluginCall) {
396456
val fallback = call.getBoolean("fallbackToSettings") ?: false
397-
CoroutineScope(Dispatchers.Main).launch {
457+
pluginScope.launch {
398458
val accepted = OneSignal.Notifications.requestPermission(fallback)
399459
val ret = JSObject()
400460
ret.put("permission", accepted)
@@ -411,8 +471,12 @@ class OneSignalCapacitorPlugin : Plugin(),
411471

412472
@PluginMethod
413473
fun registerForProvisionalAuthorization(call: PluginCall) {
474+
// Provisional authorization is an iOS-only concept (UNUserNotification
475+
// .provisional). Android has no equivalent quiet-delivery permission
476+
// tier, so report `accepted = false` rather than misleading the JS
477+
// layer into thinking a quiet permission was granted.
414478
val ret = JSObject()
415-
ret.put("accepted", true)
479+
ret.put("accepted", false)
416480
call.resolve(ret)
417481
}
418482

@@ -467,12 +531,15 @@ class OneSignalCapacitorPlugin : Plugin(),
467531
call.reject("notificationId is required")
468532
return
469533
}
470-
val event = notificationWillDisplayCache[notificationId]
534+
// JS always dispatches this after the listener loop, even when a
535+
// listener already called display(). Missing entry = already handled.
536+
val event = notificationWillDisplayCache.remove(notificationId)
471537
if (event == null) {
472-
call.reject("Could not find notification will display event")
538+
preventDefaultCache.remove(notificationId)
539+
call.resolve()
473540
return
474541
}
475-
if (!preventDefaultCache.contains(notificationId)) {
542+
if (!preventDefaultCache.remove(notificationId)) {
476543
event.notification.display()
477544
}
478545
call.resolve()
@@ -485,11 +552,13 @@ class OneSignalCapacitorPlugin : Plugin(),
485552
call.reject("notificationId is required")
486553
return
487554
}
488-
val event = notificationWillDisplayCache[notificationId]
555+
val event = notificationWillDisplayCache.remove(notificationId)
489556
if (event == null) {
490-
call.reject("Could not find notification will display event")
557+
preventDefaultCache.remove(notificationId)
558+
call.resolve()
491559
return
492560
}
561+
preventDefaultCache.remove(notificationId)
493562
event.notification.display()
494563
call.resolve()
495564
}
@@ -588,7 +657,7 @@ class OneSignalCapacitorPlugin : Plugin(),
588657

589658
@PluginMethod
590659
fun requestLocationPermission(call: PluginCall) {
591-
CoroutineScope(Dispatchers.Main).launch {
660+
pluginScope.launch {
592661
OneSignal.Location.requestPermission()
593662
call.resolve()
594663
}
@@ -610,7 +679,9 @@ class OneSignalCapacitorPlugin : Plugin(),
610679

611680
// endregion
612681

613-
// region Live Activities (no-op on Android)
682+
// region Live Activities
683+
// iOS-only feature; methods below are no-ops on Android so cross-platform
684+
// JS code can call them unconditionally. No warnings — silent success.
614685

615686
@PluginMethod
616687
fun enterLiveActivity(call: PluginCall) {
@@ -647,24 +718,22 @@ class OneSignalCapacitorPlugin : Plugin(),
647718
// region Observer Callbacks
648719

649720
override fun onWillDisplay(event: INotificationWillDisplayEvent) {
721+
// No retainUntilConsumed needed: foreground will-display only fires
722+
// while the app is foregrounded, so the JS layer's listener is
723+
// already attached. Contrast with onClick() below, which can fire
724+
// before the WebView finishes booting on a cold-start tap.
650725
val notificationId = event.notification.notificationId ?: return
651726
notificationWillDisplayCache[notificationId] = event
652727
event.preventDefault()
653728
notifyListeners("notificationForegroundWillDisplay", serializeNotification(event.notification))
654729
}
655730

656731
override fun onClick(event: INotificationClickEvent) {
657-
if (bridge != null) {
658-
val ret = JSObject()
659-
val clickResult = JSObject()
660-
clickResult.put("actionId", event.result.actionId)
661-
clickResult.put("url", event.result.url)
662-
ret.put("result", clickResult)
663-
ret.put("notification", serializeNotification(event.notification))
664-
notifyListeners("notificationClick", ret)
665-
} else {
666-
pendingClickEvent = event
667-
}
732+
// retainUntilConsumed lets Capacitor hold this event until the JS-side
733+
// click listener attaches. On Android the OneSignal SDK can deliver a
734+
// cold-start click before the WebView has finished booting and the JS
735+
// layer has called addEventListener('click', ...).
736+
notifyListeners("notificationClick", buildClickEventJson(event), true)
668737
}
669738

670739
private fun serializeNotification(notification: INotification): JSObject {

0 commit comments

Comments
 (0)