From a103180d4304bdb6ead4350f9304def7bafb4522 Mon Sep 17 00:00:00 2001
From: Philippe Auriach
Date: Thu, 10 Sep 2026 15:05:38 +0200
Subject: [PATCH 1/4] fix: forward Expo Android opens and normalize
notification stamps
---
README.md | 9 ++--
plugin/src/__tests__/plugin.test.ts | 84 +++++++++++++++++++++++++++++
plugin/src/index.ts | 70 ++++++++++++++++++++++++
src/__tests__/index.test.tsx | 18 +++++++
src/index.tsx | 15 +++++-
5 files changed, 192 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 516b9c3..00e8564 100644
--- a/README.md
+++ b/README.md
@@ -46,8 +46,8 @@ OS permission. Pass an API base URL as `endpoint` for staging or local developme
Debug logging is disabled in iOS release builds and non-debuggable Android apps.
`onOpened` returns an unsubscribe function. Pending cold-start opens are replayed
-when the first subscriber attaches. The payload contains a `carillon` object on
-iOS and a JSON string on Android; use `deliveryId` to avoid parsing it yourself.
+when the first subscriber attaches. The reserved `payload.carillon` field is an object on both platforms; the bridge
+parses the Android JSON stamp. Customer payload fields remain unchanged.
## Native setup
@@ -146,7 +146,10 @@ Add the plugin to your Expo configuration:
`expo prebuild` then writes the push entitlement and the three forwarded
callbacks on iOS, and the messaging service, the runtime permission and the
-google-services wiring on Android. Every transform is idempotent, because
+google-services wiring on Android. It also forwards notification opens from
+Kotlin `MainActivity.onCreate` and `onNewIntent`, preserving existing callbacks.
+Java activities require conversion to Kotlin for this plugin; bare installations
+can use the manual forwarding shown above. Every transform is idempotent, because
prebuild runs them again over their own output.
## Native dependencies
diff --git a/plugin/src/__tests__/plugin.test.ts b/plugin/src/__tests__/plugin.test.ts
index 89ab785..8313072 100644
--- a/plugin/src/__tests__/plugin.test.ts
+++ b/plugin/src/__tests__/plugin.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from '@jest/globals';
import {
addCarillonForwarding,
+ addAndroidOpenForwarding,
addGoogleServicesClasspath,
addImport,
addMessagingService,
@@ -205,3 +206,86 @@ describe('the google-services wiring', () => {
expect(applyGoogleServicesPlugin(once)).toBe(once);
});
});
+
+describe("Android notification opens", () => {
+ const activity = `package dev.example
+
+import android.os.Bundle
+import com.facebook.react.ReactActivity
+
+class MainActivity : ReactActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setTheme(R.style.AppTheme)
+ super.onCreate(null)
+ keepExistingBehavior()
+ }
+}`;
+
+ it("forwards cold and warm opens after their super calls", () => {
+ const result = addAndroidOpenForwarding(activity);
+ expect(result).toContain(
+ "super.onCreate(null)\n Carillon.didOpen(intent)",
+ );
+ expect(result).toContain(
+ "super.onNewIntent(intent)\n setIntent(intent)\n Carillon.didOpen(intent)",
+ );
+ expect(result).toContain("keepExistingBehavior()");
+ expect(result).toContain("import dev.carillon.sdk.Carillon");
+ expect(addAndroidOpenForwarding(result)).toBe(result);
+ });
+
+ it("keeps an existing onNewIntent and setIntent with a different parameter name", () => {
+ const input = activity.replace(
+ " override fun onCreate",
+ ` override fun onNewIntent(incoming: android.content.Intent) {
+ super.onNewIntent(incoming)
+ setIntent(incoming)
+ keepWarmBehavior()
+ }
+ override fun onCreate`,
+ );
+ const result = addAndroidOpenForwarding(input);
+ expect(result).toContain(
+ "setIntent(incoming)\n Carillon.didOpen(incoming)",
+ );
+ expect(result.match(/override fun onNewIntent/g)).toHaveLength(1);
+ expect(result.match(/setIntent\(incoming\)/g)).toHaveLength(1);
+ expect(result).toContain("keepWarmBehavior()");
+ expect(addAndroidOpenForwarding(result)).toBe(result);
+ });
+
+ it("adds a missing callback even when the other is already forwarded", () => {
+ const result = addAndroidOpenForwarding(
+ activity.replace(
+ "super.onCreate(null)",
+ "super.onCreate(null)\n Carillon.didOpen(intent)",
+ ),
+ );
+ expect(result.match(/Carillon.didOpen\(/g)).toHaveLength(2);
+ expect(result).toContain("override fun onNewIntent");
+ });
+
+ it("keeps the package declaration before new imports", () => {
+ const result = addAndroidOpenForwarding(
+ "package dev.example\n\nclass MainActivity : ReactActivity() {}",
+ );
+ expect(
+ result.startsWith(
+ "package dev.example\n\nimport dev.carillon.sdk.Carillon",
+ ),
+ ).toBe(true);
+ expect(addAndroidOpenForwarding(result)).toBe(result);
+ });
+
+ it("refuses Java and unrecognized callback structure explicitly", () => {
+ expect(() =>
+ addAndroidOpenForwarding("class MainActivity {}", "java"),
+ ).toThrow("Kotlin");
+ expect(() => addAndroidOpenForwarding("class OtherActivity {}")).toThrow(
+ "MainActivity",
+ );
+ expect(() =>
+ addAndroidOpenForwarding(activity.replace("super.onCreate(null)", "")),
+ ).toThrow("super.onCreate");
+ });
+});
diff --git a/plugin/src/index.ts b/plugin/src/index.ts
index c956d75..1dd43e1 100644
--- a/plugin/src/index.ts
+++ b/plugin/src/index.ts
@@ -4,6 +4,7 @@ import {
withAppBuildGradle,
withAppDelegate,
withEntitlementsPlist,
+ withMainActivity,
withProjectBuildGradle,
type ConfigPlugin,
} from '@expo/config-plugins';
@@ -232,6 +233,67 @@ export function applyGoogleServicesPlugin(contents: string): string {
return `${contents.trimEnd()}\n\nif (file("google-services.json").exists()) {\n apply plugin: "com.google.gms.google-services"\n}\n`;
}
+/** Forward cold and warm opens without replacing existing activity callbacks. */
+export function addAndroidOpenForwarding(
+ contents: string,
+ language = "kt",
+): string {
+ if (language !== "kt") {
+ throw new Error(
+ "Carillon requires a Kotlin MainActivity for automatic open forwarding. Convert MainActivity to Kotlin or forward Carillon.didOpen(intent) manually.",
+ );
+ }
+ let result = contents;
+ for (const name of ["onCreate", "onNewIntent"] as const) {
+ const method = new RegExp(
+ `override\\s+fun\\s+${name}\\s*\\(\\s*(\\w+)\\s*:[^{]*?\\)[^{]*\\{`,
+ ).exec(result);
+ if (method) {
+ const start = method.index + method[0].length;
+ const end = closingBraceOf(result, start);
+ if (end === -1)
+ throw new Error(`Cannot find the end of MainActivity.${name}.`);
+ const body = result.slice(start, end);
+ if (/\bCarillon\.didOpen\s*\(/.test(body)) continue;
+ const call = new RegExp(`super\\.${name}\\s*\\([^)]*\\)`).exec(body);
+ if (!call)
+ throw new Error(
+ `MainActivity.${name} must call super.${name} before Carillon can forward notification opens.`,
+ );
+ const argument = name === "onCreate" ? "intent" : method[1]!;
+ const setIntent =
+ name === "onNewIntent" ? /\bsetIntent\s*\([^)]*\)/.exec(body) : null;
+ const after =
+ setIntent && setIntent.index > call.index ? setIntent : call;
+ const offset = start + after.index + after[0].length;
+ const set =
+ name === "onNewIntent" && !setIntent
+ ? `\n setIntent(${argument})`
+ : "";
+ result = `${result.slice(0, offset)}${set}\n Carillon.didOpen(${argument})${result.slice(offset)}`;
+ } else {
+ const declaration = /class\s+MainActivity\b[^\{]*\{/.exec(result);
+ if (!declaration)
+ throw new Error(
+ "Cannot find the Kotlin MainActivity class for Carillon open forwarding.",
+ );
+ const start = declaration.index + declaration[0].length;
+ const callback =
+ name === "onCreate"
+ ? "\n override fun onCreate(savedInstanceState: android.os.Bundle?) {\n super.onCreate(savedInstanceState)\n Carillon.didOpen(intent)\n }\n"
+ : "\n override fun onNewIntent(intent: android.content.Intent) {\n super.onNewIntent(intent)\n setIntent(intent)\n Carillon.didOpen(intent)\n }\n";
+ result = `${result.slice(0, start)}${callback}${result.slice(start)}`;
+ }
+ }
+ if (!/^import .+$/m.test(result) && /^package .+$/m.test(result)) {
+ return result.replace(
+ /^(package .+)$/m,
+ "$1\n\nimport dev.carillon.sdk.Carillon",
+ );
+ }
+ return addImport("dev.carillon.sdk.Carillon", result);
+}
+
const withCarillon: ConfigPlugin = (config) => {
config = withEntitlementsPlist(config, (entitlements) => {
entitlements.modResults = setApsEnvironment(entitlements.modResults);
@@ -247,6 +309,14 @@ const withCarillon: ConfigPlugin = (config) => {
return appDelegate;
});
+ config = withMainActivity(config, (activity) => {
+ activity.modResults.contents = addAndroidOpenForwarding(
+ activity.modResults.contents,
+ activity.modResults.language,
+ );
+ return activity;
+ });
+
config = withAndroidManifest(config, (manifest) => {
const application = AndroidConfig.Manifest.getMainApplicationOrThrow(
manifest.modResults
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index 5331798..f5c4323 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -152,6 +152,24 @@ describe('the state the app sets', () => {
});
describe('onOpened', () => {
+ it('normalizes the Android stamp without changing customer data or the native event', () => {
+ const seen: unknown[] = [];
+ Carillon.onOpened((notification) => seen.push(notification));
+ const stamp = { delivery_id: anOpen.deliveryId, image: 'https://example.com/image.png' };
+ const event = { ...anOpen, payload: { order_id: '42', carillon: JSON.stringify(stamp) } };
+ mockNative.emit(event);
+ expect(seen).toEqual([{ ...event, payload: { order_id: '42', carillon: stamp } }]);
+ expect(typeof event.payload.carillon).toBe('string');
+ });
+
+ it.each(['invalid', 'null', '[]', '42'])('preserves malformed stamps: %s', (carillon) => {
+ const seen: unknown[] = [];
+ Carillon.onOpened((notification) => seen.push(notification));
+ const event = { ...anOpen, payload: { carillon } };
+ mockNative.emit(event);
+ expect(seen).toEqual([event]);
+ });
+
it('hands the whole payload over', () => {
const seen: unknown[] = [];
Carillon.onOpened((notification) => seen.push(notification));
diff --git a/src/index.tsx b/src/index.tsx
index b68b38f..0490621 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -114,7 +114,20 @@ export function optOut(): void {
*/
export function onOpened(handler: OpenedHandler): () => void {
const subscription = NativeCarillon.onOpened((event) => {
- handler(event as OpenedNotification);
+ const notification = event as OpenedNotification;
+ const stamp = notification.payload.carillon;
+ if (typeof stamp === 'string') {
+ try {
+ const parsed: unknown = JSON.parse(stamp);
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ handler({ ...notification, payload: { ...notification.payload, carillon: parsed } });
+ return;
+ }
+ } catch {
+ // Keep malformed third-party payloads observable without losing the open callback.
+ }
+ }
+ handler(notification);
});
NativeCarillon.startObservingOpens();
From d573af58db74a8b9cf97abe803baf39d9d76a9f0 Mon Sep 17 00:00:00 2001
From: Philippe Auriach
Date: Thu, 10 Sep 2026 17:17:56 +0200
Subject: [PATCH 2/4] feat: bridge rich notifications and generate Expo image
extension
---
CarillonReactNative.podspec | 2 +-
README.md | 47 +++++++-
android/build.gradle | 2 +-
.../carillon/reactnative/CarillonModule.kt | 48 ++++++++
example/ios/CarillonExample/AppDelegate.swift | 5 +-
example/ios/Podfile.lock | 10 +-
ios/Carillon.mm | 32 +++++
ios/CarillonBridge.swift | 62 ++++++++++
package.json | 2 +-
plugin/src/__tests__/plugin.test.ts | 43 +++++++
plugin/src/extension.ts | 113 ++++++++++++++++++
plugin/src/index.ts | 37 +++++-
plugin/tsconfig.json | 1 +
src/NativeCarillon.ts | 7 ++
src/__tests__/index.test.tsx | 63 ++++++++++
src/index.tsx | 60 +++++++++-
16 files changed, 518 insertions(+), 16 deletions(-)
create mode 100644 plugin/src/extension.ts
diff --git a/CarillonReactNative.podspec b/CarillonReactNative.podspec
index 191fe73..1d7afae 100644
--- a/CarillonReactNative.podspec
+++ b/CarillonReactNative.podspec
@@ -38,7 +38,7 @@ Pod::Spec.new do |s|
if carillon_swift.empty?
spm_dependency(s,
url: "https://github.com/exostack/carillon-swift.git",
- requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.1.1" },
+ requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.2.0" },
products: ["Carillon"])
else
spm_dependency(s,
diff --git a/README.md b/README.md
index 00e8564..759b620 100644
--- a/README.md
+++ b/README.md
@@ -155,7 +155,7 @@ prebuild runs them again over their own output.
## Native dependencies
The podspec resolves `carillon-swift` through Swift Package Manager with minimum
-version `0.1.1`. Gradle resolves `dev.carillon:carillon:0.1.1`.
+version `0.2.0`. Gradle resolves `dev.carillon:carillon:0.2.0`.
For local development, clone the native SDKs beside this repository:
@@ -207,3 +207,48 @@ Expo configuration transforms. Registration and retry tests live in the native S
[swift]: https://github.com/exostack/carillon-swift
[kotlin]: https://github.com/exostack/carillon-kotlin
+
+
+## Device identity
+
+```ts
+const id = await Carillon.getDeviceId();
+const unsubscribe = Carillon.onDeviceIdChanged((id) => console.log(id));
+```
+
+The SDK persists a random installation secret and the last confirmed device ID.
+Token rotation reuses that ID when the server validates the proof. Reinstallation
+or merging with an existing token registration can change the ID; the callback
+fires on first registration and when the confirmed ID changes. The ID itself is
+not a credential. Never log or export the installation secret.
+
+
+## Foreground presentation and images
+
+```ts
+const off = Carillon.onReceived(async (notification) => {
+ // Use notification.data for your route or in-app UI.
+ return 'show' // or 'suppress'
+})
+Carillon.clearNotifications()
+```
+
+Only one foreground handler is active; a new subscription replaces it. Native code defaults to
+showing after three seconds without an answer. Exceptions and rejected promises also show.
+Clearing removes delivered notifications and, on Android, cancels pending display work.
+
+For bare iOS, forward `userNotificationCenter(_:willPresent:withCompletionHandler:)` to
+`CarillonBridge.willPresent(notification, completionHandler: completionHandler)`.
+The Expo plugin inserts this forwarding. Keep the delegate installed at launch.
+
+For iOS images, the Expo plugin creates `CarillonNotificationExtension`, including its Swift
+package product dependency and the EAS `extra.eas.build.experimental.ios.appExtensions` entry.
+Set `ios.bundleIdentifier`; sign the extension bundle `.CarillonNotificationExtension`
+and rebuild after prebuild. No App Group is needed. Bare apps add that target using the
+[Swift SDK extension setup](https://github.com/exostack/carillon-swift#notification-images).
+Replace the OneSignal service extension rather than embedding both.
+
+Android foreground rendering uses `carillon_default` unless the requested channel already
+exists. Supply `carillon_notification_icon` as a drawable; otherwise the app icon is used.
+Images have a 10-second budget and fall back to text. Android 8+ channels control sound;
+Android 13+ needs notification permission. FCM handles background notification display.
diff --git a/android/build.gradle b/android/build.gradle
index d3661f9..000c424 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -6,7 +6,7 @@ buildscript {
// The Android SDK this bridge binds. A published coordinate, resolved from
// Maven Central once carillon-kotlin is there; see the README for what
// resolves it today.
- sdkVersion: "0.1.1"
+ sdkVersion: "0.2.0"
]
ext.getExtOrDefault = { prop ->
diff --git a/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt b/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt
index 98bbba1..c7edb45 100644
--- a/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt
+++ b/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt
@@ -7,7 +7,12 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.WritableMap
import dev.carillon.sdk.Carillon
+import dev.carillon.sdk.NotificationPresentation
import dev.carillon.sdk.OpenedNotification
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import android.os.Handler
+import android.os.Looper
import dev.carillon.sdk.TagValue
import dev.carillon.sdk.tagOf
import java.text.SimpleDateFormat
@@ -35,6 +40,8 @@ class CarillonModule(private val context: ReactApplicationContext) :
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var observing = false
+ private val receivedAnswers = ConcurrentHashMap Unit>()
+ private val receivedTimeouts = Handler(Looper.getMainLooper())
override fun configure(key: String, endpoint: String?, debug: Boolean?) {
// The defaults belong to the SDK, which is the only place they are written
@@ -99,8 +106,49 @@ class CarillonModule(private val context: ReactApplicationContext) :
Carillon.onOpened = { opened -> emitOnOpened(openedOf(opened)) }
}
+ override fun startObservingDeviceId() {
+ Carillon.onDeviceIdChanged = { id ->
+ val event = Arguments.createMap()
+ event.putString("deviceId", id)
+ emitOnDeviceIdChanged(event)
+ }
+ }
+
+ override fun clearNotifications() = Carillon.clearNotifications()
+
+ override fun startObservingReceived() {
+ Carillon.onReceivedAsync = { received, answer ->
+ val id = UUID.randomUUID().toString()
+ receivedAnswers[id] = answer
+ receivedTimeouts.postDelayed({ finishReceived(id, "show") }, 3000)
+ emitOnReceived(Arguments.createMap().apply {
+ putString("requestId", id)
+ putString("deliveryId", received.deliveryId)
+ putString("title", received.title)
+ putString("body", received.body)
+ putString("image", received.image)
+ putString("threadId", received.threadId)
+ putMap("data", Arguments.createMap().apply {
+ received.data.forEach { (key, value) -> putString(key, value) }
+ })
+ })
+ }
+ }
+
+ override fun finishReceived(requestId: String, decision: String) {
+ receivedAnswers.remove(requestId)?.invoke(if (decision == "suppress") NotificationPresentation.SUPPRESS else NotificationPresentation.SHOW)
+ }
+
+ override fun stopObservingReceived() {
+ Carillon.onReceivedAsync = null
+ receivedAnswers.keys.toList().forEach { finishReceived(it, "show") }
+ receivedTimeouts.removeCallbacksAndMessages(null)
+ }
+
override fun invalidate() {
+ stopObservingReceived()
Carillon.onOpened = null
+ Carillon.onDeviceIdChanged = null
observing = false
scope.cancel()
super.invalidate()
diff --git a/example/ios/CarillonExample/AppDelegate.swift b/example/ios/CarillonExample/AppDelegate.swift
index 071a2bb..1fcff76 100644
--- a/example/ios/CarillonExample/AppDelegate.swift
+++ b/example/ios/CarillonExample/AppDelegate.swift
@@ -70,15 +70,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
completionHandler()
}
- /// Shows the notification while the app is in the foreground, so a bench on
- /// screen still sees something arrive. Presentation is the app's business —
- /// the SDK takes no position on it.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
- completionHandler([.banner, .sound, .badge])
+ CarillonBridge.willPresent(notification, completionHandler: completionHandler)
}
}
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index bd636d9..6790f3d 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -21,7 +21,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- - CarillonReactNative (0.1.0):
+ - CarillonReactNative (0.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2026,7 +2026,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c
- CarillonReactNative: 06fcf8fbbd11ba9b0ceb52e3a0ef8a9562a64ad7
+ CarillonReactNative: f194d988ba8921b999b320da82c55d35c6d689fa
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: e7f572408afef1c5ca0f7169418aab10c6c28fc3
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
@@ -2037,7 +2037,7 @@ SPEC CHECKSUMS:
React: 1b1536b9099195944034e65b1830f463caaa8390
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d
- React-Core-prebuilt: c91a398a744ed302f83417326e2689ccb5b5d36b
+ React-Core-prebuilt: b9fa0e44aa7bcbac541b5605056cd5f8fe2b0b5b
React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6
React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f
React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74
@@ -2096,9 +2096,9 @@ SPEC CHECKSUMS:
React-utils: 5717eed2e4c96e4d8ad0718c2f63494c06c19e47
React-webperformancenativemodule: c465a47195d1c6f3cd7eeb84f38389124b62ac73
ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2
- ReactCodegen: b3184a229afd01e7f8058dd81b805b843caa2bf9
+ ReactCodegen: 34c467e2dc8c58294e243c221a55789267214285
ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360
- ReactNativeDependencies: b6e137046eee2f1404d9f70324c33201163ee8ca
+ ReactNativeDependencies: d3ab9ce32866a35d2789c41b6f26e6d52e8089cb
Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812
PODFILE CHECKSUM: 1b18f964e9b23fc897a491001df4a711b6601616
diff --git a/ios/Carillon.mm b/ios/Carillon.mm
index 60f76a2..625b8f7 100644
--- a/ios/Carillon.mm
+++ b/ios/Carillon.mm
@@ -1,4 +1,5 @@
#import "Carillon.h"
+#import
#if __has_include()
#import
@@ -75,6 +76,37 @@ - (void)startObservingOpens
}];
}
+- (void)startObservingDeviceId
+{
+ __weak Carillon *weakSelf = self;
+ [CarillonBridge observeDeviceId:^(NSDictionary *event) {
+ [weakSelf emitOnDeviceIdChanged:event];
+ }];
+}
+
+- (void)clearNotifications
+{
+ [CarillonBridge clearNotifications];
+}
+
+- (void)startObservingReceived
+{
+ __weak Carillon *weakSelf = self;
+ [CarillonBridge observeReceived:^(NSDictionary *event) {
+ [weakSelf emitOnReceived:event];
+ }];
+}
+
+- (void)stopObservingReceived
+{
+ [CarillonBridge stopObservingReceived];
+}
+
+- (void)finishReceived:(NSString *)requestId decision:(NSString *)decision
+{
+ [CarillonBridge finishReceived:requestId decision:decision];
+}
+
- (void)invalidate
{
[CarillonBridge stopObservingOpens];
diff --git a/ios/CarillonBridge.swift b/ios/CarillonBridge.swift
index e34645a..5e1fac7 100644
--- a/ios/CarillonBridge.swift
+++ b/ios/CarillonBridge.swift
@@ -82,9 +82,71 @@ public final class CarillonBridge: NSObject {
}
}
+ @objc
+ public static func observeDeviceId(_ handler: @escaping ([String: Any]) -> Void) {
+ Carillon.onDeviceIdChanged = { handler(["deviceId": $0]) }
+ }
+
@objc
public static func stopObservingOpens() {
+ stopObservingReceived()
Carillon.onOpened = nil
+ Carillon.onDeviceIdChanged = nil
+ }
+
+ private static let receivedLock = NSLock()
+ private static var receivedHandler: (([String: Any]) -> Void)?
+ private static var receivedAnswers: [String: (UNNotificationPresentationOptions) -> Void] = [:]
+
+ @objc public static func observeReceived(_ handler: @escaping ([String: Any]) -> Void) {
+ receivedLock.lock()
+ receivedHandler = handler
+ receivedLock.unlock()
+ }
+
+ @objc public static func finishReceived(_ requestId: String, decision: String) {
+ receivedLock.lock()
+ let completion = receivedAnswers.removeValue(forKey: requestId)
+ receivedLock.unlock()
+ completion?(decision == "suppress" ? [] : [.banner, .list, .sound, .badge])
+ }
+
+ @objc public static func stopObservingReceived() {
+ receivedLock.lock()
+ receivedHandler = nil
+ let pending = Array(receivedAnswers.values)
+ receivedAnswers.removeAll()
+ receivedLock.unlock()
+ for completion in pending { completion([.banner, .list, .sound, .badge]) }
+ }
+
+ @objc public static func clearNotifications() { Carillon.clearNotifications() }
+
+ @objc public static func willPresent(
+ _ notification: UNNotification,
+ completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ receivedLock.lock()
+ guard let handler = receivedHandler else {
+ receivedLock.unlock()
+ completionHandler([.banner, .list, .sound, .badge])
+ return
+ }
+ let id = UUID().uuidString
+ receivedAnswers[id] = completionHandler
+ receivedLock.unlock()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { finishReceived(id, decision: "show") }
+ let content = notification.request.content
+ let received = ReceivedNotification(userInfo: content.userInfo, title: content.title, body: content.body)
+ handler([
+ "requestId": id,
+ "deliveryId": received.deliveryId as Any? ?? NSNull(),
+ "title": received.title as Any? ?? NSNull(),
+ "body": received.body as Any? ?? NSNull(),
+ "data": received.data,
+ "image": received.image as Any? ?? NSNull(),
+ "threadId": received.threadId as Any? ?? NSNull(),
+ ])
}
// MARK: - The delegate callbacks the app forwards
diff --git a/package.json b/package.json
index cb22f8b..3ab920d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@exostack/carillon-react-native",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "React Native bridge over the Carillon native SDKs.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
diff --git a/plugin/src/__tests__/plugin.test.ts b/plugin/src/__tests__/plugin.test.ts
index 8313072..487d422 100644
--- a/plugin/src/__tests__/plugin.test.ts
+++ b/plugin/src/__tests__/plugin.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from '@jest/globals';
import {
addCarillonForwarding,
+ addForegroundForwarding,
addAndroidOpenForwarding,
addGoogleServicesClasspath,
addImport,
@@ -289,3 +290,45 @@ class MainActivity : ReactActivity() {
).toThrow("super.onCreate");
});
});
+
+
+describe('foreground forwarding', () => {
+ it('inserts a single callback even when older forwarding already exists', () => {
+ const result = addForegroundForwarding(addCarillonForwarding(expoAppDelegate));
+ expect(result).toContain('CarillonBridge.willPresent(notification, completionHandler: completionHandler)');
+ expect(addForegroundForwarding(result)).toBe(result);
+ });
+ it('preserves an existing callback for other notification providers', () => {
+ const source = `class AppDelegate: ExpoAppDelegate {
+ override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completion: @escaping (UNNotificationPresentationOptions) -> Void) {
+ completion([.sound])
+ }
+ }`;
+ const result = addForegroundForwarding(source);
+ expect(result).toContain('CarillonBridge.willPresent(notification, completionHandler: completion)');
+ expect(result).toContain('completion([.sound])');
+ expect(addForegroundForwarding(result)).toBe(result);
+ });
+});
+
+
+describe('notification service extension', () => {
+ it('embeds one extension and links its Swift product idempotently', () => {
+ const xcode = require('xcode');
+ const path = require('node:path');
+ const { addNotificationExtension, EXTENSION_NAME } = require('../extension');
+ const project = xcode.project(path.join(__dirname, '../../../example/ios/CarillonExample.xcodeproj/project.pbxproj'));
+ project.parseSync();
+ addNotificationExtension(project, 'dev.carillon.example');
+ const first = project.writeSync();
+ expect(first).toContain('dev.carillon.example.CarillonNotificationExtension');
+ expect(first).toContain('com.apple.product-type.app-extension');
+ expect(first).toContain('XCSwiftPackageProductDependency');
+ expect(first).toContain('NotificationService.swift');
+ expect(first).toContain('CarillonNotificationExtension.appex');
+ const targets = Object.values(project.pbxNativeTargetSection()).filter((target: unknown) => (target as { name?: string }).name === `"${EXTENSION_NAME}"`);
+ expect(targets).toHaveLength(1);
+ addNotificationExtension(project, 'dev.carillon.example');
+ expect(project.writeSync()).toBe(first);
+ });
+});
diff --git a/plugin/src/extension.ts b/plugin/src/extension.ts
new file mode 100644
index 0000000..035f965
--- /dev/null
+++ b/plugin/src/extension.ts
@@ -0,0 +1,113 @@
+import { withXcodeProject, type ConfigPlugin } from '@expo/config-plugins';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+type Project = Parameters[1]>[0]['modResults'];
+export const EXTENSION_NAME = 'CarillonNotificationExtension';
+export const notificationService = `import UserNotifications
+import CarillonNotificationExtension
+
+final class NotificationService: UNNotificationServiceExtension {
+ private var helper: CarillonNotificationExtension?
+
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
+ helper = CarillonNotificationExtension.didReceive(request, withContentHandler: handler)
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ CarillonNotificationExtension.serviceExtensionTimeWillExpire(helper)
+ }
+}
+`;
+
+export const extensionPlist = `
+
+
+CFBundleDisplayNameNotifications
+CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)
+CFBundleExecutable$(EXECUTABLE_NAME)
+CFBundleName$(PRODUCT_NAME)
+CFBundlePackageTypeXPC!
+CFBundleShortVersionString$(MARKETING_VERSION)
+CFBundleVersion$(CURRENT_PROJECT_VERSION)
+NSExtension
+NSExtensionPointIdentifiercom.apple.usernotifications.service
+NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).NotificationService
+
+`;
+
+export function addNotificationExtension(project: Project, bundleId: string): void {
+ const objects = project.hash.project.objects;
+ const targets = project.pbxNativeTargetSection();
+ const existing = Object.entries(targets).find(([, target]) => target !== null && typeof target === 'object' && 'name' in target && typeof target.name === 'string' && target.name.replaceAll('"', '') === EXTENSION_NAME);
+ const app = project.getFirstTarget().firstTarget;
+ const lists = objects.XCConfigurationList;
+ const configs = project.pbxXCBuildConfigurationSection();
+ const appConfigs = lists[app.buildConfigurationList].buildConfigurations;
+ const target = existing ? { uuid: existing[0], pbxNativeTarget: targets[existing[0]] } : project.addTarget(EXTENSION_NAME, 'app_extension', EXTENSION_NAME, `${bundleId}.${EXTENSION_NAME}`);
+ const targetConfigs = lists[target.pbxNativeTarget.buildConfigurationList].buildConfigurations;
+ for (const reference of targetConfigs) {
+ const config = configs[reference.value];
+ const matching = appConfigs.find((entry: { value: string }) => configs[entry.value].name === config.name) ?? appConfigs[0];
+ const settings = configs[matching.value].buildSettings;
+ Object.assign(config.buildSettings, {
+ IPHONEOS_DEPLOYMENT_TARGET: settings.IPHONEOS_DEPLOYMENT_TARGET ?? '15.1',
+ SWIFT_VERSION: '5.0',
+ PRODUCT_BUNDLE_IDENTIFIER: `"${bundleId}.${EXTENSION_NAME}"`,
+ APPLICATION_EXTENSION_API_ONLY: 'YES',
+ TARGETED_DEVICE_FAMILY: settings.TARGETED_DEVICE_FAMILY ?? '"1,2"',
+ MARKETING_VERSION: settings.MARKETING_VERSION ?? '1.0',
+ CURRENT_PROJECT_VERSION: settings.CURRENT_PROJECT_VERSION ?? '1',
+ ...(settings.DEVELOPMENT_TEAM ? { DEVELOPMENT_TEAM: settings.DEVELOPMENT_TEAM } : {}),
+ });
+ }
+ if (existing) return;
+ const source = `${EXTENSION_NAME}/NotificationService.swift`;
+ project.addBuildPhase([source], 'PBXSourcesBuildPhase', 'Sources', target.uuid);
+ const group = project.addPbxGroup([source], EXTENSION_NAME);
+ project.addToPbxGroup(group.uuid, project.getFirstProject().firstProject.mainGroup);
+ const frameworks = project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
+ const packageId = project.generateUuid();
+ const productId = project.generateUuid();
+ const buildId = project.generateUuid();
+ objects.XCRemoteSwiftPackageReference ??= {};
+ objects.XCSwiftPackageProductDependency ??= {};
+ objects.XCRemoteSwiftPackageReference[packageId] = {
+ isa: 'XCRemoteSwiftPackageReference',
+ repositoryURL: '"https://github.com/exostack/carillon-swift.git"',
+ requirement: { kind: 'upToNextMajorVersion', minimumVersion: '0.2.0' },
+ };
+ objects.XCSwiftPackageProductDependency[productId] = {
+ isa: 'XCSwiftPackageProductDependency', package: packageId, productName: EXTENSION_NAME,
+ };
+ objects.PBXBuildFile[buildId] = { isa: 'PBXBuildFile', productRef: productId };
+ frameworks.buildPhase.files.push({ value: buildId, comment: `${EXTENSION_NAME} in Frameworks` });
+ target.pbxNativeTarget.packageProductDependencies = [{ value: productId, comment: EXTENSION_NAME }];
+ const root = project.getFirstProject().firstProject;
+ root.packageReferences ??= [];
+ root.packageReferences.push({ value: packageId, comment: 'carillon-swift' });
+}
+
+export const withNotificationExtension: ConfigPlugin = (config) => {
+ const bundleId = config.ios?.bundleIdentifier;
+ if (!bundleId) throw new Error('Set ios.bundleIdentifier before configuring Carillon notifications.');
+ const eas = config.extra?.eas ?? {};
+ const build = eas.build ?? {};
+ const experimental = build.experimental ?? {};
+ const ios = experimental.ios ?? {};
+ const extensions = ios.appExtensions ?? [];
+ config.extra = { ...config.extra, eas: { ...eas, build: { ...build, experimental: { ...experimental, ios: {
+ ...ios, appExtensions: [
+ ...extensions.filter((item: { targetName: string }) => item.targetName !== EXTENSION_NAME),
+ { targetName: EXTENSION_NAME, bundleIdentifier: `${bundleId}.${EXTENSION_NAME}`, entitlements: {} },
+ ],
+ } } } } };
+ return withXcodeProject(config, (mod) => {
+ const folder = join(mod.modRequest.platformProjectRoot, EXTENSION_NAME);
+ mkdirSync(folder, { recursive: true });
+ writeFileSync(join(folder, 'NotificationService.swift'), notificationService);
+ writeFileSync(join(folder, `${EXTENSION_NAME}-Info.plist`), extensionPlist);
+ addNotificationExtension(mod.modResults, bundleId);
+ return mod;
+ });
+};
diff --git a/plugin/src/index.ts b/plugin/src/index.ts
index 1dd43e1..bdd17fe 100644
--- a/plugin/src/index.ts
+++ b/plugin/src/index.ts
@@ -1,3 +1,4 @@
+import { withNotificationExtension } from './extension';
import {
AndroidConfig,
withAndroidManifest,
@@ -133,6 +134,36 @@ export function addCarillonForwarding(contents: string): string {
return addImport('CarillonReactNative', addImport('UserNotifications', inserted));
}
+/** Route presentation through the bridge once; preserve existing handling for other providers. */
+export function addForegroundForwarding(contents: string): string {
+ if (contents.includes('CarillonBridge.willPresent(')) return contents;
+ const existing = /func\s+userNotificationCenter\s*\([\s\S]*?willPresent\s+(\w+):\s*UNNotification\s*,\s*withCompletionHandler\s+(\w+):[^\{]+\{/.exec(contents);
+ if (existing) {
+ const offset = existing.index + existing[0].length;
+ const notification = existing[1]!;
+ const completion = existing[2]!;
+ return contents.slice(0, offset) + `
+ if ${notification}.request.content.userInfo["carillon"] != nil {
+ CarillonBridge.willPresent(${notification}, completionHandler: ${completion})
+ return
+ }
+` + contents.slice(offset);
+ }
+ const declaration = /class\s+AppDelegate\b[^\{]*\{/.exec(contents);
+ if (!declaration) throw new Error('Cannot find AppDelegate for Carillon foreground forwarding.');
+ const start = declaration.index + declaration[0].length;
+ const method = `
+ ${needsOverride(contents) ? 'override ' : ''}func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ CarillonBridge.willPresent(notification, completionHandler: completionHandler)
+ }
+`;
+ return addImport('CarillonReactNative', addImport('UserNotifications', contents.slice(0, start) + method + contents.slice(start)));
+}
+
/** Adds an import after the last one, unless the file already has it. */
export function addImport(module: string, contents: string): string {
if (new RegExp(`^import ${module}$`, 'm').test(contents)) return contents;
@@ -302,9 +333,9 @@ const withCarillon: ConfigPlugin = (config) => {
});
config = withAppDelegate(config, (appDelegate) => {
- appDelegate.modResults.contents = addCarillonForwarding(
+ appDelegate.modResults.contents = addForegroundForwarding(addCarillonForwarding(
appDelegate.modResults.contents
- );
+ ));
return appDelegate;
});
@@ -344,6 +375,8 @@ const withCarillon: ConfigPlugin = (config) => {
return gradle;
});
+ config = withNotificationExtension(config);
+
return AndroidConfig.Permissions.withPermissions(config, [
'android.permission.POST_NOTIFICATIONS',
]);
diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json
index 1632e13..2a49a63 100644
--- a/plugin/tsconfig.json
+++ b/plugin/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
+ "types": ["node"],
"outDir": "build",
// CommonJS, not the package's ESM: `expo prebuild` reaches this through
// `require`, and a plugin it cannot require is a plugin that does not run.
diff --git a/src/NativeCarillon.ts b/src/NativeCarillon.ts
index 31a640a..a0fa2f2 100644
--- a/src/NativeCarillon.ts
+++ b/src/NativeCarillon.ts
@@ -32,6 +32,13 @@ export interface Spec extends TurboModule {
* Attach the JavaScript listener first to avoid losing cold-start events. Idempotent.
*/
startObservingOpens(): void;
+ startObservingDeviceId(): void;
+ startObservingReceived(): void;
+ stopObservingReceived(): void;
+ finishReceived(requestId: string, decision: string): void;
+ clearNotifications(): void;
+ readonly onReceived: CodegenTypes.EventEmitter;
+ readonly onDeviceIdChanged: CodegenTypes.EventEmitter;
/**
* OpenedNotification payload. UnsafeObject allows application-defined payload keys.
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index f5c4323..068e564 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -14,6 +14,21 @@ const mockNative = {
held: [] as unknown[],
calls: [] as string[],
+ receivedListeners: [] as Listener[],
+ onReceived(listener: Listener) {
+ mockNative.receivedListeners.push(listener);
+ return { remove: () => { mockNative.receivedListeners = mockNative.receivedListeners.filter((item) => item !== listener); } };
+ },
+ startObservingReceived: jest.fn(),
+ stopObservingReceived: jest.fn(),
+ finishReceived: jest.fn(),
+ clearNotifications: jest.fn(),
+ deviceListeners: [] as Listener[],
+ onDeviceIdChanged(listener: Listener) {
+ mockNative.deviceListeners.push(listener);
+ return { remove: () => { mockNative.deviceListeners = mockNative.deviceListeners.filter((item) => item !== listener); } };
+ },
+ startObservingDeviceId: jest.fn(() => { mockNative.deviceListeners.forEach((listener) => listener({deviceId: 'first'})); }),
configure: jest.fn(),
requestPermission: jest.fn(async () => 'allowed'),
identify: jest.fn(),
@@ -224,3 +239,51 @@ describe('debugInfo', () => {
});
});
});
+
+
+it('attaches device ID listeners before native observation and removes them', () => {
+ const changes: string[] = [];
+ const off = Carillon.onDeviceIdChanged((id) => changes.push(id));
+ expect(changes).toEqual(['first']);
+ off();
+ mockNative.deviceListeners.forEach((listener) => listener({deviceId: 'second'}));
+ expect(changes).toEqual(['first']);
+});
+
+it('returns null until a device ID has been confirmed', async () => {
+ expect(await Carillon.getDeviceId()).toBeNull();
+});
+
+
+describe('foreground presentation', () => {
+ const event = { requestId: 'request', deliveryId: 'delivery', title: 'Hello', body: 'Message', data: { url: '/chat' }, image: null, threadId: 'chat' };
+ it('forwards an asynchronous suppress decision and removes the listener', async () => {
+ const handler = jest.fn(async () => 'suppress' as const);
+ const remove = Carillon.onReceived(handler);
+ mockNative.receivedListeners.forEach((listener) => listener(event));
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(handler).toHaveBeenCalledWith({ deliveryId: 'delivery', title: 'Hello', body: 'Message', data: { url: '/chat' }, image: null, threadId: 'chat' });
+ expect(mockNative.finishReceived).toHaveBeenCalledWith('request', 'suppress');
+ remove();
+ expect(mockNative.receivedListeners).toHaveLength(0);
+ expect(mockNative.stopObservingReceived).toHaveBeenCalled();
+ });
+ it('falls back to show if the handler throws', async () => {
+ const remove = Carillon.onReceived(() => { throw new Error('broken app handler'); });
+ mockNative.receivedListeners.forEach((listener) => listener(event));
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(mockNative.finishReceived).toHaveBeenCalledWith('request', 'show');
+ remove();
+ });
+ it('replaces the handler and ignores an older unsubscribe', () => {
+ const old = Carillon.onReceived(() => 'suppress');
+ const current = Carillon.onReceived(() => 'show');
+ mockNative.stopObservingReceived.mockClear();
+ old();
+ expect(mockNative.stopObservingReceived).not.toHaveBeenCalled();
+ expect(mockNative.receivedListeners).toHaveLength(1);
+ current();
+ Carillon.clearNotifications();
+ expect(mockNative.clearNotifications).toHaveBeenCalled();
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 0490621..f506680 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -50,6 +50,46 @@ export type OpenedNotification = {
payload: Record;
};
+export type ForegroundDecision = 'show' | 'suppress';
+export type ReceivedNotification = {
+ deliveryId: string | null;
+ title: string | null;
+ body: string | null;
+ data: Record;
+ image: string | null;
+ threadId: string | null;
+};
+
+let removeReceived: (() => void) | undefined;
+
+/** Replaces the foreground handler. Native presentation defaults to show after three seconds. */
+export function onReceived(
+ handler: (notification: ReceivedNotification) => ForegroundDecision | Promise
+): () => void {
+ removeReceived?.();
+ const subscription = NativeCarillon.onReceived((event) => {
+ const { requestId, ...notification } = event as ReceivedNotification & { requestId: string };
+ Promise.resolve().then(() => handler(notification)).then(
+ (decision) => NativeCarillon.finishReceived(requestId, decision === 'suppress' ? 'suppress' : 'show'),
+ () => NativeCarillon.finishReceived(requestId, 'show')
+ );
+ });
+ const remove = () => {
+ subscription.remove();
+ if (removeReceived === remove) {
+ removeReceived = undefined;
+ NativeCarillon.stopObservingReceived();
+ }
+ };
+ removeReceived = remove;
+ NativeCarillon.startObservingReceived();
+ return remove;
+}
+
+export function clearNotifications(): void {
+ NativeCarillon.clearNotifications();
+}
+
export type OpenedHandler = (notification: OpenedNotification) => void;
/**
@@ -138,8 +178,22 @@ export function onOpened(handler: OpenedHandler): () => void {
}
/**
- * Returns SDK configuration, registration status, and queued-event count. Available in release builds.
+ * Returns the registered device id, or null before registration succeeds.
*/
+export async function getDeviceId(): Promise {
+ const info = (await NativeCarillon.debugInfo()) as Record;
+ return typeof info.device_id === 'string' ? info.device_id : null;
+}
+
+export function onDeviceIdChanged(handler: (id: string) => void): () => void {
+ const subscription = NativeCarillon.onDeviceIdChanged((event) => {
+ const payload = event as Record;
+ if (typeof payload.deviceId === 'string') handler(payload.deviceId);
+ });
+ NativeCarillon.startObservingDeviceId();
+ return () => subscription.remove();
+}
+
export async function debugInfo(): Promise {
return (await NativeCarillon.debugInfo()) as DebugInfo;
}
@@ -152,7 +206,11 @@ const Carillon = {
optIn,
optOut,
onOpened,
+ onReceived,
+ clearNotifications,
debugInfo,
+ getDeviceId,
+ onDeviceIdChanged,
};
export default Carillon;
From f00d581c1f2e7057ba17ff85a151a34dc4af7ad9 Mon Sep 17 00:00:00 2001
From: Philippe Auriach
Date: Thu, 10 Sep 2026 18:04:20 +0200
Subject: [PATCH 3/4] Fix generated notification extension target configuration
---
plugin/src/__tests__/plugin.test.ts | 8 ++++++++
plugin/src/extension.ts | 14 ++++++++++++--
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/plugin/src/__tests__/plugin.test.ts b/plugin/src/__tests__/plugin.test.ts
index 487d422..a8be00f 100644
--- a/plugin/src/__tests__/plugin.test.ts
+++ b/plugin/src/__tests__/plugin.test.ts
@@ -319,6 +319,8 @@ describe('notification service extension', () => {
const { addNotificationExtension, EXTENSION_NAME } = require('../extension');
const project = xcode.project(path.join(__dirname, '../../../example/ios/CarillonExample.xcodeproj/project.pbxproj'));
project.parseSync();
+ delete project.hash.project.objects.PBXTargetDependency;
+ delete project.hash.project.objects.PBXContainerItemProxy;
addNotificationExtension(project, 'dev.carillon.example');
const first = project.writeSync();
expect(first).toContain('dev.carillon.example.CarillonNotificationExtension');
@@ -328,6 +330,12 @@ describe('notification service extension', () => {
expect(first).toContain('CarillonNotificationExtension.appex');
const targets = Object.values(project.pbxNativeTargetSection()).filter((target: unknown) => (target as { name?: string }).name === `"${EXTENSION_NAME}"`);
expect(targets).toHaveLength(1);
+ const objects = project.hash.project.objects;
+ const dependencies = project.getFirstTarget().firstTarget.dependencies;
+ expect(dependencies.some((reference: { value: string }) => objects.PBXTargetDependency[reference.value]?.target)).toBe(true);
+ expect(first).toContain('SDKROOT = iphoneos');
+ expect(first).toContain('PRODUCT_MODULE_NAME = CarillonNotificationService');
+ expect(first).not.toContain('path = undefined');
addNotificationExtension(project, 'dev.carillon.example');
expect(project.writeSync()).toBe(first);
});
diff --git a/plugin/src/extension.ts b/plugin/src/extension.ts
index 035f965..067632a 100644
--- a/plugin/src/extension.ts
+++ b/plugin/src/extension.ts
@@ -38,6 +38,8 @@ export const extensionPlist = `
export function addNotificationExtension(project: Project, bundleId: string): void {
const objects = project.hash.project.objects;
+ objects.PBXTargetDependency ??= {};
+ objects.PBXContainerItemProxy ??= {};
const targets = project.pbxNativeTargetSection();
const existing = Object.entries(targets).find(([, target]) => target !== null && typeof target === 'object' && 'name' in target && typeof target.name === 'string' && target.name.replaceAll('"', '') === EXTENSION_NAME);
const app = project.getFirstTarget().firstTarget;
@@ -53,6 +55,9 @@ export function addNotificationExtension(project: Project, bundleId: string): vo
Object.assign(config.buildSettings, {
IPHONEOS_DEPLOYMENT_TARGET: settings.IPHONEOS_DEPLOYMENT_TARGET ?? '15.1',
SWIFT_VERSION: '5.0',
+ SDKROOT: 'iphoneos',
+ SUPPORTED_PLATFORMS: '"iphoneos iphonesimulator"',
+ PRODUCT_MODULE_NAME: 'CarillonNotificationService',
PRODUCT_BUNDLE_IDENTIFIER: `"${bundleId}.${EXTENSION_NAME}"`,
APPLICATION_EXTENSION_API_ONLY: 'YES',
TARGETED_DEVICE_FAMILY: settings.TARGETED_DEVICE_FAMILY ?? '"1,2"',
@@ -61,10 +66,15 @@ export function addNotificationExtension(project: Project, bundleId: string): vo
...(settings.DEVELOPMENT_TEAM ? { DEVELOPMENT_TEAM: settings.DEVELOPMENT_TEAM } : {}),
});
}
- if (existing) return;
+ if (existing) {
+ const parent = project.getFirstTarget();
+ const dependencies = parent.firstTarget.dependencies ?? [];
+ if (!dependencies.some((reference: { value: string }) => objects.PBXTargetDependency[reference.value]?.target === target.uuid)) project.addTargetDependency(parent.uuid, [target.uuid]);
+ return;
+ }
const source = `${EXTENSION_NAME}/NotificationService.swift`;
project.addBuildPhase([source], 'PBXSourcesBuildPhase', 'Sources', target.uuid);
- const group = project.addPbxGroup([source], EXTENSION_NAME);
+ const group = project.addPbxGroup([source], EXTENSION_NAME, '.');
project.addToPbxGroup(group.uuid, project.getFirstProject().firstProject.mainGroup);
const frameworks = project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
const packageId = project.generateUuid();
From 513bb391aa5348a374035f6713de850a08905539 Mon Sep 17 00:00:00 2001
From: Philippe Auriach
Date: Thu, 10 Sep 2026 18:25:17 +0200
Subject: [PATCH 4/4] Use provider-neutral extension setup guidance
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 759b620..4f71187 100644
--- a/README.md
+++ b/README.md
@@ -246,7 +246,8 @@ package product dependency and the EAS `extra.eas.build.experimental.ios.appExte
Set `ios.bundleIdentifier`; sign the extension bundle `.CarillonNotificationExtension`
and rebuild after prebuild. No App Group is needed. Bare apps add that target using the
[Swift SDK extension setup](https://github.com/exostack/carillon-swift#notification-images).
-Replace the OneSignal service extension rather than embedding both.
+If the app already has a notification service extension, integrate the image helper
+into that extension rather than embedding a second one.
Android foreground rendering uses `carillon_default` unless the requested channel already
exists. Supply `carillon_notification_icon` as a drawable; otherwise the app icon is used.