Skip to content
Open
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
6 changes: 5 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ def getExtOrIntegerDefault(name) {
}

ext.cioLocationEnabled = rootProject.findProperty("customerio_location_enabled")?.toBoolean() ?: false
ext.cioGeofenceEnabled = rootProject.findProperty("customerio_geofence_enabled")?.toBoolean() ?: false

android {
namespace = 'io.customer.reactnative.sdk'
compileSdkVersion getExtOrIntegerDefault('compileSdkVersion')
defaultConfig {
minSdkVersion getExtOrIntegerDefault('minSdkVersion')
targetSdkVersion getExtOrIntegerDefault('targetSdkVersion')
buildConfigField "boolean", "CIO_LOCATION_ENABLED", "${project.cioLocationEnabled}"
// Geofence implies Location, so the Location flag is on whenever geofence is enabled
// (mirrors the iOS geofence subspec, which also defines CIO_LOCATION_ENABLED).
buildConfigField "boolean", "CIO_LOCATION_ENABLED", "${project.cioLocationEnabled || project.cioGeofenceEnabled}"
buildConfigField "boolean", "CIO_GEOFENCE_ENABLED", "${project.cioGeofenceEnabled}"
consumerProguardFiles 'consumer-rules.pro'
}

Expand Down
12 changes: 10 additions & 2 deletions android/cio-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ dependencies {
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) {
// customerio_location_enabled=true in their gradle.properties.
// Geofence implies Location: enabling geofence pulls in location too.
if (project.cioLocationEnabled || project.cioGeofenceEnabled) {
api "io.customer.android:location:$cioAndroidSDKVersion"
} else {
compileOnly "io.customer.android:location:$cioAndroidSDKVersion"
}
// Geofence module is optional - customers enable it by adding
// customerio_geofence_enabled=true in their gradle.properties
if (project.cioGeofenceEnabled) {
api "io.customer.android:geofence:$cioAndroidSDKVersion"
} else {
compileOnly "io.customer.android:geofence:$cioAndroidSDKVersion"
}
}
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.geofence.NativeGeofenceModule
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 @@ -36,6 +37,9 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
NativeLocationModule.NAME -> if (BuildConfig.CIO_LOCATION_ENABLED) {
NativeLocationModule(reactContext)
} else null
NativeGeofenceModule.NAME -> if (BuildConfig.CIO_GEOFENCE_ENABLED) {
NativeGeofenceModule(reactContext)
} else null
NativeMessagingInAppModule.NAME -> NativeMessagingInAppModule(reactContext)
NativeMessagingPushModule.NAME -> NativeMessagingPushModule(reactContext)
else -> assertNotNull<NativeModule>(value = null) { "Unknown module name: $name" }
Expand Down Expand Up @@ -72,6 +76,7 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
NativeCustomerIOLoggingModule.NAME,
NativeCustomerIOModule.NAME,
NativeLocationModule.NAME,
NativeGeofenceModule.NAME,
NativeMessagingInAppModule.NAME,
NativeMessagingPushModule.NAME,
)
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.geofence.NativeGeofenceModule
import io.customer.reactnative.sdk.location.NativeLocationModule
import io.customer.reactnative.sdk.messaginginapp.NativeMessagingInAppModule
import io.customer.reactnative.sdk.messagingpush.NativeMessagingPushModule
Expand Down Expand Up @@ -104,12 +105,28 @@ class NativeCustomerIOModule(
region = region
)
}
// Configure location module if enabled via gradle property
// Configure location module. Geofence implies location, so register location
// (with the app's config if given, otherwise defaults) whenever location or
// geofence is enabled, since geofence relies on the location module's fixes.
// CIO_LOCATION_ENABLED is already true for geofence-enabled builds.
if (BuildConfig.CIO_LOCATION_ENABLED) {
packageConfig.getTypedValue<Map<String, Any>>(key = "location")?.let { locationConfig ->
val locationConfig = packageConfig.getTypedValue<Map<String, Any>>(key = "location")
val geofenceConfigured = BuildConfig.CIO_GEOFENCE_ENABLED &&
packageConfig.getTypedValue<Map<String, Any>>(key = "geofence") != null
if (locationConfig != null || geofenceConfigured) {
NativeLocationModule.addNativeModuleFromConfig(
builder = this,
config = locationConfig
config = locationConfig ?: emptyMap()
)
}
}
// Configure geofence module if enabled via gradle property; it runs
// automatically once registered and relies on the location module above.
if (BuildConfig.CIO_GEOFENCE_ENABLED) {
packageConfig.getTypedValue<Map<String, Any>>(key = "geofence")?.let { geofenceConfig ->
NativeGeofenceModule.addNativeModuleFromConfig(
builder = this,
config = geofenceConfig
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package io.customer.reactnative.sdk.geofence

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import io.customer.geofence.GeofenceLocationMode
import io.customer.geofence.GeofenceModuleConfig
import io.customer.geofence.ModuleGeofence
import io.customer.reactnative.sdk.NativeCustomerIOGeofenceSpec
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 Geofence Native SDK
* using TurboModules with new architecture.
*
* The reference to [ModuleGeofence] is isolated in this file so the geofence dependency is
* only loaded when the module is enabled and bundled. Geofence depends on the location
* module, which the caller registers alongside it.
*/
@ReactModule(name = NativeGeofenceModule.NAME)
class NativeGeofenceModule(
private val reactContext: ReactApplicationContext,
) : NativeCustomerIOGeofenceSpec(reactContext) {
private val logger: Logger
get() = SDKComponent.logger

private fun getModuleGeofence() = runCatching {
ModuleGeofence.instance()
}.onFailure {
logger.error("Geofence module is not initialized. Ensure geofence config is provided during SDK initialization.")
}.getOrNull()

override fun refreshFromCurrentLocation() {
getModuleGeofence()?.refreshFromCurrentLocation()
}

companion object {
const val NAME = "NativeCustomerIOGeofence"

/**
* Adds the geofence module to the native Android SDK based on the configuration
* provided by the customer app.
*
* @param builder instance of CustomerIOBuilder to add the geofence module.
* @param config configuration provided by the customer app for the geofence module.
*/
internal fun addNativeModuleFromConfig(
builder: CustomerIOBuilder,
config: Map<String, Any>
) {
val locationModeValue = config.getTypedValue<String>("locationMode")
// Uppercase before matching so casing can't diverge from iOS (which uppercases too);
// enumValueOf is case-sensitive. Unknown values fall back to the SDK default.
val locationMode = locationModeValue?.let { value ->
runCatching { enumValueOf<GeofenceLocationMode>(value.uppercase()) }.getOrNull()
}

val configBuilder = GeofenceModuleConfig.Builder()
locationMode?.let { configBuilder.setLocationMode(it) }
builder.addCustomerIOModule(ModuleGeofence(configBuilder.build()))
}
}
}
21 changes: 21 additions & 0 deletions api-extractor-output/customerio-reactnative.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@ export type CioConfig = {
location?: {
trackingMode?: CioLocationTrackingMode;
};
geofence?: {
locationMode?: CioGeofenceLocationMode;
};
ios?: {
allowBackgroundDelivery?: boolean;
};
};

// @public
export enum CioGeofenceLocationMode {
Automatic = "AUTOMATIC",
Manual = "MANUAL"
}

// @public
export enum CioLocationTrackingMode {
Manual = "MANUAL",
Expand Down Expand Up @@ -86,6 +98,8 @@ export type CustomAttributes = Record<string, any>;
export class CustomerIO {
static readonly clearIdentify: () => Promise<any>;
static readonly deleteDeviceToken: () => Promise<void>;
// (undocumented)
static readonly geofence: CustomerIOGeofence;
static readonly identify: (input?: IdentifyParams) => Promise<any>;
// (undocumented)
static readonly inAppMessaging: CustomerIOInAppMessaging;
Expand All @@ -108,6 +122,13 @@ export class CustomerIO {
}) => Promise<void>;
}

// Warning: (ae-forgotten-export) The symbol "NativeGeofenceSpec" needs to be exported by the entry point index.d.ts
//
// @public
export class CustomerIOGeofence implements NativeGeofenceSpec {
refreshFromCurrentLocation(): void;
}

// Warning: (ae-forgotten-export) The symbol "NativeInAppSpec" needs to be exported by the entry point index.d.ts
//
// @public
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

# Geofence module is optional - customers must opt in by adding this subspec.
# It pulls in Location transitively (CustomerIO/LocationGeofence depends on it).
# Geofence implies Location, so this subspec also enables the Location wrapper code.
s.subspec "geofence" do |ss|
ss.dependency "CustomerIO/LocationGeofence", package["cioNativeiOSSdkVersion"]
ss.pod_target_xcconfig = {
'OTHER_SWIFT_FLAGS' => '$(inherited) -DCIO_GEOFENCE_ENABLED -DCIO_LOCATION_ENABLED'
}
end
end
4 changes: 4 additions & 0 deletions example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Required for geofence transition delivery while the app is backgrounded (API 29+) -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Lets geofence monitoring resume after device reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<application
android:name=".MainApplication"
Expand Down
4 changes: 4 additions & 0 deletions example/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ edgeToEdgeEnabled=false

# Enable Customer.io Location module for the example app (used to verify location wiring).
customerio_location_enabled=true

# Enable Customer.io Geofence module for the example app. Geofence implies Location,
# so enabling it also pulls in the Location module.
customerio_geofence_enabled=true
3 changes: 2 additions & 1 deletion example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ prepare_react_native_project!

setup_permissions([
'LocationWhenInUse',
'LocationAlways',
])

push_provider = (ENV["PUSH_PROVIDER"] || "apn").downcase
Expand Down Expand Up @@ -60,7 +61,7 @@ target app_target_name do
:path => config[:reactNativePath],
:app_path => "#{installation_root}/..",
)
pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location"]
pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location", "geofence"]
# 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 Down
12 changes: 12 additions & 0 deletions example/ios/SampleApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import ReactAppDependencyProvider

import UserNotifications

#if canImport(CioLocationGeofence)
import CioLocationGeofence
#endif

#if USE_FCM
import FirebaseMessaging
import FirebaseCore
Expand Down Expand Up @@ -36,6 +40,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
#if canImport(CioLocationGeofence)
// Geofence cold-wake delivery: iOS can launch the app into the background for a
// geofence transition without starting the JS runtime, so the SDK can't rely on
// CustomerIO.initialize running. Bootstrapping here wires up region monitoring and
// flushes queued transitions on every launch; it is safe alongside normal init.
GeofenceModule.bootstrapForBackgroundDelivery(launchOptions: launchOptions)
#endif

let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
Expand Down
6 changes: 6 additions & 0 deletions example/ios/SampleApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location to test the Customer.io location module.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app uses your location in the background to test the Customer.io geofence module.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleURLTypes</key>
Expand Down
26 changes: 24 additions & 2 deletions example/src/components/single-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,34 @@ type SingleSelectProps<T extends Key> = {
selectedValue: T;
onValueChange: (value: T) => void;
label?: string;
/** Stack the label above the options and stretch the options to fill the width. */
fullWidth?: boolean;
};

export const SingleSelect = <T extends Key>({
data,
selectedValue,
onValueChange,
label,
fullWidth,
}: SingleSelectProps<T>) => {
const [value, setValue] = useState(selectedValue);

return (
<View style={styles.container}>
<View style={[styles.container, fullWidth && styles.containerFullWidth]}>
{label && <BoldText style={styles.label}>{label}</BoldText>}
<View style={styles.selectionItems}>
<View
style={[
styles.selectionItems,
fullWidth && styles.selectionItemsFullWidth,
]}
>
{data.map((item, index) => (
<TouchableOpacity
key={item.value}
style={[
styles.itemContainer,
fullWidth && styles.itemContainerFullWidth,
item.value === value && styles.selectedItemContainer,
index === 0 && styles.firstItemContainer,
index === data.length - 1 && styles.lastItemContainer,
Expand Down Expand Up @@ -62,17 +71,30 @@ const styles = StyleSheet.create({
gap: 8,
},

containerFullWidth: {
flexDirection: 'column',
alignItems: 'stretch',
},

selectionItems: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},

selectionItemsFullWidth: {
flexWrap: 'nowrap',
},

itemContainer: {
padding: 8,
backgroundColor: Colors.secondaryBg,
},

itemContainerFullWidth: {
flex: 1,
},

selectedItemContainer: {
backgroundColor: Colors.primaryBg,
},
Expand Down
Loading
Loading