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/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..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 @@ -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 @@ -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() private val preventDefaultCache = mutableSetOf() - 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() + 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) { @@ -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" 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() }.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") @@ -144,8 +205,6 @@ class OneSignalCapacitorPlugin : Plugin(), call.resolve() } - // endregion - // region Debug @PluginMethod @@ -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) @@ -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) } @@ -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() @@ -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() } @@ -588,7 +657,7 @@ class OneSignalCapacitorPlugin : Plugin(), @PluginMethod fun requestLocationPermission(call: PluginCall) { - CoroutineScope(Dispatchers.Main).launch { + pluginScope.launch { OneSignal.Location.requestPermission() call.resolve() } @@ -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) { @@ -647,6 +718,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() @@ -654,17 +729,11 @@ 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 - } + // 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/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`. 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 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/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 945456b..bf6d1d9 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) => { @@ -278,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); 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..bf6d1d9 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) => { @@ -278,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); diff --git a/examples/dev-android.sh b/examples/dev-android.sh new file mode 100755 index 0000000..bd877f1 --- /dev/null +++ b/examples/dev-android.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS +# 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() { + 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 + +# 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 + 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" + +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=$! + + 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..f9610bc --- /dev/null +++ b/examples/dev-ios.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# DON'T RUN THIS FILE DIRECTLY, USE PACKAGE.JSON SCRIPTS +# 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() { + 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 + +# 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 + +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 < "$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" > "$INSTALLED_STAMP" +fi - echo "$SDK_SRC_HASH" > "$SDK_STAMP" +# ── 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 ─────────────────────────────────────────────────────────────── diff --git a/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m new file mode 100644 index 0000000..8d8d50f --- /dev/null +++ b/ios/Sources/OSCapacitorLaunchOptions/OSCapacitorLaunchOptions.m @@ -0,0 +1,89 @@ +#import "OSCapacitorLaunchOptions.h" +#import +#import +#import + +@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]; + [[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:"); + Class delegateClass = [unDelegate class]; + Method original = class_getInstanceMethod(delegateClass, didReceiveSel); + if (!original) return; + + __block IMP originalIMP = method_getImplementation(original); + IMP newIMP = imp_implementationWithBlock(^(id self_, UNUserNotificationCenter *center, UNNotificationResponse *response, void (^completionHandler)(void)) { + // 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 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); + }); + + // 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 { + return _capturedLaunchOptions; +} + ++ (NSArray *)pendingColdStartResponses { + return [_capturedColdStartResponses copy]; +} + ++ (void)consumeColdStartResponses { + _coldStartResponsesConsumed = YES; + [_capturedColdStartResponses removeAllObjects]; +} + +@end diff --git a/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h new file mode 100644 index 0000000..f24ecd0 --- /dev/null +++ b/ios/Sources/OSCapacitorLaunchOptions/include/OSCapacitorLaunchOptions.h @@ -0,0 +1,35 @@ +#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; + +/// 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 responses as consumed so they are not replayed +/// twice. Call after handing each response off to the OneSignal iOS SDK. ++ (void)consumeColdStartResponses; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift index fc7626b..38a6b1f 100644 --- a/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift +++ b/ios/Sources/OneSignalCapacitorPlugin/OneSignalCapacitorPlugin.swift @@ -2,16 +2,12 @@ import Foundation import Capacitor import OneSignalFramework import OneSignalLiveActivities +#if SWIFT_PACKAGE +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" @@ -72,8 +68,28 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, ] private var notificationWillDisplayCache = [String: OSNotificationWillDisplayEvent]() - private var preventDefaultCache = [String: OSNotificationWillDisplayEvent]() - private var pendingClickEvent: OSNotificationClickEvent? + 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 { + // 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 @@ -82,21 +98,56 @@ 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" - OneSignal.initialize(appId, withLaunchOptions: nil) - 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) + // 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 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. + for response in OSCapacitorLaunchOptions.pendingColdStartResponses { + OSNotificationsManager.processNotificationResponse(response) + } + // 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 + 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) - if let pending = pendingClickEvent { - sendNotificationClickEvent(pending) - pendingClickEvent = nil - } call.resolve() } @@ -320,7 +371,7 @@ public class OneSignalCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, return } event.preventDefault() - preventDefaultCache[notificationId] = event + preventDefaultCache.insert(notificationId) call.resolve() } @@ -329,11 +380,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() @@ -344,10 +399,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() } @@ -569,18 +626,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:) @@ -637,3 +692,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) + } +} 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/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); }); 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); + }); + } } /**