Skip to content

Commit 6806b70

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 79bb2c3 commit 6806b70

File tree

7 files changed

+258
-15
lines changed

7 files changed

+258
-15
lines changed

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.debug("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: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
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
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.SupervisorJob
710

811
/**
912
* Location module for Customer.io SDK.
@@ -26,8 +29,11 @@ import io.customer.sdk.core.util.Logger
2629
*
2730
* CustomerIO.initialize(config)
2831
*
29-
* // Then use the location services
32+
* // Manual location from host app
3033
* ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194)
34+
*
35+
* // SDK-managed one-shot location
36+
* ModuleLocation.instance().locationServices.requestLocationUpdate()
3137
* ```
3238
*/
3339
class ModuleLocation @JvmOverloads constructor(
@@ -52,10 +58,27 @@ class ModuleLocation @JvmOverloads constructor(
5258
get() = _locationServices ?: UninitializedLocationServices(SDKComponent.logger)
5359

5460
override fun initialize() {
61+
val logger = SDKComponent.logger
62+
val eventBus = SDKComponent.eventBus
63+
val context = SDKComponent.android().applicationContext
64+
val dispatchers = SDKComponent.dispatchersProvider
65+
66+
val locationProvider = FusedLocationProvider(context)
67+
val orchestrator = LocationOrchestrator(
68+
config = moduleConfig,
69+
logger = logger,
70+
eventBus = eventBus,
71+
locationProvider = locationProvider
72+
)
73+
74+
val locationScope = CoroutineScope(dispatchers.default + SupervisorJob())
75+
5576
_locationServices = LocationServicesImpl(
5677
config = moduleConfig,
57-
logger = SDKComponent.logger,
58-
eventBus = SDKComponent.eventBus
78+
logger = logger,
79+
eventBus = eventBus,
80+
orchestrator = orchestrator,
81+
scope = locationScope
5982
)
6083
}
6184

@@ -92,7 +115,7 @@ private class UninitializedLocationServices(
92115

93116
override fun setLastKnownLocation(location: Location) = logNotInitialized()
94117

95-
override fun requestLocationUpdateOnce() = logNotInitialized()
118+
override fun requestLocationUpdate() = logNotInitialized()
96119

97120
override fun stopLocationUpdates() = logNotInitialized()
98121
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
@Suppress("MissingPermission")
36+
override suspend fun requestLocation(granularity: LocationGranularity): LocationSnapshot {
37+
val authStatus = currentAuthorizationStatus()
38+
if (!authStatus.isAuthorized) {
39+
throw LocationRequestException(error = LocationProviderError.PERMISSION_DENIED)
40+
}
41+
42+
if (!isLocationServicesEnabled()) {
43+
throw LocationRequestException(error = LocationProviderError.SERVICES_DISABLED)
44+
}
45+
46+
val priority = mapGranularityToPriority(granularity)
47+
val tokenSource = CancellationTokenSource()
48+
49+
return suspendCancellableCoroutine { continuation ->
50+
continuation.invokeOnCancellation {
51+
tokenSource.cancel()
52+
}
53+
54+
fusedClient.getCurrentLocation(priority, tokenSource.token)
55+
.addOnSuccessListener { location ->
56+
if (!continuation.isActive) return@addOnSuccessListener
57+
if (location != null) {
58+
continuation.resume(
59+
LocationSnapshot(
60+
latitude = location.latitude,
61+
longitude = location.longitude,
62+
timestamp = Date(location.time),
63+
horizontalAccuracy = location.accuracy.toDouble(),
64+
altitude = if (location.hasAltitude()) location.altitude else null
65+
)
66+
)
67+
} else {
68+
continuation.resumeWithException(
69+
LocationRequestException(error = LocationProviderError.TIMEOUT)
70+
)
71+
}
72+
}
73+
.addOnFailureListener { exception ->
74+
if (!continuation.isActive) return@addOnFailureListener
75+
continuation.resumeWithException(
76+
LocationRequestException(
77+
error = LocationProviderError.TIMEOUT,
78+
cause = exception
79+
)
80+
)
81+
}
82+
.addOnCanceledListener {
83+
if (!continuation.isActive) return@addOnCanceledListener
84+
continuation.cancel()
85+
}
86+
}
87+
}
88+
89+
override suspend fun currentAuthorizationStatus(): AuthorizationStatus {
90+
val hasFine = ContextCompat.checkSelfPermission(
91+
context,
92+
Manifest.permission.ACCESS_FINE_LOCATION
93+
) == PackageManager.PERMISSION_GRANTED
94+
95+
val hasCoarse = ContextCompat.checkSelfPermission(
96+
context,
97+
Manifest.permission.ACCESS_COARSE_LOCATION
98+
) == PackageManager.PERMISSION_GRANTED
99+
100+
if (!hasFine && !hasCoarse) {
101+
return AuthorizationStatus.DENIED
102+
}
103+
104+
// Check for background location on Android 10+
105+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
106+
val hasBackground = ContextCompat.checkSelfPermission(
107+
context,
108+
Manifest.permission.ACCESS_BACKGROUND_LOCATION
109+
) == PackageManager.PERMISSION_GRANTED
110+
if (hasBackground) {
111+
return AuthorizationStatus.AUTHORIZED_BACKGROUND
112+
}
113+
}
114+
115+
return AuthorizationStatus.AUTHORIZED_FOREGROUND
116+
}
117+
118+
private fun isLocationServicesEnabled(): Boolean {
119+
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
120+
?: return false
121+
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
122+
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
123+
}
124+
125+
private fun mapGranularityToPriority(granularity: LocationGranularity): Int {
126+
return when (granularity) {
127+
LocationGranularity.COARSE_CITY_OR_TIMEZONE -> Priority.PRIORITY_LOW_POWER
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)