From 92c51ca890c0b36a385edfcd41d42a42a2173146 Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Date: Thu, 25 Jun 2026 17:57:54 +0500 Subject: [PATCH 01/10] chore: add geofence module base Wire the optional geofence module without enabling behavior yet: build flags (customerio_geofence_enabled gradle property, geofence podspec subspec), the geofence opt-in config key, and the exported CustomerIOGeofence class. Enabling geofence also pulls in Location, which geofence depends on, and turns on the Location build flag on both platforms so geofence-only builds get the full Location module. Co-Authored-By: Claude Opus 4.8 (1M context) --- android/build.gradle | 6 +++++- android/cio-core.gradle | 12 ++++++++++-- api-extractor-output/customerio-reactnative.api.md | 7 +++++++ customerio-reactnative.podspec | 10 ++++++++++ src/customerio-cdp.ts | 2 ++ src/customerio-geofence.ts | 12 ++++++++++++ src/index.ts | 1 + src/types/data-pipelines.ts | 6 ++++++ 8 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/customerio-geofence.ts 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/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index c17a5e49..586f0189 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -39,6 +39,7 @@ export type CioConfig = { location?: { trackingMode?: CioLocationTrackingMode; }; + geofence?: {}; }; // @public @@ -86,6 +87,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 +111,10 @@ export class CustomerIO { }) => Promise; } +// @public +export class CustomerIOGeofence { +} + // 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/src/customerio-cdp.ts b/src/customerio-cdp.ts index a94d6879..356ce13c 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -1,3 +1,4 @@ +import { CustomerIOGeofence } from './customerio-geofence'; import { CustomerIOInAppMessaging } from './customerio-inapp'; import { CustomerIOLocation } from './customerio-location'; import { CustomerIOPushMessaging } from './customerio-push'; @@ -178,6 +179,7 @@ export class CustomerIO { */ static readonly isInitialized = () => _initialized; + static readonly geofence = new CustomerIOGeofence(); static readonly inAppMessaging = new CustomerIOInAppMessaging(); static readonly location = new CustomerIOLocation(); static readonly pushMessaging = new CustomerIOPushMessaging(); diff --git a/src/customerio-geofence.ts b/src/customerio-geofence.ts new file mode 100644 index 00000000..a8eb6486 --- /dev/null +++ b/src/customerio-geofence.ts @@ -0,0 +1,12 @@ +/** + * Public entry point for the optional Geofence module. + * + * Geofence runs automatically once enabled — there are no app-facing methods yet. + * Apps opt in by passing a `geofence` config to `initialize` and enabling the module + * at build time: set `customerio_geofence_enabled=true` in `gradle.properties` (Android) + * or add the `geofence` subspec to your Podfile (iOS). Enabling geofence also enables + * the Location module, which geofence depends on. + * + * @public + */ +export class CustomerIOGeofence {} diff --git a/src/index.ts b/src/index.ts index c8c9c940..cd6fa9d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ * This file serves as the entry point for the module customerio-reactnative. */ export * from './customerio-cdp'; +export * from './customerio-geofence'; export * from './customerio-inapp'; export * from './customerio-location'; export * from './customerio-push'; diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index 83648d2a..c487af99 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -76,6 +76,12 @@ export type CioConfig = { /** Location tracking mode. Defaults to 'manual' if location config is provided. */ trackingMode?: CioLocationTrackingMode; }; + /** + * Geofence configuration. Providing this opts the app into geofence monitoring, + * which runs automatically once enabled and has no options yet. Enabling geofence + * also enables the Location module, which geofence depends on. + */ + geofence?: {}; }; /** From 8750a9266b110d0b19dd59fa36f87e39868b207d Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Date: Tue, 30 Jun 2026 01:47:08 +0500 Subject: [PATCH 02/10] chore: register geofence module and treat geofence as implying location (#606) --- .../reactnative/sdk/NativeCustomerIOModule.kt | 23 ++++++++++++-- .../sdk/geofence/NativeGeofenceModule.kt | 30 +++++++++++++++++++ ios/wrappers/NativeCustomerIO.swift | 17 ++++++++++- ios/wrappers/geofence/NativeGeofence.swift | 20 +++++++++++++ ios/wrappers/location/NativeLocation.swift | 12 ++++---- 5 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 android/src/main/java/io/customer/reactnative/sdk/geofence/NativeGeofenceModule.kt create mode 100644 ios/wrappers/geofence/NativeGeofence.swift 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..fb846117 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/geofence/NativeGeofenceModule.kt @@ -0,0 +1,30 @@ +package io.customer.reactnative.sdk.geofence + +import io.customer.geofence.GeofenceModuleConfig +import io.customer.geofence.ModuleGeofence +import io.customer.sdk.CustomerIOBuilder + +/** + * Registers the optional geofence module with the native Android SDK. + * + * Geofence has no app-facing methods — it runs automatically once registered — so this is + * not a TurboModule. The reference to [ModuleGeofence] is isolated here 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. + */ +internal object NativeGeofenceModule { + /** + * Adds the geofence module to the native Android SDK. + * + * @param builder instance of CustomerIOBuilder to add the geofence module. + * @param config configuration provided by the customer app for the geofence module. + */ + fun addNativeModuleFromConfig( + builder: CustomerIOBuilder, + @Suppress("UNUSED_PARAMETER") config: Map + ) { + builder.addCustomerIOModule( + ModuleGeofence(GeofenceModuleConfig.Builder().build()) + ) + } +} diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 960868f0..ab7667df 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -49,11 +49,26 @@ public class NativeCustomerIO: NSObject { let sdkConfigBuilder = try SDKConfigBuilder.create(from: config) #if CIO_LOCATION_ENABLED - if let locationModule = NativeLocation.module(from: config) { + // Geofence implies location: register the location module whenever location + // config is provided or geofence is enabled, since geofence relies on the + // location module's fixes. + #if CIO_GEOFENCE_ENABLED + let geofenceConfigured = config["geofence"] != nil + #else + let geofenceConfigured = false + #endif + if let locationModule = NativeLocation.module(from: config, geofenceEnabled: geofenceConfigured) { _ = sdkConfigBuilder.addModule(locationModule) } #endif + #if CIO_GEOFENCE_ENABLED + // Geofence runs automatically once registered; relies on the location module above. + if let geofenceModule = NativeGeofence.module(from: config) { + _ = sdkConfigBuilder.addModule(geofenceModule) + } + #endif + let builtConfig = sdkConfigBuilder.build() // Only CustomerIO.initialize must run on the main thread (e.g. for Location module). diff --git a/ios/wrappers/geofence/NativeGeofence.swift b/ios/wrappers/geofence/NativeGeofence.swift new file mode 100644 index 00000000..d2d8cbd0 --- /dev/null +++ b/ios/wrappers/geofence/NativeGeofence.swift @@ -0,0 +1,20 @@ +#if CIO_GEOFENCE_ENABLED +import CioLocationGeofence + +/// Registers the optional geofence module with the native iOS SDK. +/// +/// Geofence has no app-facing methods — it runs automatically once registered — so this +/// only parses the opt-in config and builds the module. The reference to `GeofenceModule` +/// is isolated here so it is only compiled when the geofence subspec is installed. Geofence +/// depends on the location module, which the caller registers alongside it. +@objc(NativeCustomerIOGeofence) +public class NativeGeofence: NSObject { + + /// Returns a `GeofenceModule` when the app opts into geofence via the `geofence` config. + /// Geofence runs automatically once registered and has no options yet. + static func module(from config: [String: Any]) -> GeofenceModule? { + guard config["geofence"] != nil else { return nil } + return GeofenceModule() + } +} +#endif diff --git a/ios/wrappers/location/NativeLocation.swift b/ios/wrappers/location/NativeLocation.swift index 0727fb0f..a0049a18 100644 --- a/ios/wrappers/location/NativeLocation.swift +++ b/ios/wrappers/location/NativeLocation.swift @@ -6,11 +6,13 @@ import CoreLocation @objc(NativeCustomerIOLocation) public class NativeLocation: NSObject { - /// Parses the root config and returns a `LocationModule` if location config is present. - /// Keeps config parsing within the location module. - static func module(from config: [String: Any]) -> LocationModule? { - guard let locationConfig = config["location"] as? [String: Any] else { return nil } - let trackingModeValue = locationConfig["trackingMode"] as? String + /// Parses the root config and returns a `LocationModule` when location config is present + /// or when geofence is enabled (geofence depends on the location module). Keeps config + /// parsing within the location module. Defaults to `manual` tracking when no config is given. + static func module(from config: [String: Any], geofenceEnabled: Bool = false) -> LocationModule? { + let locationConfig = config["location"] as? [String: Any] + guard locationConfig != nil || geofenceEnabled else { return nil } + let trackingModeValue = locationConfig?["trackingMode"] as? String let mode: LocationTrackingMode switch trackingModeValue?.uppercased() { case "OFF": From b132adee00ae55a6170e9f8a26fc592ac410fb53 Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Date: Tue, 30 Jun 2026 01:52:50 +0500 Subject: [PATCH 03/10] chore: add iOS allowBackgroundDelivery config for geofence (#607) Co-authored-by: Claude Opus 4.8 (1M context) --- api-extractor-output/customerio-reactnative.api.md | 3 +++ ios/wrappers/NativeCustomerIO.swift | 9 +++++++++ src/types/data-pipelines.ts | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index 586f0189..15ae6563 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -40,6 +40,9 @@ export type CioConfig = { trackingMode?: CioLocationTrackingMode; }; geofence?: {}; + ios?: { + allowBackgroundDelivery?: boolean; + }; }; // @public diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index ab7667df..db04dd4d 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -69,6 +69,15 @@ public class NativeCustomerIO: NSObject { } #endif + // Customer value wins; default on when the geofence module is added, off otherwise. + #if CIO_GEOFENCE_ENABLED + let geofenceAdded = config["geofence"] != nil + #else + let geofenceAdded = false + #endif + let allowBackgroundDelivery = (config["ios"] as? [String: Any])?["allowBackgroundDelivery"] as? Bool + _ = sdkConfigBuilder.allowBackgroundDelivery(allowBackgroundDelivery ?? geofenceAdded) + let builtConfig = sdkConfigBuilder.build() // Only CustomerIO.initialize must run on the main thread (e.g. for Location module). diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index c487af99..d6be2f86 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -82,6 +82,14 @@ export type CioConfig = { * also enables the Location module, which geofence depends on. */ geofence?: {}; + /** iOS-only SDK configuration. Has no effect on Android. */ + ios?: { + /** + * Whether geofence transitions are delivered in real time on a background cold-wake. + * When unset, defaults to on if the geofence module is enabled, off otherwise. + */ + allowBackgroundDelivery?: boolean; + }; }; /** From 010ea8bb94bc7661c20804f23a9c19e00a9063c1 Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Date: Tue, 30 Jun 2026 02:38:48 +0500 Subject: [PATCH 04/10] chore: enable geofence in the example app (#608) Co-authored-by: Claude Opus 4.8 (1M context) --- .../android/app/src/main/AndroidManifest.xml | 4 + example/android/gradle.properties | 4 + example/ios/Podfile | 3 +- example/ios/SampleApp/AppDelegate.swift | 12 + example/ios/SampleApp/Info.plist | 6 + example/src/screens/location.tsx | 300 +++++++++++++----- example/src/services/storage.ts | 7 +- 7 files changed, 260 insertions(+), 76 deletions(-) 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/screens/location.tsx b/example/src/screens/location.tsx index 621f9a09..9419031b 100644 --- a/example/src/screens/location.tsx +++ b/example/src/screens/location.tsx @@ -6,9 +6,10 @@ import { } from '@components'; import { Colors } from '@colors'; import { CustomerIO } from 'customerio-reactnative'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, + AppState, Linking, Platform, ScrollView, @@ -17,7 +18,7 @@ import { View, } from 'react-native'; import Geolocation from '@react-native-community/geolocation'; -import { request, PERMISSIONS, RESULTS } from 'react-native-permissions'; +import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; import { systemWeights } from 'react-native-typography'; import { showMessage } from 'react-native-flash-message'; @@ -26,6 +27,24 @@ const LOCATION_PERMISSION = ? PERMISSIONS.IOS.LOCATION_WHEN_IN_USE : PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION; +const BACKGROUND_LOCATION_PERMISSION = + Platform.OS === 'ios' + ? PERMISSIONS.IOS.LOCATION_ALWAYS + : PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION; + +/** + * Background-location permission state, mirroring the native sample apps. + * `null` until first queried. + */ +type LocationStatus = + | 'notDetermined' + | 'foregroundOnly' + | 'backgroundGranted' + | 'denied'; + +const isGrantedStatus = (status: LocationStatus | null) => + status === 'foregroundOnly' || status === 'backgroundGranted'; + 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 +54,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 +84,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 only when gaining access from a non-granted state — not on a downgrade + // between granted states (e.g. revoking "Always" back to "When in Use" in Settings). + // The SDK's auto-fetch hook runs once per process, so a grant made in Settings + // needs an explicit request to register geofences this session. + if (status && isGrantedStatus(status) && !isGrantedStatus(previous)) { + CustomerIO.location.requestLocationUpdate(); + } + }); + return () => subscription.remove(); + }, [refreshLocationStatus]); + const setLocation = (lat: number, lng: number, source: string) => { try { CustomerIO.location.setLastKnownLocation(lat, lng); @@ -95,19 +169,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 +188,112 @@ 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); + await refreshLocationStatus(); + if (result === RESULTS.GRANTED) { + // Fetch so geofences register now (auto-fetch already ran this process). + CustomerIO.location.requestLocationUpdate(); + 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". If no prompt appears, use Open Settings.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open Settings', onPress: () => Linking.openSettings() }, + { 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. + const result = await request(LOCATION_PERMISSION); + await refreshLocationStatus(); + if (result === RESULTS.GRANTED || result === RESULTS.LIMITED) { + // Foreground granted — fetch so geofences register, then offer background. + CustomerIO.location.requestLocationUpdate(); + 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 +307,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,32 +353,26 @@ export const LocationScreen = () => { return ( - {/* OPTION 1: QUICK PRESETS */} + {/* Background Location (geofence permission) */} - - OPTION 1: QUICK PRESETS + Background Location +