Skip to content
Merged
26 changes: 26 additions & 0 deletions core/src/main/kotlin/io/customer/sdk/core/util/MainThreadPoster.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.customer.sdk.core.util

import android.os.Handler
import android.os.Looper
import io.customer.base.internal.InternalCustomerIOApi

/**
* Abstracts posting work to the main thread.
* Enables testability by allowing tests to mock or replace the implementation.
*/
@InternalCustomerIOApi
interface MainThreadPoster {
fun post(block: () -> Unit)
}

/**
* Default implementation using Android [Handler] with the main [Looper].
*/
@InternalCustomerIOApi
class HandlerMainThreadPoster : MainThreadPoster {
private val handler = Handler(Looper.getMainLooper())

override fun post(block: () -> Unit) {
handler.post(block)
}
}
16 changes: 12 additions & 4 deletions location/api/location.api
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
public final class io/customer/location/LocationModuleConfig : io/customer/sdk/core/module/CustomerIOModuleConfig {
public synthetic fun <init> (ZLkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getEnableLocationTracking ()Z
public synthetic fun <init> (Lio/customer/location/LocationTrackingMode;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getTrackingMode ()Lio/customer/location/LocationTrackingMode;
}

public final class io/customer/location/LocationModuleConfig$Builder : io/customer/sdk/core/module/CustomerIOModuleConfig$Builder {
public fun <init> ()V
public fun build ()Lio/customer/location/LocationModuleConfig;
public synthetic fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig;
public final fun setEnableLocationTracking (Z)Lio/customer/location/LocationModuleConfig$Builder;
public final fun setLocationTrackingMode (Lio/customer/location/LocationTrackingMode;)Lio/customer/location/LocationModuleConfig$Builder;
}

public abstract interface class io/customer/location/LocationServices {
public abstract fun requestLocationUpdate ()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/LocationTrackingMode : java/lang/Enum {
public static final field MANUAL Lio/customer/location/LocationTrackingMode;
public static final field OFF Lio/customer/location/LocationTrackingMode;
public static final field ON_APP_START Lio/customer/location/LocationTrackingMode;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lio/customer/location/LocationTrackingMode;
public static fun values ()[Lio/customer/location/LocationTrackingMode;
}

public final class io/customer/location/ModuleLocation : io/customer/sdk/core/module/CustomerIOModule {
Expand Down
1 change: 1 addition & 0 deletions location/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies {
api project(":core")

implementation Dependencies.googlePlayServicesLocation
implementation Dependencies.androidxProcessLifecycle
implementation Dependencies.coroutinesCore
implementation Dependencies.coroutinesAndroid
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.customer.location

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

/**
* Cancels in-flight location requests when the app enters background.
*
* Registered with [ProcessLifecycleOwner] during module initialization.
* [onStop] fires when the app transitions to background — any active
* GPS request is cancelled to avoid unnecessary work while backgrounded.
*
* No mutable state — thread safety is inherent.
*/
internal class LocationLifecycleObserver(
private val locationServices: LocationServicesImpl
) : DefaultLifecycleObserver {

override fun onStop(owner: LifecycleOwner) {
locationServices.cancelInFlightRequest()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,37 @@ import io.customer.sdk.core.module.CustomerIOModuleConfig
*/
class LocationModuleConfig private constructor(
/**
* Whether location tracking is enabled.
* The tracking mode for the location module.
*
* When false, the location module is effectively disabled and all location
* tracking operations will no-op silently.
* - [LocationTrackingMode.OFF]: Location tracking is disabled; all operations no-op.
* - [LocationTrackingMode.MANUAL]: Host app controls when location is captured (default).
* - [LocationTrackingMode.ON_APP_START]: SDK auto-captures location on cold start,
* caching it for identify context enrichment.
*/
val enableLocationTracking: Boolean
val trackingMode: LocationTrackingMode
) : CustomerIOModuleConfig {

/**
* Whether location tracking is enabled (any mode other than [LocationTrackingMode.OFF]).
*/
internal val isEnabled: Boolean
get() = trackingMode != LocationTrackingMode.OFF

class Builder : CustomerIOModuleConfig.Builder<LocationModuleConfig> {
private var enableLocationTracking: Boolean = true
private var trackingMode: LocationTrackingMode = LocationTrackingMode.MANUAL

/**
* Sets whether location tracking is enabled.
* When disabled, all location operations will no-op silently.
* Default is true.
* Sets the location tracking mode.
* Default is [LocationTrackingMode.MANUAL].
*/
fun setEnableLocationTracking(enable: Boolean): Builder {
this.enableLocationTracking = enable
fun setLocationTrackingMode(mode: LocationTrackingMode): Builder {
this.trackingMode = mode
return this
}

override fun build(): LocationModuleConfig {
return LocationModuleConfig(
enableLocationTracking = enableLocationTracking
trackingMode = trackingMode
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ internal class LocationOrchestrator(
) {

suspend fun requestLocationUpdate() {
if (!config.enableLocationTracking) {
if (!config.isEnabled) {
logger.debug("Location tracking is disabled, ignoring requestLocationUpdate.")
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,12 @@ interface LocationServices {
fun setLastKnownLocation(location: Location)

/**
* Starts a single location update and sends the result to Customer.io.
* Requests 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 requestLocationUpdate()

/**
* 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
Expand Up @@ -2,8 +2,6 @@ package io.customer.location

import android.location.Location
import io.customer.sdk.core.util.Logger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
Expand All @@ -21,11 +19,11 @@ internal class LocationServicesImpl(
private val scope: CoroutineScope
) : LocationServices {

private val lock = ReentrantLock()
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a specific reason why we removed that lock?

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 lock was originally added when we used last-wins dedup (cancel old → start new). That pattern creates a concurrent jobs scenario where the lock was needed to atomically cancel-then-relaunch.

We switched to first-wins dedup (if a request is in flight, ignore the new call) what iOS is doing. With first-wins, there's only ever one job at a time — no concurrent job scenario, so the lock's original purpose is gone.

The only theoretical race is two threads calling requestLocationUpdate() simultaneously when no job is active, both could launch a job. The consequence is one redundant GPS request

@Volatile
private var currentLocationJob: Job? = null

override fun setLastKnownLocation(latitude: Double, longitude: Double) {
if (!config.enableLocationTracking) {
if (!config.isEnabled) {
logger.debug("Location tracking is disabled, ignoring setLastKnownLocation.")
return
}
Expand All @@ -45,34 +43,29 @@ internal class LocationServicesImpl(
}

override fun requestLocationUpdate() {
lock.withLock {
// Cancel any previous in-flight request
currentLocationJob?.cancel()
// If a request is already in flight, ignore the new call
if (currentLocationJob?.isActive == true) return

currentLocationJob = scope.launch {
val thisJob = coroutineContext[Job]
try {
orchestrator.requestLocationUpdate()
} finally {
lock.withLock {
if (currentLocationJob === thisJob) {
currentLocationJob = null
}
}
currentLocationJob = scope.launch {
try {
orchestrator.requestLocationUpdate()
} finally {
// Only clear if this is still the current job — prevents
// a cancelled job's finally from nulling a newer job's reference
if (currentLocationJob === coroutineContext[Job]) {
currentLocationJob = null
}
}
}
}

override fun stopLocationUpdates() {
val job: Job?
lock.withLock {
job = currentLocationJob
currentLocationJob = null
}
// Cancelling the job triggers invokeOnCancellation in FusedLocationProvider's
// suspendCancellableCoroutine, which cancels the CancellationTokenSource.
job?.cancel()
/**
* Cancels any in-flight location request.
* Called when the app enters background to avoid unnecessary GPS work.
*/
internal fun cancelInFlightRequest() {
currentLocationJob?.cancel()
currentLocationJob = null
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.customer.location

/**
* Defines the tracking mode for the location module.
*/
enum class LocationTrackingMode {
/**
* Location tracking is disabled.
* All location operations will no-op silently.
*/
OFF,

/**
* Host app controls when location is captured.
* Use [LocationServices.setLastKnownLocation] or [LocationServices.requestLocationUpdate]
* to manually trigger location capture. This is the default mode.
*/
MANUAL,

/**
* SDK automatically captures location on cold start.
*
* The captured location goes through the normal sync path
* (cache + 24h/1km filter + track event if filter passes).
* Manual location APIs remain fully functional.
*/
ON_APP_START
}
Loading
Loading