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.