-
Notifications
You must be signed in to change notification settings - Fork 9
chore: LocationTracker coordinator with rate-limited location sends #661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,129 @@ | ||||||||||||||||||
| package io.customer.datapipelines.location | ||||||||||||||||||
|
|
||||||||||||||||||
| import io.customer.datapipelines.plugins.LocationPlugin | ||||||||||||||||||
| import io.customer.datapipelines.store.LocationPreferenceStore | ||||||||||||||||||
| import io.customer.sdk.communication.Event | ||||||||||||||||||
| import io.customer.sdk.core.util.Logger | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Coordinates all location state management: persistence, restoration, | ||||||||||||||||||
| * staleness detection, and tracking whether an identify was sent without | ||||||||||||||||||
| * location context. | ||||||||||||||||||
| */ | ||||||||||||||||||
| internal class LocationTracker( | ||||||||||||||||||
| private val locationPlugin: LocationPlugin, | ||||||||||||||||||
| private val locationPreferenceStore: LocationPreferenceStore, | ||||||||||||||||||
| private val logger: Logger | ||||||||||||||||||
| ) { | ||||||||||||||||||
| /** | ||||||||||||||||||
| * Set when an identify is sent while no location context is available. | ||||||||||||||||||
| * Cleared when a location update arrives, so the caller can react | ||||||||||||||||||
| * (e.g. send a "Location Update" track for the newly-identified user). | ||||||||||||||||||
| */ | ||||||||||||||||||
| @Volatile | ||||||||||||||||||
| internal var identifySentWithoutLocation: Boolean = false | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up debt flag lost on app restartMedium Severity
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. its removed in the other PR |
||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Reads persisted location from the preference store and sets it on the | ||||||||||||||||||
| * [LocationPlugin] so that identify events have location context immediately | ||||||||||||||||||
| * after SDK restart. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun restorePersistedLocation() { | ||||||||||||||||||
| val lat = locationPreferenceStore.getLatitude() ?: return | ||||||||||||||||||
| val lng = locationPreferenceStore.getLongitude() ?: return | ||||||||||||||||||
| locationPlugin.lastLocation = Event.LocationData(latitude = lat, longitude = lng) | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. |
||||||||||||||||||
| logger.debug("Restored persisted location: lat=$lat, lng=$lng") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Processes an incoming location event: always caches in the plugin and | ||||||||||||||||||
| * persists coordinates for identify enrichment. Only returns non-null | ||||||||||||||||||
| * (signalling the caller to send a "Location Update" track) when: | ||||||||||||||||||
| * | ||||||||||||||||||
| * 1. An identify was previously sent without location context, OR | ||||||||||||||||||
| * 2. >=24 hours have elapsed since the last "Location Update" track. | ||||||||||||||||||
| * | ||||||||||||||||||
| * @return the [Event.LocationData] to send as a track, or null if suppressed. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun onLocationReceived(event: Event.TrackLocationEvent): Event.LocationData? { | ||||||||||||||||||
| val location = event.location | ||||||||||||||||||
| logger.debug("location update received: lat=${location.latitude}, lng=${location.longitude}") | ||||||||||||||||||
|
|
||||||||||||||||||
| // Always cache and persist so identifies have context and location | ||||||||||||||||||
| // survives app restarts — regardless of whether we send a track | ||||||||||||||||||
| locationPlugin.lastLocation = location | ||||||||||||||||||
| locationPreferenceStore.saveLocation(location.latitude, location.longitude) | ||||||||||||||||||
|
|
||||||||||||||||||
| val shouldSendTrack = when { | ||||||||||||||||||
| identifySentWithoutLocation -> { | ||||||||||||||||||
| logger.debug("Sending location track: identify was previously sent without location context") | ||||||||||||||||||
| identifySentWithoutLocation = false | ||||||||||||||||||
| true | ||||||||||||||||||
| } | ||||||||||||||||||
| isStale() -> { | ||||||||||||||||||
| logger.debug("Sending location track: >=24h since last send") | ||||||||||||||||||
| true | ||||||||||||||||||
| } | ||||||||||||||||||
| else -> { | ||||||||||||||||||
| logger.debug("Location cached but track suppressed: last sent <24h ago") | ||||||||||||||||||
| false | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (shouldSendTrack) { | ||||||||||||||||||
| locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis()) | ||||||||||||||||||
| return location | ||||||||||||||||||
| } | ||||||||||||||||||
| return null | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Returns the persisted location if more than 24 hours have elapsed since | ||||||||||||||||||
| * the last "Location Update" track was sent, or null otherwise. | ||||||||||||||||||
| * Updates the sent timestamp so the next cold start won't re-send. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun getStaleLocationForResend(): Event.LocationData? { | ||||||||||||||||||
| val lat = locationPreferenceStore.getLatitude() ?: return null | ||||||||||||||||||
| val lng = locationPreferenceStore.getLongitude() ?: return null | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!isStale()) return null | ||||||||||||||||||
|
Comment on lines
+86
to
+89
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed in #662 |
||||||||||||||||||
|
|
||||||||||||||||||
| logger.debug("Location update stale on cold start, re-sending") | ||||||||||||||||||
| locationPreferenceStore.saveLastSentTimestamp(System.currentTimeMillis()) | ||||||||||||||||||
| return Event.LocationData(latitude = lat, longitude = lng) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun isStale(): Boolean { | ||||||||||||||||||
| val lastSent = locationPreferenceStore.getLastSentTimestamp() ?: return true | ||||||||||||||||||
| return (System.currentTimeMillis() - lastSent) >= LOCATION_RESEND_INTERVAL_MS | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Records that an identify call was made without location context. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun onIdentifySentWithoutLocation() { | ||||||||||||||||||
| identifySentWithoutLocation = true | ||||||||||||||||||
| logger.debug("Identify sent without location context; will send location track when location arrives") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Clears the [identifySentWithoutLocation] flag. Called when the user | ||||||||||||||||||
| * logs out — the debt belongs to the identified user, and once they're | ||||||||||||||||||
| * gone the follow-up location track is no longer owed. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun onUserReset() { | ||||||||||||||||||
| if (identifySentWithoutLocation) { | ||||||||||||||||||
| logger.debug("User reset; clearing pending identify-without-location flag") | ||||||||||||||||||
| identifySentWithoutLocation = false | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Returns true if the [LocationPlugin] has a cached location. | ||||||||||||||||||
| */ | ||||||||||||||||||
| fun hasLocationContext(): Boolean = locationPlugin.lastLocation != null | ||||||||||||||||||
|
|
||||||||||||||||||
| companion object { | ||||||||||||||||||
| private const val LOCATION_RESEND_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24 hours | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package io.customer.datapipelines.plugins | ||
|
|
||
| import com.segment.analytics.kotlin.core.Analytics | ||
| import com.segment.analytics.kotlin.core.BaseEvent | ||
| import com.segment.analytics.kotlin.core.IdentifyEvent | ||
| import com.segment.analytics.kotlin.core.platform.EventPlugin | ||
| import com.segment.analytics.kotlin.core.platform.Plugin | ||
| import com.segment.analytics.kotlin.core.utilities.putInContext | ||
| import io.customer.sdk.communication.Event | ||
| import io.customer.sdk.core.util.Logger | ||
| import kotlinx.serialization.json.JsonPrimitive | ||
|
|
||
| /** | ||
| * Plugin that enriches identify events with the last known location in context, | ||
| * so Customer.io knows where the user is when their profile is identified. | ||
| */ | ||
| internal class LocationPlugin(private val logger: Logger) : EventPlugin { | ||
| override val type: Plugin.Type = Plugin.Type.Enrichment | ||
| override lateinit var analytics: Analytics | ||
|
|
||
| @Volatile | ||
| internal var lastLocation: Event.LocationData? = null | ||
|
|
||
| override fun identify(payload: IdentifyEvent): BaseEvent { | ||
| val location = lastLocation ?: return payload | ||
| payload.putInContext("location_latitude", JsonPrimitive(location.latitude)) | ||
| payload.putInContext("location_longitude", JsonPrimitive(location.longitude)) | ||
| return payload | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package io.customer.datapipelines.store | ||
|
|
||
| import android.content.Context | ||
| import androidx.core.content.edit | ||
| import io.customer.sdk.core.util.Logger | ||
| import io.customer.sdk.data.store.PreferenceStore | ||
| import io.customer.sdk.data.store.read | ||
|
|
||
| /** | ||
| * Store for persisting location data across app restarts. | ||
| * Ensures identify events always have location context and supports | ||
| * 24-hour re-send of stale location updates on SDK startup. | ||
| */ | ||
| internal interface LocationPreferenceStore { | ||
| fun saveLocation(latitude: Double, longitude: Double) | ||
| fun saveLastSentTimestamp(timestamp: Long) | ||
| fun getLatitude(): Double? | ||
| fun getLongitude(): Double? | ||
| fun getLastSentTimestamp(): Long? | ||
| fun clearAll() | ||
| } | ||
|
|
||
| internal class LocationPreferenceStoreImpl( | ||
| context: Context, | ||
| logger: Logger | ||
| ) : PreferenceStore(context), LocationPreferenceStore { | ||
|
|
||
| private val crypto = PreferenceCrypto(KEY_ALIAS, logger) | ||
|
|
||
| override val prefsName: String by lazy { | ||
| "io.customer.sdk.location.${context.packageName}" | ||
| } | ||
|
|
||
| override fun saveLocation(latitude: Double, longitude: Double) = prefs.edit { | ||
| putString(KEY_LATITUDE, crypto.encrypt(latitude.toString())) | ||
| putString(KEY_LONGITUDE, crypto.encrypt(longitude.toString())) | ||
| } | ||
|
|
||
| override fun saveLastSentTimestamp(timestamp: Long) = prefs.edit { | ||
| putLong(KEY_LAST_SENT_TIMESTAMP, timestamp) | ||
| } | ||
|
|
||
| override fun getLatitude(): Double? = prefs.read { | ||
| getString(KEY_LATITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() } | ||
| } | ||
|
|
||
| override fun getLongitude(): Double? = prefs.read { | ||
| getString(KEY_LONGITUDE, null)?.let { crypto.decrypt(it).toDoubleOrNull() } | ||
| } | ||
|
|
||
| override fun getLastSentTimestamp(): Long? = prefs.read { | ||
| if (contains(KEY_LAST_SENT_TIMESTAMP)) getLong(KEY_LAST_SENT_TIMESTAMP, 0L) else null | ||
| } | ||
|
|
||
| companion object { | ||
| private const val KEY_ALIAS = "cio_location_key" | ||
| private const val KEY_LATITUDE = "latitude" | ||
| private const val KEY_LONGITUDE = "longitude" | ||
| private const val KEY_LAST_SENT_TIMESTAMP = "last_sent_timestamp" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we prefix these to avoid any future collisions
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. they are under separate directory, |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package io.customer.datapipelines.store | ||
|
|
||
| import android.annotation.SuppressLint | ||
| import android.os.Build | ||
| import android.security.keystore.KeyGenParameterSpec | ||
| import android.security.keystore.KeyProperties | ||
| import android.util.Base64 | ||
| import io.customer.sdk.core.util.Logger | ||
| import java.security.KeyStore | ||
| import javax.crypto.Cipher | ||
| import javax.crypto.KeyGenerator | ||
| import javax.crypto.SecretKey | ||
| import javax.crypto.spec.GCMParameterSpec | ||
|
|
||
| /** | ||
| * Encrypts and decrypts strings using an AES-256-GCM key stored in the | ||
| * Android Keystore. Falls back to plaintext on API < 23 or when the | ||
| * Keystore is unavailable (some OEMs have buggy implementations). | ||
| * | ||
| * The [MODE_PRIVATE][android.content.Context.MODE_PRIVATE] SharedPreferences | ||
| * sandbox remains the baseline protection in all cases. | ||
| */ | ||
| internal class PreferenceCrypto( | ||
| private val keyAlias: String, | ||
| private val logger: Logger | ||
| ) { | ||
| private val isKeystoreAvailable: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M | ||
|
|
||
| @Volatile | ||
| private var cachedKey: SecretKey? = null | ||
|
|
||
| @Synchronized | ||
| @SuppressLint("NewApi", "InlinedApi") | ||
| private fun getOrCreateKey(): SecretKey { | ||
| cachedKey?.let { return it } | ||
|
|
||
| val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } | ||
| val entry = keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry | ||
| if (entry != null) { | ||
| cachedKey = entry.secretKey | ||
| return entry.secretKey | ||
| } | ||
|
|
||
| val spec = KeyGenParameterSpec.Builder( | ||
| keyAlias, | ||
| KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT | ||
| ) | ||
| .setBlockModes(KeyProperties.BLOCK_MODE_GCM) | ||
| .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) | ||
| .setKeySize(256) | ||
| .build() | ||
|
|
||
| val key = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) | ||
| .apply { init(spec) } | ||
| .generateKey() | ||
| cachedKey = key | ||
| return key | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Encrypts [plaintext] with AES-256-GCM. Returns a Base64 string | ||
| * containing the 12-byte IV prepended to the ciphertext. Falls back | ||
| * to returning [plaintext] unchanged if encryption is unavailable. | ||
| */ | ||
| @SuppressLint("NewApi") | ||
| fun encrypt(plaintext: String): String { | ||
| if (!isKeystoreAvailable) return plaintext | ||
|
|
||
| return try { | ||
| val cipher = Cipher.getInstance(TRANSFORMATION) | ||
| cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) | ||
| val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) | ||
| val combined = cipher.iv + ciphertext | ||
| Base64.encodeToString(combined, Base64.NO_WRAP) | ||
| } catch (e: Exception) { | ||
| logger.debug("Keystore encryption unavailable, storing without encryption: ${e.message}") | ||
| plaintext | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Decrypts an [encoded] Base64 string produced by [encrypt]. If | ||
| * decryption fails (e.g. the value was stored as plaintext before | ||
| * encryption was enabled, or the Keystore is unavailable), returns | ||
| * [encoded] as-is, which handles migration from plaintext transparently. | ||
| */ | ||
| @SuppressLint("NewApi") | ||
| fun decrypt(encoded: String): String { | ||
| if (!isKeystoreAvailable) return encoded | ||
|
|
||
| return try { | ||
| val combined = Base64.decode(encoded, Base64.NO_WRAP) | ||
| if (combined.size <= GCM_IV_LENGTH) return encoded | ||
|
|
||
| val iv = combined.copyOfRange(0, GCM_IV_LENGTH) | ||
| val ciphertext = combined.copyOfRange(GCM_IV_LENGTH, combined.size) | ||
| val cipher = Cipher.getInstance(TRANSFORMATION) | ||
| cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_LENGTH, iv)) | ||
| String(cipher.doFinal(ciphertext), Charsets.UTF_8) | ||
| } catch (e: Exception) { | ||
| // Value is likely stored as plaintext from before encryption was | ||
| // enabled, or from a Keystore failure during write. Return as-is. | ||
| encoded | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val KEYSTORE_PROVIDER = "AndroidKeyStore" | ||
| private const val TRANSFORMATION = "AES/GCM/NoPadding" | ||
| private const val GCM_IV_LENGTH = 12 | ||
| private const val GCM_TAG_LENGTH = 128 | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this an in-memory only value? What happens when there an identify in a session then in new launch, the location is acquired
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, the debt flag is now persisted to
LocationPreferenceStoreviasavePendingLocationDebt()/getPendingLocationDebt(). The in-memory field has been removed entirely the store is the single source of truth. The flag is restored on cold start inrestorePersistedLocation()and cleared when location arrives or whenclearIdentify()is called.