Skip to content

Commit 57c7292

Browse files
committed
refactor
1 parent a7ecb32 commit 57c7292

File tree

5 files changed

+221
-59
lines changed

5 files changed

+221
-59
lines changed

datapipelines/src/main/kotlin/io/customer/datapipelines/di/SDKComponentExt.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package io.customer.datapipelines.di
22

33
import com.segment.analytics.kotlin.core.Analytics
44
import io.customer.datapipelines.config.DataPipelinesModuleConfig
5+
import io.customer.datapipelines.store.LocationPreferenceStore
6+
import io.customer.datapipelines.store.LocationPreferenceStoreImpl
57
import io.customer.sdk.DataPipelinesLogger
68
import io.customer.sdk.core.di.SDKComponent
79
import io.customer.sdk.core.extensions.getOrNull
@@ -12,3 +14,8 @@ internal val SDKComponent.analyticsFactory: ((moduleConfig: DataPipelinesModuleC
1214

1315
internal val SDKComponent.dataPipelinesLogger: DataPipelinesLogger
1416
get() = singleton<DataPipelinesLogger> { DataPipelinesLogger(logger) }
17+
18+
internal val SDKComponent.locationPreferenceStore: LocationPreferenceStore
19+
get() = singleton<LocationPreferenceStore> {
20+
LocationPreferenceStoreImpl(android().applicationContext)
21+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package io.customer.datapipelines.location
2+
3+
import io.customer.datapipelines.plugins.LocationPlugin
4+
import io.customer.datapipelines.store.LocationPreferenceStore
5+
import io.customer.sdk.communication.Event
6+
import io.customer.sdk.core.util.Logger
7+
8+
/**
9+
* Coordinates all location state management: persistence, restoration,
10+
* staleness detection, and tracking whether an identify was sent without
11+
* location context.
12+
*/
13+
internal class LocationTracker(
14+
private val locationPlugin: LocationPlugin,
15+
private val locationPreferenceStore: LocationPreferenceStore,
16+
private val logger: Logger
17+
) {
18+
/**
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.
30+
*/
31+
fun restorePersistedLocation() {
32+
val lat = locationPreferenceStore.getLatitude() ?: return
33+
val lng = locationPreferenceStore.getLongitude() ?: return
34+
locationPlugin.lastLocation = Event.LocationData(latitude = lat, longitude = lng)
35+
logger.debug("Restored persisted location: lat=$lat, lng=$lng")
36+
}
37+
38+
/**
39+
* Processes an incoming location event: always caches in the plugin and
40+
* 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+
*
46+
* @return the [Event.LocationData] to send as a track, or null if suppressed.
47+
*/
48+
fun onLocationReceived(event: Event.TrackLocationEvent): Event.LocationData? {
49+
val location = event.location
50+
logger.debug("location update received: lat=${location.latitude}, lng=${location.longitude}")
51+
52+
// Always cache and persist so identifies have context and location
53+
// survives app restarts — regardless of whether we send a track
54+
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+
}
71+
}
72+
73+
if (shouldSendTrack) {
74+
locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis())
75+
return location
76+
}
77+
return null
78+
}
79+
80+
/**
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.
84+
*/
85+
fun getStaleLocationForResend(): Event.LocationData? {
86+
val lat = locationPreferenceStore.getLatitude() ?: return null
87+
val lng = locationPreferenceStore.getLongitude() ?: return null
88+
89+
if (!isStale()) return null
90+
91+
logger.debug("Location update stale on cold start, re-sending")
92+
locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis())
93+
return Event.LocationData(latitude = lat, longitude = lng)
94+
}
95+
96+
private fun isStale(): Boolean {
97+
val lastSent = locationPreferenceStore.getLastSentTimestamp() ?: return true
98+
return (System.currentTimeMillis() - lastSent) >= LOCATION_RESEND_INTERVAL_MS
99+
}
100+
101+
/**
102+
* Records that an identify call was made without location context.
103+
*/
104+
fun onIdentifySentWithoutLocation() {
105+
identifySentWithoutLocation = true
106+
logger.debug("Identify sent without location context; will send location track when location arrives")
107+
}
108+
109+
/**
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.
113+
*/
114+
fun onUserReset() {
115+
if (identifySentWithoutLocation) {
116+
logger.debug("User reset; clearing pending identify-without-location flag")
117+
identifySentWithoutLocation = false
118+
}
119+
}
120+
121+
/**
122+
* Returns true if the [LocationPlugin] has a cached location.
123+
*/
124+
fun hasLocationContext(): Boolean = locationPlugin.lastLocation != null
125+
126+
companion object {
127+
private const val LOCATION_RESEND_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24 hours
128+
}
129+
}

datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt

Lines changed: 4 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,16 @@ package io.customer.datapipelines.plugins
33
import com.segment.analytics.kotlin.core.Analytics
44
import com.segment.analytics.kotlin.core.BaseEvent
55
import com.segment.analytics.kotlin.core.IdentifyEvent
6-
import com.segment.analytics.kotlin.core.TrackEvent
76
import com.segment.analytics.kotlin.core.platform.EventPlugin
87
import com.segment.analytics.kotlin.core.platform.Plugin
98
import com.segment.analytics.kotlin.core.utilities.putInContext
109
import io.customer.sdk.communication.Event
1110
import io.customer.sdk.core.util.Logger
12-
import io.customer.sdk.util.EventNames
1311
import kotlinx.serialization.json.JsonPrimitive
14-
import kotlinx.serialization.json.buildJsonObject
1512

1613
/**
17-
* Plugin that handles location-related event processing:
18-
*
19-
* 1. Enriches identify events with the last known location in context,
20-
* so Customer.io knows where the user is when their profile is identified.
21-
*
22-
* 2. Blocks consumer-sent "Location Update" track events to prevent flooding
23-
* the backend. Only SDK-internal location events (marked with [INTERNAL_LOCATION_KEY])
24-
* are allowed through; the marker is stripped before the event reaches the destination.
14+
* Plugin that enriches identify events with the last known location in context,
15+
* so Customer.io knows where the user is when their profile is identified.
2516
*/
2617
internal class LocationPlugin(private val logger: Logger) : EventPlugin {
2718
override val type: Plugin.Type = Plugin.Type.Enrichment
@@ -32,43 +23,8 @@ internal class LocationPlugin(private val logger: Logger) : EventPlugin {
3223

3324
override fun identify(payload: IdentifyEvent): BaseEvent {
3425
val location = lastLocation ?: return payload
35-
payload.putInContext(
36-
"location",
37-
buildJsonObject {
38-
put("latitude", JsonPrimitive(location.latitude))
39-
put("longitude", JsonPrimitive(location.longitude))
40-
}
41-
)
26+
payload.putInContext("location_latitude", JsonPrimitive(location.latitude))
27+
payload.putInContext("location_longitude", JsonPrimitive(location.longitude))
4228
return payload
4329
}
44-
45-
override fun track(payload: TrackEvent): BaseEvent? {
46-
if (payload.event != EventNames.LOCATION_UPDATE) {
47-
return payload
48-
}
49-
50-
// Check for the internal marker that only the SDK sets
51-
val isInternal = payload.properties[INTERNAL_LOCATION_KEY]?.let {
52-
(it as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true
53-
} ?: false
54-
55-
if (!isInternal) {
56-
logger.debug("Blocking consumer-sent \"${EventNames.LOCATION_UPDATE}\" event. Location events are managed by the SDK.")
57-
return null
58-
}
59-
60-
// Strip the internal marker before sending to destination
61-
payload.properties = buildJsonObject {
62-
payload.properties.forEach { (key, value) ->
63-
if (key != INTERNAL_LOCATION_KEY) {
64-
put(key, value)
65-
}
66-
}
67-
}
68-
return payload
69-
}
70-
71-
companion object {
72-
internal const val INTERNAL_LOCATION_KEY = "_cio_internal_location"
73-
}
7430
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.customer.datapipelines.store
2+
3+
import android.content.Context
4+
import androidx.core.content.edit
5+
import io.customer.sdk.data.store.PreferenceStore
6+
import io.customer.sdk.data.store.read
7+
8+
/**
9+
* Store for persisting location data across app restarts.
10+
* Ensures identify events always have location context and supports
11+
* 24-hour re-send of stale location updates on SDK startup.
12+
*/
13+
internal interface LocationPreferenceStore {
14+
fun saveLocation(latitude: Double, longitude: Double)
15+
fun saveLastSentTimestamp(timestamp: Long)
16+
fun getLatitude(): Double?
17+
fun getLongitude(): Double?
18+
fun getLastSentTimestamp(): Long?
19+
fun clearAll()
20+
}
21+
22+
internal class LocationPreferenceStoreImpl(
23+
context: Context
24+
) : PreferenceStore(context), LocationPreferenceStore {
25+
26+
override val prefsName: String by lazy {
27+
"io.customer.sdk.location.${context.packageName}"
28+
}
29+
30+
override fun saveLocation(latitude: Double, longitude: Double) = prefs.edit {
31+
// Store as String to preserve Double precision (putFloat truncates)
32+
putString(KEY_LATITUDE, latitude.toString())
33+
putString(KEY_LONGITUDE, longitude.toString())
34+
}
35+
36+
override fun saveLastSentTimestamp(timestamp: Long) = prefs.edit {
37+
putLong(KEY_LAST_SENT_TIMESTAMP, timestamp)
38+
}
39+
40+
override fun getLatitude(): Double? = prefs.read {
41+
getString(KEY_LATITUDE, null)?.toDoubleOrNull()
42+
}
43+
44+
override fun getLongitude(): Double? = prefs.read {
45+
getString(KEY_LONGITUDE, null)?.toDoubleOrNull()
46+
}
47+
48+
override fun getLastSentTimestamp(): Long? = prefs.read {
49+
if (contains(KEY_LAST_SENT_TIMESTAMP)) getLong(KEY_LAST_SENT_TIMESTAMP, 0L) else null
50+
}
51+
52+
companion object {
53+
private const val KEY_LATITUDE = "latitude"
54+
private const val KEY_LONGITUDE = "longitude"
55+
private const val KEY_LAST_SENT_TIMESTAMP = "last_sent_timestamp"
56+
}
57+
}

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

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import io.customer.base.internal.InternalCustomerIOApi
1414
import io.customer.datapipelines.config.DataPipelinesModuleConfig
1515
import io.customer.datapipelines.di.analyticsFactory
1616
import io.customer.datapipelines.di.dataPipelinesLogger
17+
import io.customer.datapipelines.di.locationPreferenceStore
1718
import io.customer.datapipelines.extensions.asMap
1819
import io.customer.datapipelines.extensions.sanitizeForJson
1920
import io.customer.datapipelines.extensions.type
2021
import io.customer.datapipelines.extensions.updateAnalyticsConfig
22+
import io.customer.datapipelines.location.LocationTracker
2123
import io.customer.datapipelines.migration.TrackingMigrationProcessor
2224
import io.customer.datapipelines.plugins.ApplicationLifecyclePlugin
2325
import io.customer.datapipelines.plugins.AutoTrackDeviceAttributesPlugin
@@ -109,6 +111,7 @@ class CustomerIO private constructor(
109111

110112
private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore)
111113
private val locationPlugin: LocationPlugin = LocationPlugin(logger)
114+
private val locationTracker: LocationTracker = LocationTracker(locationPlugin, SDKComponent.locationPreferenceStore, logger)
112115

113116
init {
114117
// Set analytics logger and debug logs based on SDK logger configuration
@@ -132,6 +135,9 @@ class CustomerIO private constructor(
132135
analytics.add(locationPlugin)
133136
analytics.add(ApplicationLifecyclePlugin())
134137

138+
// Restore persisted location so identify events have context immediately
139+
locationTracker.restorePersistedLocation()
140+
135141
// subscribe to journey events emitted from push/in-app module to send them via data pipelines
136142
subscribeToJourneyEvents()
137143
// republish profile/anonymous events for late-added modules
@@ -155,23 +161,18 @@ class CustomerIO private constructor(
155161
registerDeviceToken(deviceToken = it.token)
156162
}
157163
eventBus.subscribe<Event.TrackLocationEvent> {
158-
trackLocation(it)
164+
locationTracker.onLocationReceived(it)?.let { location ->
165+
sendLocationTrack(location)
166+
}
159167
}
160168
}
161169

162-
private fun trackLocation(event: Event.TrackLocationEvent) {
163-
val location = event.location
164-
logger.debug("tracking location update: lat=${location.latitude}, lng=${location.longitude}")
165-
166-
// Cache location for enriching future identify events
167-
locationPlugin.lastLocation = location
168-
170+
private fun sendLocationTrack(location: Event.LocationData) {
169171
track(
170172
name = EventNames.LOCATION_UPDATE,
171173
properties = mapOf(
172-
"lat" to location.latitude,
173-
"lng" to location.longitude,
174-
LocationPlugin.INTERNAL_LOCATION_KEY to true
174+
"latitude" to location.latitude,
175+
"longitude" to location.longitude
175176
)
176177
)
177178
}
@@ -211,6 +212,11 @@ class CustomerIO private constructor(
211212
if (moduleConfig.trackApplicationLifecycleEvents) {
212213
analytics.add(AutomaticApplicationLifecycleTrackingPlugin())
213214
}
215+
216+
// Re-send location if >24h since last "Location Update" track
217+
locationTracker.getStaleLocationForResend()?.let { location ->
218+
sendLocationTrack(location)
219+
}
214220
}
215221

216222
@Deprecated("Use setProfileAttributes() function instead")
@@ -265,6 +271,11 @@ class CustomerIO private constructor(
265271
}
266272

267273
logger.info("identify profile with identifier $userId and traits $traits")
274+
275+
if (!locationTracker.hasLocationContext()) {
276+
locationTracker.onIdentifySentWithoutLocation()
277+
}
278+
268279
// publish event to EventBus for other modules to consume
269280
eventBus.publish(Event.UserChangedEvent(userId = userId, anonymousId = analytics.anonymousId()))
270281
analytics.identify(
@@ -321,6 +332,7 @@ class CustomerIO private constructor(
321332
}
322333

323334
logger.debug("resetting user profile")
335+
locationTracker.onUserReset()
324336
// publish event to EventBus for other modules to consume
325337
eventBus.publish(Event.ResetEvent)
326338
analytics.reset()
@@ -416,6 +428,7 @@ class CustomerIO private constructor(
416428
}
417429

418430
companion object {
431+
419432
/**
420433
* Module identifier for DataPipelines module.
421434
*/

0 commit comments

Comments
 (0)