feat(live-activities): expose Live Activities through CustomerIO.liveActivities - #617
Conversation
…K module Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sample app builds 📱Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request. |
…to liveNotifications Follows the native iOS change that makes Live Activities a registerable CustomerIOModule reached via CustomerIO.liveActivities. - iOS: replace the held static module + initializeModule(from:) with module(from:) registered through sdkConfigBuilder.addModule, mirroring NativeLocation. Enables push-to-start on every init path. - iOS: a nil start result (module unregistered / type not enabled) becomes a rejected promise instead of a crash; unknown types fail softly. - iOS: reportDeepLinkOpen -> handleWidgetUrl, returning the redirect URL. - Android: migrate to the dedicated CustomerIOLiveNotificationsCallback and setLiveNotificationCallback; createLiveNotification no longer lives on CustomerIOPushNotificationCallback. - Config: `liveActivities` -> `liveNotifications`, `templates` -> `types`, and types are now the reverse-DNS identifiers shared by both native SDKs and the wire format. Unrecognized identifiers are ignored. - Branding: add logoResource (bundled drawable), preferred over logoUrl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ending an activity left the last content-state on screen — a Segments activity ended at "3 of 4" and stayed there. ActivityKit keeps the previous content when `Activity.end` is given none, and the bridge never passed one even though the native handle has always accepted it. `end` now takes an optional payload, mirroring `update`, so callers can show a terminal state (e.g. all segments complete). iOS-only by nature: Android's `endLiveNotification(activityId)` takes no final state and renders its own terminal presentation (the notification stops being ongoing), which is why Android already looked correct. The Android bridge accepts the argument for signature parity and ignores it, the same way branding is Android-only and ignored on iOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TEMPORARY (REL-1). Live Notifications don't exist in any released native SDK, so CI cannot build these branches against published versions. - Android: pin the `live-notifications-review-fixes-SNAPSHOT` branch snapshot and add the Sonatype snapshots repository to resolve it. - iOS: bump the native pin to 4.6.0, matching the version the Live Activities podspecs declare on the native branch. The sample Podfile supplies those pods via a :git/:branch override, since the LA subspecs are not on trunk yet. Revert all of this — snapshot repository, snapshot pin, and Podfile overrides — once Live Notifications ships in released native SDKs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerates the api-extractor report, which had no Live Activities entries.
Also fixes what the report surfaced: `{@link start}` and `{@link logoUrl}` were
unqualified and unresolvable, and LiveActivityTemplate, LiveActivitySegmentsPayload
and LiveActivityCountdownTimerPayload were missing `@public` — the `@public` in the
file header sat on a detached comment block and applied to nothing, so it is removed.
Formats storage.ts, whose import was collapsed past the 80-char print width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odule log `update` rejected on an id this process didn't start while `end` resolved, and Android resolves for both because it routes the id straight to the native SDK. "Unknown" is not proof of caller error either: the map is process-local, so an activity started before a restart, or started by a push, legitimately isn't in it. Both now resolve and log instead. The push-module lookup used runCatching around an `as?` cast, which yields null rather than throwing, so its onFailure branch was unreachable and the "push module is not initialized" error never reached anyone. Replaced with an explicit null check. Renames the Kotlin config parameter to liveNotificationsConfig and corrects a KDoc that named a `liveActivities` config key that does not exist: config and wire vocabulary is liveNotifications, the API accessor stays liveActivities. Documents why Live Activities registration is unconditional on Android, unlike the Location module beside it, and that customTypes is Android-only in practice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forwarding opened URLs to handleWidgetUrl is mandatory on iOS or Live Activity taps go unattributed, and nothing in the SDK can do it for the host. Android needs no equivalent, and the Expo config plugin injects it automatically — so this is the one platform where an integrator has to act, and it was undocumented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Custom activity types were Android-only in practice and start-only. iOS rejected them outright, because ActivityKit needs a concrete Swift type to register an activity and observe its push-to-start token, and a metatype cannot cross a bridge. The native SDK now ships CIOCustomAttributes, whose content is an untyped map, so the SDK owns the Swift type and JavaScript supplies the values. Naming the type via `liveNotifications.customType` registers it under the app's own reverse-DNS identifier, which means iOS custom activities get push-to-start and metrics for the first time, and Android and iOS finally share one code path. `customType` is singular because an activity's reported type is resolved from its attributes type: registering the one type twice would attribute every event to whichever identifier came first, so the limit is expressed in the API rather than left as a silent mis-attribution. Removes `startCustom`. `start`/`update`/`end` with a LiveActivityTemplate.Custom payload replace it and, unlike it, work on both platforms and support updates. Android routes those through the native map overloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…idn't start `update` was made to resolve on an unknown id, on the reasoning that the id might belong to a push-started activity legitimately absent from the process-local map. That reasoning was wrong: the bridge exposes no listener, so an id only ever reaches JavaScript as the return value of `start` and a push-started id can never be passed back in. What remains is an update that did not happen being reported as success — and Android performs it in the same situation, since it routes the id straight to the native SDK. Resolving here made one call return success on both platforms while only one of them did anything. `end` keeps resolving: re-ending an already-ended activity is a legitimate no-op. Nothing about an update that never applied is idempotent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android returned a real activity id for a type that was never enabled. The native start call mints one regardless, and the handler then drops the notification at debug level, so from JavaScript the call looked like it worked and nothing rendered. It now rejects with the same code and message iOS already used. `customActivityType` was only assigned when `customType` was present, so a re-initialize that dropped it kept the stale value. Android would then start under an identifier no longer allowlisted — straight into the silent drop above. Both platforms now assign unconditionally. iOS coerced missing required fields to "" and 0 while Android rejected them, so an untyped caller got a blank card on one platform and an error on the other. iOS now rejects too, naming the field. `end` removed the handle before awaiting, so a throw lost it and the retry took the unknown-id path and reported success for an activity still on screen. It now forgets the handle only once the end succeeds. Drops the `withNativeModule` wrapper: the module is registered unconditionally on both platforms, so its null branch and __DEV__ warning were unreachable and its comment wrong. `getEnforcing` matches every other module here except Location, which really can be absent. `types` no longer accepts `Custom`, which both platforms silently dropped, and the custom `data` doc now states that nested values are unsupported and diverge. Also: import order, and the README snippet was missing `import customerio_reactnative` and the subspec prerequisite that the sample app has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r have seen The previous commit recorded the enabled activity types while applying the wrapper's own config, then rejected any start whose type wasn't in that set. That holds only when the SDK config is built from JavaScript. It isn't always: the Expo config plugin generates native code that calls `.enableLiveNotificationTypes(...)` straight on the app's config builder, so the wrapper's hook never runs, the recorded set stays empty, and every Android start would have been rejected — built-in templates included. Android now reads `SDKComponent.pushModuleConfig.liveNotificationTypes`, which is authoritative whoever built the config, and derives the custom identifier from it when the wrapper didn't see one: with a single custom type it is the one enabled identifier no built-in LiveNotificationType claims. iOS drops the pre-check for the same reason and lets the SDK answer — a nil handle already means "not registered", on every initialization path. That leaves the stored identifier unread, so it is removed. Caching what the wrapper happened to observe was the mistake; asking the SDK is both shorter and correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e branch The per-PR branch this snapshot was named after was deleted when it merged, so its artifact is frozen. The base feature branch it merged into publishes a snapshot from the same open PR and now carries the review-fix API this wrapper calls.
| if (branding != null) { | ||
| val accentColor = branding.getTypedValue<String>("accentColorHex") | ||
| ?.let { runCatching { Color.parseColor(it) }.getOrNull() } | ||
| ?: Color.TRANSPARENT |
There was a problem hiding this comment.
accentColorHex is optional, so could we preserve the absence of a color here instead of passing Color.TRANSPARENT as an explicit override? Android treats that zero value as its default color, so this does not make the UI invisible, but it does bypass an app's configured default_notification_color whenever branding only supplies a logo or company name. This feels non-blocking, but keeping the value nullable through the native contract would preserve the normal app-level fallback.
| ```swift | ||
| import customerio_reactnative | ||
|
|
||
| override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { |
There was a problem hiding this comment.
Could we make these iOS steps copyable for the current React Native AppDelegate shape and include the host Info.plist flag? The sample in this same stack is UIResponder, UIApplicationDelegate, so override plus super.application(...) does not compile or route there; it correctly uses RCTLinkingManager instead. It also adds NSSupportsLiveActivities = true, which this setup never tells customers to add. Showing both delegate shapes, or linking to the exact sample, plus the Info.plist key would keep the public setup from leading to a build that cannot start activities.
| "./package.json": "./package.json" | ||
| }, | ||
| "cioNativeiOSSdkVersion": "= 4.5.3", | ||
| "cioNativeiOSSdkVersion": "= 4.6.0", |
There was a problem hiding this comment.
Can we update this pin to the native release that will actually contain Live Activities before publishing the wrapper? On the current stack, iOS #1165 is at 4.6.1 while this requires 4.6.0, and the three new Live Activities podspecs on that branch also need their 4.6.0/4.6.1 conflict fixed. A fresh sample pod install currently fails resolution, so this should move together with the native podspec fix rather than leaving the wrapper pinned to the pre-feature release.
The Live Activities branch merged main and its podspec version moved to 4.6.1, so this pin no longer resolved: CocoaPods saw `CustomerIO/DataPipelines (= 4.6.1)` from the branch-sourced CustomerIO pod and `(= 4.6.0)` from here, and failed.
| builder = this, | ||
| config = pushConfig ?: emptyMap() | ||
| config = pushConfig ?: emptyMap(), | ||
| liveNotificationsConfig = packageConfig.getTypedValue<Map<String, Any>>(key = "liveNotifications") |
There was a problem hiding this comment.
Live config needs push key
Medium Severity
On Android, liveNotifications is only passed into addNativeModuleFromConfig inside a Kotlin let on the push config map. When push is absent or null, that block never runs, so the FCM module is not registered and applyLiveActivitiesConfig never runs—even if liveNotifications is set. iOS registers Live Activities from liveNotifications alone, so the same JS config works on iOS but Live Notifications stay unavailable on Android.
Reviewed by Cursor Bugbot for commit d28b944. Configure here.
The AppDelegate snippet used `override func` and `super.application(...)`, which does not compile in a React Native app: its AppDelegate conforms to UIApplicationDelegate directly rather than subclassing, and the URL has to be passed to RCTLinkingManager. Replaced with the form the sample app actually uses, and added the NSSupportsLiveActivities Info.plist key the setup list omitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| module.updateLiveNotification(id, requireCustomActivityType(), customData(map)) | ||
| } else { | ||
| module.updateLiveNotification(id, parseData(map)) | ||
| } |
There was a problem hiding this comment.
Update reparses by payload type
Medium Severity
Android update builds notification data via parseData, which switches on payload.type each time. iOS binds the template at start and ignores a mismatched type on update. A wrong or stale type on Android can apply the wrong template shape to an existing notification id while iOS still updates the original activity correctly.
Reviewed by Cursor Bugbot for commit 90dbbbe. Configure here.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
0e96127
into
feature/live-notifications
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Reviewed by Cursor Bugbot for commit 425e65a. Configure here.
| // unchanged, so existing deep links keep working. | ||
| guard let routableUrl = NativeLiveActivities.handleWidgetUrl(url) else { return true } | ||
|
|
||
| return RCTLinkingManager.application(app, open: routableUrl, options: options) |
There was a problem hiding this comment.
Widget URL before SDK init
Medium Severity
The sample and docs forward every opened URL through NativeLiveActivities.handleWidgetUrl in AppDelegate, but CustomerIO.initialize (which registers Live Activities) runs later from JavaScript. Cold starts from a Live Activity tap can hit the handler before the module exists, losing open metrics and deep-link routing.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 425e65a. Configure here.


Exposes Live Activities (iOS) / Live Notifications (Android) on the React Native SDK, consuming the native SDK-managed module so registration happens at SDK init.
Config lives under
liveNotifications—typesare the reverse-DNS identifiers shared with both native SDKs; unrecognized ones are ignored.endoptionally takes a final content-state so iOS can render a terminal card.Depends on customerio-ios#1178.
Note
Medium Risk
Large cross-platform surface (push init, notifications, ActivityKit) with unreleased native SDK pins; iOS URL forwarding is required for correct tap attribution.
Overview
Adds Live Activities (iOS) / Live Notifications (Android) to the React Native SDK via
CustomerIO.liveActivities(start,update,end) and a newliveNotificationskey onCioConfig(enabled template types, optionalcustomType, Android branding).On JavaScript, new public types (
LiveActivityTemplate, segment/countdown/custom payloads) and a TurboModule spec;endoptionally accepts a final content-state for iOS terminal UI. On Android, a new native module forwards to the FCM push module, appliesliveNotificationsat init, and supports a pre-initsetLiveNotificationCallbackfor custom notifications. On iOS, an optionalliveactivitiespod subspec gates Swift/ObjC bridge code; SDK init registers enabled types, and apps must forward widget tap URLs throughNativeLiveActivities.handleWidgetUrlinAppDelegate.Builds temporarily pin unreleased native SDK snapshots (Android Maven + example iOS git branch). The example app adds a Live Activities screen, iOS Widget Extension (built-in + rideshare custom), and Android rideshare callback; README documents setup.
Reviewed by Cursor Bugbot for commit 425e65a. Bugbot is set up for automated code reviews on this repo. Configure here.