|
1 | 1 | package io.customer.datapipelines.location |
2 | 2 |
|
| 3 | +import android.location.Location |
3 | 4 | import io.customer.datapipelines.plugins.LocationPlugin |
4 | 5 | import io.customer.datapipelines.store.LocationPreferenceStore |
5 | 6 | import io.customer.sdk.communication.Event |
6 | 7 | import io.customer.sdk.core.util.Logger |
7 | 8 |
|
8 | 9 | /** |
9 | 10 | * 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. |
12 | 23 | */ |
13 | 24 | internal class LocationTracker( |
14 | 25 | private val locationPlugin: LocationPlugin, |
15 | 26 | private val locationPreferenceStore: LocationPreferenceStore, |
16 | | - private val logger: Logger |
| 27 | + private val logger: Logger, |
| 28 | + private val userIdProvider: () -> String? |
17 | 29 | ) { |
18 | 30 | /** |
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. |
30 | 34 | */ |
31 | 35 | 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 |
34 | 38 | locationPlugin.lastLocation = Event.LocationData(latitude = lat, longitude = lng) |
35 | 39 | logger.debug("Restored persisted location: lat=$lat, lng=$lng") |
36 | 40 | } |
37 | 41 |
|
38 | 42 | /** |
39 | 43 | * Processes an incoming location event: always caches in the plugin and |
40 | 44 | * 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. |
45 | 47 | * |
46 | 48 | * @return the [Event.LocationData] to send as a track, or null if suppressed. |
47 | 49 | */ |
48 | 50 | fun onLocationReceived(event: Event.TrackLocationEvent): Event.LocationData? { |
49 | 51 | 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}") |
51 | 53 |
|
52 | 54 | // Always cache and persist so identifies have context and location |
53 | 55 | // survives app restarts — regardless of whether we send a track |
54 | 56 | 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 |
71 | 63 | } |
72 | 64 |
|
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 |
76 | 68 | } |
77 | | - return null |
| 69 | + |
| 70 | + logger.debug("Location filter passed, sending Location Update track") |
| 71 | + return location |
78 | 72 | } |
79 | 73 |
|
80 | 74 | /** |
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. |
84 | 84 | */ |
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 |
88 | 87 |
|
89 | | - if (!isStale()) return null |
| 88 | + val lat = locationPreferenceStore.getCachedLatitude() ?: return null |
| 89 | + val lng = locationPreferenceStore.getCachedLongitude() ?: return null |
90 | 90 |
|
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") |
93 | 94 | return Event.LocationData(latitude = lat, longitude = lng) |
94 | 95 | } |
95 | 96 |
|
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") |
99 | 111 | } |
100 | 112 |
|
101 | 113 | /** |
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. |
103 | 120 | */ |
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 |
107 | 134 | } |
108 | 135 |
|
109 | 136 | /** |
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. |
113 | 142 | */ |
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()) |
119 | 145 | } |
120 | 146 |
|
121 | 147 | /** |
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. |
123 | 151 | */ |
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 | + } |
125 | 162 |
|
126 | 163 | companion object { |
127 | 164 | private const val LOCATION_RESEND_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24 hours |
| 165 | + private const val MINIMUM_DISTANCE_METERS = 1000f // 1 km |
128 | 166 | } |
129 | 167 | } |
0 commit comments