Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6e307de
fix(notifications): replay click event after JS handler is registered
fadi-george May 6, 2026
6cd408d
fix(android): use retainUntilConsumed for click events
fadi-george May 6, 2026
ac2a844
chore(demo): remove localStorage notification click logging
fadi-george May 6, 2026
aebb4d0
chore(debug): add temporary agent logging
fadi-george May 7, 2026
d315070
chore(demo): replace IonRouterOutlet with Switch to test useEffect cl…
fadi-george May 7, 2026
4d2b243
fix(android): guard listener registration against duplicate calls
fadi-george May 7, 2026
eb8d5b5
refactor(android): promote observers to named fields
fadi-george May 7, 2026
56878a8
fix(android): scope coroutines and fix cache leaks
fadi-george May 7, 2026
da52b66
fix(android): nudge SDK foreground state on init
fadi-george May 7, 2026
a5e15dc
feat(demo): add live-reload dev scripts for iOS/Android
fadi-george May 7, 2026
4e054d6
fix(ios): guard init and fix cache leaks
fadi-george May 7, 2026
450dc91
fix(js): dedupe native bridge listeners
fadi-george May 7, 2026
db03a91
fix(demo): bust Vite cache after SDK reinstall
fadi-george May 7, 2026
ff45c06
docs(demo): add README with run instructions
fadi-george May 7, 2026
afed1d4
fix(demo): split SDK build and install stamps
fadi-george May 7, 2026
ce2dddb
fix(demo): persist notification clicks to localStorage
fadi-george May 8, 2026
f1dbf55
fix(ios): break retain cycle via listener proxy
fadi-george May 8, 2026
0a9afe7
fix(ios): avoid swizzling inherited methods on parent class
fadi-george May 8, 2026
0aeab8d
fix(ios): queue multiple cold-start notification responses
fadi-george May 8, 2026
228ee68
fix(examples): always own vite on a free port
fadi-george May 8, 2026
9baef26
fix(ios): stop capturing taps after cold-start drain
fadi-george May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
dist/
coverage/
.capacitor-sdk-source.stamp
.capacitor-sdk-installed.stamp
.cap-sync.stamp

# macOS
Expand Down
6 changes: 5 additions & 1 deletion OneSignalCapacitorPlugin.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
claude[bot] marked this conversation as resolved.

s.ios.deployment_target = '14.0'
s.swift_version = '5.9'
Expand Down
13 changes: 12 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.onesignal.capacitor

import android.app.Application
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
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
Expand All @@ -24,8 +26,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
Expand All @@ -37,11 +39,82 @@ 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<String, INotificationWillDisplayEvent>()
private val preventDefaultCache = mutableSetOf<String>()
private var pendingClickEvent: INotificationClickEvent? = null
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()
ret.put("permission", permission)
notifyListeners("permissionChange", ret)
}
}

private val pushSubscriptionObserver = object : IPushSubscriptionObserver {
override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) {
val ret = JSObject()
Comment thread
fadi-george marked this conversation as resolved.
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)
}
}

// region Core
override fun handleOnDestroy() {
// 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)
OneSignal.Notifications.removeClickListener(this)
OneSignal.User.pushSubscription.removeObserver(pushSubscriptionObserver)
OneSignal.User.removeObserver(userStateObserver)
OneSignal.InAppMessages.removeLifecycleListener(this)
OneSignal.InAppMessages.removeClickListener(this)
}
pluginScope.cancel()
// 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()
}

@PluginMethod
fun initialize(call: PluginCall) {
Expand All @@ -51,68 +124,56 @@ class OneSignalCapacitorPlugin : Plugin(),
return
}

// 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
}
initialized = true

OneSignalWrapper.sdkType = "capacitor"
OneSignalWrapper.sdkVersion = "010000"
Comment thread
fadi-george marked this conversation as resolved.
OneSignal.initWithContext(context, appId)

OneSignal.Notifications.addPermissionObserver(object : IPermissionObserver {
override fun onNotificationPermissionChange(permission: Boolean) {
val ret = JSObject()
ret.put("permission", permission)
notifyListeners("permissionChange", ret)
}
})
// 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)

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.User.pushSubscription.addObserver(pushSubscriptionObserver)
OneSignal.User.addObserver(userStateObserver)
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
}

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<IApplicationService>() }.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()
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")
Expand Down Expand Up @@ -144,8 +205,6 @@ class OneSignalCapacitorPlugin : Plugin(),
call.resolve()
}

// endregion

// region Debug

@PluginMethod
Expand Down Expand Up @@ -387,14 +446,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)
Expand All @@ -411,8 +471,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)
}

Expand Down Expand Up @@ -467,12 +531,15 @@ class OneSignalCapacitorPlugin : Plugin(),
call.reject("notificationId is required")
return
}
val event = notificationWillDisplayCache[notificationId]
// 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.contains(notificationId)) {
if (!preventDefaultCache.remove(notificationId)) {
event.notification.display()
}
call.resolve()
Expand All @@ -485,11 +552,13 @@ 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")
preventDefaultCache.remove(notificationId)
call.resolve()
return
}
preventDefaultCache.remove(notificationId)
event.notification.display()
call.resolve()
}
Expand Down Expand Up @@ -588,7 +657,7 @@ class OneSignalCapacitorPlugin : Plugin(),

@PluginMethod
fun requestLocationPermission(call: PluginCall) {
CoroutineScope(Dispatchers.Main).launch {
pluginScope.launch {
OneSignal.Location.requestPermission()
call.resolve()
}
Expand All @@ -610,7 +679,9 @@ class OneSignalCapacitorPlugin : Plugin(),

// endregion

// region Live Activities (no-op on Android)
// region Live Activities
// 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) {
Expand Down Expand Up @@ -647,24 +718,22 @@ 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()
notifyListeners("notificationForegroundWillDisplay", serializeNotification(event.notification))
}

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
}
// 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 {
Expand Down
Loading
Loading