From 0add503d1dcfb14f72a3bb301060c24a6f9b1d5f Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 23 Jul 2026 08:46:51 +0300 Subject: [PATCH 1/7] feat(live-activities): expose Live Activities / Live Notifications SDK module Co-Authored-By: Claude Opus 4.8 --- .../sdk/CustomerIOReactNativePackage.kt | 3 + .../reactnative/sdk/NativeCustomerIOModule.kt | 7 +- .../NativeLiveActivitiesModule.kt | 219 ++++++++++++++++++ .../NativeMessagingPushModule.kt | 12 +- customerio-reactnative.podspec | 10 + ios/wrappers/NativeCustomerIO.swift | 5 + .../liveactivities/NativeLiveActivities.mm | 83 +++++++ .../liveactivities/NativeLiveActivities.swift | 200 ++++++++++++++++ package.json | 1 + src/customerio-cdp.ts | 2 + src/customerio-liveactivities.ts | 102 ++++++++ src/index.ts | 1 + .../modules/NativeCustomerIOLiveActivities.ts | 35 +++ src/types/data-pipelines.ts | 3 + src/types/index.ts | 1 + src/types/live-activities.ts | 100 ++++++++ 16 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt create mode 100644 ios/wrappers/liveactivities/NativeLiveActivities.mm create mode 100644 ios/wrappers/liveactivities/NativeLiveActivities.swift create mode 100644 src/customerio-liveactivities.ts create mode 100644 src/specs/modules/NativeCustomerIOLiveActivities.ts create mode 100644 src/types/live-activities.ts diff --git a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt index 44273926..d659ccd1 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager +import io.customer.reactnative.sdk.liveactivities.NativeLiveActivitiesModule import io.customer.reactnative.sdk.location.NativeLocationModule import io.customer.reactnative.sdk.logging.NativeCustomerIOLoggingModule import io.customer.reactnative.sdk.messaginginapp.InlineInAppMessageViewManager @@ -33,6 +34,7 @@ class CustomerIOReactNativePackage : BaseReactPackage() { InlineInAppMessageViewManager.NAME -> InlineInAppMessageViewManager() NativeCustomerIOLoggingModule.NAME -> NativeCustomerIOLoggingModule(reactContext) NativeCustomerIOModule.NAME -> NativeCustomerIOModule(reactContext = reactContext) + NativeLiveActivitiesModule.NAME -> NativeLiveActivitiesModule(reactContext) NativeLocationModule.NAME -> if (BuildConfig.CIO_LOCATION_ENABLED) { NativeLocationModule(reactContext) } else null @@ -71,6 +73,7 @@ class CustomerIOReactNativePackage : BaseReactPackage() { InlineInAppMessageViewManager.NAME, NativeCustomerIOLoggingModule.NAME, NativeCustomerIOModule.NAME, + NativeLiveActivitiesModule.NAME, NativeLocationModule.NAME, NativeMessagingInAppModule.NAME, NativeMessagingPushModule.NAME, diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index 3ac1645f..ad309e59 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -89,11 +89,14 @@ class NativeCustomerIOModule( packageConfig.getTypedValue(Keys.Config.CDN_HOST) ?.let { cdnHost(it) } - // Configure push messaging module based on config provided by customer app + // Configure push messaging module based on config provided by customer app. + // Live Activities are hosted by the FCM push module, so the `liveActivities` + // config is applied to the same module. packageConfig.getTypedValue>(key = "push").let { pushConfig -> NativeMessagingPushModule.addNativeModuleFromConfig( builder = this, - config = pushConfig ?: emptyMap() + config = pushConfig ?: emptyMap(), + liveActivitiesConfig = packageConfig.getTypedValue>(key = "liveActivities") ) } // Configure in-app messaging module based on config provided by customer app diff --git a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt new file mode 100644 index 00000000..08fe64ee --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -0,0 +1,219 @@ +package io.customer.reactnative.sdk.liveactivities + +import android.graphics.Color +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.livenotification.LiveNotificationAsset +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import io.customer.messagingpush.livenotification.LiveNotificationData +import io.customer.messagingpush.livenotification.LiveNotificationType +import io.customer.reactnative.sdk.NativeCustomerIOLiveActivitiesSpec +import io.customer.reactnative.sdk.extension.getTypedValue +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.Logger + +/** + * React Native module implementation for Customer.io Live Activities / Live Notifications + * using TurboModules with new architecture. + * + * Live Notifications live on the FCM push module ([ModuleMessagingPushFCM]); this bridge + * maps the wrapper's template payloads to [LiveNotificationData] and forwards them. + */ +@ReactModule(name = NativeLiveActivitiesModule.NAME) +class NativeLiveActivitiesModule( + private val reactContext: ReactApplicationContext, +) : NativeCustomerIOLiveActivitiesSpec(reactContext) { + private val logger: Logger + get() = SDKComponent.logger + + // Live Notifications are hosted by the FCM push module. Reach it via the module registry + // (the wrapper can't reference the SDK-internal MODULE_NAME constant, so use the literal). + private fun getPushModule(): ModuleMessagingPushFCM? = runCatching { + SDKComponent.modules[PUSH_FCM_MODULE_NAME] as? ModuleMessagingPushFCM + }.onFailure { + logger.error("Live Notifications: push module is not initialized. Ensure the SDK is initialized with live activity templates enabled.") + }.getOrNull() + + override fun start(payload: ReadableMap?, promise: Promise?) { + val module = getPushModule() ?: return promise.rejectNotAvailable() + try { + val data = parseData(requireNotNull(payload) { "payload is required" }) + promise?.resolve(module.startLiveNotification(data)) + } catch (ex: Throwable) { + promise?.reject("live_activity_start_failed", ex.message, ex) + } + } + + override fun update(activityId: String?, payload: ReadableMap?, promise: Promise?) { + val module = getPushModule() ?: return promise.rejectNotAvailable() + try { + val id = requireNotNull(activityId) { "activityId is required" } + val data = parseData(requireNotNull(payload) { "payload is required" }) + module.updateLiveNotification(id, data) + promise?.resolve(null) + } catch (ex: Throwable) { + promise?.reject("live_activity_update_failed", ex.message, ex) + } + } + + override fun end(activityId: String?, promise: Promise?) { + val module = getPushModule() ?: return promise.rejectNotAvailable() + try { + module.endLiveNotification(requireNotNull(activityId) { "activityId is required" }) + promise?.resolve(null) + } catch (ex: Throwable) { + promise?.reject("live_activity_end_failed", ex.message, ex) + } + } + + override fun startCustom( + activityType: String?, + payload: ReadableMap?, + promise: Promise?, + ) { + val module = getPushModule() ?: return promise.rejectNotAvailable() + try { + val type = requireNotNull(activityType) { "activityType is required" } + val data = payload?.toHashMap() ?: emptyMap() + promise?.resolve(module.startLiveNotification(type, data)) + } catch (ex: Throwable) { + promise?.reject("live_activity_start_custom_failed", ex.message, ex) + } + } + + private fun parseData(payload: ReadableMap): LiveNotificationData { + return when (val type = payload.getString("type")) { + "segments" -> LiveNotificationData.Segments( + header = payload.requireString("header"), + status = payload.requireString("status"), + substatus = payload.optString("substatus"), + segmentsTotal = payload.requireDouble("segmentsTotal").toInt(), + segmentsComplete = payload.requireDouble("segmentsComplete").toInt(), + trailingText = payload.optString("trailingText"), + ) + + "countdownTimer" -> LiveNotificationData.CountdownTimer( + header = payload.requireString("header"), + title = payload.requireString("title"), + statusMessage = payload.optString("statusMessage"), + endTime = if (payload.hasKey("endTime") && !payload.isNull("endTime")) { + payload.getDouble("endTime").toLong() + } else { + null + }, + ) + + else -> throw IllegalArgumentException("Unknown live activity template type: $type") + } + } + + private fun ReadableMap.requireString(key: String): String = + requireNotNull(if (hasKey(key)) getString(key) else null) { "$key is required" } + + private fun ReadableMap.optString(key: String): String? = + if (hasKey(key) && !isNull(key)) getString(key) else null + + private fun ReadableMap.requireDouble(key: String): Double { + require(hasKey(key) && !isNull(key)) { "$key is required" } + return getDouble(key) + } + + private fun Promise?.rejectNotAvailable() { + this?.reject( + "live_activity_module_unavailable", + "Live Notifications are unavailable. Enable live activity templates in the SDK config.", + ) + } + + companion object { + const val NAME = "NativeCustomerIOLiveActivities" + + // The SDK's ModuleMessagingPushFCM.MODULE_NAME is `internal`, so we mirror its value here. + private const val PUSH_FCM_MODULE_NAME = "MessagingPushFCM" + + // Host-app callback used to render custom (app-defined) live notifications. The native SDK + // only accepts it at build time, so the app must register it before the SDK initializes + // (e.g. in Application.onCreate before React Native starts). Stored statically because the + // SDK config is applied in applyLiveActivitiesConfig, not on this module instance. + @Volatile + private var liveNotificationCallback: + io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback? = null + + /** + * Register the host app's callback for rendering custom live notifications. Custom + * (app-defined) activity types have no built-in template, so the app must build the + * [android.app.Notification] in [io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback.createLiveNotification]. + * Call this before the Customer.io SDK is initialized. + */ + @JvmStatic + fun setLiveNotificationCallback( + callback: io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback, + ) { + liveNotificationCallback = callback + } + + /** + * Applies live activity configuration onto the FCM push module's config builder. Live + * Notifications are hosted by [ModuleMessagingPushFCM], so their config (enabled templates, + * custom types, branding) is set on the same [MessagingPushModuleConfig]. + * + * @param builder the push module's config builder. + * @param config the `liveActivities` config map from the customer app. + */ + internal fun applyLiveActivitiesConfig( + builder: MessagingPushModuleConfig.Builder, + config: Map, + ) { + // Wire the host app's custom-live-notification renderer, if one was registered. + liveNotificationCallback?.let { builder.setNotificationCallback(it) } + + val templateTypes = config.getTypedValue>("templates") + ?.mapNotNull { it as? String } + ?.mapNotNull { name -> + when (name) { + "segments" -> LiveNotificationType.SEGMENTS + "countdownTimer" -> LiveNotificationType.COUNTDOWN_TIMER + else -> null + } + } + .orEmpty() + if (templateTypes.isNotEmpty()) { + builder.enableLiveNotificationTypes(*templateTypes.toTypedArray()) + } + + val customTypes = config.getTypedValue>("customTypes") + ?.mapNotNull { it as? String } + .orEmpty() + if (customTypes.isNotEmpty()) { + builder.enableCustomLiveNotificationTypes(*customTypes.toTypedArray()) + } + + val branding = config.getTypedValue>("branding") + if (branding != null) { + val accentColor = branding.getTypedValue("accentColorHex") + ?.let { runCatching { Color.parseColor(it) }.getOrNull() } + ?: Color.TRANSPARENT + val logoUrl = branding.getTypedValue("logoUrl") + val smallIcon = branding.getTypedValue("smallIconResource") + ?.let { name -> + val context = SDKComponent.android().applicationContext + context.resources + .getIdentifier(name, "drawable", context.packageName) + .takeIf { it != 0 } + } + builder.setLiveNotificationBranding( + LiveNotificationBranding( + companyName = branding.getTypedValue("companyName").orEmpty(), + accentColor = accentColor, + smallIcon = smallIcon, + logo = logoUrl?.let { LiveNotificationAsset.RemoteUrl(it) }, + ), + ) + } + } + } +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt index 9633511c..2512f949 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt @@ -21,6 +21,7 @@ import io.customer.messagingpush.di.pushModuleConfig import io.customer.messagingpush.di.pushTrackingUtil import io.customer.reactnative.sdk.NativeCustomerIOMessagingPushSpec import io.customer.reactnative.sdk.constant.Keys +import io.customer.reactnative.sdk.liveactivities.NativeLiveActivitiesModule import io.customer.reactnative.sdk.extension.getTypedValue import io.customer.reactnative.sdk.extension.takeIfNotBlank import io.customer.reactnative.sdk.util.unsupportedOnAndroid @@ -257,7 +258,8 @@ class NativeMessagingPushModule( */ internal fun addNativeModuleFromConfig( builder: CustomerIOBuilder, - config: Map + config: Map, + liveActivitiesConfig: Map? = null, ) { val androidConfig = config.getTypedValue>(key = "android") ?: emptyMap() @@ -275,6 +277,14 @@ class NativeMessagingPushModule( val module = ModuleMessagingPushFCM( moduleConfig = MessagingPushModuleConfig.Builder().apply { setPushClickBehavior(pushClickBehavior = pushClickBehavior) + // Live Notifications are hosted by the FCM push module, so their config is + // applied to the same MessagingPushModuleConfig. + liveActivitiesConfig?.let { liveConfig -> + NativeLiveActivitiesModule.applyLiveActivitiesConfig( + builder = this, + config = liveConfig, + ) + } }.build(), ) builder.addCustomerIOModule(module) diff --git a/customerio-reactnative.podspec b/customerio-reactnative.podspec index 7396d9bc..8e539046 100644 --- a/customerio-reactnative.podspec +++ b/customerio-reactnative.podspec @@ -57,4 +57,14 @@ Pod::Spec.new do |s| 'OTHER_SWIFT_FLAGS' => '$(inherited) -DCIO_LOCATION_ENABLED' } end + + # Live Activities module is optional - customers must opt in by adding this subspec, and must + # also add a Widget Extension target that renders the built-in templates. See the docs. + s.subspec "liveactivities" do |ss| + ss.dependency "CustomerIO/LiveActivities", package["cioNativeiOSSdkVersion"] + ss.dependency "CustomerIO/LiveActivitiesTemplates", package["cioNativeiOSSdkVersion"] + ss.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -DCIO_LIVEACTIVITIES_ENABLED' + } + end end diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 960868f0..094d8ea4 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -64,6 +64,11 @@ public class NativeCustomerIO: NSObject { } CustomerIO.initialize(withConfig: builtConfig) + #if CIO_LIVEACTIVITIES_ENABLED + // Initialize Live Activities after CustomerIO.initialize (it reads the shared SDK). + NativeLiveActivities.initializeModule(from: config) + #endif + do { // Initialize in-app messaging if config provided if let inAppConfig = try MessagingInAppConfigBuilder.build(from: config) { diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.mm b/ios/wrappers/liveactivities/NativeLiveActivities.mm new file mode 100644 index 00000000..3319844a --- /dev/null +++ b/ios/wrappers/liveactivities/NativeLiveActivities.mm @@ -0,0 +1,83 @@ +#import +#import + +// Objective-C wrapper for new architecture TurboModule implementation +@interface RCTNativeLiveActivities : NSObject +// Bridge to Swift implementation for cross-language compatibility +@property(nonatomic, strong) id swiftBridge; +@end + +@implementation RCTNativeLiveActivities + +RCT_EXPORT_MODULE() + +// Create TurboModule instance for new architecture JSI integration +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params { + return std::make_shared(params); +} + +- (instancetype)init { + if (self = [super init]) { + // Runtime class lookup - the Swift class only exists when the liveactivities subspec is installed. + Class swiftClass = NSClassFromString(@"NativeCustomerIOLiveActivities"); + if (swiftClass) { + _swiftBridge = [[swiftClass alloc] init]; + } + } + return self; +} + +// Module initialization can happen on background thread ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (void)start:(NSDictionary *)payload + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (!_swiftBridge) { + reject(@"live_activity_module_unavailable", @"Live Activities are unavailable.", nil); + return; + } + [_swiftBridge start:payload resolve:resolve reject:reject]; +} + +- (void)update:(NSString *)activityId + payload:(NSDictionary *)payload + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (!_swiftBridge) { + reject(@"live_activity_module_unavailable", @"Live Activities are unavailable.", nil); + return; + } + [_swiftBridge update:activityId payload:payload resolve:resolve reject:reject]; +} + +- (void)end:(NSString *)activityId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (!_swiftBridge) { + reject(@"live_activity_module_unavailable", @"Live Activities are unavailable.", nil); + return; + } + [_swiftBridge end:activityId resolve:resolve reject:reject]; +} + +- (void)startCustom:(NSString *)activityType + payload:(NSDictionary *)payload + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (!_swiftBridge) { + reject(@"live_activity_module_unavailable", @"Live Activities are unavailable.", nil); + return; + } + [_swiftBridge startCustom:activityType payload:payload resolve:resolve reject:reject]; +} + +// Export class factory function for React Native component registration +Class NativeCustomerIOLiveActivitiesCls(void) { + return RCTNativeLiveActivities.class; +} + +@end diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift new file mode 100644 index 00000000..e6699b0f --- /dev/null +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -0,0 +1,200 @@ +#if CIO_LIVEACTIVITIES_ENABLED +import ActivityKit +import CioDataPipelines +import CioLiveActivities +import CioLiveActivities_Attributes +import CioLiveActivities_Templates +import Foundation + +@objc(NativeCustomerIOLiveActivities) +public class NativeLiveActivities: NSObject { + /// The held Live Activities module (not a singleton in the native SDK), created during SDK init. + private static var module: LiveActivitiesModule? + + /// Type-erased handles keyed by activity id. The native `start` returns a generic + /// `CIOLiveActivity` that can't cross the bridge, so we keep closures that capture + /// the concrete handle and rebuild its content-state from a JS map on update. + private struct ActivityBox { + let update: ([String: Any]) async throws -> Void + let end: () async -> Void + } + + private static var activities: [String: ActivityBox] = [:] + private static let lock = NSLock() + + // MARK: - Init from SDK config + + /// Initialize the Live Activities module from the SDK config's `liveActivities` key. Registers + /// the enabled built-in template attribute types so `start` can request them. + static func initializeModule(from config: [String: Any]) { + guard let laConfig = config["liveActivities"] as? [String: Any] else { return } + guard #available(iOS 16.2, *) else { return } + let templates = (laConfig["templates"] as? [String]) ?? [] + var builder = LiveActivityConfigBuilder() + if templates.contains("segments") { + builder = builder.register(CIOSegmentsAttributes.self) + } + if templates.contains("countdownTimer") { + builder = builder.register(CIOCountdownTimerAttributes.self) + } + module = LiveActivitiesModule.initialize(builder.build()) + } + + // MARK: - Bridge methods + + @objc(start:resolve:reject:) + public func start( + _ payload: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.2, *), let module = Self.module else { + return reject(Self.unavailableCode, Self.unavailableMessage, nil) + } + guard let map = payload as? [String: Any], let type = map["type"] as? String else { + return reject("live_activity_start_failed", "payload.type is required", nil) + } + do { + let id: String + switch type { + case "segments": + let handle = try module.start( + CIOSegmentsAttributes(header: map["header"] as? String ?? ""), + contentState: Self.segmentsState(from: map) + ) + Self.store(handle: handle, contentBuilder: Self.segmentsState) + id = handle.id + case "countdownTimer": + let handle = try module.start( + CIOCountdownTimerAttributes(header: map["header"] as? String ?? ""), + contentState: Self.countdownState(from: map) + ) + Self.store(handle: handle, contentBuilder: Self.countdownState) + id = handle.id + default: + return reject("live_activity_start_failed", "Unknown live activity template type: \(type)", nil) + } + resolve(id) + } catch { + reject("live_activity_start_failed", error.localizedDescription, error) + } + } + + @objc(update:payload:resolve:reject:) + public func update( + _ activityId: String, + payload: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + Self.lock.lock() + let box = Self.activities[activityId] + Self.lock.unlock() + guard let box else { + return reject("live_activity_update_failed", "No live activity found for id \(activityId)", nil) + } + guard let map = payload as? [String: Any] else { + return reject("live_activity_update_failed", "payload is required", nil) + } + Task { + do { + try await box.update(map) + resolve(nil) + } catch { + reject("live_activity_update_failed", error.localizedDescription, error) + } + } + } + + @objc(end:resolve:reject:) + public func end( + _ activityId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject _: @escaping RCTPromiseRejectBlock + ) { + Self.lock.lock() + let box = Self.activities.removeValue(forKey: activityId) + Self.lock.unlock() + // Unknown/already-ended id is treated as success (idempotent end). + guard let box else { return resolve(nil) } + Task { + await box.end() + resolve(nil) + } + } + + @objc(startCustom:payload:resolve:reject:) + public func startCustom( + _: String, + payload _: NSDictionary, + resolve _: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + // Custom activity types on iOS require a native Widget Extension + ActivityAttributes and an + // `adopt(_:)` call; they cannot be data-driven across the bridge. + reject( + "live_activity_custom_unsupported_ios", + "Custom live activity types are not supported from JavaScript on iOS. Use a native Widget Extension and adopt().", + nil + ) + } + + /// Report an `opened` metric when the app is opened from a tapped Live Activity. + /// + /// Not exposed to JavaScript: a Live Activity tap arrives through the app's native URL/scene + /// entry point, so call this from there (see the sample app's `AppDelegate`). Returns `true` + /// if `url` matched a Customer.io-tracked activity's deep link. + @discardableResult + public static func reportDeepLinkOpen(_ url: URL) -> Bool { + module?.handleDeepLinkOpen(url) ?? false + } + + // MARK: - Helpers + + @available(iOS 16.2, *) + private static func store( + handle: CIOLiveActivity, + contentBuilder: @escaping ([String: Any]) throws -> A.ContentState + ) { + let box = ActivityBox( + update: { map in await handle.update(try contentBuilder(map)) }, + end: { await handle.end() } + ) + lock.lock() + activities[handle.id] = box + lock.unlock() + } + + @available(iOS 16.2, *) + private static func segmentsState(from map: [String: Any]) throws -> CIOSegmentsAttributes.ContentState { + CIOSegmentsAttributes.ContentState( + status: map["status"] as? String ?? "", + substatus: map["substatus"] as? String, + segmentsTotal: intValue(map["segmentsTotal"]), + segmentsComplete: intValue(map["segmentsComplete"]), + trailingText: map["trailingText"] as? String + ) + } + + @available(iOS 16.2, *) + private static func countdownState(from map: [String: Any]) throws -> CIOCountdownTimerAttributes.ContentState { + var endTime: EpochSecondsDate? + if let seconds = map["endTime"] as? NSNumber { + endTime = EpochSecondsDate(Date(timeIntervalSince1970: seconds.doubleValue)) + } + return CIOCountdownTimerAttributes.ContentState( + title: map["title"] as? String ?? "", + statusMessage: map["statusMessage"] as? String, + endTime: endTime + ) + } + + private static func intValue(_ any: Any?) -> Int { + (any as? NSNumber)?.intValue ?? 0 + } + + private static let unavailableCode = "live_activity_module_unavailable" + private static let unavailableMessage = + "Live Activities are unavailable. Enable live activity templates in the SDK config and add the widget extension." +} +#endif diff --git a/package.json b/package.json index 54b2c49a..19b49f69 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "modulesProvider": { "NativeCustomerIOLogging": "RCTNativeCustomerIOLogging", "NativeCustomerIO": "RCTNativeCustomerIO", + "NativeCustomerIOLiveActivities": "RCTNativeLiveActivities", "NativeCustomerIOLocation": "RCTNativeCustomerIOLocation", "NativeCustomerIOMessagingInApp": "RCTNativeMessagingInApp", "NativeCustomerIOMessagingPush": "RCTNativeMessagingPush" diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index a94d6879..4d903346 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -1,4 +1,5 @@ import { CustomerIOInAppMessaging } from './customerio-inapp'; +import { CustomerIOLiveActivities } from './customerio-liveactivities'; import { CustomerIOLocation } from './customerio-location'; import { CustomerIOPushMessaging } from './customerio-push'; import { NativeLoggerListener } from './native-logger-listener'; @@ -179,6 +180,7 @@ export class CustomerIO { static readonly isInitialized = () => _initialized; static readonly inAppMessaging = new CustomerIOInAppMessaging(); + static readonly liveActivities = new CustomerIOLiveActivities(); static readonly location = new CustomerIOLocation(); static readonly pushMessaging = new CustomerIOPushMessaging(); } diff --git a/src/customerio-liveactivities.ts b/src/customerio-liveactivities.ts new file mode 100644 index 00000000..f05beaaa --- /dev/null +++ b/src/customerio-liveactivities.ts @@ -0,0 +1,102 @@ +import { type TurboModule } from 'react-native'; +import { + default as NativeModule, + type Spec as CodegenSpec, +} from './specs/modules/NativeCustomerIOLiveActivities'; +import type { LiveActivityPayload } from './types/live-activities'; + +/** + * Ensures all methods defined in codegen spec are implemented by the public module + * + * @internal + */ +interface NativeLiveActivitiesSpec extends Omit< + CodegenSpec, + keyof TurboModule +> {} + +// Live Activities is an optional module — NativeModule may be null when it is not +// compiled in. Promise-returning methods reject with a clear error in that case. +let hasWarnedNotEnabled = false; +const withNativeModule = ( + fn: (native: CodegenSpec) => Promise +): Promise => { + if (NativeModule) { + return fn(NativeModule); + } + if (__DEV__ && !hasWarnedNotEnabled) { + hasWarnedNotEnabled = true; + console.warn( + 'Customer.io: Live Activities module is not available. ' + + 'On iOS, add the CustomerIO Live Activities pods and a Widget Extension; ' + + 'on Android it ships with messaging-push-fcm.' + ); + } + return Promise.reject( + new Error('Customer.io: Live Activities module is not available.') + ); +}; + +/** + * Live Activities (iOS) / Live Notifications (Android). + * + * Start, update, and end live, updating notifications from built-in templates + * (Segments, Countdown Timer). Enable templates via the `liveActivities` key of the + * SDK config passed to {@link CustomerIO.initialize}. + * + * @public + */ +export class CustomerIOLiveActivities implements NativeLiveActivitiesSpec { + /** + * Start a built-in-template activity. + * + * @param payload - Template payload; `payload.type` selects the template. + * @returns The SDK-minted activity id, used for later `update`/`end`. + */ + start(payload: LiveActivityPayload): Promise { + return withNativeModule((native) => native.start(payload)); + } + + /** + * Replace the whole content-state of a running activity. + * + * ActivityKit (iOS) replaces content-state wholesale, so pass the **full** desired + * state — not just the changed fields. Static attributes (e.g. `header`) cannot + * change after start and are ignored on iOS. + * + * @param activityId - Id returned by {@link start}. + * @param payload - The complete desired state. + */ + update(activityId: string, payload: LiveActivityPayload): Promise { + return withNativeModule((native) => native.update(activityId, payload)); + } + + /** + * End a running activity. + * + * @param activityId - Id returned by {@link start}. + */ + end(activityId: string): Promise { + return withNativeModule((native) => native.end(activityId)); + } + + /** + * Start a custom (app-defined) activity type. + * + * On **Android**, the host app renders it via its `createLiveNotification` callback. + * On **iOS**, custom types require a native `adopt` call, so this rejects — use a + * native Widget Extension + `ActivityAttributes` for custom iOS activities. + * + * @param activityType - Reverse-DNS identifier registered via config `customTypes`. + * @param payload - Flat key/value data passed to the native template/callback. + * @returns The SDK-minted activity id. + */ + startCustom( + activityType: string, + payload: Record + ): Promise { + return withNativeModule((native) => + native.startCustom(activityType, payload) + ); + } +} diff --git a/src/index.ts b/src/index.ts index c8c9c940..54506a4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ */ export * from './customerio-cdp'; export * from './customerio-inapp'; +export * from './customerio-liveactivities'; export * from './customerio-location'; export * from './customerio-push'; export * from './types'; diff --git a/src/specs/modules/NativeCustomerIOLiveActivities.ts b/src/specs/modules/NativeCustomerIOLiveActivities.ts new file mode 100644 index 00000000..4a6879b8 --- /dev/null +++ b/src/specs/modules/NativeCustomerIOLiveActivities.ts @@ -0,0 +1,35 @@ +import { TurboModuleRegistry, type TurboModule } from 'react-native'; +import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; + +/** + * Native module specification for CustomerIO Live Activities React Native SDK + * + * Live Activities (iOS) / Live Notifications (Android) let you show a live, + * updating notification driven by a built-in template (Segments, Countdown Timer). + * + * @see NativeCustomerIO.ts for detailed documentation on TurboModule patterns, + * Codegen compatibility, and type safety approach. + */ + +type NativeBridgeObject = UnsafeObject; + +export interface Spec extends TurboModule { + /** Start a built-in-template activity. Resolves with the SDK-minted activity id. */ + start(payload: NativeBridgeObject): Promise; + /** Replace the whole content-state of a running activity. Pass the full desired state. */ + update(activityId: string, payload: NativeBridgeObject): Promise; + /** End a running activity. */ + end(activityId: string): Promise; + /** + * Start a custom (app-defined) activity type. Android renders it via the host app's + * `createLiveNotification` callback; on iOS custom types require a native `adopt` call, + * so this rejects on iOS. + */ + startCustom( + activityType: string, + payload: NativeBridgeObject + ): Promise; +} + +// Optional module — NativeModule may be null when Live Activities is not compiled in. +export default TurboModuleRegistry.get('NativeCustomerIOLiveActivities'); diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index 83648d2a..b655941e 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -1,3 +1,4 @@ +import type { LiveActivitiesConfig } from './live-activities'; import type { PushClickBehaviorAndroid } from './push'; /** @@ -76,6 +77,8 @@ export type CioConfig = { /** Location tracking mode. Defaults to 'manual' if location config is provided. */ trackingMode?: CioLocationTrackingMode; }; + /** Live Activities (iOS) / Live Notifications (Android) configuration. */ + liveActivities?: LiveActivitiesConfig; }; /** diff --git a/src/types/index.ts b/src/types/index.ts index f27cfed6..0d1600c4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from './data-pipelines'; export * from './in-app'; +export * from './live-activities'; export * from './push'; diff --git a/src/types/live-activities.ts b/src/types/live-activities.ts new file mode 100644 index 00000000..c028365b --- /dev/null +++ b/src/types/live-activities.ts @@ -0,0 +1,100 @@ +/** + * Public types for the Live Activities module. + * + * Live Activities (iOS) / Live Notifications (Android) render live, updating + * notifications from built-in templates. Two templates ship today: Segments and + * Countdown Timer. Field names and identifiers are identical across platforms. + * + * @public + */ + +/** + * Built-in template identifiers usable from the wrapper. + * + * Use these enum members (not raw strings) for `LiveActivitiesConfig.templates` + * and a payload's `type` to avoid typos. + */ +export enum LiveActivityTemplate { + Segments = 'segments', + CountdownTimer = 'countdownTimer', +} + +/** + * Segments template — a progress activity split into N segments. + * `header` is a static attribute (fixed at start); the rest are content-state. + */ +export interface LiveActivitySegmentsPayload { + type: LiveActivityTemplate.Segments; + /** Static header line (set at start, never changes). */ + header: string; + /** Primary status line. */ + status: string; + /** Optional secondary status line. */ + substatus?: string; + /** Total number of segments. */ + segmentsTotal: number; + /** Number of completed segments (clamped to 0..segmentsTotal). */ + segmentsComplete: number; + /** Optional trailing text (e.g. an ETA). */ + trailingText?: string; +} + +/** + * Countdown Timer template — a live countdown to a target time. + * `header` is a static attribute (fixed at start); the rest are content-state. + */ +export interface LiveActivityCountdownTimerPayload { + type: LiveActivityTemplate.CountdownTimer; + /** Static header line (set at start, never changes). */ + header: string; + /** Primary title line. */ + title: string; + /** Optional status message. */ + statusMessage?: string; + /** Target time as epoch **seconds**. Omit to render the finished state. */ + endTime?: number; +} + +/** + * Payload for {@link CustomerIOLiveActivities.start} and + * {@link CustomerIOLiveActivities.update}. + * + * Because ActivityKit replaces content-state wholesale on update, `update` takes the + * same full payload as `start` — pass the complete desired state each time. + * + * @public + */ +export type LiveActivityPayload = + | LiveActivitySegmentsPayload + | LiveActivityCountdownTimerPayload; + +/** + * Live Activities branding (Android only). On iOS, branding is compiled into the + * widget extension's SwiftUI, so these values are ignored there. + * + * @public + */ +export interface LiveActivitiesBranding { + /** Reserved for future use. */ + companyName?: string; + /** Accent color as "#RRGGBB". */ + accentColorHex?: string; + /** Remote logo URL (downloaded and cached natively). */ + logoUrl?: string; + /** Android drawable resource name, resolved natively. */ + smallIconResource?: string; +} + +/** + * Live Activities configuration, passed under the `liveActivities` key of the SDK config. + * + * @public + */ +export interface LiveActivitiesConfig { + /** Built-in templates to enable. Enables the matching native type on each platform. */ + templates?: LiveActivityTemplate[]; + /** Reverse-DNS identifiers for custom (app-defined) activity types. */ + customTypes?: string[]; + /** Android Live Notification branding (ignored on iOS). */ + branding?: LiveActivitiesBranding; +} From 03dec8b933562f53525785be1d3611700b541639 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sun, 26 Jul 2026 07:39:07 +0300 Subject: [PATCH 2/7] refactor(live-activities): consume SDK-managed module, rename config to liveNotifications Follows the native iOS change that makes Live Activities a registerable CustomerIOModule reached via CustomerIO.liveActivities. - iOS: replace the held static module + initializeModule(from:) with module(from:) registered through sdkConfigBuilder.addModule, mirroring NativeLocation. Enables push-to-start on every init path. - iOS: a nil start result (module unregistered / type not enabled) becomes a rejected promise instead of a crash; unknown types fail softly. - iOS: reportDeepLinkOpen -> handleWidgetUrl, returning the redirect URL. - Android: migrate to the dedicated CustomerIOLiveNotificationsCallback and setLiveNotificationCallback; createLiveNotification no longer lives on CustomerIOPushNotificationCallback. - Config: `liveActivities` -> `liveNotifications`, `templates` -> `types`, and types are now the reverse-DNS identifiers shared by both native SDKs and the wire format. Unrecognized identifiers are ignored. - Branding: add logoResource (bundled drawable), preferred over logoUrl. Co-Authored-By: Claude Opus 4.8 --- .../reactnative/sdk/NativeCustomerIOModule.kt | 2 +- .../NativeLiveActivitiesModule.kt | 65 +++++++------ ios/wrappers/NativeCustomerIO.swift | 11 ++- .../liveactivities/NativeLiveActivities.swift | 92 +++++++++++++------ src/customerio-liveactivities.ts | 4 +- src/types/data-pipelines.ts | 2 +- src/types/live-activities.ts | 30 ++++-- 7 files changed, 133 insertions(+), 73 deletions(-) diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index ad309e59..e265dfdf 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -96,7 +96,7 @@ class NativeCustomerIOModule( NativeMessagingPushModule.addNativeModuleFromConfig( builder = this, config = pushConfig ?: emptyMap(), - liveActivitiesConfig = packageConfig.getTypedValue>(key = "liveActivities") + liveActivitiesConfig = packageConfig.getTypedValue>(key = "liveNotifications") ) } // Configure in-app messaging module based on config provided by customer app diff --git a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt index 08fe64ee..7282627d 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import io.customer.messagingpush.MessagingPushModuleConfig import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.data.communication.CustomerIOLiveNotificationsCallback import io.customer.messagingpush.livenotification.LiveNotificationAsset import io.customer.messagingpush.livenotification.LiveNotificationBranding import io.customer.messagingpush.livenotification.LiveNotificationData @@ -87,7 +88,7 @@ class NativeLiveActivitiesModule( private fun parseData(payload: ReadableMap): LiveNotificationData { return when (val type = payload.getString("type")) { - "segments" -> LiveNotificationData.Segments( + LiveNotificationType.SEGMENTS.identifier -> LiveNotificationData.Segments( header = payload.requireString("header"), status = payload.requireString("status"), substatus = payload.optString("substatus"), @@ -96,7 +97,7 @@ class NativeLiveActivitiesModule( trailingText = payload.optString("trailingText"), ) - "countdownTimer" -> LiveNotificationData.CountdownTimer( + LiveNotificationType.COUNTDOWN_TIMER.identifier -> LiveNotificationData.CountdownTimer( header = payload.requireString("header"), title = payload.requireString("title"), statusMessage = payload.optString("statusMessage"), @@ -107,7 +108,9 @@ class NativeLiveActivitiesModule( }, ) - else -> throw IllegalArgumentException("Unknown live activity template type: $type") + // A newer native SDK may know this type even though this wrapper build doesn't. + // Reject softly (the caller turns this into a rejected promise) rather than crash. + else -> throw IllegalArgumentException("Unsupported Live Activity template: $type") } } @@ -140,19 +143,18 @@ class NativeLiveActivitiesModule( // (e.g. in Application.onCreate before React Native starts). Stored statically because the // SDK config is applied in applyLiveActivitiesConfig, not on this module instance. @Volatile - private var liveNotificationCallback: - io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback? = null + private var liveNotificationCallback: CustomerIOLiveNotificationsCallback? = null /** - * Register the host app's callback for rendering custom live notifications. Custom + * Register the host app's callback for rendering live notifications itself. Custom * (app-defined) activity types have no built-in template, so the app must build the - * [android.app.Notification] in [io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback.createLiveNotification]. + * [android.app.Notification] in + * [CustomerIOLiveNotificationsCallback.createLiveNotification]; returning `null` there + * falls back to the SDK's built-in template. * Call this before the Customer.io SDK is initialized. */ @JvmStatic - fun setLiveNotificationCallback( - callback: io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback, - ) { + fun setLiveNotificationCallback(callback: CustomerIOLiveNotificationsCallback) { liveNotificationCallback = callback } @@ -168,17 +170,15 @@ class NativeLiveActivitiesModule( builder: MessagingPushModuleConfig.Builder, config: Map, ) { - // Wire the host app's custom-live-notification renderer, if one was registered. - liveNotificationCallback?.let { builder.setNotificationCallback(it) } + // Wire the host app's live-notification renderer, if one was registered. + liveNotificationCallback?.let { builder.setLiveNotificationCallback(it) } - val templateTypes = config.getTypedValue>("templates") + // Unrecognized identifiers are ignored: a newer native SDK may ship types this + // wrapper build doesn't know, and that must never break the ones it does know. + val templateTypes = config.getTypedValue>("types") ?.mapNotNull { it as? String } - ?.mapNotNull { name -> - when (name) { - "segments" -> LiveNotificationType.SEGMENTS - "countdownTimer" -> LiveNotificationType.COUNTDOWN_TIMER - else -> null - } + ?.mapNotNull { identifier -> + LiveNotificationType.entries.firstOrNull { it.identifier == identifier } } .orEmpty() if (templateTypes.isNotEmpty()) { @@ -197,23 +197,34 @@ class NativeLiveActivitiesModule( val accentColor = branding.getTypedValue("accentColorHex") ?.let { runCatching { Color.parseColor(it) }.getOrNull() } ?: Color.TRANSPARENT - val logoUrl = branding.getTypedValue("logoUrl") + // A bundled drawable is preferred over a remote URL: it renders without a network + // round-trip, so the logo is present on the very first frame. + val logo = branding.getTypedValue("logoResource") + ?.let { name -> drawableResId(name)?.let(LiveNotificationAsset::Drawable) } + ?: branding.getTypedValue("logoUrl") + ?.let(LiveNotificationAsset::RemoteUrl) val smallIcon = branding.getTypedValue("smallIconResource") - ?.let { name -> - val context = SDKComponent.android().applicationContext - context.resources - .getIdentifier(name, "drawable", context.packageName) - .takeIf { it != 0 } - } + ?.let { name -> drawableResId(name) } builder.setLiveNotificationBranding( LiveNotificationBranding( companyName = branding.getTypedValue("companyName").orEmpty(), accentColor = accentColor, smallIcon = smallIcon, - logo = logoUrl?.let { LiveNotificationAsset.RemoteUrl(it) }, + logo = logo, ), ) } } + + /** + * Resolve a bundled drawable by name, or `null` when the host app doesn't ship one under + * that name — a missing asset must degrade to "no image", never crash rendering. + */ + private fun drawableResId(name: String): Int? { + val context = SDKComponent.android().applicationContext + return context.resources + .getIdentifier(name, "drawable", context.packageName) + .takeIf { it != 0 } + } } } diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 094d8ea4..2119780e 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -54,6 +54,12 @@ public class NativeCustomerIO: NSObject { } #endif + #if CIO_LIVEACTIVITIES_ENABLED + if let liveActivitiesModule = NativeLiveActivities.module(from: config) { + _ = sdkConfigBuilder.addModule(liveActivitiesModule) + } + #endif + let builtConfig = sdkConfigBuilder.build() // Only CustomerIO.initialize must run on the main thread (e.g. for Location module). @@ -64,11 +70,6 @@ public class NativeCustomerIO: NSObject { } CustomerIO.initialize(withConfig: builtConfig) - #if CIO_LIVEACTIVITIES_ENABLED - // Initialize Live Activities after CustomerIO.initialize (it reads the shared SDK). - NativeLiveActivities.initializeModule(from: config) - #endif - do { // Initialize in-app messaging if config provided if let inAppConfig = try MessagingInAppConfigBuilder.build(from: config) { diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index e6699b0f..d87c7dbd 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -8,8 +8,13 @@ import Foundation @objc(NativeCustomerIOLiveActivities) public class NativeLiveActivities: NSObject { - /// The held Live Activities module (not a singleton in the native SDK), created during SDK init. - private static var module: LiveActivitiesModule? + /// Reverse-DNS activity type identifiers for the SDK's built-in templates. These are the same + /// strings the backend sends as `notificationType` and that Android's `LiveNotificationType` + /// exposes, so JS, both native SDKs, and the wire format share one vocabulary. + enum TypeIdentifier { + static let segments = "io.customer.livenotifications.segments" + static let countdownTimer = "io.customer.livenotifications.countdowntimer" + } /// Type-erased handles keyed by activity id. The native `start` returns a generic /// `CIOLiveActivity` that can't cross the bridge, so we keep closures that capture @@ -22,22 +27,31 @@ public class NativeLiveActivities: NSObject { private static var activities: [String: ActivityBox] = [:] private static let lock = NSLock() - // MARK: - Init from SDK config + // MARK: - Module registration - /// Initialize the Live Activities module from the SDK config's `liveActivities` key. Registers - /// the enabled built-in template attribute types so `start` can request them. - static func initializeModule(from config: [String: Any]) { - guard let laConfig = config["liveActivities"] as? [String: Any] else { return } - guard #available(iOS 16.2, *) else { return } - let templates = (laConfig["templates"] as? [String]) ?? [] + /// Build the Live Activities module from the SDK config's `liveNotifications` key, mirroring + /// ``NativeLocation/module(from:)``. Registers the enabled built-in template attribute types so + /// `CustomerIO.liveActivities.start` can request them, and so push-to-start tokens register for + /// them at SDK init. + /// + /// Unrecognized type identifiers are ignored: a newer native SDK may ship templates this + /// wrapper build doesn't know, and that must never break registration of the ones it does. + static func module(from config: [String: Any]) -> LiveActivitiesModule? { + guard let liveConfig = config["liveNotifications"] as? [String: Any] else { return nil } + guard #available(iOS 16.2, *) else { return nil } + let types = (liveConfig["types"] as? [String]) ?? [] var builder = LiveActivityConfigBuilder() - if templates.contains("segments") { - builder = builder.register(CIOSegmentsAttributes.self) - } - if templates.contains("countdownTimer") { - builder = builder.register(CIOCountdownTimerAttributes.self) + for type in types { + switch type { + case TypeIdentifier.segments: + builder = builder.register(CIOSegmentsAttributes.self) + case TypeIdentifier.countdownTimer: + builder = builder.register(CIOCountdownTimerAttributes.self) + default: + continue + } } - module = LiveActivitiesModule.initialize(builder.build()) + return LiveActivitiesModule(config: builder.build()) } // MARK: - Bridge methods @@ -48,31 +62,40 @@ public class NativeLiveActivities: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - guard #available(iOS 16.2, *), let module = Self.module else { + guard #available(iOS 16.2, *) else { return reject(Self.unavailableCode, Self.unavailableMessage, nil) } guard let map = payload as? [String: Any], let type = map["type"] as? String else { return reject("live_activity_start_failed", "payload.type is required", nil) } do { + // A nil handle means the module isn't registered or this type wasn't enabled. The + // native SDK logs and returns nil rather than throwing, so surface it as a rejected + // promise — never a crash. let id: String switch type { - case "segments": - let handle = try module.start( + case TypeIdentifier.segments: + guard let handle = try CustomerIO.liveActivities.start( CIOSegmentsAttributes(header: map["header"] as? String ?? ""), contentState: Self.segmentsState(from: map) - ) + ) else { + return reject(Self.notRegisteredCode, Self.notRegisteredMessage(type), nil) + } Self.store(handle: handle, contentBuilder: Self.segmentsState) id = handle.id - case "countdownTimer": - let handle = try module.start( + case TypeIdentifier.countdownTimer: + guard let handle = try CustomerIO.liveActivities.start( CIOCountdownTimerAttributes(header: map["header"] as? String ?? ""), contentState: Self.countdownState(from: map) - ) + ) else { + return reject(Self.notRegisteredCode, Self.notRegisteredMessage(type), nil) + } Self.store(handle: handle, contentBuilder: Self.countdownState) id = handle.id default: - return reject("live_activity_start_failed", "Unknown live activity template type: \(type)", nil) + // A newer native SDK may know this type even though this wrapper build doesn't. + // Fail softly so an unrecognized template can never crash the host app. + return reject(Self.unsupportedTypeCode, "Unsupported Live Activity template: \(type)", nil) } resolve(id) } catch { @@ -139,14 +162,17 @@ public class NativeLiveActivities: NSObject { ) } - /// Report an `opened` metric when the app is opened from a tapped Live Activity. + /// Report an `opened` metric for a tapped Live Activity and return the deep link to route to. /// /// Not exposed to JavaScript: a Live Activity tap arrives through the app's native URL/scene - /// entry point, so call this from there (see the sample app's `AppDelegate`). Returns `true` - /// if `url` matched a Customer.io-tracked activity's deep link. + /// entry point, so call this from there (see the sample app's `AppDelegate`). + /// + /// - Returns: the customer's redirect URL for a Customer.io widget URL (`nil` when it carries + /// none), or `url` unchanged when it isn't a Customer.io URL — so existing routing still + /// handles non-CIO links. @discardableResult - public static func reportDeepLinkOpen(_ url: URL) -> Bool { - module?.handleDeepLinkOpen(url) ?? false + public static func handleWidgetUrl(_ url: URL) -> URL? { + CustomerIO.liveActivities.handleWidgetUrl(url) } // MARK: - Helpers @@ -195,6 +221,14 @@ public class NativeLiveActivities: NSObject { private static let unavailableCode = "live_activity_module_unavailable" private static let unavailableMessage = - "Live Activities are unavailable. Enable live activity templates in the SDK config and add the widget extension." + "Live Activities require iOS 16.2 or later." + + private static let notRegisteredCode = "live_activity_type_not_registered" + private static func notRegisteredMessage(_ type: String) -> String { + "Live Activity type '\(type)' is not registered. Add it to `liveNotifications.types` in your " + + "Customer.io SDK config, and make sure your widget extension renders it." + } + + private static let unsupportedTypeCode = "live_activity_type_unsupported" } #endif diff --git a/src/customerio-liveactivities.ts b/src/customerio-liveactivities.ts index f05beaaa..04c8139c 100644 --- a/src/customerio-liveactivities.ts +++ b/src/customerio-liveactivities.ts @@ -41,8 +41,8 @@ const withNativeModule = ( * Live Activities (iOS) / Live Notifications (Android). * * Start, update, and end live, updating notifications from built-in templates - * (Segments, Countdown Timer). Enable templates via the `liveActivities` key of the - * SDK config passed to {@link CustomerIO.initialize}. + * (Segments, Countdown Timer). Enable the activity types via the `liveNotifications` + * key of the SDK config passed to {@link CustomerIO.initialize}. * * @public */ diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index b655941e..1c492d26 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -78,7 +78,7 @@ export type CioConfig = { trackingMode?: CioLocationTrackingMode; }; /** Live Activities (iOS) / Live Notifications (Android) configuration. */ - liveActivities?: LiveActivitiesConfig; + liveNotifications?: LiveActivitiesConfig; }; /** diff --git a/src/types/live-activities.ts b/src/types/live-activities.ts index c028365b..fd6e9b6a 100644 --- a/src/types/live-activities.ts +++ b/src/types/live-activities.ts @@ -9,14 +9,19 @@ */ /** - * Built-in template identifiers usable from the wrapper. + * Built-in activity type identifiers usable from the wrapper. * - * Use these enum members (not raw strings) for `LiveActivitiesConfig.templates` - * and a payload's `type` to avoid typos. + * The values are the reverse-DNS identifiers Customer.io uses everywhere — the + * `notificationType` on the wire, Android's `LiveNotificationType`, and iOS's + * `CIOSegmentsAttributes.identifier` — so JS, both native SDKs, and the backend + * share one vocabulary. + * + * Use these enum members (not raw strings) for `LiveActivitiesConfig.types` and a + * payload's `type` to avoid typos. */ export enum LiveActivityTemplate { - Segments = 'segments', - CountdownTimer = 'countdownTimer', + Segments = 'io.customer.livenotifications.segments', + CountdownTimer = 'io.customer.livenotifications.countdowntimer', } /** @@ -79,6 +84,11 @@ export interface LiveActivitiesBranding { companyName?: string; /** Accent color as "#RRGGBB". */ accentColorHex?: string; + /** + * Bundled Android drawable resource name for the logo, resolved natively. + * Preferred over {@link logoUrl} when both are set — it needs no network. + */ + logoResource?: string; /** Remote logo URL (downloaded and cached natively). */ logoUrl?: string; /** Android drawable resource name, resolved natively. */ @@ -86,13 +96,17 @@ export interface LiveActivitiesBranding { } /** - * Live Activities configuration, passed under the `liveActivities` key of the SDK config. + * Live Activities configuration, passed under the `liveNotifications` key of the SDK config. * * @public */ export interface LiveActivitiesConfig { - /** Built-in templates to enable. Enables the matching native type on each platform. */ - templates?: LiveActivityTemplate[]; + /** + * Built-in activity types to enable. Enables the matching native type on each + * platform and registers push-to-start for it. Unrecognized identifiers are + * ignored, so a newer native template can't break an older wrapper build. + */ + types?: LiveActivityTemplate[]; /** Reverse-DNS identifiers for custom (app-defined) activity types. */ customTypes?: string[]; /** Android Live Notification branding (ignored on iOS). */ From f842c6aebac1d12bf6cf418fdd43c81d4b197cdb Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Mon, 27 Jul 2026 08:01:16 +0300 Subject: [PATCH 3/7] feat(live-activities): allow end() to render a final content-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending an activity left the last content-state on screen — a Segments activity ended at "3 of 4" and stayed there. ActivityKit keeps the previous content when `Activity.end` is given none, and the bridge never passed one even though the native handle has always accepted it. `end` now takes an optional payload, mirroring `update`, so callers can show a terminal state (e.g. all segments complete). iOS-only by nature: Android's `endLiveNotification(activityId)` takes no final state and renders its own terminal presentation (the notification stops being ongoing), which is why Android already looked correct. The Android bridge accepts the argument for signature parity and ignores it, the same way branding is Android-only and ignored on iOS. Co-Authored-By: Claude Opus 4.8 --- .../NativeLiveActivitiesModule.kt | 7 ++++++- .../liveactivities/NativeLiveActivities.mm | 3 ++- .../liveactivities/NativeLiveActivities.swift | 21 +++++++++++++------ src/customerio-liveactivities.ts | 10 ++++++--- .../modules/NativeCustomerIOLiveActivities.ts | 9 ++++++-- src/types/live-activities.ts | 3 +-- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt index 7282627d..c2601f56 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -61,7 +61,12 @@ class NativeLiveActivitiesModule( } } - override fun end(activityId: String?, promise: Promise?) { + /** + * Ends a live notification. [payload] is accepted for signature parity with iOS, where a final + * content-state is what makes ActivityKit render a terminal state; Android renders its own + * terminal state (the notification simply stops being ongoing), so it is ignored here. + */ + override fun end(activityId: String?, payload: ReadableMap?, promise: Promise?) { val module = getPushModule() ?: return promise.rejectNotAvailable() try { module.endLiveNotification(requireNotNull(activityId) { "activityId is required" }) diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.mm b/ios/wrappers/liveactivities/NativeLiveActivities.mm index 3319844a..afd45f44 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.mm +++ b/ios/wrappers/liveactivities/NativeLiveActivities.mm @@ -55,13 +55,14 @@ - (void)update:(NSString *)activityId } - (void)end:(NSString *)activityId + payload:(NSDictionary *)payload resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { if (!_swiftBridge) { reject(@"live_activity_module_unavailable", @"Live Activities are unavailable.", nil); return; } - [_swiftBridge end:activityId resolve:resolve reject:reject]; + [_swiftBridge end:activityId payload:payload resolve:resolve reject:reject]; } - (void)startCustom:(NSString *)activityType diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index d87c7dbd..e01a6250 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -21,7 +21,7 @@ public class NativeLiveActivities: NSObject { /// the concrete handle and rebuild its content-state from a JS map on update. private struct ActivityBox { let update: ([String: Any]) async throws -> Void - let end: () async -> Void + let end: ([String: Any]?) async throws -> Void } private static var activities: [String: ActivityBox] = [:] @@ -129,20 +129,26 @@ public class NativeLiveActivities: NSObject { } } - @objc(end:resolve:reject:) + @objc(end:payload:resolve:reject:) public func end( _ activityId: String, + payload: NSDictionary?, resolve: @escaping RCTPromiseResolveBlock, - reject _: @escaping RCTPromiseRejectBlock + reject: @escaping RCTPromiseRejectBlock ) { Self.lock.lock() let box = Self.activities.removeValue(forKey: activityId) Self.lock.unlock() // Unknown/already-ended id is treated as success (idempotent end). guard let box else { return resolve(nil) } + let map = payload as? [String: Any] Task { - await box.end() - resolve(nil) + do { + try await box.end(map) + resolve(nil) + } catch { + reject("live_activity_end_failed", error.localizedDescription, error) + } } } @@ -183,8 +189,11 @@ public class NativeLiveActivities: NSObject { contentBuilder: @escaping ([String: Any]) throws -> A.ContentState ) { let box = ActivityBox( + // ActivityKit keeps the last content-state on screen when `end` is given none, so a + // final payload is what lets the activity show a terminal state rather than freezing + // mid-progress. update: { map in await handle.update(try contentBuilder(map)) }, - end: { await handle.end() } + end: { map in await handle.end(try map.map(contentBuilder)) } ) lock.lock() activities[handle.id] = box diff --git a/src/customerio-liveactivities.ts b/src/customerio-liveactivities.ts index 04c8139c..5f5c25aa 100644 --- a/src/customerio-liveactivities.ts +++ b/src/customerio-liveactivities.ts @@ -72,12 +72,16 @@ export class CustomerIOLiveActivities implements NativeLiveActivitiesSpec { } /** - * End a running activity. + * End a running activity, optionally rendering a final content-state. * * @param activityId - Id returned by {@link start}. + * @param payload - **iOS only.** The final content-state to render as the activity ends. + * ActivityKit keeps the last content-state on screen when none is given, so pass one to show + * a terminal state (e.g. all segments complete). Android renders its own terminal state and + * ignores this. */ - end(activityId: string): Promise { - return withNativeModule((native) => native.end(activityId)); + end(activityId: string, payload?: LiveActivityPayload): Promise { + return withNativeModule((native) => native.end(activityId, payload)); } /** diff --git a/src/specs/modules/NativeCustomerIOLiveActivities.ts b/src/specs/modules/NativeCustomerIOLiveActivities.ts index 4a6879b8..41cef71c 100644 --- a/src/specs/modules/NativeCustomerIOLiveActivities.ts +++ b/src/specs/modules/NativeCustomerIOLiveActivities.ts @@ -18,8 +18,13 @@ export interface Spec extends TurboModule { start(payload: NativeBridgeObject): Promise; /** Replace the whole content-state of a running activity. Pass the full desired state. */ update(activityId: string, payload: NativeBridgeObject): Promise; - /** End a running activity. */ - end(activityId: string): Promise; + /** + * End a running activity, optionally rendering a final content-state. + * + * `payload` is iOS-only: ActivityKit keeps the last content-state on screen unless a final one + * is supplied. Android renders its own terminal state, so it ignores the payload. + */ + end(activityId: string, payload?: NativeBridgeObject): Promise; /** * Start a custom (app-defined) activity type. Android renders it via the host app's * `createLiveNotification` callback; on iOS custom types require a native `adopt` call, diff --git a/src/types/live-activities.ts b/src/types/live-activities.ts index fd6e9b6a..276516d8 100644 --- a/src/types/live-activities.ts +++ b/src/types/live-activities.ts @@ -70,8 +70,7 @@ export interface LiveActivityCountdownTimerPayload { * @public */ export type LiveActivityPayload = - | LiveActivitySegmentsPayload - | LiveActivityCountdownTimerPayload; + LiveActivitySegmentsPayload | LiveActivityCountdownTimerPayload; /** * Live Activities branding (Android only). On iOS, branding is compiled into the From d61cc25531f1fe2932b22c54eba09332b783f6cf Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Mon, 27 Jul 2026 08:41:27 +0300 Subject: [PATCH 4/7] chore: build against the unreleased native Live Notifications SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEMPORARY (REL-1). Live Notifications don't exist in any released native SDK, so CI cannot build these branches against published versions. - Android: pin the `live-notifications-review-fixes-SNAPSHOT` branch snapshot and add the Sonatype snapshots repository to resolve it. - iOS: bump the native pin to 4.6.0, matching the version the Live Activities podspecs declare on the native branch. The sample Podfile supplies those pods via a :git/:branch override, since the LA subspecs are not on trunk yet. Revert all of this — snapshot repository, snapshot pin, and Podfile overrides — once Live Notifications ships in released native SDKs. Co-Authored-By: Claude Opus 4.8 --- android/build.gradle | 4 ++++ android/gradle.properties | 4 +++- package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 69608026..4eba8afe 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -59,6 +59,10 @@ repositories { mavenCentral() google() + // TEMPORARY (REL-1): remove once Live Notifications ships in a released Android SDK. + // Live Notifications are only available from the unreleased branch snapshot. + maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } + def found = false def defaultDir = null def androidSourcesName = 'React Native sources' diff --git a/android/gradle.properties b/android/gradle.properties index d2df90ed..1538f7e8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,4 +2,6 @@ customerio.reactnative.kotlinVersion=2.1.20 customerio.reactnative.compileSdkVersion=36 customerio.reactnative.targetSdkVersion=36 customerio.reactnative.minSdkVersion=21 -customerio.reactnative.cioSDKVersionAndroid=4.18.2 +# TEMPORARY (REL-1): pinned to the unreleased Live Notifications branch snapshot. +# Restore a released version once Live Notifications ships. +customerio.reactnative.cioSDKVersionAndroid=live-notifications-review-fixes-SNAPSHOT diff --git a/package.json b/package.json index 19b49f69..84a68337 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "./package.json": "./package.json" }, - "cioNativeiOSSdkVersion": "= 4.5.3", + "cioNativeiOSSdkVersion": "= 4.6.0", "cioiOSFirebaseWrapperSdkVersion": "= 1.0.0", "files": [ "src", From b0bce634a9b4077e4acd5c6e5a12d5843359065c Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Mon, 27 Jul 2026 09:41:32 +0300 Subject: [PATCH 5/7] fix(live-activities): satisfy lint and public API validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerates the api-extractor report, which had no Live Activities entries. Also fixes what the report surfaced: `{@link start}` and `{@link logoUrl}` were unqualified and unresolvable, and LiveActivityTemplate, LiveActivitySegmentsPayload and LiveActivityCountdownTimerPayload were missing `@public` — the `@public` in the file header sat on a detached comment block and applied to nothing, so it is removed. Formats storage.ts, whose import was collapsed past the 80-char print width. Co-Authored-By: Claude Opus 5 (1M context) --- .../customerio-reactnative.api.md | 62 +++++++++++++++++++ example/src/services/storage.ts | 7 ++- src/customerio-liveactivities.ts | 4 +- src/types/live-activities.ts | 11 +++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index c17a5e49..7ee72c47 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -39,6 +39,7 @@ export type CioConfig = { location?: { trackingMode?: CioLocationTrackingMode; }; + liveNotifications?: LiveActivitiesConfig; }; // @public @@ -93,6 +94,8 @@ export class CustomerIO { // @deprecated static readonly isInitialized: () => boolean; // (undocumented) + static readonly liveActivities: CustomerIOLiveActivities; + // (undocumented) static readonly location: CustomerIOLocation; // (undocumented) static readonly pushMessaging: CustomerIOPushMessaging; @@ -118,6 +121,16 @@ export class CustomerIOInAppMessaging implements NativeInAppSpec { registerEventsListener(listener: (event: InAppMessageEvent) => void): EventSubscription; } +// Warning: (ae-forgotten-export) The symbol "NativeLiveActivitiesSpec" needs to be exported by the entry point index.d.ts +// +// @public +export class CustomerIOLiveActivities implements NativeLiveActivitiesSpec { + end(activityId: string, payload?: LiveActivityPayload): Promise; + start(payload: LiveActivityPayload): Promise; + startCustom(activityType: string, payload: Record): Promise; + update(activityId: string, payload: LiveActivityPayload): Promise; +} + // Warning: (ae-forgotten-export) The symbol "NativeLocationSpec" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -213,6 +226,55 @@ export interface InlineInAppMessageViewProps extends Omit void; } +// @public +export interface LiveActivitiesBranding { + accentColorHex?: string; + companyName?: string; + logoResource?: string; + logoUrl?: string; + smallIconResource?: string; +} + +// @public +export interface LiveActivitiesConfig { + branding?: LiveActivitiesBranding; + customTypes?: string[]; + types?: LiveActivityTemplate[]; +} + +// @public +export interface LiveActivityCountdownTimerPayload { + endTime?: number; + header: string; + statusMessage?: string; + title: string; + // (undocumented) + type: LiveActivityTemplate.CountdownTimer; +} + +// @public +export type LiveActivityPayload = LiveActivitySegmentsPayload | LiveActivityCountdownTimerPayload; + +// @public +export interface LiveActivitySegmentsPayload { + header: string; + segmentsComplete: number; + segmentsTotal: number; + status: string; + substatus?: string; + trailingText?: string; + // (undocumented) + type: LiveActivityTemplate.Segments; +} + +// @public +export enum LiveActivityTemplate { + // (undocumented) + CountdownTimer = "io.customer.livenotifications.countdowntimer", + // (undocumented) + Segments = "io.customer.livenotifications.segments" +} + // @public export enum MetricEvent { Converted = "converted", diff --git a/example/src/services/storage.ts b/example/src/services/storage.ts index cadc943b..204497e2 100644 --- a/example/src/services/storage.ts +++ b/example/src/services/storage.ts @@ -1,6 +1,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { User } from '@utils'; -import { CioConfig, CioLocationTrackingMode, CioLogLevel, CioRegion } from 'customerio-reactnative'; +import { + CioConfig, + CioLocationTrackingMode, + CioLogLevel, + CioRegion, +} from 'customerio-reactnative'; import { Env } from '../env'; const USER_STORAGE_KEY = 'user'; diff --git a/src/customerio-liveactivities.ts b/src/customerio-liveactivities.ts index 5f5c25aa..5262baac 100644 --- a/src/customerio-liveactivities.ts +++ b/src/customerio-liveactivities.ts @@ -64,7 +64,7 @@ export class CustomerIOLiveActivities implements NativeLiveActivitiesSpec { * state — not just the changed fields. Static attributes (e.g. `header`) cannot * change after start and are ignored on iOS. * - * @param activityId - Id returned by {@link start}. + * @param activityId - Id returned by {@link CustomerIOLiveActivities.start}. * @param payload - The complete desired state. */ update(activityId: string, payload: LiveActivityPayload): Promise { @@ -74,7 +74,7 @@ export class CustomerIOLiveActivities implements NativeLiveActivitiesSpec { /** * End a running activity, optionally rendering a final content-state. * - * @param activityId - Id returned by {@link start}. + * @param activityId - Id returned by {@link CustomerIOLiveActivities.start}. * @param payload - **iOS only.** The final content-state to render as the activity ends. * ActivityKit keeps the last content-state on screen when none is given, so pass one to show * a terminal state (e.g. all segments complete). Android renders its own terminal state and diff --git a/src/types/live-activities.ts b/src/types/live-activities.ts index 276516d8..69ccf371 100644 --- a/src/types/live-activities.ts +++ b/src/types/live-activities.ts @@ -4,8 +4,6 @@ * Live Activities (iOS) / Live Notifications (Android) render live, updating * notifications from built-in templates. Two templates ship today: Segments and * Countdown Timer. Field names and identifiers are identical across platforms. - * - * @public */ /** @@ -18,6 +16,8 @@ * * Use these enum members (not raw strings) for `LiveActivitiesConfig.types` and a * payload's `type` to avoid typos. + * + * @public */ export enum LiveActivityTemplate { Segments = 'io.customer.livenotifications.segments', @@ -27,6 +27,8 @@ export enum LiveActivityTemplate { /** * Segments template — a progress activity split into N segments. * `header` is a static attribute (fixed at start); the rest are content-state. + * + * @public */ export interface LiveActivitySegmentsPayload { type: LiveActivityTemplate.Segments; @@ -47,6 +49,8 @@ export interface LiveActivitySegmentsPayload { /** * Countdown Timer template — a live countdown to a target time. * `header` is a static attribute (fixed at start); the rest are content-state. + * + * @public */ export interface LiveActivityCountdownTimerPayload { type: LiveActivityTemplate.CountdownTimer; @@ -85,7 +89,8 @@ export interface LiveActivitiesBranding { accentColorHex?: string; /** * Bundled Android drawable resource name for the logo, resolved natively. - * Preferred over {@link logoUrl} when both are set — it needs no network. + * Preferred over {@link LiveActivitiesBranding.logoUrl} when both are set — it + * needs no network. */ logoResource?: string; /** Remote logo URL (downloaded and cached natively). */ From ce5fd239cc0283f5f466349a65c4774a05f70d12 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Mon, 27 Jul 2026 10:32:20 +0300 Subject: [PATCH 6/7] fix(live-activities): unify unknown-id handling and repair the push-module log `update` rejected on an id this process didn't start while `end` resolved, and Android resolves for both because it routes the id straight to the native SDK. "Unknown" is not proof of caller error either: the map is process-local, so an activity started before a restart, or started by a push, legitimately isn't in it. Both now resolve and log instead. The push-module lookup used runCatching around an `as?` cast, which yields null rather than throwing, so its onFailure branch was unreachable and the "push module is not initialized" error never reached anyone. Replaced with an explicit null check. Renames the Kotlin config parameter to liveNotificationsConfig and corrects a KDoc that named a `liveActivities` config key that does not exist: config and wire vocabulary is liveNotifications, the API accessor stays liveActivities. Documents why Live Activities registration is unconditional on Android, unlike the Location module beside it, and that customTypes is Android-only in practice. Co-Authored-By: Claude Opus 5 (1M context) --- .../sdk/CustomerIOReactNativePackage.kt | 6 +++++ .../reactnative/sdk/NativeCustomerIOModule.kt | 4 +-- .../NativeLiveActivitiesModule.kt | 17 +++++++----- .../NativeMessagingPushModule.kt | 4 +-- .../liveactivities/NativeLiveActivities.swift | 26 ++++++++++++++++--- src/types/live-activities.ts | 9 ++++++- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt index d659ccd1..e32305ed 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt @@ -34,6 +34,12 @@ class CustomerIOReactNativePackage : BaseReactPackage() { InlineInAppMessageViewManager.NAME -> InlineInAppMessageViewManager() NativeCustomerIOLoggingModule.NAME -> NativeCustomerIOLoggingModule(reactContext) NativeCustomerIOModule.NAME -> NativeCustomerIOModule(reactContext = reactContext) + // Unconditional on purpose, unlike Location below. Location has its own artifact + // (io.customer.android:location) that the flag switches between `api` and `compileOnly`, + // so gating it genuinely keeps a dependency off the consumer's classpath. Live + // Notifications ship inside messaging-push-fcm, already a hard dependency here, so a flag + // would remove nothing — and the module is inert until `liveNotifications` config enables + // a type. iOS gates its equivalent only because the subspec pulls in extra pods. NativeLiveActivitiesModule.NAME -> NativeLiveActivitiesModule(reactContext) NativeLocationModule.NAME -> if (BuildConfig.CIO_LOCATION_ENABLED) { NativeLocationModule(reactContext) diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index e265dfdf..d8288a62 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -90,13 +90,13 @@ class NativeCustomerIOModule( ?.let { cdnHost(it) } // Configure push messaging module based on config provided by customer app. - // Live Activities are hosted by the FCM push module, so the `liveActivities` + // Live Activities are hosted by the FCM push module, so the `liveNotifications` // config is applied to the same module. packageConfig.getTypedValue>(key = "push").let { pushConfig -> NativeMessagingPushModule.addNativeModuleFromConfig( builder = this, config = pushConfig ?: emptyMap(), - liveActivitiesConfig = packageConfig.getTypedValue>(key = "liveNotifications") + liveNotificationsConfig = packageConfig.getTypedValue>(key = "liveNotifications") ) } // Configure in-app messaging module based on config provided by customer app diff --git a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt index c2601f56..a3a7dee3 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -33,11 +33,16 @@ class NativeLiveActivitiesModule( // Live Notifications are hosted by the FCM push module. Reach it via the module registry // (the wrapper can't reference the SDK-internal MODULE_NAME constant, so use the literal). - private fun getPushModule(): ModuleMessagingPushFCM? = runCatching { - SDKComponent.modules[PUSH_FCM_MODULE_NAME] as? ModuleMessagingPushFCM - }.onFailure { - logger.error("Live Notifications: push module is not initialized. Ensure the SDK is initialized with live activity templates enabled.") - }.getOrNull() + // + // Deliberately not runCatching: `as?` yields null instead of throwing, so a failure branch keyed + // on a thrown error would never run and the message below would never be logged. + private fun getPushModule(): ModuleMessagingPushFCM? { + val module = SDKComponent.modules[PUSH_FCM_MODULE_NAME] as? ModuleMessagingPushFCM + if (module == null) { + logger.error("Live Notifications: push module is not initialized. Ensure the SDK is initialized with live activity templates enabled.") + } + return module + } override fun start(payload: ReadableMap?, promise: Promise?) { val module = getPushModule() ?: return promise.rejectNotAvailable() @@ -169,7 +174,7 @@ class NativeLiveActivitiesModule( * custom types, branding) is set on the same [MessagingPushModuleConfig]. * * @param builder the push module's config builder. - * @param config the `liveActivities` config map from the customer app. + * @param config the `liveNotifications` config map from the customer app. */ internal fun applyLiveActivitiesConfig( builder: MessagingPushModuleConfig.Builder, diff --git a/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt index 2512f949..153a1e8f 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/messagingpush/NativeMessagingPushModule.kt @@ -259,7 +259,7 @@ class NativeMessagingPushModule( internal fun addNativeModuleFromConfig( builder: CustomerIOBuilder, config: Map, - liveActivitiesConfig: Map? = null, + liveNotificationsConfig: Map? = null, ) { val androidConfig = config.getTypedValue>(key = "android") ?: emptyMap() @@ -279,7 +279,7 @@ class NativeMessagingPushModule( setPushClickBehavior(pushClickBehavior = pushClickBehavior) // Live Notifications are hosted by the FCM push module, so their config is // applied to the same MessagingPushModuleConfig. - liveActivitiesConfig?.let { liveConfig -> + liveNotificationsConfig?.let { liveConfig -> NativeLiveActivitiesModule.applyLiveActivitiesConfig( builder = this, config = liveConfig, diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index e01a6250..a3f8cb11 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -1,6 +1,7 @@ #if CIO_LIVEACTIVITIES_ENABLED import ActivityKit import CioDataPipelines +import CioInternalCommon import CioLiveActivities import CioLiveActivities_Attributes import CioLiveActivities_Templates @@ -27,6 +28,16 @@ public class NativeLiveActivities: NSObject { private static var activities: [String: ActivityBox] = [:] private static let lock = NSLock() + /// Records an `update`/`end` aimed at an activity this process never started. Not surfaced to + /// JavaScript — see the call sites — but worth a log, because the same message covers both a + /// genuine caller mistake and the expected post-restart / push-started cases. + private static func logUnknownActivity(_ activityId: String, method: String) { + DIGraphShared.shared.logger.info( + "Live Activities: \(method) ignored — no activity with id \(activityId) was started by this app session. " + + "Activities started before an app restart, or started by a push, are not tracked in-process." + ) + } + // MARK: - Module registration /// Build the Live Activities module from the SDK config's `liveNotifications` key, mirroring @@ -113,8 +124,13 @@ public class NativeLiveActivities: NSObject { Self.lock.lock() let box = Self.activities[activityId] Self.lock.unlock() + // An id this process didn't start resolves rather than rejects, matching `end` here and both + // methods on Android (which routes the id to the native SDK and never learns it was unknown). + // "Unknown" is not proof of caller error: the map is process-local, so an activity started + // before an app restart, or started by a push, legitimately isn't in it. Logged, not thrown. guard let box else { - return reject("live_activity_update_failed", "No live activity found for id \(activityId)", nil) + Self.logUnknownActivity(activityId, method: "update") + return resolve(nil) } guard let map = payload as? [String: Any] else { return reject("live_activity_update_failed", "payload is required", nil) @@ -139,8 +155,12 @@ public class NativeLiveActivities: NSObject { Self.lock.lock() let box = Self.activities.removeValue(forKey: activityId) Self.lock.unlock() - // Unknown/already-ended id is treated as success (idempotent end). - guard let box else { return resolve(nil) } + // Unknown/already-ended id is treated as success (idempotent end). See `update` for why an + // unknown id is not an error. + guard let box else { + Self.logUnknownActivity(activityId, method: "end") + return resolve(nil) + } let map = payload as? [String: Any] Task { do { diff --git a/src/types/live-activities.ts b/src/types/live-activities.ts index 69ccf371..72d042b6 100644 --- a/src/types/live-activities.ts +++ b/src/types/live-activities.ts @@ -111,7 +111,14 @@ export interface LiveActivitiesConfig { * ignored, so a newer native template can't break an older wrapper build. */ types?: LiveActivityTemplate[]; - /** Reverse-DNS identifiers for custom (app-defined) activity types. */ + /** + * Reverse-DNS identifiers for custom (app-defined) activity types. + * + * **Android only in practice.** Android renders these through the app's + * `createLiveNotification` callback. On iOS a custom type needs a native Widget + * Extension and an `adopt()` call, so it cannot be started from JavaScript — + * `startCustom` always rejects there and this list is ignored. + */ customTypes?: string[]; /** Android Live Notification branding (ignored on iOS). */ branding?: LiveActivitiesBranding; From 3f0cb5885a98fc1ed40f311f9adad89cee6a0aae Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Mon, 27 Jul 2026 11:51:51 +0300 Subject: [PATCH 7/7] docs: note the required Live Activity URL forwarding step on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forwarding opened URLs to handleWidgetUrl is mandatory on iOS or Live Activity taps go unattributed, and nothing in the SDK can do it for the host. Android needs no equivalent, and the Expo config plugin injects it automatically — so this is the one platform where an integrator has to act, and it was undocumented. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 226524c7..8e1dc723 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,25 @@ This SDK supports [rich push notifications](https://customer.io/docs/sdk/react-n --- +## 🔴 Live Activities + +Enable the activity types you use under the `liveNotifications` key of your SDK config. On iOS, also add the `liveactivities` pod subspec and a Widget Extension that renders the SDK's built-in templates. + +**One manual step is required on iOS.** Forward every opened URL to the SDK from your `AppDelegate`, or taps on a Live Activity are not attributed: + +```swift +override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Reports an `opened` metric and returns the deep link to route to. A non-Customer.io URL comes + // back unchanged; `nil` means the activity carried no deep link, so there is nothing to open. + guard let routableUrl = NativeLiveActivities.handleWidgetUrl(url) else { return true } + return super.application(app, open: routableUrl, options: options) +} +``` + +Android needs no equivalent step. Expo apps need none either — the [config plugin](https://github.com/customerio/customerio-expo-plugin) injects this for you. + +--- + ## Identify Users, Track Events, and More Customer.io helps you personalize your mobile experience: