diff --git a/README.md b/README.md index 226524c7..4ed49519 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,27 @@ 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. `NativeLiveActivities` comes from the wrapper pod, so import it — and note this only compiles once the `liveactivities` subspec is installed: + +```swift +import customerio_reactnative + +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: 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..2a3e456d 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=feature-live-notifications-SNAPSHOT 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..e32305ed 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,13 @@ 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) } else null @@ -71,6 +79,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..d8288a62 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 `liveNotifications` + // config is applied to the same module. packageConfig.getTypedValue>(key = "push").let { pushConfig -> NativeMessagingPushModule.addNativeModuleFromConfig( builder = this, - config = pushConfig ?: emptyMap() + config = pushConfig ?: emptyMap(), + 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 new file mode 100644 index 00000000..1921a094 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -0,0 +1,304 @@ +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.data.communication.CustomerIOLiveNotificationsCallback +import io.customer.messagingpush.di.pushModuleConfig +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). + // + // 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() + try { + val map = requireNotNull(payload) { "payload is required" } + // Custom types have no built-in template, so they take the SDK's untyped map path and are + // rendered by the app's own callback. Built-ins keep the typed path. + val isCustom = map.getString("type") == CUSTOM_TYPE_DISCRIMINATOR + val activityType = if (isCustom) { + requireCustomActivityType() + } else { + requireNotNull(map.getString("type")) { "payload.type is required" } + } + if (activityType !in enabledActivityTypes()) { + return promise.rejectNotRegistered(activityType) + } + val id = if (isCustom) { + module.startLiveNotification(activityType, customData(map)) + } else { + module.startLiveNotification(parseData(map)) + } + promise?.resolve(id) + } 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 map = requireNotNull(payload) { "payload is required" } + if (map.getString("type") == CUSTOM_TYPE_DISCRIMINATOR) { + module.updateLiveNotification(id, requireCustomActivityType(), customData(map)) + } else { + module.updateLiveNotification(id, parseData(map)) + } + promise?.resolve(null) + } catch (ex: Throwable) { + promise?.reject("live_activity_update_failed", ex.message, ex) + } + } + + /** + * 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" }) + promise?.resolve(null) + } catch (ex: Throwable) { + promise?.reject("live_activity_end_failed", ex.message, ex) + } + } + + /** + * The app's own identifier for the custom template. Absent means the app sent a custom payload + * without configuring `liveNotifications.customType` — say so, rather than starting a + * notification the allowlist would silently drop. + */ + /** + * Identifiers the SDK currently has enabled. Read from the live module config rather than cached + * at wrapper init: the config can also be built by generated native code that never calls + * [applyLiveActivitiesConfig] — the Expo config plugin does exactly that — and a cached set would + * be empty there, rejecting every start. + */ + private fun enabledActivityTypes(): Set = + SDKComponent.pushModuleConfig.liveNotificationTypes + + /** + * The custom identifier, preferring what the wrapper saw and otherwise deriving it from the live + * config: exactly one custom type is supported, so it is the single enabled identifier that no + * built-in [LiveNotificationType] claims. + */ + private fun customActivityTypeOrNull(): String? = customActivityType + ?: enabledActivityTypes().firstOrNull { identifier -> + LiveNotificationType.entries.none { it.identifier == identifier } + } + + private fun requireCustomActivityType(): String = requireNotNull(customActivityTypeOrNull()) { + "No custom Live Activity type is configured. Set `liveNotifications.customType` in your " + + "Customer.io SDK config to your own reverse-DNS identifier, and render it from your " + + "CustomerIOLiveNotificationsCallback." + } + + /** Flattens the payload's `data` map. Android stringifies every value downstream anyway. */ + private fun customData(payload: ReadableMap): Map = + payload.getMap("data")?.toHashMap() ?: emptyMap() + + private fun parseData(payload: ReadableMap): LiveNotificationData { + return when (val type = payload.getString("type")) { + LiveNotificationType.SEGMENTS.identifier -> 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"), + ) + + LiveNotificationType.COUNTDOWN_TIMER.identifier -> 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 + }, + ) + + // 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") + } + } + + 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.", + ) + } + + /** Mirrors iOS's `live_activity_type_not_registered` so the same mistake reads the same way. */ + private fun Promise?.rejectNotRegistered(activityType: String?) { + this?.reject( + "live_activity_type_not_registered", + "Live Activity type '$activityType' is not registered. Add it to " + + "`liveNotifications.types` in your Customer.io SDK config (or set " + + "`liveNotifications.customType` for a custom type).", + ) + } + + 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" + + // Discriminator JavaScript sends for the custom template. Not a wire identifier — the + // notification is started under the app's own `liveNotifications.customType`. + private const val CUSTOM_TYPE_DISCRIMINATOR = "custom" + + // The app's own identifier for the custom template, captured from config at SDK init. + @Volatile + private var customActivityType: String? = null + + // 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: CustomerIOLiveNotificationsCallback? = null + + /** + * 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 + * [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: CustomerIOLiveNotificationsCallback) { + 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 `liveNotifications` config map from the customer app. + */ + internal fun applyLiveActivitiesConfig( + builder: MessagingPushModuleConfig.Builder, + config: Map, + ) { + // Wire the host app's live-notification renderer, if one was registered. + liveNotificationCallback?.let { builder.setLiveNotificationCallback(it) } + + // 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 { identifier -> + LiveNotificationType.entries.firstOrNull { it.identifier == identifier } + } + .orEmpty() + if (templateTypes.isNotEmpty()) { + builder.enableLiveNotificationTypes(*templateTypes.toTypedArray()) + } + + // The custom template. Allowlisting the identifier is both necessary and sufficient: + // LiveNotificationHandler drops any push whose activityType isn't in this set, and a type + // with no built-in template falls through to the host app's render callback. + // + // Assigned unconditionally: a re-initialize that drops `customType` must clear it, or + // `start` would keep minting notifications under an identifier no longer allowlisted — + // which the handler then discards, leaving the caller with an id and nothing on screen. + val customType = config.getTypedValue("customType")?.trim()?.takeIf { it.isNotEmpty() } + customActivityType = customType + if (customType != null) { + builder.enableCustomLiveNotificationTypes(customType) + } + + val branding = config.getTypedValue>("branding") + if (branding != null) { + val accentColor = branding.getTypedValue("accentColorHex") + ?.let { runCatching { Color.parseColor(it) }.getOrNull() } + ?: Color.TRANSPARENT + // 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 -> drawableResId(name) } + builder.setLiveNotificationBranding( + LiveNotificationBranding( + companyName = branding.getTypedValue("companyName").orEmpty(), + accentColor = accentColor, + smallIcon = smallIcon, + 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/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..e6a0de8a 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 @@ -23,6 +23,7 @@ import io.customer.reactnative.sdk.NativeCustomerIOMessagingPushSpec import io.customer.reactnative.sdk.constant.Keys import io.customer.reactnative.sdk.extension.getTypedValue import io.customer.reactnative.sdk.extension.takeIfNotBlank +import io.customer.reactnative.sdk.liveactivities.NativeLiveActivitiesModule import io.customer.reactnative.sdk.util.unsupportedOnAndroid import io.customer.sdk.CustomerIO import io.customer.sdk.CustomerIOBuilder @@ -257,7 +258,8 @@ class NativeMessagingPushModule( */ internal fun addNativeModuleFromConfig( builder: CustomerIOBuilder, - config: Map + config: Map, + liveNotificationsConfig: 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. + liveNotificationsConfig?.let { liveConfig -> + NativeLiveActivitiesModule.applyLiveActivitiesConfig( + builder = this, + config = liveConfig, + ) + } }.build(), ) builder.addCustomerIOModule(module) diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index c17a5e49..7966c6a6 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,15 @@ 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; + 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 +225,63 @@ 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; + customType?: string; + types?: Exclude[]; +} + +// @public +export interface LiveActivityCountdownTimerPayload { + endTime?: number; + header: string; + statusMessage?: string; + title: string; + // (undocumented) + type: LiveActivityTemplate.CountdownTimer; +} + +// @public +export interface LiveActivityCustomPayload { + data: Record; + // (undocumented) + type: LiveActivityTemplate.Custom; +} + +// @public +export type LiveActivityPayload = LiveActivitySegmentsPayload | LiveActivityCountdownTimerPayload | LiveActivityCustomPayload; + +// @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", + Custom = "custom", + // (undocumented) + Segments = "io.customer.livenotifications.segments" +} + // @public export enum MetricEvent { Converted = "converted", 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/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/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 960868f0..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). diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.mm b/ios/wrappers/liveactivities/NativeLiveActivities.mm new file mode 100644 index 00000000..d25ed324 --- /dev/null +++ b/ios/wrappers/liveactivities/NativeLiveActivities.mm @@ -0,0 +1,73 @@ +#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 + 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 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..f22375d7 --- /dev/null +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -0,0 +1,327 @@ +#if CIO_LIVEACTIVITIES_ENABLED +import ActivityKit +import CioDataPipelines +import CioInternalCommon +import CioLiveActivities +import CioLiveActivities_Attributes +import CioLiveActivities_Templates +import Foundation + +@objc(NativeCustomerIOLiveActivities) +public class NativeLiveActivities: NSObject { + /// 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" + /// Discriminator JavaScript sends for the custom template. Not a wire identifier — the + /// activity is reported under the app's own `liveNotifications.customType`. + static let custom = "custom" + } + + /// 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: ([String: Any]?) async throws -> Void + } + + 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 + /// ``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() + for type in types { + switch type { + case TypeIdentifier.segments: + builder = builder.register(CIOSegmentsAttributes.self) + case TypeIdentifier.countdownTimer: + builder = builder.register(CIOCountdownTimerAttributes.self) + default: + continue + } + } + // The custom template registers one SDK-owned Swift type under the app's own identifier. + // That indirection is what lets a JavaScript app have a custom activity at all: the SDK needs + // a metatype to register and to observe push-to-start for, and a metatype can't cross a bridge. + let trimmedCustomType = (liveConfig["customType"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let customType = (trimmedCustomType?.isEmpty == false) ? trimmedCustomType : nil + if let customType { + builder = builder.register(CIOCustomAttributes.self, identifier: customType) + } + return LiveActivitiesModule(config: 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, *) 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 TypeIdentifier.segments: + guard let handle = try CustomerIO.liveActivities.start( + CIOSegmentsAttributes(header: try Self.requireString(map, "header")), + 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 TypeIdentifier.countdownTimer: + guard let handle = try CustomerIO.liveActivities.start( + CIOCountdownTimerAttributes(header: try Self.requireString(map, "header")), + contentState: Self.countdownState(from: map) + ) else { + return reject(Self.notRegisteredCode, Self.notRegisteredMessage(type), nil) + } + Self.store(handle: handle, contentBuilder: Self.countdownState) + id = handle.id + case TypeIdentifier.custom: + // No pre-check on the stored identifier: the SDK config can also be built by generated + // native code that never calls `module(from:)` — the Expo config plugin does exactly + // that — so a nil stored value doesn't mean the type is unregistered. Attempt the start + // and let the SDK answer; a nil handle means it genuinely isn't registered. + guard let handle = try CustomerIO.liveActivities.start( + CIOCustomAttributes(), + contentState: Self.customState(from: map) + ) else { + return reject(Self.notRegisteredCode, Self.customNotRegisteredMessage, nil) + } + Self.store(handle: handle, contentBuilder: Self.customState) + id = handle.id + default: + // 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 { + 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() + // An unknown id means the update did not happen, so it must not report success. Unlike `end` + // — where re-ending an already-ended activity is a legitimate no-op — there is nothing + // idempotent about an update that never applied. Android *does* perform it (it routes the id + // straight to the native SDK), so resolving here would make the same call report success on + // both platforms while only one of them did anything. + guard let box else { + Self.logUnknownActivity(activityId, method: "update") + return reject( + "live_activity_update_failed", + "No live activity found for id \(activityId). On iOS only activities started in this " + + "app session can be updated.", + 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:payload:resolve:reject:) + public func end( + _ activityId: String, + payload: NSDictionary?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + Self.lock.lock() + let box = Self.activities[activityId] + Self.lock.unlock() + // 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 { + try await box.end(map) + // Dropped only once the end succeeded. Removing up front would lose the handle on a + // throw, and the retry would then take the unknown-id path above and report success + // for an activity still on screen. + Self.forget(activityId) + resolve(nil) + } catch { + reject("live_activity_end_failed", error.localizedDescription, error) + } + } + } + + /// 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: 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 handleWidgetUrl(_ url: URL) -> URL? { + CustomerIO.liveActivities.handleWidgetUrl(url) + } + + // MARK: - Helpers + + @available(iOS 16.2, *) + private static func store( + handle: CIOLiveActivity, + 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: { map in await handle.end(try map.map(contentBuilder)) } + ) + lock.lock() + activities[handle.id] = box + lock.unlock() + } + + private static func forget(_ activityId: String) { + lock.lock() + activities.removeValue(forKey: activityId) + lock.unlock() + } + + /// Thrown for a missing required field so the caller sees a rejected promise naming it, rather + /// than an activity rendering with a blank line. Matches Android, whose `requireString` / + /// `requireDouble` already reject — an untyped JavaScript caller should not get a silent + /// half-rendered card on one platform and an error on the other. + private struct MissingFieldError: LocalizedError { + let field: String + var errorDescription: String? { "\(field) is required" } + } + + private static func requireString(_ map: [String: Any], _ key: String) throws -> String { + guard let value = map[key] as? String else { throw MissingFieldError(field: key) } + return value + } + + private static func requireInt(_ map: [String: Any], _ key: String) throws -> Int { + guard let value = map[key] as? NSNumber else { throw MissingFieldError(field: key) } + return value.intValue + } + + @available(iOS 16.2, *) + private static func segmentsState(from map: [String: Any]) throws -> CIOSegmentsAttributes.ContentState { + CIOSegmentsAttributes.ContentState( + status: try requireString(map, "status"), + substatus: map["substatus"] as? String, + segmentsTotal: try requireInt(map, "segmentsTotal"), + segmentsComplete: try requireInt(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: try requireString(map, "title"), + statusMessage: map["statusMessage"] as? String, + endTime: endTime + ) + } + + /// Builds the custom template's content-state from the JS payload's `data` map. + /// + /// Values are coerced to strings rather than rejected: JavaScript numbers and booleans arrive as + /// `NSNumber`, and refusing them would make `{ eta: 5 }` fail for no reason a caller can see. + /// Anything without a sensible text form is dropped instead of stringifying as gibberish. + @available(iOS 16.2, *) + private static func customState(from map: [String: Any]) throws -> CIOCustomAttributes.ContentState { + let raw = map["data"] as? [String: Any] ?? [:] + var data: [String: String] = [:] + for (key, value) in raw { + switch value { + case let string as String: data[key] = string + case let number as NSNumber: data[key] = number.stringValue + default: continue + } + } + return CIOCustomAttributes.ContentState(data: data) + } + + private static let unavailableCode = "live_activity_module_unavailable" + private static let unavailableMessage = + "Live Activities require iOS 16.2 or later." + + private static let customNotRegisteredMessage = + "No custom Live Activity type is registered. Set `liveNotifications.customType` in your " + + "Customer.io SDK config to your own reverse-DNS identifier, and render CIOCustomAttributes " + + "in your Widget Extension." + + 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/package.json b/package.json index 54b2c49a..a8f9986f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "./package.json": "./package.json" }, - "cioNativeiOSSdkVersion": "= 4.5.3", + "cioNativeiOSSdkVersion": "= 4.6.1", "cioiOSFirebaseWrapperSdkVersion": "= 1.0.0", "files": [ "src", @@ -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..7773afa3 --- /dev/null +++ b/src/customerio-liveactivities.ts @@ -0,0 +1,64 @@ +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 (iOS) / Live Notifications (Android). + * + * Start, update, and end live, updating notifications from built-in templates + * (Segments, Countdown Timer). Enable the activity types via the `liveNotifications` + * 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 NativeModule.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 CustomerIOLiveActivities.start}. + * @param payload - The complete desired state. + */ + update(activityId: string, payload: LiveActivityPayload): Promise { + return NativeModule.update(activityId, payload); + } + + /** + * End a running activity, optionally rendering a final content-state. + * + * @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 + * ignores this. + */ + end(activityId: string, payload?: LiveActivityPayload): Promise { + return NativeModule.end(activityId, 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..c650ea6d --- /dev/null +++ b/src/specs/modules/NativeCustomerIOLiveActivities.ts @@ -0,0 +1,37 @@ +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) + * or by a custom type you render yourself. + * + * @see NativeCustomerIO.ts for detailed documentation on TurboModule patterns, + * Codegen compatibility, and type safety approach. + */ + +type NativeBridgeObject = UnsafeObject; + +export interface Spec extends TurboModule { + /** Start an 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, 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; +} + +// Registered unconditionally on both platforms — Android in CustomerIOReactNativePackage, iOS via +// an ObjC bridge that is always compiled. When the Live Activities pods are absent the module is +// still present and its methods reject with `live_activity_module_unavailable`, so `getEnforcing` +// is accurate here (unlike Location, which really can be absent and uses `get`). +export default TurboModuleRegistry.getEnforcing( + 'NativeCustomerIOLiveActivities' +); diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index 83648d2a..1c492d26 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. */ + liveNotifications?: 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..292f05e3 --- /dev/null +++ b/src/types/live-activities.ts @@ -0,0 +1,170 @@ +/** + * 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. + */ + +/** + * Built-in activity type identifiers usable from the wrapper. + * + * 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. + * + * @public + */ +export enum LiveActivityTemplate { + Segments = 'io.customer.livenotifications.segments', + CountdownTimer = 'io.customer.livenotifications.countdowntimer', + /** + * Your own activity type, rendered by SwiftUI you write. + * + * Unlike the members above, this value is a marker rather than an identifier — your activity is + * named by {@link LiveActivitiesConfig.customType}, and the SDK reports it under that name. It + * belongs in a payload's `type`, never in {@link LiveActivitiesConfig.types}. + */ + Custom = 'custom', +} + +/** + * 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; + /** 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; +} + +/** + * Custom template — your own activity type, rendered by SwiftUI you write. + * + * Unlike the built-in templates there is no schema: `data` is an untyped map, because a bridge + * payload carries nothing for the SDK to validate against. Every value is a string, so your widget + * parses whichever ones it needs. + * + * Requires {@link LiveActivitiesConfig.customType} to be set. On iOS you must also render + * `CIOCustomAttributes` in your Widget Extension; on Android your `createLiveNotification` + * callback receives the same map. + * + * @public + */ +export interface LiveActivityCustomPayload { + type: LiveActivityTemplate.Custom; + /** + * The full content-state, re-sent in its entirety on every update. Neither platform keeps a + * static/dynamic split for custom types, so include anything your widget needs each time. + * + * Values must be strings. Numbers and booleans are coerced, but nested objects and arrays are + * not supported and the platforms disagree on them — iOS drops them, Android stringifies them — + * so flatten anything structured before sending it. + */ + data: Record; +} + +/** + * 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; + /** 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 + | LiveActivityCustomPayload; + +/** + * 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; + /** + * Bundled Android drawable resource name for the logo, resolved natively. + * Preferred over {@link LiveActivitiesBranding.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. */ + smallIconResource?: string; +} + +/** + * Live Activities configuration, passed under the `liveNotifications` key of the SDK config. + * + * @public + */ +export interface LiveActivitiesConfig { + /** + * 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. + * + * `Custom` is excluded on purpose — it is enabled by {@link LiveActivitiesConfig.customType}, + * and both platforms would silently drop it from this list. + */ + types?: Exclude[]; + /** + * Your own reverse-DNS identifier for the custom activity type, e.g. + * `'com.myapp.rideshare'`. Setting it enables the custom template on both platforms. + * + * Start one with `{ type: LiveActivityTemplate.Custom, data: { … } }`; the SDK reports it + * under this identifier, and your campaigns target it by the same name. + * + * Singular by design: iOS resolves an activity's type from its Swift attributes type, and + * every custom activity shares one. A second identifier could not be told apart, so one + * identifier is the limit rather than a silent mis-attribution. + * + * You must also render it yourself — `CIOCustomAttributes` in an iOS Widget Extension, and + * the `createLiveNotification` callback on Android. + */ + customType?: string; + /** Android Live Notification branding (ignored on iOS). */ + branding?: LiveActivitiesBranding; +}