Skip to content

Commit 2a698db

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 2a698db

File tree

5 files changed

+264
-11
lines changed

5 files changed

+264
-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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
} catch (e: LocationRequestException) {
46+
logger.debug("Location request failed: ${e.error}")
47+
} catch (e: Exception) {
48+
logger.debug("Location request failed with unexpected error: ${e.message}")
49+
}
50+
}
51+
52+
private fun postLocation(latitude: Double, longitude: Double) {
53+
logger.debug("Tracking location: lat=$latitude, lng=$longitude")
54+
val locationData = Event.LocationData(
55+
latitude = latitude,
56+
longitude = longitude
57+
)
58+
eventBus.publish(Event.TrackLocationEvent(location = locationData))
59+
}
60+
}

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

Lines changed: 36 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,32 @@ 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+
try {
60+
orchestrator.requestLocationUpdateOnce()
61+
} finally {
62+
lock.withLock {
63+
currentLocationJob = null
64+
}
65+
}
66+
}
67+
}
4768
}
4869

4970
override fun stopLocationUpdates() {
50-
// Will be implemented in the SDK-managed location PR
51-
logger.debug("stopLocationUpdates is not yet implemented.")
71+
val job: Job?
72+
lock.withLock {
73+
job = currentLocationJob
74+
currentLocationJob = null
75+
}
76+
job?.cancel()
77+
scope.launch {
78+
orchestrator.cancelRequestLocation()
79+
}
5280
}
5381

5482
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 (location != null) {
63+
continuation.resume(
64+
LocationSnapshot(
65+
latitude = location.latitude,
66+
longitude = location.longitude,
67+
timestamp = Date(location.time),
68+
horizontalAccuracy = location.accuracy.toDouble(),
69+
altitude = if (location.hasAltitude()) location.altitude else null
70+
)
71+
)
72+
} else {
73+
continuation.resumeWithException(
74+
LocationRequestException(error = LocationProviderError.TIMEOUT)
75+
)
76+
}
77+
}
78+
.addOnFailureListener { exception ->
79+
cancellationTokenSource = null
80+
continuation.resumeWithException(
81+
LocationRequestException(
82+
error = LocationProviderError.TIMEOUT,
83+
cause = exception
84+
)
85+
)
86+
}
87+
}
88+
}
89+
90+
override suspend fun cancelRequestLocation() {
91+
cancellationTokenSource?.cancel()
92+
cancellationTokenSource = null
93+
}
94+
95+
override suspend fun currentAuthorizationStatus(): AuthorizationStatus {
96+
val hasFine = ContextCompat.checkSelfPermission(
97+
context,
98+
Manifest.permission.ACCESS_FINE_LOCATION
99+
) == PackageManager.PERMISSION_GRANTED
100+
101+
val hasCoarse = ContextCompat.checkSelfPermission(
102+
context,
103+
Manifest.permission.ACCESS_COARSE_LOCATION
104+
) == PackageManager.PERMISSION_GRANTED
105+
106+
if (!hasFine && !hasCoarse) {
107+
return AuthorizationStatus.DENIED
108+
}
109+
110+
// Check for background location on Android 10+
111+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
112+
val hasBackground = ContextCompat.checkSelfPermission(
113+
context,
114+
Manifest.permission.ACCESS_BACKGROUND_LOCATION
115+
) == PackageManager.PERMISSION_GRANTED
116+
if (hasBackground) {
117+
return AuthorizationStatus.AUTHORIZED_BACKGROUND
118+
}
119+
}
120+
121+
return AuthorizationStatus.AUTHORIZED_FOREGROUND
122+
}
123+
124+
private fun isLocationServicesEnabled(): Boolean {
125+
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
126+
?: return false
127+
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
128+
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
129+
}
130+
131+
private fun mapGranularityToPriority(granularity: LocationGranularity): Int {
132+
return when (granularity) {
133+
LocationGranularity.COARSE_CITY_OR_TIMEZONE -> Priority.PRIORITY_LOW_POWER
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)