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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +73,7 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
InlineInAppMessageViewManager.NAME,
NativeCustomerIOLoggingModule.NAME,
NativeCustomerIOModule.NAME,
NativeLiveActivitiesModule.NAME,
NativeLocationModule.NAME,
NativeMessagingInAppModule.NAME,
NativeMessagingPushModule.NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,14 @@ class NativeCustomerIOModule(
packageConfig.getTypedValue<String>(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<Map<String, Any>>(key = "push").let { pushConfig ->
NativeMessagingPushModule.addNativeModuleFromConfig(
builder = this,
config = pushConfig ?: emptyMap()
config = pushConfig ?: emptyMap(),
liveActivitiesConfig = packageConfig.getTypedValue<Map<String, Any>>(key = "liveActivities")
)
}
// Configure in-app messaging module based on config provided by customer app
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?>()
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<String, Any>,
) {
val templateTypes = config.getTypedValue<List<*>>("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<List<*>>("customTypes")
?.mapNotNull { it as? String }
.orEmpty()
if (customTypes.isNotEmpty()) {
builder.enableCustomLiveNotificationTypes(*customTypes.toTypedArray())
}

val branding = config.getTypedValue<Map<String, Any>>("branding")
if (branding != null) {
val accentColor = branding.getTypedValue<String>("accentColorHex")
?.let { runCatching { Color.parseColor(it) }.getOrNull() }
?: Color.TRANSPARENT
val logoUrl = branding.getTypedValue<String>("logoUrl")
val smallIcon = branding.getTypedValue<String>("smallIconResource")
?.let { name ->
val context = SDKComponent.android().applicationContext
context.resources
.getIdentifier(name, "drawable", context.packageName)
.takeIf { it != 0 }
}
builder.setLiveNotificationBranding(
LiveNotificationBranding(
companyName = branding.getTypedValue<String>("companyName").orEmpty(),
accentColor = accentColor,
smallIcon = smallIcon,
logo = logoUrl?.let { LiveNotificationAsset.RemoteUrl(it) },
),
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,7 +258,8 @@ class NativeMessagingPushModule(
*/
internal fun addNativeModuleFromConfig(
builder: CustomerIOBuilder,
config: Map<String, Any>
config: Map<String, Any>,
liveActivitiesConfig: Map<String, Any>? = null,
) {
val androidConfig =
config.getTypedValue<Map<String, Any>>(key = "android") ?: emptyMap()
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions customerio-reactnative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
106 changes: 106 additions & 0 deletions docs/live-activities.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading