Skip to content
Draft
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,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<String, Any?>()
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<String, Any>,
) {
// Wire the host app's custom-live-notification renderer, if one was registered.
liveNotificationCallback?.let { builder.setNotificationCallback(it) }

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
5 changes: 5 additions & 0 deletions ios/wrappers/NativeCustomerIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading