Skip to content

Commit fc58c72

Browse files
committed
guardrails
1 parent 56f31d2 commit fc58c72

File tree

3 files changed

+168
-95
lines changed

3 files changed

+168
-95
lines changed
Lines changed: 105 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,167 @@
11
package io.customer.datapipelines.location
22

3+
import android.location.Location
34
import io.customer.datapipelines.plugins.LocationPlugin
45
import io.customer.datapipelines.store.LocationPreferenceStore
56
import io.customer.sdk.communication.Event
67
import io.customer.sdk.core.util.Logger
78

89
/**
910
* Coordinates all location state management: persistence, restoration,
10-
* staleness detection, and tracking whether an identify was sent without
11-
* location context.
11+
* and filter-based sync decisions.
12+
*
13+
* Maintains two location references via [LocationPreferenceStore]:
14+
* - **Cached**: the latest received location, used for identify enrichment.
15+
* - **Synced**: the last location actually sent to the server, used by
16+
* [shouldSync] to decide whether a new "Location Update" track should be sent.
17+
*
18+
* A "Location Update" track is sent only when both conditions are met:
19+
* 1. >= 24 hours since the last sync, AND
20+
* 2. >= 1 km distance from the last synced location.
21+
*
22+
* If no synced location exists yet (first time), the filter passes automatically.
1223
*/
1324
internal class LocationTracker(
1425
private val locationPlugin: LocationPlugin,
1526
private val locationPreferenceStore: LocationPreferenceStore,
16-
private val logger: Logger
27+
private val logger: Logger,
28+
private val userIdProvider: () -> String?
1729
) {
1830
/**
19-
* Set when an identify is sent while no location context is available.
20-
* Cleared when a location update arrives, so the caller can react
21-
* (e.g. send a "Location Update" track for the newly-identified user).
22-
*/
23-
@Volatile
24-
internal var identifySentWithoutLocation: Boolean = false
25-
26-
/**
27-
* Reads persisted location from the preference store and sets it on the
28-
* [LocationPlugin] so that identify events have location context immediately
29-
* after SDK restart.
31+
* Reads persisted cached location from the preference store and sets it on
32+
* the [LocationPlugin] so that identify events have location context
33+
* immediately after SDK restart.
3034
*/
3135
fun restorePersistedLocation() {
32-
val lat = locationPreferenceStore.getLatitude() ?: return
33-
val lng = locationPreferenceStore.getLongitude() ?: return
36+
val lat = locationPreferenceStore.getCachedLatitude() ?: return
37+
val lng = locationPreferenceStore.getCachedLongitude() ?: return
3438
locationPlugin.lastLocation = Event.LocationData(latitude = lat, longitude = lng)
3539
logger.debug("Restored persisted location: lat=$lat, lng=$lng")
3640
}
3741

3842
/**
3943
* Processes an incoming location event: always caches in the plugin and
4044
* persists coordinates for identify enrichment. Only returns non-null
41-
* (signalling the caller to send a "Location Update" track) when:
42-
*
43-
* 1. An identify was previously sent without location context, OR
44-
* 2. >=24 hours have elapsed since the last "Location Update" track.
45+
* (signalling the caller to send a "Location Update" track) when the
46+
* [shouldSync] filter passes.
4547
*
4648
* @return the [Event.LocationData] to send as a track, or null if suppressed.
4749
*/
4850
fun onLocationReceived(event: Event.TrackLocationEvent): Event.LocationData? {
4951
val location = event.location
50-
logger.debug("location update received: lat=${location.latitude}, lng=${location.longitude}")
52+
logger.debug("Location update received: lat=${location.latitude}, lng=${location.longitude}")
5153

5254
// Always cache and persist so identifies have context and location
5355
// survives app restarts — regardless of whether we send a track
5456
locationPlugin.lastLocation = location
55-
locationPreferenceStore.saveLocation(location.latitude, location.longitude)
56-
57-
val shouldSendTrack = when {
58-
identifySentWithoutLocation -> {
59-
logger.debug("Sending location track: identify was previously sent without location context")
60-
identifySentWithoutLocation = false
61-
true
62-
}
63-
isStale() -> {
64-
logger.debug("Sending location track: >=24h since last send")
65-
true
66-
}
67-
else -> {
68-
logger.debug("Location cached but track suppressed: last sent <24h ago")
69-
false
70-
}
57+
locationPreferenceStore.saveCachedLocation(location.latitude, location.longitude)
58+
59+
// Only send location tracks for identified users
60+
if (userIdProvider().isNullOrEmpty()) {
61+
logger.debug("Location cached but track skipped: no identified user")
62+
return null
7163
}
7264

73-
if (shouldSendTrack) {
74-
locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis())
75-
return location
65+
if (!shouldSync(location.latitude, location.longitude)) {
66+
logger.debug("Location cached but track suppressed: filter not met")
67+
return null
7668
}
77-
return null
69+
70+
logger.debug("Location filter passed, sending Location Update track")
71+
return location
7872
}
7973

8074
/**
81-
* Returns the persisted location if more than 24 hours have elapsed since
82-
* the last "Location Update" track was sent, or null otherwise.
83-
* Updates the sent timestamp so the next cold start won't re-send.
75+
* Re-evaluates whether the cached location should be synced. Called on
76+
* identify and on cold start to handle cases where:
77+
* - An identify was sent without location context in a previous session,
78+
* and location has since arrived.
79+
* - The app was restarted after >24h and the cached location should be
80+
* re-sent.
81+
*
82+
* @return the [Event.LocationData] to send as a track, or null if
83+
* the filter does not pass or no cached location exists.
8484
*/
85-
fun getStaleLocationForResend(): Event.LocationData? {
86-
val lat = locationPreferenceStore.getLatitude() ?: return null
87-
val lng = locationPreferenceStore.getLongitude() ?: return null
85+
fun syncCachedLocationIfNeeded(): Event.LocationData? {
86+
if (userIdProvider().isNullOrEmpty()) return null
8887

89-
if (!isStale()) return null
88+
val lat = locationPreferenceStore.getCachedLatitude() ?: return null
89+
val lng = locationPreferenceStore.getCachedLongitude() ?: return null
9090

91-
logger.debug("Location update stale on cold start, re-sending")
92-
locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis())
91+
if (!shouldSync(lat, lng)) return null
92+
93+
logger.debug("Syncing cached location: lat=$lat, lng=$lng")
9394
return Event.LocationData(latitude = lat, longitude = lng)
9495
}
9596

96-
private fun isStale(): Boolean {
97-
val lastSent = locationPreferenceStore.getLastSentTimestamp() ?: return true
98-
return (System.currentTimeMillis() - lastSent) >= LOCATION_RESEND_INTERVAL_MS
97+
/**
98+
* Returns true if the [LocationPlugin] has a cached location.
99+
*/
100+
fun hasLocationContext(): Boolean = locationPlugin.lastLocation != null
101+
102+
/**
103+
* Resets user-level location state on logout. Clears synced data
104+
* (timestamp and synced coordinates) so the next user gets their own
105+
* location track — not gated by the previous user's 24h window.
106+
* Cached coordinates are kept as they are device-level, not user-level.
107+
*/
108+
fun onUserReset() {
109+
locationPreferenceStore.clearSyncedData()
110+
logger.debug("User reset: cleared synced location data")
99111
}
100112

101113
/**
102-
* Records that an identify call was made without location context.
114+
* Determines whether a location should be synced to the server based on
115+
* two criteria:
116+
* 1. **Time**: >= [LOCATION_RESEND_INTERVAL_MS] since the last sync.
117+
* 2. **Distance**: >= [MINIMUM_DISTANCE_METERS] from the last synced location.
118+
*
119+
* If no synced location exists yet, the filter passes automatically.
103120
*/
104-
fun onIdentifySentWithoutLocation() {
105-
identifySentWithoutLocation = true
106-
logger.debug("Identify sent without location context; will send location track when location arrives")
121+
private fun shouldSync(latitude: Double, longitude: Double): Boolean {
122+
val lastTimestamp = locationPreferenceStore.getSyncedTimestamp()
123+
// Never synced before — always pass
124+
if (lastTimestamp == null) return true
125+
126+
val timeSinceLastSync = System.currentTimeMillis() - lastTimestamp
127+
if (timeSinceLastSync < LOCATION_RESEND_INTERVAL_MS) return false
128+
129+
val syncedLat = locationPreferenceStore.getSyncedLatitude() ?: return true
130+
val syncedLng = locationPreferenceStore.getSyncedLongitude() ?: return true
131+
132+
val distance = distanceBetween(syncedLat, syncedLng, latitude, longitude)
133+
return distance >= MINIMUM_DISTANCE_METERS
107134
}
108135

109136
/**
110-
* Clears the [identifySentWithoutLocation] flag. Called when the user
111-
* logs out — the debt belongs to the identified user, and once they're
112-
* gone the follow-up location track is no longer owed.
137+
* Records that a location was successfully queued for delivery.
138+
* Must be called AFTER the "Location Update" track has been enqueued
139+
* via [analytics.track], so that if the process is killed between
140+
* the track and the record, the worst case is a harmless duplicate
141+
* rather than a missed send.
113142
*/
114-
fun onUserReset() {
115-
if (identifySentWithoutLocation) {
116-
logger.debug("User reset; clearing pending identify-without-location flag")
117-
identifySentWithoutLocation = false
118-
}
143+
fun confirmSync(latitude: Double, longitude: Double) {
144+
locationPreferenceStore.saveSyncedLocation(latitude, longitude, System.currentTimeMillis())
119145
}
120146

121147
/**
122-
* Returns true if the [LocationPlugin] has a cached location.
148+
* Computes the distance in meters between two lat/lng points using
149+
* [android.location.Location.distanceBetween]. This is a static math
150+
* utility — no location permissions required.
123151
*/
124-
fun hasLocationContext(): Boolean = locationPlugin.lastLocation != null
152+
private fun distanceBetween(
153+
lat1: Double,
154+
lng1: Double,
155+
lat2: Double,
156+
lng2: Double
157+
): Float {
158+
val results = FloatArray(1)
159+
Location.distanceBetween(lat1, lng1, lat2, lng2, results)
160+
return results[0]
161+
}
125162

126163
companion object {
127164
private const val LOCATION_RESEND_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24 hours
165+
private const val MINIMUM_DISTANCE_METERS = 1000f // 1 km
128166
}
129167
}

datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationPreferenceStore.kt

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@ import io.customer.sdk.data.store.read
88

99
/**
1010
* Store for persisting location data across app restarts.
11-
* Ensures identify events always have location context and supports
12-
* 24-hour re-send of stale location updates on SDK startup.
11+
*
12+
* Maintains two location references:
13+
* - **Cached**: the latest received location, used for identify enrichment.
14+
* - **Synced**: the last location actually sent to the server, used by
15+
* the filter to decide whether a new location should be sent.
1316
*/
1417
internal interface LocationPreferenceStore {
15-
fun saveLocation(latitude: Double, longitude: Double)
16-
fun saveLastSentTimestamp(timestamp: Long)
17-
fun getLatitude(): Double?
18-
fun getLongitude(): Double?
19-
fun getLastSentTimestamp(): Long?
18+
fun saveCachedLocation(latitude: Double, longitude: Double)
19+
fun getCachedLatitude(): Double?
20+
fun getCachedLongitude(): Double?
21+
22+
fun saveSyncedLocation(latitude: Double, longitude: Double, timestamp: Long)
23+
fun getSyncedLatitude(): Double?
24+
fun getSyncedLongitude(): Double?
25+
fun getSyncedTimestamp(): Long?
26+
fun clearSyncedData()
27+
2028
fun clearAll()
2129
}
2230

@@ -31,31 +39,53 @@ internal class LocationPreferenceStoreImpl(
3139
"io.customer.sdk.location.${context.packageName}"
3240
}
3341

34-
override fun saveLocation(latitude: Double, longitude: Double) = prefs.edit {
35-
putString(KEY_LATITUDE, crypto.encrypt(latitude.toString()))
36-
putString(KEY_LONGITUDE, crypto.encrypt(longitude.toString()))
42+
// -- Cached location (latest received, for identify enrichment) --
43+
44+
override fun saveCachedLocation(latitude: Double, longitude: Double) = prefs.edit {
45+
putString(KEY_CACHED_LATITUDE, crypto.encrypt(latitude.toString()))
46+
putString(KEY_CACHED_LONGITUDE, crypto.encrypt(longitude.toString()))
47+
}
48+
49+
override fun getCachedLatitude(): Double? = prefs.read {
50+
getString(KEY_CACHED_LATITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
51+
}
52+
53+
override fun getCachedLongitude(): Double? = prefs.read {
54+
getString(KEY_CACHED_LONGITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
55+
}
56+
57+
// -- Synced location (last sent to server, for filter comparison) --
58+
59+
override fun saveSyncedLocation(latitude: Double, longitude: Double, timestamp: Long) = prefs.edit {
60+
putString(KEY_SYNCED_LATITUDE, crypto.encrypt(latitude.toString()))
61+
putString(KEY_SYNCED_LONGITUDE, crypto.encrypt(longitude.toString()))
62+
putLong(KEY_SYNCED_TIMESTAMP, timestamp)
3763
}
3864

39-
override fun saveLastSentTimestamp(timestamp: Long) = prefs.edit {
40-
putLong(KEY_LAST_SENT_TIMESTAMP, timestamp)
65+
override fun getSyncedLatitude(): Double? = prefs.read {
66+
getString(KEY_SYNCED_LATITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
4167
}
4268

43-
override fun getLatitude(): Double? = prefs.read {
44-
getString(KEY_LATITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
69+
override fun getSyncedLongitude(): Double? = prefs.read {
70+
getString(KEY_SYNCED_LONGITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
4571
}
4672

47-
override fun getLongitude(): Double? = prefs.read {
48-
getString(KEY_LONGITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() }
73+
override fun getSyncedTimestamp(): Long? = prefs.read {
74+
if (contains(KEY_SYNCED_TIMESTAMP)) getLong(KEY_SYNCED_TIMESTAMP, 0L) else null
4975
}
5076

51-
override fun getLastSentTimestamp(): Long? = prefs.read {
52-
if (contains(KEY_LAST_SENT_TIMESTAMP)) getLong(KEY_LAST_SENT_TIMESTAMP, 0L) else null
77+
override fun clearSyncedData() = prefs.edit {
78+
remove(KEY_SYNCED_LATITUDE)
79+
remove(KEY_SYNCED_LONGITUDE)
80+
remove(KEY_SYNCED_TIMESTAMP)
5381
}
5482

5583
companion object {
5684
private const val KEY_ALIAS = "cio_location_key"
57-
private const val KEY_LATITUDE = "latitude"
58-
private const val KEY_LONGITUDE = "longitude"
59-
private const val KEY_LAST_SENT_TIMESTAMP = "last_sent_timestamp"
85+
private const val KEY_CACHED_LATITUDE = "cio_location_cached_latitude"
86+
private const val KEY_CACHED_LONGITUDE = "cio_location_cached_longitude"
87+
private const val KEY_SYNCED_LATITUDE = "cio_location_synced_latitude"
88+
private const val KEY_SYNCED_LONGITUDE = "cio_location_synced_longitude"
89+
private const val KEY_SYNCED_TIMESTAMP = "cio_location_synced_timestamp"
6090
}
6191
}

datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ class CustomerIO private constructor(
111111

112112
private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore)
113113
private val locationPlugin: LocationPlugin = LocationPlugin(logger)
114-
private val locationTracker: LocationTracker = LocationTracker(locationPlugin, SDKComponent.locationPreferenceStore, logger)
114+
private val locationTracker: LocationTracker = LocationTracker(locationPlugin, SDKComponent.locationPreferenceStore, logger) { analytics.userId() }
115115

116116
init {
117117
// Set analytics logger and debug logs based on SDK logger configuration
@@ -163,6 +163,7 @@ class CustomerIO private constructor(
163163
eventBus.subscribe<Event.TrackLocationEvent> {
164164
locationTracker.onLocationReceived(it)?.let { location ->
165165
sendLocationTrack(location)
166+
locationTracker.confirmSync(location.latitude, location.longitude)
166167
}
167168
}
168169
}
@@ -213,9 +214,10 @@ class CustomerIO private constructor(
213214
analytics.add(AutomaticApplicationLifecycleTrackingPlugin())
214215
}
215216

216-
// Re-send location if >24h since last "Location Update" track
217-
locationTracker.getStaleLocationForResend()?.let { location ->
217+
// Re-evaluate cached location on cold start (e.g. >24h + >1km since last sync)
218+
locationTracker.syncCachedLocationIfNeeded()?.let { location ->
218219
sendLocationTrack(location)
220+
locationTracker.confirmSync(location.latitude, location.longitude)
219221
}
220222
}
221223

@@ -272,10 +274,6 @@ class CustomerIO private constructor(
272274

273275
logger.info("identify profile with identifier $userId and traits $traits")
274276

275-
if (!locationTracker.hasLocationContext()) {
276-
locationTracker.onIdentifySentWithoutLocation()
277-
}
278-
279277
// publish event to EventBus for other modules to consume
280278
eventBus.publish(Event.UserChangedEvent(userId = userId, anonymousId = analytics.anonymousId()))
281279
analytics.identify(
@@ -284,6 +282,13 @@ class CustomerIO private constructor(
284282
serializationStrategy = serializationStrategy
285283
)
286284

285+
// Re-evaluate cached location now that the user is identified.
286+
// Must come after analytics.identify() so userIdProvider returns the correct userId.
287+
locationTracker.syncCachedLocationIfNeeded()?.let { location ->
288+
sendLocationTrack(location)
289+
locationTracker.confirmSync(location.latitude, location.longitude)
290+
}
291+
287292
if (isFirstTimeIdentifying || isChangingIdentifiedProfile) {
288293
logger.debug("first time identified or changing identified profile")
289294
val existingDeviceToken = registeredDeviceToken

0 commit comments

Comments
 (0)