Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package io.customer.datapipelines.di

import com.segment.analytics.kotlin.core.Analytics
import io.customer.datapipelines.config.DataPipelinesModuleConfig
import io.customer.datapipelines.store.LocationPreferenceStore
import io.customer.datapipelines.store.LocationPreferenceStoreImpl
import io.customer.sdk.DataPipelinesLogger
import io.customer.sdk.core.di.SDKComponent
import io.customer.sdk.core.extensions.getOrNull
Expand All @@ -12,3 +14,8 @@ internal val SDKComponent.analyticsFactory: ((moduleConfig: DataPipelinesModuleC

internal val SDKComponent.dataPipelinesLogger: DataPipelinesLogger
get() = singleton<DataPipelinesLogger> { DataPipelinesLogger(logger) }

internal val SDKComponent.locationPreferenceStore: LocationPreferenceStore
get() = singleton<LocationPreferenceStore> {
LocationPreferenceStoreImpl(android().applicationContext, logger)
}
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
Copy link
Contributor

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

Copy link
Contributor Author

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 LocationPreferenceStore via savePendingLocationDebt()/getPendingLocationDebt(). The in-memory field has been removed entirely the store is the single source of truth. The flag is restored on cold start in restorePersistedLocation() and cleared when location arrives or when clearIdentify() is called.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up debt flag lost on app restart

Medium Severity

identifySentWithoutLocation is an in-memory @Volatile boolean that is never persisted. If identify() is called without location context and the app is killed before any location arrives, this flag is lost on cold start. When the location eventually arrives in the new session, no compensating "Location Update" track is sent for the previous identify, silently dropping the follow-up obligation.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is locationPlugin.lastLocation access thread safe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. lastLocation is declared @Volatile. The operations are simple reference reads (lastLocation ?: return payload) and writes (lastLocation = location) both atomic on the JVM. There are no compound read-modify-write operations. @Volatile guarantees cross-thread visibility, which is all that's needed here.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't isStale be checked before instead?

Suggested change
val lat = locationPreferenceStore.getLatitude() ?: return null
val lng = locationPreferenceStore.getLongitude() ?: return null
if (!isStale()) return null
if (!isStale()) return null
val lat = locationPreferenceStore.getLatitude() ?: return null
val lng = locationPreferenceStore.getLongitude() ?: return null

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we prefix these to avoid any future collisions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they are under separate directory, io.customer.sdk.location.${context.packageName} so doubt collision, but sure can do add prefix

}
}
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
}

/**
* 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
}
}
Loading
Loading