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..111d4167 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/liveactivities/NativeLiveActivitiesModule.kt @@ -0,0 +1,200 @@ +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) + } + } + + override fun handleDeepLinkOpen(url: String?, promise: Promise?) { + // Deep-link/opened attribution for Live Activities is an iOS-only concept; no-op on Android. + promise?.resolve(false) + } + + 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" + + /** + * 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, + ) { + 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/docs/live-activities.md b/docs/live-activities.md new file mode 100644 index 00000000..67be3463 --- /dev/null +++ b/docs/live-activities.md @@ -0,0 +1,106 @@ +# Live Activities (iOS) / Live Notifications (Android) + +The `CustomerIO.liveActivities` module shows a live, updating notification driven by a +built-in template. Two templates ship today: **Segments** and **Countdown Timer**. + +- **Android** renders built-in templates entirely inside the SDK — **no native code required**. +- **iOS** requires a **Widget Extension** in your app to render (ActivityKit has no data-only + entry point). The SwiftUI for the built-in templates ships in the SDK, so your widget file is + ~10 lines (below). Live Activities need **iOS 16.2+** (push-to-start needs 17.2+). + +## Enable templates at init + +```ts +import { CustomerIO } from 'customerio-reactnative'; + +CustomerIO.initialize({ + cdpApiKey: '…', + liveActivities: { + templates: ['segments', 'countdownTimer'], + // Android-only branding (iOS branding is compiled into the widget): + branding: { companyName: 'Acme', accentColorHex: '#FF6D00', logoUrl: 'https://…/logo.png' }, + }, +}); +``` + +## Start / update / end + +```ts +// Segments +const id = await CustomerIO.liveActivities.start({ + type: 'segments', + header: 'Order #4021', + status: 'Preparing your order', + segmentsTotal: 4, + segmentsComplete: 1, +}); + +// update() replaces the whole content-state — pass the FULL desired state each time. +await CustomerIO.liveActivities.update(id, { + type: 'segments', + header: 'Order #4021', + status: 'Out for delivery', + segmentsTotal: 4, + segmentsComplete: 3, +}); + +await CustomerIO.liveActivities.end(id); + +// Countdown Timer — endTime is epoch SECONDS +const c = await CustomerIO.liveActivities.start({ + type: 'countdownTimer', + header: 'Flash Sale', + title: '50% off ends in', + endTime: Math.floor(Date.now() / 1000) + 3600, +}); +``` + +`start` resolves with an `activityId` — the only handle you need for `update`/`end`. There is +no lifecycle event callback; delivery/lifecycle metrics are reported to Customer.io automatically. + +### Custom types + +`startCustom(activityType, data)` starts an app-defined type. **Android** renders it via your +`CustomerIOPushNotificationCallback.createLiveNotification` callback. **On iOS this rejects** — +custom types require a native Widget Extension + `ActivityAttributes` and an `adopt()` call. + +### Deep-link / opened metric (iOS) + +Call `CustomerIO.liveActivities.handleDeepLinkOpen(url)` from your URL-handling entry point to +report an `opened` metric when the app is opened from a tapped Live Activity. No-op on Android. + +## iOS setup (one time) + +1. Add the Live Activities pods to your app target's Podfile: + ```ruby + pod 'customerio-reactnative/liveactivities' + ``` +2. Add `NSSupportsLiveActivities = YES` to your app's `Info.plist`. +3. Add a **Widget Extension** target (File ▸ New ▸ Target ▸ Widget Extension). In its Podfile + target, add: + ```ruby + pod 'CustomerIO/LiveActivitiesTemplates' + pod 'CustomerIO/LiveActivitiesAttributes' + ``` +4. Replace the generated widget bundle with the built-in templates: + ```swift + import WidgetKit + import CioLiveActivities_Templates // ships CIOSegmentsLiveActivity, CIOCountdownTimerLiveActivity + import CioLiveActivities_Attributes + + @main + struct CIOLiveActivitiesWidgets: WidgetBundle { + var body: some Widget { + CIOSegmentsLiveActivity() + CIOCountdownTimerLiveActivity() + } + } + ``` + +> Expo apps can generate the widget extension automatically via the config plugin (planned). + +## Android setup + +Nothing beyond the standard `messaging-push-fcm` setup — built-in templates render in-SDK. For +API 36+ the SDK uses `Notification.ProgressStyle`; older versions fall back to a native progress +notification. diff --git a/example/src/navigation/props.ts b/example/src/navigation/props.ts index f7e24907..d8a8ba0b 100644 --- a/example/src/navigation/props.ts +++ b/example/src/navigation/props.ts @@ -12,6 +12,7 @@ export const CustomDeviceAttrScreenName = 'Device Attributes' as const; export const InternalSettingsScreenName = 'Internal Settings' as const; export const InlineExamplesScreenName = 'Inline Examples' as const; export const InboxMessagesScreenName = 'Inbox Messages' as const; +export const LiveActivitiesScreenName = 'Live Activities' as const; export const LocationScreenName = 'Location' as const; export type NavigationStackParamList = { @@ -25,6 +26,7 @@ export type NavigationStackParamList = { [InternalSettingsScreenName]: undefined; [InlineExamplesScreenName]: undefined; [InboxMessagesScreenName]: undefined; + [LiveActivitiesScreenName]: undefined; [LocationScreenName]: undefined; }; diff --git a/example/src/screens/content-navigator.tsx b/example/src/screens/content-navigator.tsx index 7a3b420e..201a2a68 100644 --- a/example/src/screens/content-navigator.tsx +++ b/example/src/screens/content-navigator.tsx @@ -5,6 +5,7 @@ import { InboxMessagesScreenName, InlineExamplesScreenName, InternalSettingsScreenName, + LiveActivitiesScreenName, LocationScreenName, LoginScreenName, NavigationCallbackContext, @@ -23,6 +24,7 @@ import { InboxMessagesScreen, InlineExamplesScreen, InternalSettingsScreen, + LiveActivitiesScreen, LocationScreen, LogingScreen, SettingsScreen, @@ -130,6 +132,15 @@ export const ContentNavigator = ({ appName }: { appName: string }) => { headerBackVisible: true, }} /> + navigation.navigate(LocationScreenName)} /> +