From b18ac227a24ccc1700f0838e064035cf47f9ec83 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Sun, 15 Feb 2026 16:53:38 +0500 Subject: [PATCH] feat: add manual location API (setLastKnownLocation) Implement the manual location tracking API allowing host apps to send their own location data to Customer.io: - LocationServices interface: public API with setLastKnownLocation (raw lat/lng and Android Location overloads), requestLocationUpdateOnce, and stopLocationUpdates stubs - LocationServicesImpl: validates coordinates, checks config, posts TrackLocationEvent via EventBus - UninitializedLocationServices: error-logging stub for pre-init calls - ModuleLocation: wires LocationServicesImpl on initialize() Co-Authored-By: Claude Opus 4.6 --- location/api/location.api | 8 +++ .../io/customer/location/LocationServices.kt | 60 +++++++++++++++++ .../customer/location/LocationServicesImpl.kt | 65 +++++++++++++++++++ .../io/customer/location/ModuleLocation.kt | 56 +++++++++++++++- 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 location/src/main/kotlin/io/customer/location/LocationServices.kt create mode 100644 location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt diff --git a/location/api/location.api b/location/api/location.api index 5f147b426..542a58b5a 100644 --- a/location/api/location.api +++ b/location/api/location.api @@ -10,12 +10,20 @@ public final class io/customer/location/LocationModuleConfig$Builder : io/custom public final fun setEnableLocationTracking (Z)Lio/customer/location/LocationModuleConfig$Builder; } +public abstract interface class io/customer/location/LocationServices { + public abstract fun requestLocationUpdateOnce ()V + public abstract fun setLastKnownLocation (DD)V + public abstract fun setLastKnownLocation (Landroid/location/Location;)V + public abstract fun stopLocationUpdates ()V +} + public final class io/customer/location/ModuleLocation : io/customer/sdk/core/module/CustomerIOModule { public static final field Companion Lio/customer/location/ModuleLocation$Companion; public static final field MODULE_NAME Ljava/lang/String; public fun ()V public fun (Lio/customer/location/LocationModuleConfig;)V public synthetic fun (Lio/customer/location/LocationModuleConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getLocationServices ()Lio/customer/location/LocationServices; public fun getModuleConfig ()Lio/customer/location/LocationModuleConfig; public synthetic fun getModuleConfig ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; public fun getModuleName ()Ljava/lang/String; diff --git a/location/src/main/kotlin/io/customer/location/LocationServices.kt b/location/src/main/kotlin/io/customer/location/LocationServices.kt new file mode 100644 index 000000000..e650905f4 --- /dev/null +++ b/location/src/main/kotlin/io/customer/location/LocationServices.kt @@ -0,0 +1,60 @@ +package io.customer.location + +import android.location.Location + +/** + * Public API for the Location module. + * + * Use [ModuleLocation.locationServices] after initializing the SDK with [ModuleLocation] + * to get the instance. + * + * Example: + * ``` + * // Manual location from host app's existing location system + * ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194) + * + * // Or pass an Android Location object + * ModuleLocation.instance().locationServices.setLastKnownLocation(androidLocation) + * ``` + */ +interface LocationServices { + /** + * Sets the last known location from the host app's existing location system. + * + * Use this method when your app already manages location and you want to + * send that data to Customer.io without the SDK managing location permissions + * or the FusedLocationProviderClient directly. + * + * @param latitude the latitude in degrees, must be between -90 and 90 + * @param longitude the longitude in degrees, must be between -180 and 180 + */ + fun setLastKnownLocation(latitude: Double, longitude: Double) + + /** + * Sets the last known location from an Android [Location] object. + * + * Convenience overload for apps that already have a [Location] instance + * from their own location system. + * + * @param location the Android Location object to track + */ + fun setLastKnownLocation(location: Location) + + /** + * Starts a single location update and sends the result to Customer.io. + * + * No-ops if location tracking is disabled or permission is not granted. + * Only one request at a time; calling again cancels any in-flight request + * and starts a new one. + * + * The SDK does not request location permission. The host app must request + * runtime permissions and only call this when permission is granted. + */ + fun requestLocationUpdateOnce() + + /** + * Cancels any in-flight location request. + * No-op if nothing is in progress. + */ + fun stopLocationUpdates() +} diff --git a/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt b/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt new file mode 100644 index 000000000..77785c29e --- /dev/null +++ b/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt @@ -0,0 +1,65 @@ +package io.customer.location + +import android.location.Location +import io.customer.sdk.communication.Event +import io.customer.sdk.communication.EventBus +import io.customer.sdk.core.util.Logger + +/** + * Real implementation of [LocationServices]. + * Handles manual location setting with validation and config checks. + * + * SDK-managed location (requestLocationUpdateOnce) will be implemented in a future PR. + */ +internal class LocationServicesImpl( + private val config: LocationModuleConfig, + private val logger: Logger, + private val eventBus: EventBus +) : LocationServices { + + override fun setLastKnownLocation(latitude: Double, longitude: Double) { + if (!config.enableLocationTracking) { + logger.debug("Location tracking is disabled, ignoring setLastKnownLocation.") + return + } + + if (!isValidCoordinate(latitude, longitude)) { + logger.error("Invalid coordinates: lat=$latitude, lng=$longitude. Latitude must be [-90, 90] and longitude [-180, 180].") + return + } + + logger.debug("Tracking location: lat=$latitude, lng=$longitude") + + val locationData = Event.LocationData( + latitude = latitude, + longitude = longitude + ) + eventBus.publish(Event.TrackLocationEvent(location = locationData)) + } + + override fun setLastKnownLocation(location: Location) { + setLastKnownLocation(location.latitude, location.longitude) + } + + override fun requestLocationUpdateOnce() { + // Will be implemented in the SDK-managed location PR + logger.debug("requestLocationUpdateOnce is not yet implemented.") + } + + override fun stopLocationUpdates() { + // Will be implemented in the SDK-managed location PR + logger.debug("stopLocationUpdates is not yet implemented.") + } + + companion object { + /** + * Validates that latitude is within [-90, 90] and longitude is within [-180, 180]. + * Also rejects NaN and Infinity values. + */ + internal fun isValidCoordinate(latitude: Double, longitude: Double): Boolean { + if (latitude.isNaN() || latitude.isInfinite()) return false + if (longitude.isNaN() || longitude.isInfinite()) return false + return latitude in -90.0..90.0 && longitude in -180.0..180.0 + } + } +} diff --git a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt index 1522aa2cb..86afed059 100644 --- a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt +++ b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt @@ -1,7 +1,9 @@ package io.customer.location +import android.location.Location import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule +import io.customer.sdk.core.util.Logger /** * Location module for Customer.io SDK. @@ -23,6 +25,9 @@ import io.customer.sdk.core.module.CustomerIOModule * .build() * * CustomerIO.initialize(config) + * + * // Then use the location services + * ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194) * ``` */ class ModuleLocation @JvmOverloads constructor( @@ -30,17 +35,64 @@ class ModuleLocation @JvmOverloads constructor( ) : CustomerIOModule { override val moduleName: String = MODULE_NAME + private var _locationServices: LocationServices? = null + + /** + * Access the location services API. + * + * This property is only usable after [CustomerIO.initialize] has been called with + * [ModuleLocation] registered. The SDK calls [initialize] on all registered modules + * during startup, which wires up the real implementation. + * + * If accessed before initialization (e.g. calling location APIs before + * [CustomerIO.initialize]), calls will no-op and log an error instead of crashing. + * This guards against race conditions during app startup or incorrect call order. + */ + val locationServices: LocationServices + get() = _locationServices ?: UninitializedLocationServices(SDKComponent.logger) + override fun initialize() { - // Module initialization will be implemented in future PRs + _locationServices = LocationServicesImpl( + config = moduleConfig, + logger = SDKComponent.logger, + eventBus = SDKComponent.eventBus + ) } companion object { const val MODULE_NAME: String = "Location" + /** + * Returns the initialized [ModuleLocation] instance. + * + * @throws IllegalStateException if the module hasn't been registered with the SDK + */ @JvmStatic fun instance(): ModuleLocation { return SDKComponent.modules[MODULE_NAME] as? ModuleLocation - ?: throw IllegalStateException("ModuleLocation not initialized") + ?: throw IllegalStateException("ModuleLocation not initialized. Add ModuleLocation to CustomerIOConfigBuilder before calling CustomerIO.initialize().") } } } + +/** + * No-op fallback returned when [ModuleLocation.locationServices] is accessed + * before the SDK has been initialized. Logs an error for each call to help + * developers diagnose incorrect call order during development. + */ +private class UninitializedLocationServices( + private val logger: Logger +) : LocationServices { + + private fun logNotInitialized() { + logger.error("Location module is not initialized. Call CustomerIO.initialize() with ModuleLocation before using location APIs.") + } + + override fun setLastKnownLocation(latitude: Double, longitude: Double) = logNotInitialized() + + override fun setLastKnownLocation(location: Location) = logNotInitialized() + + override fun requestLocationUpdateOnce() = logNotInitialized() + + override fun stopLocationUpdates() = logNotInitialized() +}