diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/di/SDKComponentExt.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/di/SDKComponentExt.kt index 8dba0932f..fa6b03303 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/di/SDKComponentExt.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/di/SDKComponentExt.kt @@ -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 @@ -12,3 +14,8 @@ internal val SDKComponent.analyticsFactory: ((moduleConfig: DataPipelinesModuleC internal val SDKComponent.dataPipelinesLogger: DataPipelinesLogger get() = singleton { DataPipelinesLogger(logger) } + +internal val SDKComponent.locationPreferenceStore: LocationPreferenceStore + get() = singleton { + LocationPreferenceStoreImpl(android().applicationContext, logger) + } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationTracker.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationTracker.kt new file mode 100644 index 000000000..9b9c3c89c --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationTracker.kt @@ -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 + + /** + * 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) + 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 + + 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 + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt new file mode 100644 index 000000000..d8018afde --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt @@ -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 + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationPreferenceStore.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationPreferenceStore.kt new file mode 100644 index 000000000..6a0f2b50a --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationPreferenceStore.kt @@ -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" + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/store/PreferenceCrypto.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/store/PreferenceCrypto.kt new file mode 100644 index 000000000..2867f47b5 --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/store/PreferenceCrypto.kt @@ -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 + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 4ffb2ae60..331e3232d 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -14,10 +14,12 @@ import io.customer.base.internal.InternalCustomerIOApi import io.customer.datapipelines.config.DataPipelinesModuleConfig import io.customer.datapipelines.di.analyticsFactory import io.customer.datapipelines.di.dataPipelinesLogger +import io.customer.datapipelines.di.locationPreferenceStore import io.customer.datapipelines.extensions.asMap import io.customer.datapipelines.extensions.sanitizeForJson import io.customer.datapipelines.extensions.type import io.customer.datapipelines.extensions.updateAnalyticsConfig +import io.customer.datapipelines.location.LocationTracker import io.customer.datapipelines.migration.TrackingMigrationProcessor import io.customer.datapipelines.plugins.ApplicationLifecyclePlugin import io.customer.datapipelines.plugins.AutoTrackDeviceAttributesPlugin @@ -25,6 +27,7 @@ import io.customer.datapipelines.plugins.AutomaticActivityScreenTrackingPlugin import io.customer.datapipelines.plugins.AutomaticApplicationLifecycleTrackingPlugin import io.customer.datapipelines.plugins.ContextPlugin import io.customer.datapipelines.plugins.CustomerIODestination +import io.customer.datapipelines.plugins.LocationPlugin import io.customer.datapipelines.plugins.ScreenFilterPlugin import io.customer.sdk.communication.Event import io.customer.sdk.communication.subscribe @@ -107,6 +110,8 @@ class CustomerIO private constructor( ) private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore) + private val locationPlugin: LocationPlugin = LocationPlugin(logger) + private val locationTracker: LocationTracker = LocationTracker(locationPlugin, SDKComponent.locationPreferenceStore, logger) init { // Set analytics logger and debug logs based on SDK logger configuration @@ -127,8 +132,12 @@ class CustomerIO private constructor( // Add plugin to filter events based on SDK configuration analytics.add(ScreenFilterPlugin(moduleConfig.screenViewUse)) + analytics.add(locationPlugin) analytics.add(ApplicationLifecyclePlugin()) + // Restore persisted location so identify events have context immediately + locationTracker.restorePersistedLocation() + // subscribe to journey events emitted from push/in-app module to send them via data pipelines subscribeToJourneyEvents() // republish profile/anonymous events for late-added modules @@ -152,18 +161,18 @@ class CustomerIO private constructor( registerDeviceToken(deviceToken = it.token) } eventBus.subscribe { - trackLocation(it) + locationTracker.onLocationReceived(it)?.let { location -> + sendLocationTrack(location) + } } } - private fun trackLocation(event: Event.TrackLocationEvent) { - val location = event.location - logger.debug("tracking location update: lat=${location.latitude}, lng=${location.longitude}") + private fun sendLocationTrack(location: Event.LocationData) { track( name = EventNames.LOCATION_UPDATE, properties = mapOf( - "lat" to location.latitude, - "lng" to location.longitude + "latitude" to location.latitude, + "longitude" to location.longitude ) ) } @@ -203,6 +212,11 @@ class CustomerIO private constructor( if (moduleConfig.trackApplicationLifecycleEvents) { analytics.add(AutomaticApplicationLifecycleTrackingPlugin()) } + + // Re-send location if >24h since last "Location Update" track + locationTracker.getStaleLocationForResend()?.let { location -> + sendLocationTrack(location) + } } @Deprecated("Use setProfileAttributes() function instead") @@ -257,6 +271,11 @@ class CustomerIO private constructor( } logger.info("identify profile with identifier $userId and traits $traits") + + if (!locationTracker.hasLocationContext()) { + locationTracker.onIdentifySentWithoutLocation() + } + // publish event to EventBus for other modules to consume eventBus.publish(Event.UserChangedEvent(userId = userId, anonymousId = analytics.anonymousId())) analytics.identify( @@ -313,6 +332,7 @@ class CustomerIO private constructor( } logger.debug("resetting user profile") + locationTracker.onUserReset() // publish event to EventBus for other modules to consume eventBus.publish(Event.ResetEvent) analytics.reset() @@ -408,6 +428,7 @@ class CustomerIO private constructor( } companion object { + /** * Module identifier for DataPipelines module. */