Skip to content
Merged
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
8 changes: 8 additions & 0 deletions location/api/location.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
public fun <init> (Lio/customer/location/LocationModuleConfig;)V
public synthetic fun <init> (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;
Expand Down
60 changes: 60 additions & 0 deletions location/src/main/kotlin/io/customer/location/LocationServices.kt
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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.")
Copy link

Choose a reason for hiding this comment

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

Location update APIs are non-functional

Medium Severity

requestLocationUpdateOnce() and stopLocationUpdates() are exposed in LocationServices but currently only log messages and perform no location request or cancellation. Calling these APIs appears successful but never emits a TrackLocationEvent, so SDK-managed location tracking is effectively unavailable despite the public contract.

Additional Locations (1)

Fix in Cursor Fix in Web

}

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
}
}
}
56 changes: 54 additions & 2 deletions location/src/main/kotlin/io/customer/location/ModuleLocation.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -23,24 +25,74 @@ 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(
override val moduleConfig: LocationModuleConfig = LocationModuleConfig.Builder().build()
) : CustomerIOModule<LocationModuleConfig> {
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)
Copy link

Choose a reason for hiding this comment

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

Cached service stays permanently uninitialized

Medium Severity

locationServices returns a new UninitializedLocationServices instance when _locationServices is null. If code reads and stores ModuleLocation.instance().locationServices before module initialization completes, that stored object never switches to LocationServicesImpl, so later setLastKnownLocation calls keep no-oping even after SDK initialization.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The getter evaluates on every access, it's not a cached field. Normal usage (ModuleLocation.instance().locationServices.setLastKnownLocation(...)) always hits the getter, so it
picks up the real implementation after init. The only risk is if a caller stores the reference before CustomerIO.initialize() completes, which is a very narrow window since both
happen sequentially in Application.onCreate(). Even in that case, the no-op logs an error on every call, making it easy to spot during development.


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().")
Copy link

Choose a reason for hiding this comment

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

Pre-init access still throws via instance

Medium Severity

locationServices has a no-op fallback for pre-initialization access, but the documented access path ModuleLocation.instance().locationServices still throws before SDK initialization. instance() fails before the fallback can run, so early calls can crash instead of no-oping.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

instance() throwing before registration is intentional and consistent with all other SDK modules (ModuleMessagingInApp.instance(), etc.). It surfaces a configuration error, the
developer forgot to call addCustomerIOModule(ModuleLocation()). The no-op fallback on locationServices covers a different case: the module is registered but initialize() hasn't run yet. These are two separate failure modes with two appropriate behaviors, crash for misconfiguration, no-op for timing.

}
}
}

/**
* 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()
}
Loading