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
4 changes: 4 additions & 0 deletions .github/workflows/build-sample-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ on:
env:
RUBY_VERSION: '3.2'
NODE_VERSION: '20'
# SHOWCASE ONLY (showcase/optional-cio-inclusion): force the disabled path so
# CI validates that the sample apps build with Customer.io fully excluded.
# Do not merge — normal builds must leave this unset (defaults to enabled).
CIO_ENABLED: '0'

jobs:
build-sample-app:
Expand Down
27 changes: 27 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@ All `CustomerIO.*` calls are located in [`src/App.tsx`](src/App.tsx) for easy re

---

## 🧩 Conditional Customer.io inclusion (`CIO_ENABLED`)

This app demonstrates how a host that ships **separate builds per customer** can include Customer.io for some builds and **fully exclude** it for others — no compiled CIO code, none of the SDK's JS bundled, and zero runtime footprint — all driven by a single build-time flag.

Set `CIO_ENABLED=0` at build time to exclude Customer.io; omit it (or set anything else) to include it (the default).

| Layer | File | What the flag does |
| --- | --- | --- |
| JS bundle | [`metro.config.js`](metro.config.js) | Resolves the `@cio` facade to [`src/cio/index.noop.ts`](src/cio/index.noop.ts) (inert stub) instead of [`index.real.ts`](src/cio/index.real.ts), so `customerio-reactnative` is never bundled. |
| Native linking | [`react-native.config.js`](react-native.config.js) | Disables autolinking (`platforms: { ios: null, android: null }`) so the native module isn't compiled in and its manifest entries/pods don't merge. |
| iOS pods | [`ios/Podfile`](ios/Podfile) | Skips the `customerio-reactnative` + rich-push pods. |
| iOS native code | [`ios/SampleApp/AppDelegate.swift`](ios/SampleApp/AppDelegate.swift), [`ios/NotificationServiceExtension/NotificationService.swift`](ios/NotificationServiceExtension/NotificationService.swift) | `#if canImport(CioMessagingPush…)` guards compile out the CIO app-delegate wrapper, push init, and the rich-push Notification Service Extension logic automatically when the pods are absent (the NSE falls back to delivering the notification unmodified). |

> **All four must read the same flag.** The app imports CIO only through the `@cio` facade, never from `customerio-reactnative` directly. Every native bridge in the SDK uses `TurboModuleRegistry.getEnforcing(...)`, which throws at launch if the JS is loaded while the native module is unlinked — so a lazy/conditional `import()` is **not** a safe substitute for the facade swap.

```bash
# Include Customer.io (default)
npm run ios
npm run android

# Exclude Customer.io entirely
CIO_ENABLED=0 npm run pods && CIO_ENABLED=0 npm run ios
CIO_ENABLED=0 npm run android
```

---

## 📱 iOS SDK Integration

Key files involved in the native iOS setup:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import UserNotifications

#if USE_FCM
#if canImport(CioMessagingPushFCM)
import CioMessagingPushFCM
typealias PushInitializer = MessagingPushFCM
#endif
#else

#if canImport(CioMessagingPushAPN)
import CioMessagingPushAPN
typealias PushInitializer = MessagingPushAPN
#endif
#endif


class NotificationService: UNNotificationServiceExtension {
Expand All @@ -15,9 +19,10 @@ class NotificationService: UNNotificationServiceExtension {

override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {

#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN)
// Customer.io rich push is included in this build (CIO_ENABLED != 0).
// Rename the file NotificationServiceExtension/Env.swift.sample to NotificationServiceExtension/Env.swift
// and set the CDP_API_KEY value to your CDP API Key

PushInitializer.initializeForExtension(
withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.CDP_API_KEY)
.logLevel(.debug)
Expand All @@ -26,9 +31,22 @@ class NotificationService: UNNotificationServiceExtension {
)

MessagingPush.shared.didReceive(request, withContentHandler: contentHandler)
#else
// Customer.io excluded (CIO_ENABLED=0): no rich-push processing — deliver
// the notification unmodified so the extension is inert but valid.
self.contentHandler = contentHandler
self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
contentHandler(bestAttemptContent ?? request.content)
#endif
}

override func serviceExtensionTimeWillExpire() {
#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN)
MessagingPush.shared.serviceExtensionTimeWillExpire()
#else
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
#endif
}
}
15 changes: 13 additions & 2 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ setup_permissions([

push_provider = (ENV["PUSH_PROVIDER"] || "apn").downcase

# Single build-time flag — must agree with example/metro.config.js and
# example/react-native.config.js. CIO_ENABLED=0 excludes the Customer.io pods
# from the build entirely (no compiled CIO code in the app or the NSE target).
# The matching #if canImport(...) guards in AppDelegate.swift then compile out
# the native push setup automatically, since those modules won't be importable.
cio_enabled = (ENV["CIO_ENABLED"] != "0")

app_target_name = "SampleApp"
nse_target_name = "NotificationServiceExtension"
installation_root = Pod::Config.instance.installation_root
Expand Down Expand Up @@ -60,7 +67,9 @@ target app_target_name do
:path => config[:reactNativePath],
:app_path => "#{installation_root}/..",
)
pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location"]
if cio_enabled
pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location"]
end
# install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: false, push_service: push_provider)
# install_non_production_ios_sdk_git_branch(branch_name: 'feature/wrappers-inline-support', is_app_extension: false, push_service: push_provider)

Expand All @@ -79,7 +88,9 @@ target nse_target_name do
inherit! :none
# Ideally, installing non-production SDK to main target should be enough
# We should not need to install non-production SDK to app extension separately
pod "customerio-reactnative-richpush/#{push_provider}", :path => cio_package_path
if cio_enabled
pod "customerio-reactnative-richpush/#{push_provider}", :path => cio_package_path
end
# install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: true, push_service: push_provider)
# install_non_production_ios_sdk_git_branch(branch_name: 'BRANCH-NAME-HERE', is_app_extension: true, push_service: push_provider)
end
48 changes: 35 additions & 13 deletions example/ios/SampleApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,41 @@ import ReactAppDependencyProvider
import UserNotifications

#if USE_FCM
#if canImport(FirebaseCore)
import FirebaseMessaging
import FirebaseCore
#endif
#if canImport(CioMessagingPushFCM)
import CioMessagingPushFCM
import CioFirebaseWrapper

typealias CioMessagingPushHandler = MessagingPushFCM
#endif

let UNIVERSAL_LINK_URL = URL(string: "http://www.amiapp-reactnative-fcm.com")!

#else
#if canImport(CioMessagingPushAPN)
import CioMessagingPushAPN

typealias CioMessagingPushHandler = MessagingPushAPN
#endif

let UNIVERSAL_LINK_URL = URL(string: "http://www.amiapp-reactnative-apns.com")!
#endif

// Customer.io availability is decided at build time by CIO_ENABLED (see the
// Podfile): when CIO is excluded, none of its pods are installed, so the
// modules below aren't importable and the app uses a plain app delegate with
// zero Customer.io code. When CIO is included, the app delegate is wrapped by
// CioAppDelegateWrapper so the SDK can observe lifecycle/push callbacks.
#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN)
@main
class AppDelegateWithCioIntegration: CioAppDelegateWrapper<AppDelegate> {}
#else
@main
class AppDelegateMain: AppDelegate {}
#endif

class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
Expand All @@ -44,48 +60,52 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
reactNativeFactory = factory

window = UIWindow(frame: UIScreen.main.bounds)


let remotePush = launchOptions?[UIApplication.LaunchOptionsKey.remoteNotification] as? [String: [String: [String: String]]]
if let link = remotePush?["CIO"]?["push"]?["link"], let url = URL(string:link) {
var launchOptions = launchOptions ?? [:]
if launchOptions[UIApplication.LaunchOptionsKey.url] == nil {
launchOptions[UIApplication.LaunchOptionsKey.url] = url
}
}

let appName = Bundle.main.displayName

factory.startReactNative(
withModuleName: appName,
in: window,
initialProperties: ["appName": appName],
launchOptions: launchOptions
)

#if USE_FCM
#if canImport(FirebaseCore)
FirebaseApp.configure()
Messaging.messaging().delegate = self
#endif


#endif


#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN)
CioMessagingPushHandler.initialize(
withConfig: MessagingPushConfigBuilder()
.appGroupId("group.io.customer.ami.cio")
.build()
)

#endif

return true
}

}

// MARK: Deep linking
extension AppDelegate {
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}

func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
Expand All @@ -97,30 +117,32 @@ extension AppDelegate {
restorationHandler: restorationHandler
)
}

return false

}
}

// MARK: Push setup

#if USE_FCM

#if canImport(FirebaseMessaging)
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
// Not needed when CioAppDelegateWrapper is used
// MessagingPush.shared.messaging(messaging, didReceiveRegistrationToken: fcmToken)
}
}
#endif

#else
extension AppDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Not needed when CioAppDelegateWrapper is used
// MessagingPush.shared.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
// Not needed when CioAppDelegateWrapper is used
// MessagingPush.shared.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
Expand Down
32 changes: 30 additions & 2 deletions example/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,35 @@ const { getDefaultConfig } = require('@react-native/metro-config');
* Metro configuration
* https://facebook.github.io/metro/docs/configuration
*
* Conditional Customer.io inclusion:
* `@cio` is the single facade specifier the sample app imports CIO through.
* The build-time flag CIO_ENABLED decides which file it resolves to:
* - CIO_ENABLED=0 -> src/cio/index.noop.ts (inert stub, CIO not bundled)
* - otherwise -> src/cio/index.real.ts (real SDK)
* This MUST agree with example/react-native.config.js and the iOS Podfile,
* which gate native linking on the same flag. If they disagree (native off but
* JS real), the SDK's getEnforcing TurboModules throw at launch.
*
* @type {import('metro-config').MetroConfig}
*/
const defaultConfig = getDefaultConfig(__dirname);
module.exports = defaultConfig;
const cioEnabled = process.env.CIO_ENABLED !== '0';
const cioFacade = path.resolve(
__dirname,
cioEnabled ? 'src/cio/index.real.ts' : 'src/cio/index.noop.ts'
);

const config = getDefaultConfig(__dirname);

const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === '@cio') {
return { type: 'sourceFile', filePath: cioFacade };
}
return (upstreamResolveRequest ?? context.resolveRequest)(
context,
moduleName,
platform
);
};

module.exports = config;
26 changes: 20 additions & 6 deletions example/react-native.config.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
const path = require('path');
const pkg = require('../package.json');

// Single build-time flag — must agree with example/metro.config.js and the iOS
// Podfile. CIO_ENABLED=0 disables autolinking for customerio-reactnative on
// both platforms, so the native module is not compiled into the app: no native
// code, and none of the library's merged AndroidManifest entries (e.g. the
// POST_NOTIFICATIONS permission) or iOS pods land in the build artifact.
const cioEnabled = process.env.CIO_ENABLED !== '0';

/** @type import("@react-native-community/cli-types").Config */
module.exports = {
dependencies: {
[pkg.name]: {
root: path.join(__dirname, '..'),

platforms: {
// Codegen script incorrectly fails without this
// So we explicitly specify the platforms with empty object
ios: {},
android: {},
},
platforms: cioEnabled
? {
// Codegen script incorrectly fails without this
// So we explicitly specify the platforms with empty object
ios: {},
android: {},
}
: {
// Exclude CIO: null disables autolinking on each platform so
// use_native_modules! / the Android settings plugin skip it.
ios: null,
android: null,
},
},
},
};
2 changes: 1 addition & 1 deletion example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { NavigationContainer } from '@react-navigation/native';
import { ContentNavigator } from '@screens';
import { Storage } from '@services';
import { appTheme } from '@utils';
import { CioConfig, CioPushPermissionStatus, CustomerIO, InAppMessageEvent, InAppMessageEventType } from 'customerio-reactnative';
import { CioConfig, CioPushPermissionStatus, CustomerIO, InAppMessageEvent, InAppMessageEventType } from '@cio';
import FlashMessage from 'react-native-flash-message';
import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { enableFreeze, enableScreens } from 'react-native-screens';
Expand Down
Loading
Loading