Skip to content

Commit 42f29b8

Browse files
Shahroz16claude
andauthored
chore: add SDK-managed one-shot location (requestLocationUpdateOnce) (#657)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79bb2c3 commit 42f29b8

File tree

12 files changed

+318
-30
lines changed

12 files changed

+318
-30
lines changed

common-test/src/main/java/io/customer/commontest/util/ScopeProviderStub.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
99
class ScopeProviderStub private constructor(
1010
override val eventBusScope: TestScope,
1111
override val lifecycleListenerScope: TestScope,
12-
override val inAppLifecycleScope: TestScope
12+
override val inAppLifecycleScope: TestScope,
13+
override val locationScope: TestScope
1314
) : ScopeProvider {
1415

1516
@Suppress("FunctionName")
@@ -18,13 +19,15 @@ class ScopeProviderStub private constructor(
1819
fun Unconfined(): ScopeProviderStub = ScopeProviderStub(
1920
eventBusScope = TestScope(UnconfinedTestDispatcher()),
2021
lifecycleListenerScope = TestScope(UnconfinedTestDispatcher()),
21-
inAppLifecycleScope = TestScope(UnconfinedTestDispatcher())
22+
inAppLifecycleScope = TestScope(UnconfinedTestDispatcher()),
23+
locationScope = TestScope(UnconfinedTestDispatcher())
2224
)
2325

2426
fun Standard(): ScopeProviderStub = ScopeProviderStub(
2527
eventBusScope = TestScope(StandardTestDispatcher()),
2628
lifecycleListenerScope = TestScope(StandardTestDispatcher()),
27-
inAppLifecycleScope = TestScope(StandardTestDispatcher())
29+
inAppLifecycleScope = TestScope(StandardTestDispatcher()),
30+
locationScope = TestScope(StandardTestDispatcher())
2831
)
2932
}
3033
}

core/api/core.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ public abstract interface class io/customer/sdk/core/util/ScopeProvider {
250250
public abstract fun getEventBusScope ()Lkotlinx/coroutines/CoroutineScope;
251251
public abstract fun getInAppLifecycleScope ()Lkotlinx/coroutines/CoroutineScope;
252252
public abstract fun getLifecycleListenerScope ()Lkotlinx/coroutines/CoroutineScope;
253+
public abstract fun getLocationScope ()Lkotlinx/coroutines/CoroutineScope;
253254
}
254255

255256
public final class io/customer/sdk/core/util/SdkDispatchers : io/customer/sdk/core/util/DispatchersProvider {
@@ -264,6 +265,7 @@ public final class io/customer/sdk/core/util/SdkScopeProvider : io/customer/sdk/
264265
public fun getEventBusScope ()Lkotlinx/coroutines/CoroutineScope;
265266
public fun getInAppLifecycleScope ()Lkotlinx/coroutines/CoroutineScope;
266267
public fun getLifecycleListenerScope ()Lkotlinx/coroutines/CoroutineScope;
268+
public fun getLocationScope ()Lkotlinx/coroutines/CoroutineScope;
267269
}
268270

269271
public abstract class io/customer/sdk/data/model/Region {

core/src/main/kotlin/io/customer/sdk/core/util/ScopeProvider.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface ScopeProvider {
77
val eventBusScope: CoroutineScope
88
val lifecycleListenerScope: CoroutineScope
99
val inAppLifecycleScope: CoroutineScope
10+
val locationScope: CoroutineScope
1011
}
1112

1213
class SdkScopeProvider(private val dispatchers: DispatchersProvider) : ScopeProvider {
@@ -16,4 +17,6 @@ class SdkScopeProvider(private val dispatchers: DispatchersProvider) : ScopeProv
1617
get() = CoroutineScope(dispatchers.default + SupervisorJob())
1718
override val inAppLifecycleScope: CoroutineScope
1819
get() = CoroutineScope(dispatchers.default + SupervisorJob())
20+
override val locationScope: CoroutineScope
21+
get() = CoroutineScope(dispatchers.default + SupervisorJob())
1922
}

datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginBehaviorTest.kt

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,23 +134,21 @@ class ContextPluginBehaviorTest : JUnitTest(dispatcher = StandardTestDispatcher(
134134
executor.shutdown()
135135

136136
// For each event executed by SDK, verify writer token that was active during the event's execution
137+
val sortedWrites = writerLog.entries.sortedBy { it.key }
137138
val mismatches = outputReaderPlugin.trackEvents.mapNotNull { event ->
138139
val executionStartTime = event.context.getStringAtPath("test.executionStartTime")?.toLong().shouldNotBeNull()
139140
val executionEndTime = event.context.getStringAtPath("test.executionEndTime")?.toLong().shouldNotBeNull()
140141
val actualToken = event.context.deviceToken
141142

142-
// Find the latest write before the event execution end time
143-
val latestWriteBeforeEvent = writerLog
144-
.filterKeys { it <= executionEndTime }
145-
.maxByOrNull { it.key }
146-
// Find the newest write after the latest write
147-
// This is because the writer might have written a new token after the event was executed
148-
// So having a newer token is valid
149-
val nextWriteAfterLatest = writerLog
150-
.filterKeys { it > (latestWriteBeforeEvent?.key ?: Long.MAX_VALUE) }
151-
.minByOrNull { it.key }
152-
// Valid tokens are the latest write before the event and the next write after the latest
153-
val validTokens = setOfNotNull(latestWriteBeforeEvent?.value, nextWriteAfterLatest?.value)
143+
// Find the index of the latest write logged before the event finished.
144+
// Note: registerDeviceToken sets the @Volatile field BEFORE writerLog
145+
// records the timestamp, so on slow CI the actual token may be several
146+
// writes ahead of the latest log entry. We allow a window of up to 3
147+
// entries beyond the latest logged write to account for this gap.
148+
val latestBeforeIndex = sortedWrites.indexOfLast { it.key <= executionEndTime }
149+
val windowStart = latestBeforeIndex.coerceAtLeast(0)
150+
val windowEnd = (latestBeforeIndex + 3).coerceAtMost(sortedWrites.size - 1)
151+
val validTokens = (windowStart..windowEnd).map { sortedWrites[it].value }.toSet()
154152

155153
// If the actual token is not in valid tokens, it's a mismatch
156154
if (actualToken !in validTokens) {

location/api/location.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public final class io/customer/location/LocationModuleConfig$Builder : io/custom
1111
}
1212

1313
public abstract interface class io/customer/location/LocationServices {
14-
public abstract fun requestLocationUpdateOnce ()V
14+
public abstract fun requestLocationUpdate ()V
1515
public abstract fun setLastKnownLocation (DD)V
1616
public abstract fun setLastKnownLocation (Landroid/location/Location;)V
1717
public abstract fun stopLocationUpdates ()V
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

4+
<!-- Coarse location is sufficient for city/timezone level tracking -->
5+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
6+
47
</manifest>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.customer.location
2+
3+
import io.customer.location.provider.LocationProvider
4+
import io.customer.location.provider.LocationRequestException
5+
import io.customer.location.type.LocationGranularity
6+
import io.customer.sdk.communication.Event
7+
import io.customer.sdk.communication.EventBus
8+
import io.customer.sdk.core.util.Logger
9+
import kotlinx.coroutines.CancellationException
10+
11+
/**
12+
* Coordinates location requests: one-shot only.
13+
* Uses an injected [LocationProvider] for platform location access.
14+
*/
15+
internal class LocationOrchestrator(
16+
private val config: LocationModuleConfig,
17+
private val logger: Logger,
18+
private val eventBus: EventBus,
19+
private val locationProvider: LocationProvider
20+
) {
21+
22+
suspend fun requestLocationUpdate() {
23+
if (!config.enableLocationTracking) {
24+
logger.debug("Location tracking is disabled, ignoring requestLocationUpdate.")
25+
return
26+
}
27+
28+
val authStatus = locationProvider.currentAuthorizationStatus()
29+
if (!authStatus.isAuthorized) {
30+
logger.debug("Location permission not granted ($authStatus), ignoring request.")
31+
return
32+
}
33+
34+
try {
35+
val snapshot = locationProvider.requestLocation(
36+
granularity = LocationGranularity.DEFAULT
37+
)
38+
postLocation(snapshot.latitude, snapshot.longitude)
39+
} catch (e: CancellationException) {
40+
logger.debug("Location request was cancelled.")
41+
throw e
42+
} catch (e: LocationRequestException) {
43+
logger.debug("Location request failed: ${e.error}")
44+
} catch (e: Exception) {
45+
logger.error("Location request failed with unexpected error: ${e.message}")
46+
}
47+
}
48+
49+
private fun postLocation(latitude: Double, longitude: Double) {
50+
logger.debug("Tracking location: lat=$latitude, lng=$longitude")
51+
val locationData = Event.LocationData(
52+
latitude = latitude,
53+
longitude = longitude
54+
)
55+
eventBus.publish(Event.TrackLocationEvent(location = locationData))
56+
}
57+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface LocationServices {
5050
* The SDK does not request location permission. The host app must request
5151
* runtime permissions and only call this when permission is granted.
5252
*/
53-
fun requestLocationUpdateOnce()
53+
fun requestLocationUpdate()
5454

5555
/**
5656
* Cancels any in-flight location request.

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

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,28 @@ import android.location.Location
44
import io.customer.sdk.communication.Event
55
import io.customer.sdk.communication.EventBus
66
import io.customer.sdk.core.util.Logger
7+
import java.util.concurrent.locks.ReentrantLock
8+
import kotlin.concurrent.withLock
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Job
11+
import kotlinx.coroutines.launch
712

813
/**
914
* Real implementation of [LocationServices].
10-
* Handles manual location setting with validation and config checks.
11-
*
12-
* SDK-managed location (requestLocationUpdateOnce) will be implemented in a future PR.
15+
* Handles manual location setting with validation and config checks,
16+
* and SDK-managed one-shot location via [LocationOrchestrator].
1317
*/
1418
internal class LocationServicesImpl(
1519
private val config: LocationModuleConfig,
1620
private val logger: Logger,
17-
private val eventBus: EventBus
21+
private val eventBus: EventBus,
22+
private val orchestrator: LocationOrchestrator,
23+
private val scope: CoroutineScope
1824
) : LocationServices {
1925

26+
private val lock = ReentrantLock()
27+
private var currentLocationJob: Job? = null
28+
2029
override fun setLastKnownLocation(latitude: Double, longitude: Double) {
2130
if (!config.enableLocationTracking) {
2231
logger.debug("Location tracking is disabled, ignoring setLastKnownLocation.")
@@ -41,14 +50,35 @@ internal class LocationServicesImpl(
4150
setLastKnownLocation(location.latitude, location.longitude)
4251
}
4352

44-
override fun requestLocationUpdateOnce() {
45-
// Will be implemented in the SDK-managed location PR
46-
logger.debug("requestLocationUpdateOnce is not yet implemented.")
53+
override fun requestLocationUpdate() {
54+
lock.withLock {
55+
// Cancel any previous in-flight request
56+
currentLocationJob?.cancel()
57+
58+
currentLocationJob = scope.launch {
59+
val thisJob = coroutineContext[Job]
60+
try {
61+
orchestrator.requestLocationUpdate()
62+
} finally {
63+
lock.withLock {
64+
if (currentLocationJob === thisJob) {
65+
currentLocationJob = null
66+
}
67+
}
68+
}
69+
}
70+
}
4771
}
4872

4973
override fun stopLocationUpdates() {
50-
// Will be implemented in the SDK-managed location PR
51-
logger.debug("stopLocationUpdates is not yet implemented.")
74+
val job: Job?
75+
lock.withLock {
76+
job = currentLocationJob
77+
currentLocationJob = null
78+
}
79+
// Cancelling the job triggers invokeOnCancellation in FusedLocationProvider's
80+
// suspendCancellableCoroutine, which cancels the CancellationTokenSource.
81+
job?.cancel()
5282
}
5383

5484
companion object {

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.customer.location
22

33
import android.location.Location
4+
import io.customer.location.provider.FusedLocationProvider
45
import io.customer.sdk.core.di.SDKComponent
56
import io.customer.sdk.core.module.CustomerIOModule
67
import io.customer.sdk.core.util.Logger
@@ -26,8 +27,11 @@ import io.customer.sdk.core.util.Logger
2627
*
2728
* CustomerIO.initialize(config)
2829
*
29-
* // Then use the location services
30+
* // Manual location from host app
3031
* ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194)
32+
*
33+
* // SDK-managed one-shot location
34+
* ModuleLocation.instance().locationServices.requestLocationUpdate()
3135
* ```
3236
*/
3337
class ModuleLocation @JvmOverloads constructor(
@@ -52,10 +56,24 @@ class ModuleLocation @JvmOverloads constructor(
5256
get() = _locationServices ?: UninitializedLocationServices(SDKComponent.logger)
5357

5458
override fun initialize() {
59+
val logger = SDKComponent.logger
60+
val eventBus = SDKComponent.eventBus
61+
val context = SDKComponent.android().applicationContext
62+
63+
val locationProvider = FusedLocationProvider(context)
64+
val orchestrator = LocationOrchestrator(
65+
config = moduleConfig,
66+
logger = logger,
67+
eventBus = eventBus,
68+
locationProvider = locationProvider
69+
)
70+
5571
_locationServices = LocationServicesImpl(
5672
config = moduleConfig,
57-
logger = SDKComponent.logger,
58-
eventBus = SDKComponent.eventBus
73+
logger = logger,
74+
eventBus = eventBus,
75+
orchestrator = orchestrator,
76+
scope = SDKComponent.scopeProvider.locationScope
5977
)
6078
}
6179

@@ -92,7 +110,7 @@ private class UninitializedLocationServices(
92110

93111
override fun setLastKnownLocation(location: Location) = logNotInitialized()
94112

95-
override fun requestLocationUpdateOnce() = logNotInitialized()
113+
override fun requestLocationUpdate() = logNotInitialized()
96114

97115
override fun stopLocationUpdates() = logNotInitialized()
98116
}

0 commit comments

Comments
 (0)