Skip to content

Commit 7293a4e

Browse files
authored
chore: update Location tracking module config (#665)
1 parent 1f518e8 commit 7293a4e

File tree

19 files changed

+267
-113
lines changed

19 files changed

+267
-113
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.customer.sdk.core.util
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
import io.customer.base.internal.InternalCustomerIOApi
6+
7+
/**
8+
* Abstracts posting work to the main thread.
9+
* Enables testability by allowing tests to mock or replace the implementation.
10+
*/
11+
@InternalCustomerIOApi
12+
interface MainThreadPoster {
13+
fun post(block: () -> Unit)
14+
}
15+
16+
/**
17+
* Default implementation using Android [Handler] with the main [Looper].
18+
*/
19+
@InternalCustomerIOApi
20+
class HandlerMainThreadPoster : MainThreadPoster {
21+
private val handler = Handler(Looper.getMainLooper())
22+
23+
override fun post(block: () -> Unit) {
24+
handler.post(block)
25+
}
26+
}

location/api/location.api

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
public final class io/customer/location/LocationModuleConfig : io/customer/sdk/core/module/CustomerIOModuleConfig {
2-
public synthetic fun <init> (ZLkotlin/jvm/internal/DefaultConstructorMarker;)V
3-
public final fun getEnableLocationTracking ()Z
2+
public synthetic fun <init> (Lio/customer/location/LocationTrackingMode;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
3+
public final fun getTrackingMode ()Lio/customer/location/LocationTrackingMode;
44
}
55

66
public final class io/customer/location/LocationModuleConfig$Builder : io/customer/sdk/core/module/CustomerIOModuleConfig$Builder {
77
public fun <init> ()V
88
public fun build ()Lio/customer/location/LocationModuleConfig;
99
public synthetic fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig;
10-
public final fun setEnableLocationTracking (Z)Lio/customer/location/LocationModuleConfig$Builder;
10+
public final fun setLocationTrackingMode (Lio/customer/location/LocationTrackingMode;)Lio/customer/location/LocationModuleConfig$Builder;
1111
}
1212

1313
public abstract interface class io/customer/location/LocationServices {
1414
public abstract fun requestLocationUpdate ()V
1515
public abstract fun setLastKnownLocation (DD)V
1616
public abstract fun setLastKnownLocation (Landroid/location/Location;)V
17-
public abstract fun stopLocationUpdates ()V
17+
}
18+
19+
public final class io/customer/location/LocationTrackingMode : java/lang/Enum {
20+
public static final field MANUAL Lio/customer/location/LocationTrackingMode;
21+
public static final field OFF Lio/customer/location/LocationTrackingMode;
22+
public static final field ON_APP_START Lio/customer/location/LocationTrackingMode;
23+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
24+
public static fun valueOf (Ljava/lang/String;)Lio/customer/location/LocationTrackingMode;
25+
public static fun values ()[Lio/customer/location/LocationTrackingMode;
1826
}
1927

2028
public final class io/customer/location/ModuleLocation : io/customer/sdk/core/module/CustomerIOModule {

location/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies {
4444
api project(":core")
4545

4646
implementation Dependencies.googlePlayServicesLocation
47+
implementation Dependencies.androidxProcessLifecycle
4748
implementation Dependencies.coroutinesCore
4849
implementation Dependencies.coroutinesAndroid
4950
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.customer.location
2+
3+
import androidx.lifecycle.DefaultLifecycleObserver
4+
import androidx.lifecycle.LifecycleOwner
5+
6+
/**
7+
* Manages location lifecycle tied to app foreground/background transitions.
8+
*
9+
* Registered with [ProcessLifecycleOwner] during module initialization.
10+
* - [onStart]: triggers a one-shot location request on the first foreground entry
11+
* when [trackingMode] is [LocationTrackingMode.ON_APP_START].
12+
* - [onStop]: cancels any in-flight GPS request when the app enters background.
13+
*
14+
* Thread safety: all lifecycle callbacks are delivered on the main thread
15+
* by [ProcessLifecycleOwner], so no synchronization is needed.
16+
*/
17+
internal class LocationLifecycleObserver(
18+
private val locationServices: LocationServicesImpl,
19+
private val trackingMode: LocationTrackingMode
20+
) : DefaultLifecycleObserver {
21+
22+
private var hasRequestedOnStart = false
23+
24+
override fun onStart(owner: LifecycleOwner) {
25+
if (trackingMode == LocationTrackingMode.ON_APP_START && !hasRequestedOnStart) {
26+
hasRequestedOnStart = true
27+
locationServices.requestLocationUpdate()
28+
}
29+
}
30+
31+
override fun onStop(owner: LifecycleOwner) {
32+
val wasCancelled = locationServices.cancelInFlightRequest()
33+
// If the GPS request was still in flight when we backgrounded,
34+
// allow onStart to retry on the next foreground entry.
35+
if (wasCancelled) {
36+
hasRequestedOnStart = false
37+
}
38+
}
39+
}

location/src/main/kotlin/io/customer/location/LocationModuleConfig.kt

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,37 @@ import io.customer.sdk.core.module.CustomerIOModuleConfig
88
*/
99
class LocationModuleConfig private constructor(
1010
/**
11-
* Whether location tracking is enabled.
11+
* The tracking mode for the location module.
1212
*
13-
* When false, the location module is effectively disabled and all location
14-
* tracking operations will no-op silently.
13+
* - [LocationTrackingMode.OFF]: Location tracking is disabled; all operations no-op.
14+
* - [LocationTrackingMode.MANUAL]: Host app controls when location is captured (default).
15+
* - [LocationTrackingMode.ON_APP_START]: SDK auto-captures location on cold start,
16+
* caching it for identify context enrichment.
1517
*/
16-
val enableLocationTracking: Boolean
18+
val trackingMode: LocationTrackingMode
1719
) : CustomerIOModuleConfig {
1820

21+
/**
22+
* Whether location tracking is enabled (any mode other than [LocationTrackingMode.OFF]).
23+
*/
24+
internal val isEnabled: Boolean
25+
get() = trackingMode != LocationTrackingMode.OFF
26+
1927
class Builder : CustomerIOModuleConfig.Builder<LocationModuleConfig> {
20-
private var enableLocationTracking: Boolean = true
28+
private var trackingMode: LocationTrackingMode = LocationTrackingMode.MANUAL
2129

2230
/**
23-
* Sets whether location tracking is enabled.
24-
* When disabled, all location operations will no-op silently.
25-
* Default is true.
31+
* Sets the location tracking mode.
32+
* Default is [LocationTrackingMode.MANUAL].
2633
*/
27-
fun setEnableLocationTracking(enable: Boolean): Builder {
28-
this.enableLocationTracking = enable
34+
fun setLocationTrackingMode(mode: LocationTrackingMode): Builder {
35+
this.trackingMode = mode
2936
return this
3037
}
3138

3239
override fun build(): LocationModuleConfig {
3340
return LocationModuleConfig(
34-
enableLocationTracking = enableLocationTracking
41+
trackingMode = trackingMode
3542
)
3643
}
3744
}

location/src/main/kotlin/io/customer/location/LocationOrchestrator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ internal class LocationOrchestrator(
1818
) {
1919

2020
suspend fun requestLocationUpdate() {
21-
if (!config.enableLocationTracking) {
21+
if (!config.isEnabled) {
2222
logger.debug("Location tracking is disabled, ignoring requestLocationUpdate.")
2323
return
2424
}

location/src/main/kotlin/io/customer/location/LocationServices.kt

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,12 @@ interface LocationServices {
4141
fun setLastKnownLocation(location: Location)
4242

4343
/**
44-
* Starts a single location update and sends the result to Customer.io.
44+
* Requests a single location update and sends the result to Customer.io.
4545
*
4646
* No-ops if location tracking is disabled or permission is not granted.
47-
* Only one request at a time; calling again cancels any in-flight request
48-
* and starts a new one.
4947
*
5048
* The SDK does not request location permission. The host app must request
5149
* runtime permissions and only call this when permission is granted.
5250
*/
5351
fun requestLocationUpdate()
54-
55-
/**
56-
* Cancels any in-flight location request.
57-
* No-op if nothing is in progress.
58-
*/
59-
fun stopLocationUpdates()
6052
}

location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package io.customer.location
22

33
import android.location.Location
44
import io.customer.sdk.core.util.Logger
5-
import java.util.concurrent.locks.ReentrantLock
6-
import kotlin.concurrent.withLock
75
import kotlinx.coroutines.CoroutineScope
86
import kotlinx.coroutines.Job
97
import kotlinx.coroutines.launch
@@ -21,11 +19,11 @@ internal class LocationServicesImpl(
2119
private val scope: CoroutineScope
2220
) : LocationServices {
2321

24-
private val lock = ReentrantLock()
22+
@Volatile
2523
private var currentLocationJob: Job? = null
2624

2725
override fun setLastKnownLocation(latitude: Double, longitude: Double) {
28-
if (!config.enableLocationTracking) {
26+
if (!config.isEnabled) {
2927
logger.debug("Location tracking is disabled, ignoring setLastKnownLocation.")
3028
return
3129
}
@@ -45,34 +43,33 @@ internal class LocationServicesImpl(
4543
}
4644

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

52-
currentLocationJob = scope.launch {
53-
val thisJob = coroutineContext[Job]
54-
try {
55-
orchestrator.requestLocationUpdate()
56-
} finally {
57-
lock.withLock {
58-
if (currentLocationJob === thisJob) {
59-
currentLocationJob = null
60-
}
61-
}
49+
currentLocationJob = scope.launch {
50+
try {
51+
orchestrator.requestLocationUpdate()
52+
} finally {
53+
// Only clear if this is still the current job — prevents
54+
// a cancelled job's finally from nulling a newer job's reference
55+
if (currentLocationJob === coroutineContext[Job]) {
56+
currentLocationJob = null
6257
}
6358
}
6459
}
6560
}
6661

67-
override fun stopLocationUpdates() {
68-
val job: Job?
69-
lock.withLock {
70-
job = currentLocationJob
71-
currentLocationJob = null
72-
}
73-
// Cancelling the job triggers invokeOnCancellation in FusedLocationProvider's
74-
// suspendCancellableCoroutine, which cancels the CancellationTokenSource.
75-
job?.cancel()
62+
/**
63+
* Cancels any in-flight location request.
64+
* Called when the app enters background to avoid unnecessary GPS work.
65+
*
66+
* @return true if an active request was cancelled, false if nothing was in flight.
67+
*/
68+
internal fun cancelInFlightRequest(): Boolean {
69+
val job = currentLocationJob ?: return false
70+
currentLocationJob = null
71+
job.cancel()
72+
return true
7673
}
7774

7875
companion object {

location/src/main/kotlin/io/customer/location/LocationTracker.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import io.customer.sdk.core.pipeline.DataPipeline
66
import io.customer.sdk.core.pipeline.IdentifyHook
77
import io.customer.sdk.core.util.Logger
88
import io.customer.sdk.util.EventNames
9+
import java.lang.ref.WeakReference
910

1011
/**
1112
* Coordinates all location state management: persistence, restoration,
@@ -30,12 +31,18 @@ import io.customer.sdk.util.EventNames
3031
* sync filter's state.
3132
*/
3233
internal class LocationTracker(
33-
private val dataPipeline: DataPipeline?,
34+
dataPipeline: DataPipeline?,
3435
private val locationPreferenceStore: LocationPreferenceStore,
3536
private val locationSyncFilter: LocationSyncFilter,
3637
private val logger: Logger
3738
) : IdentifyHook {
3839

40+
// WeakReference prevents this hook (retained by IdentifyHookRegistry
41+
// inside a Segment plugin) from keeping CustomerIO alive after SDK
42+
// cleanup. The strong reference lives in CustomerIO.instance — as long
43+
// as the SDK is initialized, the weak ref is valid.
44+
private val dataPipelineRef = WeakReference(dataPipeline)
45+
3946
@Volatile
4047
private var lastLocation: LocationCoordinates? = null
4148

@@ -117,7 +124,7 @@ internal class LocationTracker(
117124
* track event via [DataPipeline] if both pass.
118125
*/
119126
private fun trySendLocationTrack(latitude: Double, longitude: Double) {
120-
val pipeline = dataPipeline ?: return
127+
val pipeline = dataPipelineRef.get() ?: return
121128
if (!pipeline.isUserIdentified) return
122129
if (!locationSyncFilter.filterAndRecord(latitude, longitude)) return
123130

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.customer.location
2+
3+
/**
4+
* Defines the tracking mode for the location module.
5+
*/
6+
enum class LocationTrackingMode {
7+
/**
8+
* Location tracking is disabled.
9+
* All location operations will no-op silently.
10+
*/
11+
OFF,
12+
13+
/**
14+
* Host app controls when location is captured.
15+
* Use [LocationServices.setLastKnownLocation] or [LocationServices.requestLocationUpdate]
16+
* to manually trigger location capture. This is the default mode.
17+
*/
18+
MANUAL,
19+
20+
/**
21+
* SDK automatically captures location on cold start.
22+
*
23+
* The captured location goes through the normal sync path
24+
* (cache + 24h/1km filter + track event if filter passes).
25+
* Manual location APIs remain fully functional.
26+
*/
27+
ON_APP_START
28+
}

0 commit comments

Comments
 (0)