diff --git a/android/build.gradle b/android/build.gradle index 69608026..53ef27dc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,7 @@ 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' @@ -33,7 +34,10 @@ android { 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' } diff --git a/android/cio-core.gradle b/android/cio-core.gradle index 36f4cf60..9facabbc 100644 --- a/android/cio-core.gradle +++ b/android/cio-core.gradle @@ -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" + } } diff --git a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt index 44273926..8c6c1837 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt @@ -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 @@ -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(value = null) { "Unknown module name: $name" } @@ -72,6 +76,7 @@ class CustomerIOReactNativePackage : BaseReactPackage() { NativeCustomerIOLoggingModule.NAME, NativeCustomerIOModule.NAME, NativeLocationModule.NAME, + NativeGeofenceModule.NAME, NativeMessagingInAppModule.NAME, NativeMessagingPushModule.NAME, ) diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index 3ac1645f..d01442dd 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -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 @@ -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>(key = "location")?.let { locationConfig -> + val locationConfig = packageConfig.getTypedValue>(key = "location") + val geofenceConfigured = BuildConfig.CIO_GEOFENCE_ENABLED && + packageConfig.getTypedValue>(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>(key = "geofence")?.let { geofenceConfig -> + NativeGeofenceModule.addNativeModuleFromConfig( + builder = this, + config = geofenceConfig ) } } diff --git a/android/src/main/java/io/customer/reactnative/sdk/geofence/NativeGeofenceModule.kt b/android/src/main/java/io/customer/reactnative/sdk/geofence/NativeGeofenceModule.kt new file mode 100644 index 00000000..a783c74e --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/geofence/NativeGeofenceModule.kt @@ -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 + ) { + val locationModeValue = config.getTypedValue("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(value.uppercase()) }.getOrNull() + } + + val configBuilder = GeofenceModuleConfig.Builder() + locationMode?.let { configBuilder.setLocationMode(it) } + builder.addCustomerIOModule(ModuleGeofence(configBuilder.build())) + } + } +} diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index c17a5e49..16b1d856 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -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", @@ -86,6 +98,8 @@ export type CustomAttributes = Record; export class CustomerIO { static readonly clearIdentify: () => Promise; static readonly deleteDeviceToken: () => Promise; + // (undocumented) + static readonly geofence: CustomerIOGeofence; static readonly identify: (input?: IdentifyParams) => Promise; // (undocumented) static readonly inAppMessaging: CustomerIOInAppMessaging; @@ -108,6 +122,13 @@ export class CustomerIO { }) => Promise; } +// 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 diff --git a/customerio-reactnative.podspec b/customerio-reactnative.podspec index 7396d9bc..0b10ca76 100644 --- a/customerio-reactnative.podspec +++ b/customerio-reactnative.podspec @@ -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 diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 7771d283..9bc7ccb4 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + 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) diff --git a/example/ios/SampleApp/AppDelegate.swift b/example/ios/SampleApp/AppDelegate.swift index 48b2cfe9..4d0030ce 100644 --- a/example/ios/SampleApp/AppDelegate.swift +++ b/example/ios/SampleApp/AppDelegate.swift @@ -5,6 +5,10 @@ import ReactAppDependencyProvider import UserNotifications +#if canImport(CioLocationGeofence) +import CioLocationGeofence +#endif + #if USE_FCM import FirebaseMessaging import FirebaseCore @@ -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() diff --git a/example/ios/SampleApp/Info.plist b/example/ios/SampleApp/Info.plist index 4b4d69f8..552b278b 100644 --- a/example/ios/SampleApp/Info.plist +++ b/example/ios/SampleApp/Info.plist @@ -15,6 +15,12 @@ NSLocationWhenInUseUsageDescription This app uses your location to test the Customer.io location module. + NSLocationAlwaysAndWhenInUseUsageDescription + This app uses your location in the background to test the Customer.io geofence module. + UIBackgroundModes + + location + UIViewControllerBasedStatusBarAppearance CFBundleURLTypes diff --git a/example/src/components/single-select.tsx b/example/src/components/single-select.tsx index cc573e43..9863d2a8 100644 --- a/example/src/components/single-select.tsx +++ b/example/src/components/single-select.tsx @@ -10,6 +10,8 @@ type SingleSelectProps = { 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 = ({ @@ -17,18 +19,25 @@ export const SingleSelect = ({ selectedValue, onValueChange, label, + fullWidth, }: SingleSelectProps) => { const [value, setValue] = useState(selectedValue); return ( - + {label && {label}} - + {data.map((item, index) => ( + status === 'backgroundGranted' ? 2 : status === 'foregroundOnly' ? 1 : 0; + const PRESETS: { label: string; lat: number; lng: number }[] = [ { label: 'New York', lat: 40.7128, lng: -74.006 }, { label: 'London', lat: 51.5074, lng: -0.1278 }, @@ -35,14 +56,6 @@ const PRESETS: { label: string; lat: number; lng: number }[] = [ { label: '0, 0', lat: 0, lng: 0 }, ]; -const OrSeparator = () => ( - - - OR - - -); - const SectionCard = ({ children, style, @@ -73,6 +86,69 @@ export const LocationScreen = () => { const [sdkRequestingLabel, setSdkRequestingLabel] = useState(false); const [useCurrentLocationLoading, setUseCurrentLocationLoading] = useState(false); + // Drives the state-aware Background Location button (mirrors the native samples). + const [locationStatus, setLocationStatus] = useState(null); + const locationStatusRef = useRef(null); + // Sequence guard: mount, AppState resume, and permission handlers can refresh + // concurrently — drop a stale completion so it can't overwrite a newer result. + const refreshSeqRef = useRef(0); + + // Reads current permissions and updates the button state. Returns the resolved + // status, or null if this run was superseded by a newer one (or errored). + const refreshLocationStatus = useCallback(async (): Promise< + LocationStatus | null + > => { + const seq = ++refreshSeqRef.current; + try { + const foreground = await check(LOCATION_PERMISSION); + const background = await check(BACKGROUND_LOCATION_PERMISSION); + // Drop a superseded completion so it can't overwrite a newer refresh. + if (seq !== refreshSeqRef.current) return null; + let status: LocationStatus; + if (background === RESULTS.GRANTED) { + status = 'backgroundGranted'; + } else if ( + foreground === RESULTS.GRANTED || + foreground === RESULTS.LIMITED + ) { + status = 'foregroundOnly'; + } else if (foreground === RESULTS.BLOCKED) { + status = 'denied'; + } else { + status = 'notDetermined'; + } + locationStatusRef.current = status; + setLocationStatus(status); + return status; + } catch (e) { + // Only surface the error if this run is still the latest. + if (seq === refreshSeqRef.current) { + showMessage({ message: (e as Error).message, type: 'danger' }); + } + return null; + } + }, []); + + // Query on mount; re-query on resume so the button reflects changes made in + // Settings, and fetch if a grant happened there. In-app grants fetch directly + // from their handlers, so this only covers the return-from-Settings path. + useEffect(() => { + refreshLocationStatus(); + const subscription = AppState.addEventListener('change', async (state) => { + if (state !== 'active') return; + const previous = locationStatusRef.current; + const status = await refreshLocationStatus(); + // Fetch whenever the permission level increased (a grant made in Settings), + // including foregroundOnly → backgroundGranted; skip downgrades between states + // (e.g. revoking "Always" back to "When in Use"). The SDK's auto-fetch hook runs + // once per process, so a grant made in Settings needs an explicit request. + if (status && grantRank(status) > grantRank(previous)) { + CustomerIO.geofence.refreshFromCurrentLocation(); + } + }); + return () => subscription.remove(); + }, [refreshLocationStatus]); + const setLocation = (lat: number, lng: number, source: string) => { try { CustomerIO.location.setLastKnownLocation(lat, lng); @@ -95,19 +171,13 @@ export const LocationScreen = () => { const latText = latitude.trim(); const lonText = longitude.trim(); if (!latText || !lonText) { - showMessage({ - message: 'Please enter valid coordinates', - type: 'warning', - }); + showMessage({ message: 'Please enter valid coordinates', type: 'warning' }); return; } const lat = parseFloat(latText); const lng = parseFloat(lonText); if (Number.isNaN(lat) || Number.isNaN(lng)) { - showMessage({ - message: 'Please enter valid coordinates', - type: 'warning', - }); + showMessage({ message: 'Please enter valid coordinates', type: 'warning' }); return; } if (lat < -90 || lat > 90 || lng < -180 || lng > 180) { @@ -120,17 +190,126 @@ export const LocationScreen = () => { setLocation(lat, lng, 'Manual'); }; - /** Option 2: Request permission, then SDK fetches location once. */ + const backgroundButtonLabel = (() => { + switch (locationStatus) { + case 'foregroundOnly': + return Platform.OS === 'ios' + ? "Upgrade to 'Always'" + : 'Allow all the time'; + case 'backgroundGranted': + return Platform.OS === 'ios' + ? '✓ Always — granted' + : '✓ Background — granted'; + case 'denied': + return 'Open Settings'; + default: + return 'Grant location access'; + } + })(); + + const backgroundButtonEnabled = locationStatus !== 'backgroundGranted'; + + const showOpenSettingsDialog = () => { + Alert.alert( + 'Location Permission Required', + 'Location permission is denied. Please enable it from app settings.', + [ + { text: 'Open Settings', onPress: () => Linking.openSettings() }, + { text: 'OK', style: 'cancel' }, + ] + ); + }; + + /** Escalate to background ("Always"/"Allow all the time") after foreground is granted. */ + const requestBackgroundLocation = async () => { + try { + const result = await request(BACKGROUND_LOCATION_PERMISSION); + const next = await refreshLocationStatus(); + // A concurrent refresh (e.g. AppState resume on return from Settings) superseded + // this read and owns the outcome — don't act on the stale request payload. + if (next === null) { + return; + } + // The freshly-read status is the single source of truth: request() can lag + // behind the OS (e.g. "Always" enabled in Settings), and it can also report + // GRANTED when the fresh check still reads foregroundOnly — so trust `next`. + if (next === 'backgroundGranted') { + // Refresh geofences from the current location now (silent — no analytics event). + CustomerIO.geofence.refreshFromCurrentLocation(); + showMessage({ + message: 'Background location granted — fetching location to start geofence', + type: 'success', + }); + } else if (result === RESULTS.BLOCKED) { + showOpenSettingsDialog(); + } else { + // iOS resolves the "Always" prompt asynchronously; the button updates on + // resume, which fetches if granted. Point the user to Settings otherwise. + showMessage({ + message: + 'Requesting background location — enable "Always" in Settings if no prompt appears', + type: 'info', + }); + } + } catch (e) { + showMessage({ message: (e as Error).message, type: 'danger' }); + } + }; + + const showBackgroundRationale = () => { + Alert.alert( + 'Allow background location?', + 'Geofence transitions only fire while the app is backgrounded if you grant ' + + '"Always" / "Allow all the time". Continue and choose it when prompted — or ' + + 'in Settings if no prompt appears.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Continue', onPress: () => requestBackgroundLocation() }, + ] + ); + }; + + /** State-aware Background Location action, matching the native sample apps. */ + const handleBackgroundLocationTap = async () => { + switch (locationStatus) { + case 'foregroundOnly': + showBackgroundRationale(); + return; + case 'backgroundGranted': + return; + case 'denied': + Linking.openSettings(); + return; + default: { + // notDetermined: request foreground first, then escalate to background. + // The in-app foreground prompt makes request()'s result authoritative here + // (unlike the background/Settings path); a concurrent AppState refresh can + // supersede a status read, so branch on the result to avoid skipping the + // background rationale. + const result = await request(LOCATION_PERMISSION); + await refreshLocationStatus(); + if (result === RESULTS.GRANTED || result === RESULTS.LIMITED) { + // Foreground granted — refresh geofences from current location, then offer background. + CustomerIO.geofence.refreshFromCurrentLocation(); + showBackgroundRationale(); + } else if (result === RESULTS.BLOCKED) { + showOpenSettingsDialog(); + } else { + showMessage({ message: 'Location permission denied', type: 'info' }); + } + } + } + }; + + /** Request permission, then SDK fetches location once. */ const handleRequestSdkLocationUpdate = async () => { try { const result = await request(LOCATION_PERMISSION); + await refreshLocationStatus(); if (result === RESULTS.GRANTED || result === RESULTS.LIMITED) { setSdkRequestingLabel(true); CustomerIO.location.requestLocationUpdate(); - showMessage({ - message: 'SDK requested location update', - type: 'success', - }); + showMessage({ message: 'SDK requested location update', type: 'success' }); } else if (result === RESULTS.DENIED || result === RESULTS.BLOCKED) { showLocationPermissionAlert(); } else { @@ -144,10 +323,11 @@ export const LocationScreen = () => { } }; - /** Option 3: Request permission, get device location, then setLastKnownLocation. */ + /** Request permission, get device location, then setLastKnownLocation. */ const handleUseCurrentLocation = async () => { try { const result = await request(LOCATION_PERMISSION); + await refreshLocationStatus(); if (result === RESULTS.GRANTED || result === RESULTS.LIMITED) { setUseCurrentLocationLoading(true); Geolocation.getCurrentPosition( @@ -189,36 +369,32 @@ export const LocationScreen = () => { return ( - {/* OPTION 1: QUICK PRESETS */} + {/* Background Location (geofence permission) */} - - OPTION 1: QUICK PRESETS + Background Location +