11package com.onesignal.capacitor
22
3+ import android.app.Application
34import com.getcapacitor.JSObject
45import com.getcapacitor.Plugin
56import com.getcapacitor.PluginCall
67import com.getcapacitor.PluginMethod
78import com.getcapacitor.annotation.CapacitorPlugin
89import com.onesignal.OneSignal
910import com.onesignal.common.OneSignalWrapper
11+ import com.onesignal.core.internal.application.IApplicationService
1012import com.onesignal.inAppMessages.IInAppMessageClickEvent
1113import com.onesignal.inAppMessages.IInAppMessageClickListener
1214import com.onesignal.inAppMessages.IInAppMessageDidDismissEvent
@@ -24,8 +26,8 @@ import com.onesignal.user.state.IUserStateObserver
2426import com.onesignal.user.state.UserChangedState
2527import com.onesignal.user.subscriptions.IPushSubscriptionObserver
2628import 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
2931import kotlinx.coroutines.launch
3032import org.json.JSONArray
3133import 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