Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ca41f6b
feat: add location tracking module support
Shahroz16 Mar 9, 2026
060ecad
chore: make location module an optional dependency
Shahroz16 Mar 9, 2026
9d6053d
refactor: use build flags instead of canImport and reflection for opt…
Shahroz16 Mar 9, 2026
9361227
fix: resolve simplify review findings for location module
Shahroz16 Mar 9, 2026
9bc1de6
style: fix pre-existing prettier formatting errors
Shahroz16 Mar 9, 2026
d3c40d3
fix: read gradle property value instead of just checking existence
Shahroz16 Mar 9, 2026
1df0a4b
fix: always register location module info to prevent import-time crash
Shahroz16 Mar 9, 2026
3f464a3
android version bump
Shahroz16 Mar 9, 2026
c93c4b6
fix: use TurboModuleRegistry.get for optional location module
Shahroz16 Mar 9, 2026
28f561b
fix: add R8 consumer rules for optional location compileOnly dependency
Shahroz16 Mar 10, 2026
c2c52a2
Fix wrapper layer for iOS
mahmoud-elmorabea Mar 10, 2026
b6ff4b2
Merge branch 'feat/location-module' of github.com:customerio/customer…
mahmoud-elmorabea Mar 10, 2026
b434f1d
Update public API baseline
mahmoud-elmorabea Mar 10, 2026
b7404d6
fix: log clear warning when location module is not enabled
Shahroz16 Mar 10, 2026
04b0570
Merge branch 'feat/location-module' of github.com:customerio/customer…
Shahroz16 Mar 10, 2026
d0531fd
style: fix prettier formatting in customerio-inapp.ts
Shahroz16 Mar 10, 2026
ca2bed7
Review comments
mahmoud-elmorabea Mar 10, 2026
3bc2d88
Bump ios sdk version
mahmoud-elmorabea Mar 11, 2026
2256101
chore: Build test screen for location module (#571)
mahmoud-elmorabea Mar 11, 2026
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
9 changes: 8 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,21 @@ def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties['customerio.reactnative.' + name]).toInteger()
}

ext.cioLocationEnabled = rootProject.hasProperty("customerio_location_enabled")
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

android {
namespace = 'io.customer.reactnative.sdk'
compileSdkVersion getExtOrIntegerDefault('compileSdkVersion')
defaultConfig {
minSdkVersion getExtOrIntegerDefault('minSdkVersion')
targetSdkVersion getExtOrIntegerDefault('targetSdkVersion')
buildConfigField "boolean", "CIO_LOCATION_ENABLED", "${project.cioLocationEnabled}"
}

buildFeatures {
buildConfig true
}

buildTypes {
release {
minifyEnabled false
Expand Down
7 changes: 7 additions & 0 deletions android/cio-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@ dependencies {
api "io.customer.android:datapipelines:$cioAndroidSDKVersion"
api "io.customer.android:messaging-push-fcm:$cioAndroidSDKVersion"
api "io.customer.android:messaging-in-app:$cioAndroidSDKVersion"
// Location module is optional - customers enable it by adding
// customerio_location_enabled=true in their gradle.properties
if (project.cioLocationEnabled) {
implementation "io.customer.android:location:$cioAndroidSDKVersion"
} else {
compileOnly "io.customer.android:location:$cioAndroidSDKVersion"
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.

This is a nice addition but it probably won't work properly since the code still references location classes. Ideally to solve this, we would need to completely remove location class references when the build flag is disabled to fully eliminate these challenges. So I think this is a bigger problem to solve. Unless we really need this for location right now, we should probably address it in a separate PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Kotlin compiler eliminates dead code at compile time; When BuildConfig.CIO_LOCATION_ENABLED = false, the compiler strips the entire if block from bytecode. I decompiled the AAR and confirmed:
    NativeCustomerIOModule has zero references to any io.customer.location.* class in its constant pool.
    The getModule() path compiles down to just aconst_null (return null).
  2. Manifest permissions don't leak — Built the RN sample app with location disabled and checked the
    merged manifest. ACCESS_COARSE_LOCATION is completely absent. compileOnly means the location AAR's manifest is never fed to the manifest merger.
  3. App runs without crashes — Installed and launched on an emulator with location disabled. No
    NoClassDefFoundError or ClassNotFoundException.

The compileOnly + BuildConfig approach serves our exact use case: customers who don't enable
location don't get the permission in their app.

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.

Thanks for the detailed response. Can you check why the sample app build is failing during minifyReleaseWithR8 then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Seems like when R8 runs on a release build, it scans all classes and fails because the compileOnly location classes aren't on the runtime classpath. Adding rule would solve that.

}
}
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.location.NativeLocationModule
import io.customer.reactnative.sdk.logging.NativeCustomerIOLoggingModule
import io.customer.reactnative.sdk.messaginginapp.InlineInAppMessageViewManager
import io.customer.reactnative.sdk.messaginginapp.NativeMessagingInAppModule
Expand All @@ -32,6 +33,9 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
InlineInAppMessageViewManager.NAME -> InlineInAppMessageViewManager()
NativeCustomerIOLoggingModule.NAME -> NativeCustomerIOLoggingModule(reactContext)
NativeCustomerIOModule.NAME -> NativeCustomerIOModule(reactContext = reactContext)
NativeLocationModule.NAME -> if (BuildConfig.CIO_LOCATION_ENABLED) {
NativeLocationModule(reactContext)
} else null
Comment thread
cursor[bot] marked this conversation as resolved.
NativeMessagingInAppModule.NAME -> NativeMessagingInAppModule(reactContext)
NativeMessagingPushModule.NAME -> NativeMessagingPushModule(reactContext)
else -> assertNotNull<NativeModule>(value = null) { "Unknown module name: $name" }
Expand Down Expand Up @@ -61,13 +65,16 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
// List of all Fabric ViewManagers and TurboModules registered in this package.
// Used by React Native to identify and instantiate the modules.
val moduleNames: List<String> = listOf(
InlineInAppMessageViewManager.NAME,
NativeCustomerIOLoggingModule.NAME,
NativeCustomerIOModule.NAME,
NativeMessagingInAppModule.NAME,
NativeMessagingPushModule.NAME,
)
val moduleNames: List<String> = buildList {
add(InlineInAppMessageViewManager.NAME)
add(NativeCustomerIOLoggingModule.NAME)
add(NativeCustomerIOModule.NAME)
if (BuildConfig.CIO_LOCATION_ENABLED) {
add(NativeLocationModule.NAME)
}
add(NativeMessagingInAppModule.NAME)
add(NativeMessagingPushModule.NAME)
}
return ReactModuleInfoProvider {
// Register all ViewManagers and TurboModules
moduleNames.associateWith { moduleName ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.customer.datapipelines.config.ScreenView
import io.customer.reactnative.sdk.constant.Keys
import io.customer.reactnative.sdk.extension.getTypedValue
import io.customer.reactnative.sdk.extension.toMap
import io.customer.reactnative.sdk.location.NativeLocationModule
import io.customer.reactnative.sdk.messaginginapp.NativeMessagingInAppModule
import io.customer.reactnative.sdk.messagingpush.NativeMessagingPushModule
import io.customer.reactnative.sdk.util.assertNotNull
Expand Down Expand Up @@ -99,6 +100,15 @@ class NativeCustomerIOModule(
region = region
)
}
// Configure location module if enabled via gradle property
if (BuildConfig.CIO_LOCATION_ENABLED) {
packageConfig.getTypedValue<Map<String, Any>>(key = "location")?.let { locationConfig ->
NativeLocationModule.addNativeModuleFromConfig(
builder = this,
config = locationConfig
)
}
}
}.build()

logger.info("Customer.io instance initialized successfully from app")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.customer.reactnative.sdk.location

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import io.customer.location.LocationModuleConfig
import io.customer.location.LocationTrackingMode
import io.customer.location.ModuleLocation
import io.customer.reactnative.sdk.NativeCustomerIOLocationSpec
import io.customer.reactnative.sdk.extension.getTypedValue
import io.customer.sdk.CustomerIOBuilder
import io.customer.sdk.core.di.SDKComponent
import io.customer.sdk.core.util.Logger

/**
* React Native module implementation for Customer.io Location Native SDK
* using TurboModules with new architecture.
*/
@ReactModule(name = NativeLocationModule.NAME)
class NativeLocationModule(
private val reactContext: ReactApplicationContext,
) : NativeCustomerIOLocationSpec(reactContext) {
private val logger: Logger
get() = SDKComponent.logger

private fun getLocationServices() = runCatching {
ModuleLocation.instance().locationServices
}.onFailure {
logger.error("Location module is not initialized. Ensure location config is provided during SDK initialization.")
}.getOrNull()

override fun setLastKnownLocation(latitude: Double, longitude: Double) {
getLocationServices()?.setLastKnownLocation(latitude, longitude)
}

override fun requestLocationUpdate() {
getLocationServices()?.requestLocationUpdate()
}

companion object {
const val NAME = "NativeCustomerIOLocation"

/**
* Adds location module to native Android SDK based on the configuration provided by
* customer app.
*
* @param builder instance of CustomerIOBuilder to add location module.
* @param config configuration provided by customer app for location module.
*/
internal fun addNativeModuleFromConfig(
builder: CustomerIOBuilder,
config: Map<String, Any>
) {
val trackingModeValue = config.getTypedValue<String>("trackingMode")
val trackingMode = trackingModeValue?.let { value ->
runCatching { enumValueOf<LocationTrackingMode>(value) }.getOrNull()
} ?: LocationTrackingMode.MANUAL
Comment thread
cursor[bot] marked this conversation as resolved.

val module = ModuleLocation(
LocationModuleConfig.Builder()
.setLocationTrackingMode(trackingMode)
.build()
)
builder.addCustomerIOModule(module)
}
}
}
8 changes: 8 additions & 0 deletions customerio-reactnative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,12 @@ Pod::Spec.new do |s|
# Customer.io Firebase Wrapper - provides Firebase integration
ss.dependency "CioFirebaseWrapper", package["cioiOSFirebaseWrapperSdkVersion"]
end

# Location module is optional - customers must opt in by adding this subspec.
s.subspec "location" do |ss|
ss.dependency "CustomerIO/Location", package["cioNativeiOSSdkVersion"]
ss.pod_target_xcconfig = {
'OTHER_SWIFT_FLAGS' => '$(inherited) -DCIO_LOCATION_ENABLED'
}
end
end
22 changes: 22 additions & 0 deletions ios/wrappers/NativeCustomerIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import CioAnalytics
import CioDataPipelines
import CioInternalCommon
import CioMessagingInApp
#if CIO_LOCATION_ENABLED
import CioLocation
#endif

@objc(NativeCustomerIO)
public class NativeCustomerIO: NSObject {
Expand Down Expand Up @@ -47,6 +50,24 @@ public class NativeCustomerIO: NSObject {
}

let sdkConfigBuilder = try SDKConfigBuilder.create(from: config)

#if CIO_LOCATION_ENABLED
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.

How do customers set this? In their Podfile?

// Add location module to config builder if location config is provided
if let locationConfig = config["location"] as? [String: Any] {
let trackingModeValue = locationConfig["trackingMode"] as? String
let mode: LocationTrackingMode
switch trackingModeValue?.uppercased() {
Comment thread
mahmoud-elmorabea marked this conversation as resolved.
Outdated
case "OFF":
mode = .off
case "ON_APP_START":
mode = .onAppStart
default:
mode = .manual
}
_ = sdkConfigBuilder.addModule(LocationModule(config: LocationConfig(mode: mode)))
}
#endif

CustomerIO.initialize(withConfig: sdkConfigBuilder.build())

do {
Expand All @@ -58,6 +79,7 @@ public class NativeCustomerIO: NSObject {
} catch {
logger.error("[InApp] Failed to initialize module with error: \(error)")
}

logger.debug(
"Customer.io SDK (\(packageSource ?? "") \(packageVersion ?? "")) initialized with config: \(config)"
)
Expand Down
52 changes: 52 additions & 0 deletions ios/wrappers/location/NativeLocation.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#import <React/RCTBridgeModule.h>
#import <RNCustomerIOSpec/RNCustomerIOSpec.h>

// Objective-C wrapper for new architecture TurboModule implementation
@interface RCTNativeCustomerIOLocation : NSObject <NativeCustomerIOLocationSpec>
// Bridge to Swift implementation for cross-language compatibility
@property(nonatomic, strong) id<NativeCustomerIOLocationSpec> swiftBridge;
@end

@implementation RCTNativeCustomerIOLocation

RCT_EXPORT_MODULE()

// Create TurboModule instance for new architecture JSI integration
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeCustomerIOLocationSpecJSI>(params);
}

- (instancetype)init {
if (self = [super init]) {
// Use runtime class lookup - NativeLocation class only exists when CioLocation subspec is installed
Class swiftClass = NSClassFromString(@"NativeCustomerIOLocation");
if (swiftClass) {
_swiftBridge = [[swiftClass alloc] init];
}
}
return self;
}

// Module initialization can happen on background thread
+ (BOOL)requiresMainQueueSetup {
return NO;
}

- (void)setLastKnownLocation:(double)latitude
longitude:(double)longitude {
if (!_swiftBridge) return;
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.

There is a helper assertBridgeAvailable method for this in case you want to utilize it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

assertBridgeAvailable calls RCT_ASSERT_NOT_NIL which crashes when the bridge is nil. That's correct
for push/in-app since they're always required — if the bridge is missing, something is broken.

For location, the bridge being nil is expected when the customer hasn't installed the location
subspec. Using assertBridgeAvailable would crash every app that doesn't opt into location. The if
(!_swiftBridge) return; pattern is the right one here — it silently no-ops, which is the intended
behavior for an optional module.

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.

I'm okay with the current implementation. But just for clarity, RCT_ASSERT_NOT_NIL uses NSAssert to help with debugging. And these checks are generally in places where the code should never be called in the first place. If it is called, not having the bridge and failing silently could lead to unexpected behavior, no?

[_swiftBridge setLastKnownLocation:latitude longitude:longitude];
}

- (void)requestLocationUpdate {
if (!_swiftBridge) return;
[_swiftBridge requestLocationUpdate];
}

// Export class factory function for React Native component registration
Class<RCTBridgeModule> NativeCustomerIOLocationCls(void) {
return RCTNativeCustomerIOLocation.class;
}

@end
19 changes: 19 additions & 0 deletions ios/wrappers/location/NativeLocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#if CIO_LOCATION_ENABLED
import CioLocation
import CoreLocation

@objc(NativeCustomerIOLocation)
public class NativeLocation: NSObject {

@objc
func setLastKnownLocation(_ latitude: Double, longitude: Double) {
let location = CLLocation(latitude: latitude, longitude: longitude)
CustomerIO.location.setLastKnownLocation(location)
}

@objc
func requestLocationUpdate() {
CustomerIO.location.requestLocationUpdate()
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
#endif
9 changes: 5 additions & 4 deletions src/components/InlineInAppMessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import type { InAppMessage } from '../types';
* Props for the InlineInAppMessageView component.
*/
/** @public */
export interface InlineInAppMessageViewProps extends Omit<
NativeProps,
'onSizeChange' | 'onStateChange' | 'onActionClick'
> {
export interface InlineInAppMessageViewProps
extends Omit<
NativeProps,
'onSizeChange' | 'onStateChange' | 'onActionClick'
> {
/** Custom loading component to display while message is loading */
loadingComponent?: React.ReactNode;
/** Props for the default ActivityIndicator with optional minimum height */
Expand Down
2 changes: 2 additions & 0 deletions src/customerio-cdp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CustomerIOInAppMessaging } from './customerio-inapp';
import { CustomerIOLocation } from './customerio-location';
import { CustomerIOPushMessaging } from './customerio-push';
import { NativeLoggerListener } from './native-logger-listener';
import {
Expand Down Expand Up @@ -177,6 +178,7 @@ export class CustomerIO {
static readonly isInitialized = () => _initialized;

static readonly inAppMessaging = new CustomerIOInAppMessaging();
static readonly location = new CustomerIOLocation();
static readonly pushMessaging = new CustomerIOPushMessaging();
}

Expand Down
9 changes: 5 additions & 4 deletions src/customerio-inapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import { callNativeModule, ensureNativeModule } from './utils/native-bridge';
*
* @internal
*/
interface NativeInAppSpec extends Omit<
CodegenSpec,
keyof TurboModule | NotificationInboxSpec | 'onInAppEventReceived'
> {}
interface NativeInAppSpec
extends Omit<
CodegenSpec,
keyof TurboModule | NotificationInboxSpec | 'onInAppEventReceived'
> {}

// Reference to the native CustomerIO Data Pipelines module for SDK operations
const nativeModule = ensureNativeModule(NativeCustomerIOMessagingInApp);
Expand Down
47 changes: 47 additions & 0 deletions src/customerio-location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type TurboModule } from 'react-native';
import {
default as NativeModule,
type Spec as CodegenSpec,
} from './specs/modules/NativeCustomerIOLocation';
import { callNativeModule, ensureNativeModule } from './utils/native-bridge';

/**
* Ensures all methods defined in codegen spec are implemented by the public module
*
* @internal
*/
interface NativeLocationSpec extends Omit<CodegenSpec, keyof TurboModule> {}

const nativeModule = ensureNativeModule(NativeModule);

const withNativeModule = <R>(fn: (native: CodegenSpec) => R): R => {
return callNativeModule(nativeModule, fn);
};

/** @public */
export class CustomerIOLocation implements NativeLocationSpec {
/**
* Sets the last known location from the host app's existing location system.
* Use this when your app already manages location and you want to send that data
* to Customer.io without the SDK managing location permissions directly.
*
* @param latitude - Latitude in degrees, must be between -90 and 90
* @param longitude - Longitude in degrees, must be between -180 and 180
*/
setLastKnownLocation(latitude: number, longitude: number): void {
return withNativeModule((native) =>
native.setLastKnownLocation(latitude, longitude)
);
}

/**
* Requests a single location update and sends the result to Customer.io.
* No-ops if location tracking is disabled or permission is not granted.
*
* The SDK does not request location permission. The host app must request
* runtime permissions and only call this when permission is granted.
*/
requestLocationUpdate(): void {
return withNativeModule((native) => native.requestLocationUpdate());
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
*/
export * from './customerio-cdp';
export * from './customerio-inapp';
export * from './customerio-location';
export * from './customerio-push';
export * from './types';
Loading
Loading