Skip to content

feat(live-activities): expose Live Activities through CustomerIO.liveActivities - #617

Merged
mahmoud-elmorabea merged 15 commits into
feature/live-notificationsfrom
live-activities-sdk
Jul 30, 2026
Merged

feat(live-activities): expose Live Activities through CustomerIO.liveActivities#617
mahmoud-elmorabea merged 15 commits into
feature/live-notificationsfrom
live-activities-sdk

Conversation

@mahmoud-elmorabea

@mahmoud-elmorabea mahmoud-elmorabea commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 liveNotificationstypes are the reverse-DNS identifiers shared with both native SDKs; unrecognized ones are ignored. end optionally 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 new liveNotifications key on CioConfig (enabled template types, optional customType, Android branding).

On JavaScript, new public types (LiveActivityTemplate, segment/countdown/custom payloads) and a TurboModule spec; end optionally accepts a final content-state for iOS terminal UI. On Android, a new native module forwards to the FCM push module, applies liveNotifications at init, and supports a pre-init setLiveNotificationCallback for custom notifications. On iOS, an optional liveactivities pod subspec gates Swift/ObjC bridge code; SDK init registers enabled types, and apps must forward widget tap URLs through NativeLiveActivities.handleWidgetUrl in AppDelegate.

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.

…K module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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.


@mahmoud-elmorabea
mahmoud-elmorabea changed the base branch from main to feature/live-notifications July 23, 2026 06:03
mahmoud-elmorabea and others added 2 commits July 26, 2026 07:39
…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>
@mahmoud-elmorabea mahmoud-elmorabea changed the title feat(live-activities): expose Live Activities / Live Notifications SDK module feat(live-activities): expose Live Activities through CustomerIO.liveActivities Jul 27, 2026
mahmoud-elmorabea and others added 9 commits July 27, 2026 08:41
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.
@mahmoud-elmorabea
mahmoud-elmorabea marked this pull request as ready for review July 28, 2026 06:31
@mahmoud-elmorabea
mahmoud-elmorabea requested a review from a team as a code owner July 28, 2026 06:31
if (branding != null) {
val accentColor = branding.getTypedValue<String>("accentColorHex")
?.let { runCatching { Color.parseColor(it) }.getOrNull() }
?: Color.TRANSPARENT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md Outdated
```swift
import customerio_reactnative

override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread package.json Outdated
"./package.json": "./package.json"
},
"cioNativeiOSSdkVersion": "= 4.5.3",
"cioNativeiOSSdkVersion": "= 4.6.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90dbbbe. Configure here.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@mahmoud-elmorabea
mahmoud-elmorabea merged commit 0e96127 into feature/live-notifications Jul 30, 2026
12 of 14 checks passed
@mahmoud-elmorabea
mahmoud-elmorabea deleted the live-activities-sdk branch July 30, 2026 08:16

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 425e65a. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants