Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CarillonReactNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -146,13 +146,16 @@ 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

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:

Expand Down Expand Up @@ -204,3 +207,49 @@ 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 `<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).
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.
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.
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
48 changes: 48 additions & 0 deletions android/src/main/java/dev/carillon/reactnative/CarillonModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, (NotificationPresentation) -> 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
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 1 addition & 4 deletions example/ios/CarillonExample/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
10 changes: 5 additions & 5 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- CarillonReactNative (0.1.0):
- CarillonReactNative (0.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2026,7 +2026,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c
CarillonReactNative: 06fcf8fbbd11ba9b0ceb52e3a0ef8a9562a64ad7
CarillonReactNative: f194d988ba8921b999b320da82c55d35c6d689fa
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: e7f572408afef1c5ca0f7169418aab10c6c28fc3
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions ios/Carillon.mm
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import "Carillon.h"
#import <UserNotifications/UserNotifications.h>

#if __has_include(<CarillonReactNative/CarillonReactNative-Swift.h>)
#import <CarillonReactNative/CarillonReactNative-Swift.h>
Expand Down Expand Up @@ -75,6 +76,37 @@ - (void)startObservingOpens
}];
}

- (void)startObservingDeviceId
{
__weak Carillon *weakSelf = self;
[CarillonBridge observeDeviceId:^(NSDictionary<NSString *, id> *event) {
[weakSelf emitOnDeviceIdChanged:event];
}];
}

- (void)clearNotifications
{
[CarillonBridge clearNotifications];
}

- (void)startObservingReceived
{
__weak Carillon *weakSelf = self;
[CarillonBridge observeReceived:^(NSDictionary<NSString *, id> *event) {
[weakSelf emitOnReceived:event];
}];
}

- (void)stopObservingReceived
{
[CarillonBridge stopObservingReceived];
}

- (void)finishReceived:(NSString *)requestId decision:(NSString *)decision
{
[CarillonBridge finishReceived:requestId decision:decision];
}

- (void)invalidate
{
[CarillonBridge stopObservingOpens];
Expand Down
62 changes: 62 additions & 0 deletions ios/CarillonBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading