Skip to content

Commit 03eb67c

Browse files
Shahroz16claude
andcommitted
feat: add SDK-managed one-shot location (requestLocationUpdateOnce)
Implement SDK-managed location tracking using FusedLocationProviderClient: - FusedLocationProvider: wraps Google's FusedLocationProviderClient with getCurrentLocation for one-shot requests, cancellation token support, permission pre-checks, and location services availability detection - LocationOrchestrator: coordinates request lifecycle with config checks, authorization verification, and EventBus posting on success - LocationServicesImpl: coroutine-based job management for one-shot requests with cancellation (cancel previous before starting new) - ModuleLocation: wires FusedLocationProvider, orchestrator, and scope - AndroidManifest: declares ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION permissions (merged into host app manifest) Uses PRIORITY_LOW_POWER (~10km accuracy) for privacy-conscious city/timezone level tracking. SDK never requests permissions - host app is responsible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cc37ac3 commit 03eb67c

File tree

5 files changed

+276
-11
lines changed

5 files changed

+276
-11
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
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+
<!-- Fine location is optional; the SDK uses coarse accuracy by default
7+
but host apps may grant fine location for other purposes -->
8+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
9+
410
</manifest>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 cancelRequestLocation() {
23+
locationProvider.cancelRequestLocation()
24+
}
25+
26+
suspend fun requestLocationUpdateOnce() {
27+
if (!config.enableLocationTracking) {
28+
logger.debug("Location tracking is disabled, ignoring requestLocationUpdateOnce.")
29+
return
30+
}
31+
32+
val authStatus = locationProvider.currentAuthorizationStatus()
33+
if (!authStatus.isAuthorized) {
34+
logger.debug("Location permission not granted ($authStatus), ignoring request.")
35+
return
36+
}
37+
38+
try {
39+
val snapshot = locationProvider.requestLocation(
40+
granularity = LocationGranularity.DEFAULT
41+
)
42+
postLocation(snapshot.latitude, snapshot.longitude)
43+
} catch (e: CancellationException) {
44+
logger.debug("Location request was cancelled.")
45+
throw e
46+
} catch (e: LocationRequestException) {
47+
logger.debug("Location request failed: ${e.error}")
48+
} catch (e: Exception) {
49+
logger.debug("Location request failed with unexpected error: ${e.message}")
50+
}
51+
}
52+
53+
private fun postLocation(latitude: Double, longitude: Double) {
54+
logger.debug("Tracking location: lat=$latitude, lng=$longitude")
55+
val locationData = Event.LocationData(
56+
latitude = latitude,
57+
longitude = longitude
58+
)
59+
eventBus.publish(Event.TrackLocationEvent(location = locationData))
60+
}
61+
}

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

Lines changed: 40 additions & 8 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.")
@@ -42,13 +51,36 @@ internal class LocationServicesImpl(
4251
}
4352

4453
override fun requestLocationUpdateOnce() {
45-
// Will be implemented in the SDK-managed location PR
46-
logger.debug("requestLocationUpdateOnce is not yet implemented.")
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.requestLocationUpdateOnce()
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+
// job.cancel() triggers invokeOnCancellation in FusedLocationProvider's
80+
// suspendCancellableCoroutine, which cancels the CancellationTokenSource.
81+
// No separate orchestrator.cancelRequestLocation() needed — doing so
82+
// asynchronously could race with a new request and cancel its token.
83+
job?.cancel()
5284
}
5385

5486
companion object {

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

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

3+
import io.customer.location.provider.FusedLocationProvider
34
import io.customer.sdk.core.di.SDKComponent
45
import io.customer.sdk.core.module.CustomerIOModule
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.SupervisorJob
58

69
/**
710
* Location module for Customer.io SDK.
@@ -24,8 +27,11 @@ import io.customer.sdk.core.module.CustomerIOModule
2427
*
2528
* CustomerIO.initialize(config)
2629
*
27-
* // Then use the location services
30+
* // Manual location from host app
2831
* ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194)
32+
*
33+
* // SDK-managed one-shot location
34+
* ModuleLocation.instance().locationServices.requestLocationUpdateOnce()
2935
* ```
3036
*/
3137
class ModuleLocation @JvmOverloads constructor(
@@ -43,10 +49,27 @@ class ModuleLocation @JvmOverloads constructor(
4349
private set
4450

4551
override fun initialize() {
52+
val logger = SDKComponent.logger
53+
val eventBus = SDKComponent.eventBus
54+
val context = SDKComponent.android().applicationContext
55+
val dispatchers = SDKComponent.dispatchersProvider
56+
57+
val locationProvider = FusedLocationProvider(context)
58+
val orchestrator = LocationOrchestrator(
59+
config = moduleConfig,
60+
logger = logger,
61+
eventBus = eventBus,
62+
locationProvider = locationProvider
63+
)
64+
65+
val locationScope = CoroutineScope(dispatchers.default + SupervisorJob())
66+
4667
locationServices = LocationServicesImpl(
4768
config = moduleConfig,
48-
logger = SDKComponent.logger,
49-
eventBus = SDKComponent.eventBus
69+
logger = logger,
70+
eventBus = eventBus,
71+
orchestrator = orchestrator,
72+
scope = locationScope
5073
)
5174
}
5275

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package io.customer.location.provider
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.content.pm.PackageManager
6+
import android.location.LocationManager
7+
import android.os.Build
8+
import androidx.core.content.ContextCompat
9+
import com.google.android.gms.location.FusedLocationProviderClient
10+
import com.google.android.gms.location.LocationServices
11+
import com.google.android.gms.location.Priority
12+
import com.google.android.gms.tasks.CancellationTokenSource
13+
import io.customer.location.type.AuthorizationStatus
14+
import io.customer.location.type.LocationGranularity
15+
import io.customer.location.type.LocationProviderError
16+
import io.customer.location.type.LocationSnapshot
17+
import java.util.Date
18+
import kotlin.coroutines.resume
19+
import kotlin.coroutines.resumeWithException
20+
import kotlinx.coroutines.suspendCancellableCoroutine
21+
22+
/**
23+
* [LocationProvider] implementation wrapping Google's [FusedLocationProviderClient].
24+
*
25+
* Uses [FusedLocationProviderClient.getCurrentLocation] for one-shot location requests
26+
* with cancellation support. Does not request permissions - the host app is responsible.
27+
*/
28+
internal class FusedLocationProvider(
29+
private val context: Context
30+
) : LocationProvider {
31+
32+
private val fusedClient: FusedLocationProviderClient =
33+
LocationServices.getFusedLocationProviderClient(context)
34+
35+
@Volatile
36+
private var cancellationTokenSource: CancellationTokenSource? = null
37+
38+
@Suppress("MissingPermission")
39+
override suspend fun requestLocation(granularity: LocationGranularity): LocationSnapshot {
40+
val authStatus = currentAuthorizationStatus()
41+
if (!authStatus.isAuthorized) {
42+
throw LocationRequestException(error = LocationProviderError.PERMISSION_DENIED)
43+
}
44+
45+
if (!isLocationServicesEnabled()) {
46+
throw LocationRequestException(error = LocationProviderError.SERVICES_DISABLED)
47+
}
48+
49+
val priority = mapGranularityToPriority(granularity)
50+
val tokenSource = CancellationTokenSource()
51+
cancellationTokenSource = tokenSource
52+
53+
return suspendCancellableCoroutine { continuation ->
54+
continuation.invokeOnCancellation {
55+
tokenSource.cancel()
56+
cancellationTokenSource = null
57+
}
58+
59+
fusedClient.getCurrentLocation(priority, tokenSource.token)
60+
.addOnSuccessListener { location ->
61+
cancellationTokenSource = null
62+
if (!continuation.isActive) return@addOnSuccessListener
63+
if (location != null) {
64+
continuation.resume(
65+
LocationSnapshot(
66+
latitude = location.latitude,
67+
longitude = location.longitude,
68+
timestamp = Date(location.time),
69+
horizontalAccuracy = location.accuracy.toDouble(),
70+
altitude = if (location.hasAltitude()) location.altitude else null
71+
)
72+
)
73+
} else {
74+
continuation.resumeWithException(
75+
LocationRequestException(error = LocationProviderError.TIMEOUT)
76+
)
77+
}
78+
}
79+
.addOnFailureListener { exception ->
80+
cancellationTokenSource = null
81+
if (!continuation.isActive) return@addOnFailureListener
82+
continuation.resumeWithException(
83+
LocationRequestException(
84+
error = LocationProviderError.TIMEOUT,
85+
cause = exception
86+
)
87+
)
88+
}
89+
.addOnCanceledListener {
90+
cancellationTokenSource = null
91+
if (!continuation.isActive) return@addOnCanceledListener
92+
continuation.cancel()
93+
}
94+
}
95+
}
96+
97+
override suspend fun cancelRequestLocation() {
98+
cancellationTokenSource?.cancel()
99+
cancellationTokenSource = null
100+
}
101+
102+
override suspend fun currentAuthorizationStatus(): AuthorizationStatus {
103+
val hasFine = ContextCompat.checkSelfPermission(
104+
context,
105+
Manifest.permission.ACCESS_FINE_LOCATION
106+
) == PackageManager.PERMISSION_GRANTED
107+
108+
val hasCoarse = ContextCompat.checkSelfPermission(
109+
context,
110+
Manifest.permission.ACCESS_COARSE_LOCATION
111+
) == PackageManager.PERMISSION_GRANTED
112+
113+
if (!hasFine && !hasCoarse) {
114+
return AuthorizationStatus.DENIED
115+
}
116+
117+
// Check for background location on Android 10+
118+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
119+
val hasBackground = ContextCompat.checkSelfPermission(
120+
context,
121+
Manifest.permission.ACCESS_BACKGROUND_LOCATION
122+
) == PackageManager.PERMISSION_GRANTED
123+
if (hasBackground) {
124+
return AuthorizationStatus.AUTHORIZED_BACKGROUND
125+
}
126+
}
127+
128+
return AuthorizationStatus.AUTHORIZED_FOREGROUND
129+
}
130+
131+
private fun isLocationServicesEnabled(): Boolean {
132+
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
133+
?: return false
134+
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
135+
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
136+
}
137+
138+
private fun mapGranularityToPriority(granularity: LocationGranularity): Int {
139+
return when (granularity) {
140+
LocationGranularity.COARSE_CITY_OR_TIMEZONE -> Priority.PRIORITY_LOW_POWER
141+
}
142+
}
143+
}

0 commit comments

Comments
 (0)