From 9e1be7d00725c28fd91ed3052a082501c784b263 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 24 Feb 2026 02:34:42 +0500 Subject: [PATCH 1/5] wip --- core/api/core.api | 49 ++--- .../io/customer/sdk/communication/Event.kt | 20 -- .../sdk/communication/LocationCache.kt | 5 - .../sdk/core/pipeline/DataPipeline.kt | 12 + .../pipeline/ProfileEnrichmentProvider.kt | 11 + .../pipeline/ProfileEnrichmentRegistry.kt | 34 +++ datapipelines/api/datapipelines.api | 2 +- .../datapipelines/plugins/LocationPlugin.kt | 36 --- .../plugins/ProfileEnrichmentPlugin.kt | 46 ++++ .../main/kotlin/io/customer/sdk/CustomerIO.kt | 36 +-- .../LocationSyncFilterIntegrationTest.kt | 114 ---------- .../customer/location/LocationOrchestrator.kt | 13 +- .../customer/location/LocationServicesImpl.kt | 7 +- .../io/customer/location/LocationTracker.kt | 145 +++++++++---- .../io/customer/location/ModuleLocation.kt | 21 +- .../customer/location/di/SDKComponentExt.kt | 7 - .../location/sync}/LocationSyncFilter.kt | 6 +- .../location/sync}/LocationSyncStore.kt | 4 +- .../customer/location/LocationTrackerTest.kt | 205 ++++++++++++------ .../location/sync}/LocationSyncFilterTest.kt | 3 +- 20 files changed, 394 insertions(+), 382 deletions(-) delete mode 100644 core/src/main/kotlin/io/customer/sdk/communication/LocationCache.kt create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt delete mode 100644 datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt create mode 100644 datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt delete mode 100644 datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterIntegrationTest.kt delete mode 100644 location/src/main/kotlin/io/customer/location/di/SDKComponentExt.kt rename {datapipelines/src/main/kotlin/io/customer/datapipelines/location => location/src/main/kotlin/io/customer/location/sync}/LocationSyncFilter.kt (92%) rename {datapipelines/src/main/kotlin/io/customer/datapipelines/store => location/src/main/kotlin/io/customer/location/sync}/LocationSyncStore.kt (95%) rename {datapipelines/src/test/java/io/customer/datapipelines/location => location/src/test/java/io/customer/location/sync}/LocationSyncFilterTest.kt (98%) diff --git a/core/api/core.api b/core/api/core.api index 7cdb6f307..3933708ab 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -16,19 +16,6 @@ public final class io/customer/sdk/communication/Event$DeleteDeviceTokenEvent : public fun ()V } -public final class io/customer/sdk/communication/Event$LocationData { - public fun (DD)V - public final fun component1 ()D - public final fun component2 ()D - public final fun copy (DD)Lio/customer/sdk/communication/Event$LocationData; - public static synthetic fun copy$default (Lio/customer/sdk/communication/Event$LocationData;DDILjava/lang/Object;)Lio/customer/sdk/communication/Event$LocationData; - public fun equals (Ljava/lang/Object;)Z - public final fun getLatitude ()D - public final fun getLongitude ()D - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/customer/sdk/communication/Event$RegisterDeviceTokenEvent : io/customer/sdk/communication/Event { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; @@ -71,17 +58,6 @@ public final class io/customer/sdk/communication/Event$TrackInAppMetricEvent : i public fun toString ()Ljava/lang/String; } -public final class io/customer/sdk/communication/Event$TrackLocationEvent : io/customer/sdk/communication/Event { - public fun (Lio/customer/sdk/communication/Event$LocationData;)V - public final fun component1 ()Lio/customer/sdk/communication/Event$LocationData; - public final fun copy (Lio/customer/sdk/communication/Event$LocationData;)Lio/customer/sdk/communication/Event$TrackLocationEvent; - public static synthetic fun copy$default (Lio/customer/sdk/communication/Event$TrackLocationEvent;Lio/customer/sdk/communication/Event$LocationData;ILjava/lang/Object;)Lio/customer/sdk/communication/Event$TrackLocationEvent; - public fun equals (Ljava/lang/Object;)Z - public final fun getLocation ()Lio/customer/sdk/communication/Event$LocationData; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/customer/sdk/communication/Event$TrackPushMetricEvent : io/customer/sdk/communication/Event { public fun (Ljava/lang/String;Lio/customer/sdk/events/Metric;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; @@ -128,11 +104,6 @@ public final class io/customer/sdk/communication/EventBusImpl : io/customer/sdk/ public fun subscribe (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; } -public abstract interface class io/customer/sdk/communication/LocationCache { - public abstract fun getLastLocation ()Lio/customer/sdk/communication/Event$LocationData; - public abstract fun setLastLocation (Lio/customer/sdk/communication/Event$LocationData;)V -} - public final class io/customer/sdk/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -212,6 +183,26 @@ public abstract interface class io/customer/sdk/core/module/CustomerIOModuleConf public abstract fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; } +public abstract interface class io/customer/sdk/core/pipeline/DataPipeline { + public abstract fun getUserId ()Ljava/lang/String; + public abstract fun track (Ljava/lang/String;Ljava/util/Map;)V +} + +public abstract interface class io/customer/sdk/core/pipeline/ProfileEnrichmentProvider { + public abstract fun getProfileEnrichmentAttributes ()Ljava/util/Map; +} + +public final class io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry { + public fun ()V + public final fun clear ()V + public final fun getAll ()Ljava/util/List; + public final fun register (Lio/customer/sdk/core/pipeline/ProfileEnrichmentProvider;)V +} + +public final class io/customer/sdk/core/pipeline/ProfileEnrichmentRegistryKt { + public static final fun getProfileEnrichmentRegistry (Lio/customer/sdk/core/di/SDKComponent;)Lio/customer/sdk/core/pipeline/ProfileEnrichmentRegistry; +} + public final class io/customer/sdk/core/util/CioLogLevel : java/lang/Enum { public static final field Companion Lio/customer/sdk/core/util/CioLogLevel$Companion; public static final field DEBUG Lio/customer/sdk/core/util/CioLogLevel; diff --git a/core/src/main/kotlin/io/customer/sdk/communication/Event.kt b/core/src/main/kotlin/io/customer/sdk/communication/Event.kt index 698a6e01c..13abb49bd 100644 --- a/core/src/main/kotlin/io/customer/sdk/communication/Event.kt +++ b/core/src/main/kotlin/io/customer/sdk/communication/Event.kt @@ -51,24 +51,4 @@ sealed class Event { ) : Event() class DeleteDeviceTokenEvent : Event() - - /** - * Event emitted when a new location is available. - * Published by the Location module on every location update. - * DataPipelines applies the userId gate and sync filter (24h + 1km) - * before sending to the server. - */ - data class TrackLocationEvent( - val location: LocationData - ) : Event() - - /** - * Location data in a framework-agnostic format. - * Used to pass location information between modules without - * requiring Android location framework imports. - */ - data class LocationData( - val latitude: Double, - val longitude: Double - ) } diff --git a/core/src/main/kotlin/io/customer/sdk/communication/LocationCache.kt b/core/src/main/kotlin/io/customer/sdk/communication/LocationCache.kt deleted file mode 100644 index 8e09b6bcf..000000000 --- a/core/src/main/kotlin/io/customer/sdk/communication/LocationCache.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.customer.sdk.communication - -interface LocationCache { - var lastLocation: Event.LocationData? -} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt new file mode 100644 index 000000000..bbd9d0e45 --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -0,0 +1,12 @@ +package io.customer.sdk.core.pipeline + +/** + * Abstraction for sending track events to the data pipeline. + * + * Modules retrieve an implementation via `SDKComponent.getOrNull()` + * to send events directly without going through EventBus. + */ +interface DataPipeline { + val userId: String? + fun track(name: String, properties: Map) +} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt new file mode 100644 index 000000000..3a56cec7c --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt @@ -0,0 +1,11 @@ +package io.customer.sdk.core.pipeline + +/** + * Interface for modules that contribute attributes to identify events. + * + * Implementations return a map of primitive-valued attributes (String, Number, Boolean) + * that will be added to the identify event context. Return null to skip enrichment. + */ +interface ProfileEnrichmentProvider { + fun getProfileEnrichmentAttributes(): Map? +} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt new file mode 100644 index 000000000..2d7b4c43d --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt @@ -0,0 +1,34 @@ +package io.customer.sdk.core.pipeline + +import io.customer.sdk.core.di.SDKComponent + +/** + * Thread-safe registry of [ProfileEnrichmentProvider] instances. + * + * Modules register providers during initialization. The datapipelines module + * queries all providers when enriching identify events. + * + * Cleared automatically when [SDKComponent.reset] clears singletons. + */ +class ProfileEnrichmentRegistry { + private val providers = mutableListOf() + + @Synchronized + fun register(provider: ProfileEnrichmentProvider) { + providers.add(provider) + } + + @Synchronized + fun getAll(): List = providers.toList() + + @Synchronized + fun clear() { + providers.clear() + } +} + +/** + * Singleton accessor for [ProfileEnrichmentRegistry] via [SDKComponent]. + */ +val SDKComponent.profileEnrichmentRegistry: ProfileEnrichmentRegistry + get() = singleton { ProfileEnrichmentRegistry() } diff --git a/datapipelines/api/datapipelines.api b/datapipelines/api/datapipelines.api index 26b782115..b238a350d 100644 --- a/datapipelines/api/datapipelines.api +++ b/datapipelines/api/datapipelines.api @@ -130,7 +130,7 @@ public final class io/customer/datapipelines/plugins/StringExtensionsKt { public static final fun getScreenNameFromActivity (Ljava/lang/String;)Ljava/lang/String; } -public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInstance, io/customer/sdk/core/module/CustomerIOModule { +public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInstance, io/customer/sdk/core/module/CustomerIOModule, io/customer/sdk/core/pipeline/DataPipeline { public static final field Companion Lio/customer/sdk/CustomerIO$Companion; public synthetic fun (Lio/customer/sdk/core/di/AndroidSDKComponent;Lio/customer/datapipelines/config/DataPipelinesModuleConfig;Lcom/segment/analytics/kotlin/core/Analytics;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getAnonymousId ()Ljava/lang/String; diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt deleted file mode 100644 index 3a7e32431..000000000 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/LocationPlugin.kt +++ /dev/null @@ -1,36 +0,0 @@ -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.communication.LocationCache -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, LocationCache { - override val type: Plugin.Type = Plugin.Type.Enrichment - override lateinit var analytics: Analytics - - @Volatile - override 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 - } - - override fun reset() { - super.reset() - lastLocation = null - } -} diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt new file mode 100644 index 000000000..a34703b05 --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt @@ -0,0 +1,46 @@ +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.core.pipeline.ProfileEnrichmentRegistry +import io.customer.sdk.core.util.Logger +import kotlinx.serialization.json.JsonPrimitive + +/** + * Generic Segment enrichment plugin that queries all registered + * [ProfileEnrichmentProvider] instances and adds their attributes + * to identify event context. + * + * This plugin has zero knowledge of specific modules — providers + * manage their own state and return primitive-valued maps. + */ +internal class ProfileEnrichmentPlugin( + private val registry: ProfileEnrichmentRegistry, + private val logger: Logger +) : EventPlugin { + override val type: Plugin.Type = Plugin.Type.Enrichment + override lateinit var analytics: Analytics + + override fun identify(payload: IdentifyEvent): BaseEvent { + for (provider in registry.getAll()) { + val attributes = provider.getProfileEnrichmentAttributes() ?: continue + for ((key, value) in attributes) { + val jsonValue = when (value) { + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + else -> { + logger.debug("Skipping non-primitive enrichment attribute: $key") + continue + } + } + payload.putInContext(key, jsonValue) + } + } + return payload + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 1d236f7ca..6f9ba024f 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -18,7 +18,6 @@ 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.LocationSyncFilter import io.customer.datapipelines.migration.TrackingMigrationProcessor import io.customer.datapipelines.plugins.ApplicationLifecyclePlugin import io.customer.datapipelines.plugins.AutoTrackDeviceAttributesPlugin @@ -26,15 +25,15 @@ 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.ProfileEnrichmentPlugin import io.customer.datapipelines.plugins.ScreenFilterPlugin -import io.customer.datapipelines.store.LocationSyncStoreImpl import io.customer.sdk.communication.Event -import io.customer.sdk.communication.LocationCache import io.customer.sdk.communication.subscribe import io.customer.sdk.core.di.AndroidSDKComponent import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule +import io.customer.sdk.core.pipeline.DataPipeline +import io.customer.sdk.core.pipeline.profileEnrichmentRegistry import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Logger import io.customer.sdk.data.model.CustomAttributes @@ -66,7 +65,7 @@ class CustomerIO private constructor( androidSDKComponent: AndroidSDKComponent, override val moduleConfig: DataPipelinesModuleConfig, overrideAnalytics: Analytics? = null -) : CustomerIOModule, DataPipelineInstance() { +) : CustomerIOModule, DataPipelineInstance(), DataPipeline { override val moduleName: String = MODULE_NAME private val logger: Logger = SDKComponent.logger @@ -75,9 +74,6 @@ class CustomerIO private constructor( private val deviceStore = androidSDKComponent.deviceStore private val eventBus = SDKComponent.eventBus internal var migrationProcessor: MigrationProcessor? = null - private val locationSyncFilter = LocationSyncFilter( - LocationSyncStoreImpl(androidSDKComponent.applicationContext, logger) - ) // Display logs under the CIO tag for easier filtering in logcat private val errorLogger = object : ErrorHandler { @@ -114,7 +110,6 @@ class CustomerIO private constructor( ) private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore) - private val locationPlugin: LocationPlugin = LocationPlugin(logger) init { // Set analytics logger and debug logs based on SDK logger configuration @@ -135,11 +130,11 @@ class CustomerIO private constructor( // Add plugin to filter events based on SDK configuration analytics.add(ScreenFilterPlugin(moduleConfig.screenViewUse)) - analytics.add(locationPlugin) + analytics.add(ProfileEnrichmentPlugin(SDKComponent.profileEnrichmentRegistry, logger)) analytics.add(ApplicationLifecyclePlugin()) - // Register LocationPlugin as LocationCache so the location module can update it - SDKComponent.registerDependency { locationPlugin } + // Register this instance as DataPipeline so modules can send track events directly + SDKComponent.registerDependency { this } // subscribe to journey events emitted from push/in-app module to send them via data pipelines subscribeToJourneyEvents() @@ -163,21 +158,6 @@ class CustomerIO private constructor( eventBus.subscribe { registerDeviceToken(deviceToken = it.token) } - eventBus.subscribe { - if (userId.isNullOrEmpty()) return@subscribe - if (!locationSyncFilter.filterAndRecord(it.location.latitude, it.location.longitude)) return@subscribe - sendLocationTrack(it.location) - } - } - - private fun sendLocationTrack(location: Event.LocationData) { - track( - name = EventNames.LOCATION_UPDATE, - properties = mapOf( - "latitude" to location.latitude, - "longitude" to location.longitude - ) - ) } private fun migrateTrackingEvents() { @@ -258,7 +238,6 @@ class CustomerIO private constructor( if (isChangingIdentifiedProfile) { logger.info("changing profile from id $currentlyIdentifiedProfile to $userId") - locationSyncFilter.clearSyncedData() if (registeredDeviceToken != null) { dataPipelinesLogger.logDeletingTokenDueToNewProfileIdentification() deleteDeviceToken { event -> @@ -329,7 +308,6 @@ class CustomerIO private constructor( } logger.debug("resetting user profile") - locationSyncFilter.clearSyncedData() // publish event to EventBus for other modules to consume eventBus.publish(Event.ResetEvent) analytics.reset() diff --git a/datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterIntegrationTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterIntegrationTest.kt deleted file mode 100644 index aeaa08014..000000000 --- a/datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterIntegrationTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.customer.datapipelines.location - -import com.segment.analytics.kotlin.core.TrackEvent -import io.customer.commontest.config.TestConfig -import io.customer.commontest.util.ScopeProviderStub -import io.customer.datapipelines.testutils.core.IntegrationTest -import io.customer.datapipelines.testutils.core.testConfiguration -import io.customer.datapipelines.testutils.utils.OutputReaderPlugin -import io.customer.datapipelines.testutils.utils.trackEvents -import io.customer.sdk.communication.Event -import io.customer.sdk.core.di.SDKComponent -import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.util.EventNames -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -/** - * Integration tests verifying that the location sync filter inside [CustomerIO] - * correctly resets when the identified profile changes or is cleared. - * - * Uses Robolectric because [LocationSyncFilter] calls - * [android.location.Location.distanceBetween] (native method) and - * [LocationSyncStoreImpl] uses real SharedPreferences. - */ -@RunWith(RobolectricTestRunner::class) -class LocationSyncFilterIntegrationTest : IntegrationTest() { - - private lateinit var outputReaderPlugin: OutputReaderPlugin - - override fun setup(testConfig: TestConfig) { - super.setup( - testConfiguration { - sdkConfig { - autoAddCustomerIODestination(true) - } - diGraph { - sdk { - overrideDependency(ScopeProviderStub.Unconfined()) - } - } - } - ) - - outputReaderPlugin = OutputReaderPlugin() - analytics.add(outputReaderPlugin) - } - - private fun locationTrackEvents(): List = - outputReaderPlugin.trackEvents.filter { it.event == EventNames.LOCATION_UPDATE } - - private fun publishLocation(lat: Double, lng: Double) { - SDKComponent.eventBus.publish( - Event.TrackLocationEvent(Event.LocationData(lat, lng)) - ) - } - - // -- Profile switch -- - - @Test - fun givenProfileSwitch_expectNewProfileLocationNotSuppressed() { - sdkInstance.identify("user-a") - publishLocation(37.7749, -122.4194) - locationTrackEvents().size shouldBeEqualTo 1 - - // Switch profile → clearSyncedData() called internally - sdkInstance.identify("user-b") - publishLocation(37.7749, -122.4194) - - // Second user's location must not be suppressed by first user's window - locationTrackEvents().size shouldBeEqualTo 2 - } - - // -- Clear identify -- - - @Test - fun givenClearIdentify_thenReIdentify_expectLocationNotSuppressed() { - sdkInstance.identify("user-a") - publishLocation(37.7749, -122.4194) - locationTrackEvents().size shouldBeEqualTo 1 - - // Logout → clears synced data - sdkInstance.clearIdentify() - - // Re-identify as new user - sdkInstance.identify("user-b") - publishLocation(37.7749, -122.4194) - - locationTrackEvents().size shouldBeEqualTo 2 - } - - // -- Same user duplicate suppression (control test) -- - - @Test - fun givenSameUser_duplicateLocationWithin24h_expectSecondSuppressed() { - sdkInstance.identify("user-a") - publishLocation(37.7749, -122.4194) - locationTrackEvents().size shouldBeEqualTo 1 - - // Same location within 24h → must be suppressed - publishLocation(37.7749, -122.4194) - locationTrackEvents().size shouldBeEqualTo 1 - } - - // -- No identified user -- - - @Test - fun givenNoIdentifiedUser_expectLocationNotTracked() { - // No identify call → userId gate blocks - publishLocation(37.7749, -122.4194) - locationTrackEvents().size shouldBeEqualTo 0 - } -} diff --git a/location/src/main/kotlin/io/customer/location/LocationOrchestrator.kt b/location/src/main/kotlin/io/customer/location/LocationOrchestrator.kt index 3512ae5dc..da21e1657 100644 --- a/location/src/main/kotlin/io/customer/location/LocationOrchestrator.kt +++ b/location/src/main/kotlin/io/customer/location/LocationOrchestrator.kt @@ -3,7 +3,6 @@ package io.customer.location import io.customer.location.provider.LocationProvider import io.customer.location.provider.LocationRequestException import io.customer.location.type.LocationGranularity -import io.customer.sdk.communication.Event import io.customer.sdk.core.util.Logger import kotlinx.coroutines.CancellationException @@ -34,7 +33,8 @@ internal class LocationOrchestrator( val snapshot = locationProvider.requestLocation( granularity = LocationGranularity.DEFAULT ) - postLocation(snapshot.latitude, snapshot.longitude) + logger.debug("Tracking location: lat=${snapshot.latitude}, lng=${snapshot.longitude}") + locationTracker.onLocationReceived(snapshot.latitude, snapshot.longitude) } catch (e: CancellationException) { logger.debug("Location request was cancelled.") throw e @@ -44,13 +44,4 @@ internal class LocationOrchestrator( logger.error("Location request failed with unexpected error: ${e.message}") } } - - private fun postLocation(latitude: Double, longitude: Double) { - logger.debug("Tracking location: lat=$latitude, lng=$longitude") - val locationData = Event.LocationData( - latitude = latitude, - longitude = longitude - ) - locationTracker.onLocationReceived(locationData) - } } diff --git a/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt b/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt index 70d5926b6..3cae715b2 100644 --- a/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt +++ b/location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt @@ -1,7 +1,6 @@ package io.customer.location import android.location.Location -import io.customer.sdk.communication.Event import io.customer.sdk.core.util.Logger import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -38,11 +37,7 @@ internal class LocationServicesImpl( logger.debug("Tracking location: lat=$latitude, lng=$longitude") - val locationData = Event.LocationData( - latitude = latitude, - longitude = longitude - ) - locationTracker.onLocationReceived(locationData) + locationTracker.onLocationReceived(latitude, longitude) } override fun setLastKnownLocation(location: Location) { diff --git a/location/src/main/kotlin/io/customer/location/LocationTracker.kt b/location/src/main/kotlin/io/customer/location/LocationTracker.kt index b3de6db6e..0d0fbe304 100644 --- a/location/src/main/kotlin/io/customer/location/LocationTracker.kt +++ b/location/src/main/kotlin/io/customer/location/LocationTracker.kt @@ -1,83 +1,138 @@ package io.customer.location import io.customer.location.store.LocationPreferenceStore -import io.customer.sdk.communication.Event -import io.customer.sdk.communication.EventBus -import io.customer.sdk.communication.LocationCache +import io.customer.location.sync.LocationSyncFilter +import io.customer.sdk.core.pipeline.DataPipeline +import io.customer.sdk.core.pipeline.ProfileEnrichmentProvider import io.customer.sdk.core.util.Logger +import io.customer.sdk.util.EventNames /** * Coordinates all location state management: persistence, restoration, - * and publishing location updates to datapipelines. + * profile enrichment, and sending location track events. * - * Maintains a cached location reference via [LocationPreferenceStore]: - * - **Cached**: the latest received location, used for identify enrichment - * and surviving app restarts. + * Implements [ProfileEnrichmentProvider] to enrich identify events with + * the latest location. Sends location track events directly via + * [DataPipeline], applying the userId gate and sync filter locally. * - * Every location update is published as a [Event.TrackLocationEvent]. - * Filtering (24h + 1km) and the userId gate are handled synchronously - * by datapipelines when it receives the event. + * Tracks the last known userId to detect profile switches and reset + * the sync filter accordingly. */ internal class LocationTracker( - private val locationCache: LocationCache?, + private val dataPipeline: DataPipeline?, private val locationPreferenceStore: LocationPreferenceStore, - private val logger: Logger, - private val eventBus: EventBus -) { + private val locationSyncFilter: LocationSyncFilter, + private val logger: Logger +) : ProfileEnrichmentProvider { + + @Volatile + private var lastLocation: LocationCoordinates? = null + + @Volatile + private var lastKnownUserId: String? = null + + override fun getProfileEnrichmentAttributes(): Map? { + val location = lastLocation ?: return null + return mapOf( + "location_latitude" to location.latitude, + "location_longitude" to location.longitude + ) + } + /** - * Reads persisted cached location from the preference store and sets it on - * the [LocationCache] so that identify events have location context + * Reads persisted cached location from the preference store and sets the + * in-memory cache so that identify events have location context * immediately after SDK restart. */ fun restorePersistedLocation() { val lat = locationPreferenceStore.getCachedLatitude() ?: return val lng = locationPreferenceStore.getCachedLongitude() ?: return - locationCache?.lastLocation = Event.LocationData(latitude = lat, longitude = lng) + lastLocation = LocationCoordinates(latitude = lat, longitude = lng) logger.debug("Restored persisted location: lat=$lat, lng=$lng") } /** - * Processes an incoming location: caches in the plugin, persists - * coordinates for identify enrichment, and publishes a - * [Event.TrackLocationEvent] for datapipelines to filter and send. + * Processes an incoming location: caches in memory, persists + * coordinates, and attempts to send a location track event. + */ + fun onLocationReceived(latitude: Double, longitude: Double) { + logger.debug("Location update received: lat=$latitude, lng=$longitude") + + lastLocation = LocationCoordinates(latitude = latitude, longitude = longitude) + locationPreferenceStore.saveCachedLocation(latitude, longitude) + + trySendLocationTrack(latitude, longitude) + } + + /** + * Called when the user identity changes (identify or clearIdentify). + * Detects profile switches to reset the sync filter, then attempts + * to sync the cached location for the new user. */ - fun onLocationReceived(location: Event.LocationData) { - logger.debug("Location update received: lat=${location.latitude}, lng=${location.longitude}") + fun onUserChanged(userId: String?, anonymousId: String) { + val previousUserId = lastKnownUserId + lastKnownUserId = userId - locationCache?.lastLocation = location - locationPreferenceStore.saveCachedLocation(location.latitude, location.longitude) + // Detect profile switch: previous user existed and differs from new user + if (previousUserId != null && previousUserId != userId) { + logger.debug("Profile switch detected ($previousUserId -> $userId), clearing sync filter") + locationSyncFilter.clearSyncedData() + } - logger.debug("Publishing TrackLocationEvent") - eventBus.publish(Event.TrackLocationEvent(location = location)) + if (!userId.isNullOrEmpty()) { + syncCachedLocationIfNeeded() + } } /** - * Re-publishes the cached location as a [Event.TrackLocationEvent]. - * Called on identify (via [Event.UserChangedEvent]) and on cold start - * to handle cases where: - * - An identify was sent without location context in a previous session, - * and location has since arrived. - * - The app was restarted after >24h and the cached location should be - * re-evaluated by datapipelines. - * - * Datapipelines applies the sync filter, so this is safe to call - * unconditionally when a user is identified. + * Clears all location state on identity reset (clearIdentify). + * Resets in-memory cache, persisted location, and sync filter. */ - fun syncCachedLocationIfNeeded() { + fun onReset() { + lastLocation = null + lastKnownUserId = null + locationPreferenceStore.clearCachedLocation() + locationSyncFilter.clearSyncedData() + logger.debug("Location state reset") + } + + /** + * Re-evaluates the cached location for sending. + * Called on identify (via [onUserChanged]) and on cold start to handle + * cases where a location was cached but not yet sent for the current user. + */ + internal fun syncCachedLocationIfNeeded() { val lat = locationPreferenceStore.getCachedLatitude() ?: return val lng = locationPreferenceStore.getCachedLongitude() ?: return - logger.debug("Re-publishing cached location: lat=$lat, lng=$lng") - eventBus.publish(Event.TrackLocationEvent(location = Event.LocationData(latitude = lat, longitude = lng))) + logger.debug("Re-evaluating cached location: lat=$lat, lng=$lng") + trySendLocationTrack(lat, lng) } /** - * Clears all persisted location data from the preference store. - * Called on [Event.ResetEvent] (clearIdentify) to ensure no stale - * location survives a full identity reset. + * Applies the userId gate and sync filter, then sends a location + * track event via [DataPipeline] if both pass. */ - fun clearCachedLocation() { - locationPreferenceStore.clearCachedLocation() - logger.debug("Cleared cached location from preference store") + private fun trySendLocationTrack(latitude: Double, longitude: Double) { + val pipeline = dataPipeline ?: return + if (pipeline.userId.isNullOrEmpty()) return + if (!locationSyncFilter.filterAndRecord(latitude, longitude)) return + + logger.debug("Sending location track: lat=$latitude, lng=$longitude") + pipeline.track( + name = EventNames.LOCATION_UPDATE, + properties = mapOf( + "latitude" to latitude, + "longitude" to longitude + ) + ) } } + +/** + * Internal location coordinate holder, replacing the cross-module Event.LocationData. + */ +internal data class LocationCoordinates( + val latitude: Double, + val longitude: Double +) diff --git a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt index 67c12f7af..8f7868854 100644 --- a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt +++ b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt @@ -1,13 +1,16 @@ package io.customer.location import android.location.Location -import io.customer.location.di.locationCache import io.customer.location.provider.FusedLocationProvider import io.customer.location.store.LocationPreferenceStoreImpl +import io.customer.location.sync.LocationSyncFilter +import io.customer.location.sync.LocationSyncStoreImpl import io.customer.sdk.communication.Event import io.customer.sdk.communication.subscribe import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule +import io.customer.sdk.core.pipeline.DataPipeline +import io.customer.sdk.core.pipeline.profileEnrichmentRegistry import io.customer.sdk.core.util.Logger /** @@ -64,20 +67,24 @@ class ModuleLocation @JvmOverloads constructor( val eventBus = SDKComponent.eventBus val context = SDKComponent.android().applicationContext - val locationCache = SDKComponent.locationCache + val dataPipeline = SDKComponent.getOrNull() val store = LocationPreferenceStoreImpl(context, logger) - val locationTracker = LocationTracker(locationCache, store, logger, eventBus) + val locationSyncFilter = LocationSyncFilter( + LocationSyncStoreImpl(context, logger) + ) + val locationTracker = LocationTracker(dataPipeline, store, locationSyncFilter, logger) locationTracker.restorePersistedLocation() + // Register as ProfileEnrichmentProvider for identify enrichment + SDKComponent.profileEnrichmentRegistry.register(locationTracker) + eventBus.subscribe { - locationTracker.clearCachedLocation() + locationTracker.onReset() } eventBus.subscribe { - if (!it.userId.isNullOrEmpty()) { - locationTracker.syncCachedLocationIfNeeded() - } + locationTracker.onUserChanged(it.userId, it.anonymousId) } val locationProvider = FusedLocationProvider(context) diff --git a/location/src/main/kotlin/io/customer/location/di/SDKComponentExt.kt b/location/src/main/kotlin/io/customer/location/di/SDKComponentExt.kt deleted file mode 100644 index c55bb8ae4..000000000 --- a/location/src/main/kotlin/io/customer/location/di/SDKComponentExt.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.customer.location.di - -import io.customer.sdk.communication.LocationCache -import io.customer.sdk.core.di.SDKComponent - -internal val SDKComponent.locationCache: LocationCache? - get() = getOrNull() diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationSyncFilter.kt b/location/src/main/kotlin/io/customer/location/sync/LocationSyncFilter.kt similarity index 92% rename from datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationSyncFilter.kt rename to location/src/main/kotlin/io/customer/location/sync/LocationSyncFilter.kt index 8475f416c..4fd976303 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/location/LocationSyncFilter.kt +++ b/location/src/main/kotlin/io/customer/location/sync/LocationSyncFilter.kt @@ -1,7 +1,6 @@ -package io.customer.datapipelines.location +package io.customer.location.sync import android.location.Location -import io.customer.datapipelines.store.LocationSyncStore /** * Determines whether a location update should be sent to the server and @@ -13,9 +12,6 @@ import io.customer.datapipelines.store.LocationSyncStore * * If no synced location exists yet (first time or after reset), the filter * passes automatically. - * - * This filter lives in datapipelines (same module as the userId gate) so the - * entire flow is synchronous — no round-trip confirmation events needed. */ internal class LocationSyncFilter( private val store: LocationSyncStore diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationSyncStore.kt b/location/src/main/kotlin/io/customer/location/sync/LocationSyncStore.kt similarity index 95% rename from datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationSyncStore.kt rename to location/src/main/kotlin/io/customer/location/sync/LocationSyncStore.kt index 9e37eda4f..621fa646b 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/store/LocationSyncStore.kt +++ b/location/src/main/kotlin/io/customer/location/sync/LocationSyncStore.kt @@ -1,4 +1,4 @@ -package io.customer.datapipelines.store +package io.customer.location.sync import android.content.Context import androidx.core.content.edit @@ -11,7 +11,7 @@ import io.customer.sdk.data.store.read * Store for persisting the last synced location data. * * Tracks the coordinates and timestamp of the last location successfully - * sent to the server, used by [io.customer.datapipelines.location.LocationSyncFilter] + * sent to the server, used by [LocationSyncFilter] * to decide whether a new location update should be sent. * * Coordinates are encrypted at rest using [PreferenceCrypto] (AES-256-GCM diff --git a/location/src/test/java/io/customer/location/LocationTrackerTest.kt b/location/src/test/java/io/customer/location/LocationTrackerTest.kt index ebec9b92e..69caa6fa5 100644 --- a/location/src/test/java/io/customer/location/LocationTrackerTest.kt +++ b/location/src/test/java/io/customer/location/LocationTrackerTest.kt @@ -1,172 +1,251 @@ package io.customer.location import io.customer.location.store.LocationPreferenceStore -import io.customer.sdk.communication.Event -import io.customer.sdk.communication.EventBus -import io.customer.sdk.communication.LocationCache +import io.customer.location.sync.LocationSyncFilter +import io.customer.sdk.core.pipeline.DataPipeline import io.customer.sdk.core.util.Logger +import io.customer.sdk.util.EventNames import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class LocationTrackerTest { - private val locationCache: LocationCache = mockk(relaxUnitFun = true) + private val dataPipeline: DataPipeline = mockk(relaxUnitFun = true) private val store: LocationPreferenceStore = mockk(relaxUnitFun = true) + private val syncFilter: LocationSyncFilter = mockk(relaxUnitFun = true) private val logger: Logger = mockk(relaxUnitFun = true) - private val eventBus: EventBus = mockk(relaxUnitFun = true) private lateinit var tracker: LocationTracker @BeforeEach fun setup() { - tracker = LocationTracker(locationCache, store, logger, eventBus) + every { dataPipeline.userId } returns "user-123" + every { syncFilter.filterAndRecord(any(), any()) } returns true + tracker = LocationTracker(dataPipeline, store, syncFilter, logger) } // -- onLocationReceived -- @Test - fun givenLocationReceived_expectCachesInPlugin() { - val location = Event.LocationData(37.7749, -122.4194) + fun givenLocationReceived_expectPersistsToStore() { + tracker.onLocationReceived(37.7749, -122.4194) - tracker.onLocationReceived(location) + verify { store.saveCachedLocation(37.7749, -122.4194) } + } - verify { locationCache.lastLocation = location } + @Test + fun givenLocationReceived_userIdentified_filterPasses_expectTrackCalled() { + tracker.onLocationReceived(37.7749, -122.4194) + + verify { + dataPipeline.track( + name = EventNames.LOCATION_UPDATE, + properties = mapOf("latitude" to 37.7749, "longitude" to -122.4194) + ) + } } @Test - fun givenLocationReceived_expectPersistsToStore() { - val location = Event.LocationData(37.7749, -122.4194) + fun givenLocationReceived_noUserId_expectTrackNotCalled() { + every { dataPipeline.userId } returns null - tracker.onLocationReceived(location) + tracker.onLocationReceived(37.7749, -122.4194) - verify { store.saveCachedLocation(37.7749, -122.4194) } + verify(exactly = 0) { dataPipeline.track(any(), any()) } } @Test - fun givenLocationReceived_expectPublishesTrackLocationEvent() { - val location = Event.LocationData(37.7749, -122.4194) + fun givenLocationReceived_emptyUserId_expectTrackNotCalled() { + every { dataPipeline.userId } returns "" - tracker.onLocationReceived(location) + tracker.onLocationReceived(37.7749, -122.4194) - val eventSlot = slot() - verify { eventBus.publish(capture(eventSlot)) } - eventSlot.captured.location shouldBeEqualTo location + verify(exactly = 0) { dataPipeline.track(any(), any()) } } @Test - fun givenLocationReceived_expectAlwaysPublishes() { - // Every call should publish, no filtering - tracker.onLocationReceived(Event.LocationData(37.7749, -122.4194)) - tracker.onLocationReceived(Event.LocationData(37.7750, -122.4195)) - tracker.onLocationReceived(Event.LocationData(37.7751, -122.4196)) + fun givenLocationReceived_filterRejects_expectTrackNotCalled() { + every { syncFilter.filterAndRecord(any(), any()) } returns false + + tracker.onLocationReceived(37.7749, -122.4194) - verify(exactly = 3) { eventBus.publish(any()) } + verify(exactly = 0) { dataPipeline.track(any(), any()) } + } + + @Test + fun givenNullDataPipeline_expectNoException() { + val trackerWithNullPipeline = LocationTracker(null, store, syncFilter, logger) + + trackerWithNullPipeline.onLocationReceived(37.7749, -122.4194) + + // Persist still happens, but no track call + verify { store.saveCachedLocation(37.7749, -122.4194) } } // -- syncCachedLocationIfNeeded -- @Test - fun givenCachedLocationExists_expectPublishesTrackLocationEvent() { + fun givenCachedLocationExists_expectTriesSendLocationTrack() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 tracker.syncCachedLocationIfNeeded() - val eventSlot = slot() - verify { eventBus.publish(capture(eventSlot)) } - eventSlot.captured.location.latitude shouldBeEqualTo 37.7749 - eventSlot.captured.location.longitude shouldBeEqualTo -122.4194 + verify { + dataPipeline.track( + name = EventNames.LOCATION_UPDATE, + properties = mapOf("latitude" to 37.7749, "longitude" to -122.4194) + ) + } } @Test - fun givenNoCachedLatitude_expectNoEvent() { + fun givenNoCachedLatitude_expectNoTrack() { every { store.getCachedLatitude() } returns null every { store.getCachedLongitude() } returns -122.4194 tracker.syncCachedLocationIfNeeded() - verify(exactly = 0) { eventBus.publish(any()) } + verify(exactly = 0) { dataPipeline.track(any(), any()) } } @Test - fun givenNoCachedLongitude_expectNoEvent() { + fun givenNoCachedLongitude_expectNoTrack() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns null tracker.syncCachedLocationIfNeeded() - verify(exactly = 0) { eventBus.publish(any()) } + verify(exactly = 0) { dataPipeline.track(any(), any()) } } // -- restorePersistedLocation -- @Test - fun givenPersistedLocation_expectSetsLocationCache() { + fun givenPersistedLocation_expectSetsInMemoryCache() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 tracker.restorePersistedLocation() - val locationSlot = slot() - verify { locationCache.lastLocation = capture(locationSlot) } - locationSlot.captured.latitude shouldBeEqualTo 37.7749 - locationSlot.captured.longitude shouldBeEqualTo -122.4194 + val attrs = tracker.getProfileEnrichmentAttributes() + attrs.shouldNotBeNull() + attrs["location_latitude"] shouldBeEqualTo 37.7749 + attrs["location_longitude"] shouldBeEqualTo -122.4194 } @Test - fun givenNoPersistedLatitude_expectNoOp() { + fun givenNoPersistedLatitude_expectNoEnrichment() { every { store.getCachedLatitude() } returns null tracker.restorePersistedLocation() - verify(exactly = 0) { locationCache.lastLocation = any() } + tracker.getProfileEnrichmentAttributes().shouldBeNull() } @Test - fun givenNoPersistedLongitude_expectNoOp() { + fun givenNoPersistedLongitude_expectNoEnrichment() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns null tracker.restorePersistedLocation() - verify(exactly = 0) { locationCache.lastLocation = any() } + tracker.getProfileEnrichmentAttributes().shouldBeNull() + } + + // -- getProfileEnrichmentAttributes -- + + @Test + fun givenNoLocation_expectReturnsNull() { + tracker.getProfileEnrichmentAttributes().shouldBeNull() + } + + @Test + fun givenLocationReceived_expectReturnsLocationMap() { + tracker.onLocationReceived(37.7749, -122.4194) + + val attrs = tracker.getProfileEnrichmentAttributes() + attrs.shouldNotBeNull() + attrs["location_latitude"] shouldBeEqualTo 37.7749 + attrs["location_longitude"] shouldBeEqualTo -122.4194 + } + + // -- onUserChanged -- + + @Test + fun givenProfileSwitch_expectClearsSyncFilter() { + every { store.getCachedLatitude() } returns null + + // First call sets lastKnownUserId + tracker.onUserChanged("user-a", "anon-1") + + // Switch to different user + tracker.onUserChanged("user-b", "anon-1") + + verify { syncFilter.clearSyncedData() } } @Test - fun givenNullLocationCache_expectNoException() { - val trackerWithNullCache = LocationTracker(null, store, logger, eventBus) + fun givenFirstIdentify_expectNoClearSyncFilter() { + every { store.getCachedLatitude() } returns null + + tracker.onUserChanged("user-a", "anon-1") + + // First identify should not clear (no previous user) + verify(exactly = 0) { syncFilter.clearSyncedData() } + } + + @Test + fun givenSameUserIdentify_expectNoClearSyncFilter() { + every { store.getCachedLatitude() } returns null + + tracker.onUserChanged("user-a", "anon-1") + tracker.onUserChanged("user-a", "anon-1") + + verify(exactly = 0) { syncFilter.clearSyncedData() } + } + + @Test + fun givenUserChangedWithUserId_expectSyncsCachedLocation() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 - // Should not throw - trackerWithNullCache.restorePersistedLocation() - } + tracker.onUserChanged("user-a", "anon-1") - // -- clearCachedLocation -- + verify { + dataPipeline.track( + name = EventNames.LOCATION_UPDATE, + properties = mapOf("latitude" to 37.7749, "longitude" to -122.4194) + ) + } + } @Test - fun clearCachedLocation_expectClearsStore() { - tracker.clearCachedLocation() + fun givenUserChangedWithNullUserId_expectNoSync() { + every { store.getCachedLatitude() } returns 37.7749 + every { store.getCachedLongitude() } returns -122.4194 - verify { store.clearCachedLocation() } + tracker.onUserChanged(null, "anon-1") + + verify(exactly = 0) { dataPipeline.track(any(), any()) } } + // -- onReset -- + @Test - fun givenNullLocationCache_onLocationReceived_expectStillPersistsAndPublishes() { - val trackerWithNullCache = LocationTracker(null, store, logger, eventBus) - val location = Event.LocationData(37.7749, -122.4194) + fun givenReset_expectClearsEverything() { + tracker.onLocationReceived(37.7749, -122.4194) - trackerWithNullCache.onLocationReceived(location) + tracker.onReset() - // Cache update is skipped (null), but persist and publish must still happen - verify { store.saveCachedLocation(37.7749, -122.4194) } - val eventSlot = slot() - verify { eventBus.publish(capture(eventSlot)) } - eventSlot.captured.location shouldBeEqualTo location + verify { store.clearCachedLocation() } + verify { syncFilter.clearSyncedData() } + tracker.getProfileEnrichmentAttributes().shouldBeNull() } } diff --git a/datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterTest.kt b/location/src/test/java/io/customer/location/sync/LocationSyncFilterTest.kt similarity index 98% rename from datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterTest.kt rename to location/src/test/java/io/customer/location/sync/LocationSyncFilterTest.kt index 2746790d8..d2afd3e56 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/location/LocationSyncFilterTest.kt +++ b/location/src/test/java/io/customer/location/sync/LocationSyncFilterTest.kt @@ -1,6 +1,5 @@ -package io.customer.datapipelines.location +package io.customer.location.sync -import io.customer.datapipelines.store.LocationSyncStore import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue From 85d5763d7c5a42c7d658012867c1ce93b1eff338 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 24 Feb 2026 15:11:10 +0500 Subject: [PATCH 2/5] move suppression to gradle --- core/api/core.api | 20 ---------------- .../sdk/core/pipeline/DataPipeline.kt | 5 ++++ .../pipeline/ProfileEnrichmentProvider.kt | 5 ++++ .../pipeline/ProfileEnrichmentRegistry.kt | 9 ++++++- datapipelines/build.gradle | 9 +++++++ .../plugins/ProfileEnrichmentPlugin.kt | 24 +++++++++++-------- location/build.gradle | 9 +++++++ 7 files changed, 50 insertions(+), 31 deletions(-) diff --git a/core/api/core.api b/core/api/core.api index 3933708ab..8ee26aaea 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -183,26 +183,6 @@ public abstract interface class io/customer/sdk/core/module/CustomerIOModuleConf public abstract fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; } -public abstract interface class io/customer/sdk/core/pipeline/DataPipeline { - public abstract fun getUserId ()Ljava/lang/String; - public abstract fun track (Ljava/lang/String;Ljava/util/Map;)V -} - -public abstract interface class io/customer/sdk/core/pipeline/ProfileEnrichmentProvider { - public abstract fun getProfileEnrichmentAttributes ()Ljava/util/Map; -} - -public final class io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry { - public fun ()V - public final fun clear ()V - public final fun getAll ()Ljava/util/List; - public final fun register (Lio/customer/sdk/core/pipeline/ProfileEnrichmentProvider;)V -} - -public final class io/customer/sdk/core/pipeline/ProfileEnrichmentRegistryKt { - public static final fun getProfileEnrichmentRegistry (Lio/customer/sdk/core/di/SDKComponent;)Lio/customer/sdk/core/pipeline/ProfileEnrichmentRegistry; -} - public final class io/customer/sdk/core/util/CioLogLevel : java/lang/Enum { public static final field Companion Lio/customer/sdk/core/util/CioLogLevel$Companion; public static final field DEBUG Lio/customer/sdk/core/util/CioLogLevel; diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt index bbd9d0e45..fc1d19736 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -1,11 +1,16 @@ package io.customer.sdk.core.pipeline +import io.customer.base.internal.InternalCustomerIOApi + /** * Abstraction for sending track events to the data pipeline. * * Modules retrieve an implementation via `SDKComponent.getOrNull()` * to send events directly without going through EventBus. + * + * This is an internal SDK contract — not intended for use by host app developers. */ +@InternalCustomerIOApi interface DataPipeline { val userId: String? fun track(name: String, properties: Map) diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt index 3a56cec7c..8995743b0 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt @@ -1,11 +1,16 @@ package io.customer.sdk.core.pipeline +import io.customer.base.internal.InternalCustomerIOApi + /** * Interface for modules that contribute attributes to identify events. * * Implementations return a map of primitive-valued attributes (String, Number, Boolean) * that will be added to the identify event context. Return null to skip enrichment. + * + * This is an internal SDK contract — not intended for use by host app developers. */ +@InternalCustomerIOApi interface ProfileEnrichmentProvider { fun getProfileEnrichmentAttributes(): Map? } diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt index 2d7b4c43d..39072b3d5 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt @@ -1,5 +1,6 @@ package io.customer.sdk.core.pipeline +import io.customer.base.internal.InternalCustomerIOApi import io.customer.sdk.core.di.SDKComponent /** @@ -9,13 +10,18 @@ import io.customer.sdk.core.di.SDKComponent * queries all providers when enriching identify events. * * Cleared automatically when [SDKComponent.reset] clears singletons. + * + * This is an internal SDK contract — not intended for use by host app developers. */ +@InternalCustomerIOApi class ProfileEnrichmentRegistry { private val providers = mutableListOf() @Synchronized fun register(provider: ProfileEnrichmentProvider) { - providers.add(provider) + if (provider !in providers) { + providers.add(provider) + } } @Synchronized @@ -30,5 +36,6 @@ class ProfileEnrichmentRegistry { /** * Singleton accessor for [ProfileEnrichmentRegistry] via [SDKComponent]. */ +@InternalCustomerIOApi val SDKComponent.profileEnrichmentRegistry: ProfileEnrichmentRegistry get() = singleton { ProfileEnrichmentRegistry() } diff --git a/datapipelines/build.gradle b/datapipelines/build.gradle index b4b2379a8..014d9639b 100644 --- a/datapipelines/build.gradle +++ b/datapipelines/build.gradle @@ -1,5 +1,6 @@ import io.customer.android.Configurations import io.customer.android.Dependencies +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id 'com.android.library' @@ -32,6 +33,14 @@ android { } } +tasks.withType(KotlinCompile).all { + kotlinOptions { + freeCompilerArgs += [ + '-opt-in=io.customer.base.internal.InternalCustomerIOApi', + ] + } +} + dependencies { api project(":base") api project(":core") diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt index a34703b05..d10aec86c 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt @@ -27,18 +27,22 @@ internal class ProfileEnrichmentPlugin( override fun identify(payload: IdentifyEvent): BaseEvent { for (provider in registry.getAll()) { - val attributes = provider.getProfileEnrichmentAttributes() ?: continue - for ((key, value) in attributes) { - val jsonValue = when (value) { - is String -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - else -> { - logger.debug("Skipping non-primitive enrichment attribute: $key") - continue + try { + val attributes = provider.getProfileEnrichmentAttributes() ?: continue + for ((key, value) in attributes) { + val jsonValue = when (value) { + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + else -> { + logger.debug("Skipping non-primitive enrichment attribute: $key") + continue + } } + payload.putInContext(key, jsonValue) } - payload.putInContext(key, jsonValue) + } catch (e: Exception) { + logger.error("ProfileEnrichmentProvider failed: ${e.message}") } } return payload diff --git a/location/build.gradle b/location/build.gradle index dc580a020..f523948bc 100644 --- a/location/build.gradle +++ b/location/build.gradle @@ -1,5 +1,6 @@ import io.customer.android.Configurations import io.customer.android.Dependencies +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id 'com.android.library' @@ -30,6 +31,14 @@ android { } } +tasks.withType(KotlinCompile).all { + kotlinOptions { + freeCompilerArgs += [ + '-opt-in=io.customer.base.internal.InternalCustomerIOApi', + ] + } +} + dependencies { api project(":base") api project(":core") From 30b5cf6a255cde860280d8f3864a5c6fb296e3ef Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Wed, 25 Feb 2026 19:03:33 +0500 Subject: [PATCH 3/5] pr suggestions --- .../sdk/core/pipeline/DataPipeline.kt | 2 +- .../core/pipeline/IdentifyContextProvider.kt | 20 +++++ ...Registry.kt => IdentifyContextRegistry.kt} | 18 ++--- .../pipeline/ProfileEnrichmentProvider.kt | 16 ---- datapipelines/api/datapipelines.api | 1 + ...mentPlugin.kt => IdentifyContextPlugin.kt} | 19 ++--- .../main/kotlin/io/customer/sdk/CustomerIO.kt | 9 ++- .../io/customer/location/LocationTracker.kt | 14 ++-- .../io/customer/location/ModuleLocation.kt | 7 +- .../customer/location/LocationTrackerTest.kt | 74 +++++++++++-------- 10 files changed, 101 insertions(+), 79 deletions(-) create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt rename core/src/main/kotlin/io/customer/sdk/core/pipeline/{ProfileEnrichmentRegistry.kt => IdentifyContextRegistry.kt} (53%) delete mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt rename datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/{ProfileEnrichmentPlugin.kt => IdentifyContextPlugin.kt} (73%) diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt index fc1d19736..26e0e83b6 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -12,6 +12,6 @@ import io.customer.base.internal.InternalCustomerIOApi */ @InternalCustomerIOApi interface DataPipeline { - val userId: String? + val isUserIdentified: Boolean fun track(name: String, properties: Map) } diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt new file mode 100644 index 000000000..854795000 --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt @@ -0,0 +1,20 @@ +package io.customer.sdk.core.pipeline + +import io.customer.base.internal.InternalCustomerIOApi + +/** + * Interface for modules that contribute context entries to identify events. + * + * Implementations return a map of primitive-valued entries (String, Number, Boolean) + * that will be added to the identify event's context via `putInContext()`. + * Return an empty map when there is nothing to contribute. + * + * These are NOT profile traits/attributes — they are context-level enrichment + * data (e.g., location coordinates) attached to the identify event payload. + * + * This is an internal SDK contract — not intended for use by host app developers. + */ +@InternalCustomerIOApi +interface IdentifyContextProvider { + fun getIdentifyContext(): Map +} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt similarity index 53% rename from core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt rename to core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt index 39072b3d5..a6fabaec6 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentRegistry.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt @@ -4,28 +4,28 @@ import io.customer.base.internal.InternalCustomerIOApi import io.customer.sdk.core.di.SDKComponent /** - * Thread-safe registry of [ProfileEnrichmentProvider] instances. + * Thread-safe registry of [IdentifyContextProvider] instances. * * Modules register providers during initialization. The datapipelines module - * queries all providers when enriching identify events. + * queries all providers when enriching identify event context. * * Cleared automatically when [SDKComponent.reset] clears singletons. * * This is an internal SDK contract — not intended for use by host app developers. */ @InternalCustomerIOApi -class ProfileEnrichmentRegistry { - private val providers = mutableListOf() +class IdentifyContextRegistry { + private val providers = mutableListOf() @Synchronized - fun register(provider: ProfileEnrichmentProvider) { + fun register(provider: IdentifyContextProvider) { if (provider !in providers) { providers.add(provider) } } @Synchronized - fun getAll(): List = providers.toList() + fun getAll(): List = providers.toList() @Synchronized fun clear() { @@ -34,8 +34,8 @@ class ProfileEnrichmentRegistry { } /** - * Singleton accessor for [ProfileEnrichmentRegistry] via [SDKComponent]. + * Singleton accessor for [IdentifyContextRegistry] via [SDKComponent]. */ @InternalCustomerIOApi -val SDKComponent.profileEnrichmentRegistry: ProfileEnrichmentRegistry - get() = singleton { ProfileEnrichmentRegistry() } +val SDKComponent.identifyContextRegistry: IdentifyContextRegistry + get() = singleton { IdentifyContextRegistry() } diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt deleted file mode 100644 index 8995743b0..000000000 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/ProfileEnrichmentProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.customer.sdk.core.pipeline - -import io.customer.base.internal.InternalCustomerIOApi - -/** - * Interface for modules that contribute attributes to identify events. - * - * Implementations return a map of primitive-valued attributes (String, Number, Boolean) - * that will be added to the identify event context. Return null to skip enrichment. - * - * This is an internal SDK contract — not intended for use by host app developers. - */ -@InternalCustomerIOApi -interface ProfileEnrichmentProvider { - fun getProfileEnrichmentAttributes(): Map? -} diff --git a/datapipelines/api/datapipelines.api b/datapipelines/api/datapipelines.api index b238a350d..fcd8b077e 100644 --- a/datapipelines/api/datapipelines.api +++ b/datapipelines/api/datapipelines.api @@ -144,6 +144,7 @@ public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInst public fun initialize ()V public static final fun initialize (Lio/customer/sdk/CustomerIOConfig;)V public static final fun instance ()Lio/customer/sdk/CustomerIO; + public fun isUserIdentified ()Z public fun setDeviceAttributes (Ljava/util/Map;)V public fun setDeviceAttributesDeprecated (Ljava/util/Map;)V public fun setProfileAttributes (Ljava/util/Map;)V diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt similarity index 73% rename from datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt rename to datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt index d10aec86c..7cfa7dfcb 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ProfileEnrichmentPlugin.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt @@ -6,20 +6,20 @@ 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.core.pipeline.ProfileEnrichmentRegistry +import io.customer.sdk.core.pipeline.IdentifyContextRegistry import io.customer.sdk.core.util.Logger import kotlinx.serialization.json.JsonPrimitive /** * Generic Segment enrichment plugin that queries all registered - * [ProfileEnrichmentProvider] instances and adds their attributes - * to identify event context. + * [IdentifyContextProvider][io.customer.sdk.core.pipeline.IdentifyContextProvider] + * instances and adds their entries to the identify event context. * * This plugin has zero knowledge of specific modules — providers * manage their own state and return primitive-valued maps. */ -internal class ProfileEnrichmentPlugin( - private val registry: ProfileEnrichmentRegistry, +internal class IdentifyContextPlugin( + private val registry: IdentifyContextRegistry, private val logger: Logger ) : EventPlugin { override val type: Plugin.Type = Plugin.Type.Enrichment @@ -28,21 +28,22 @@ internal class ProfileEnrichmentPlugin( override fun identify(payload: IdentifyEvent): BaseEvent { for (provider in registry.getAll()) { try { - val attributes = provider.getProfileEnrichmentAttributes() ?: continue - for ((key, value) in attributes) { + val context = provider.getIdentifyContext() + if (context.isEmpty()) continue + for ((key, value) in context) { val jsonValue = when (value) { is String -> JsonPrimitive(value) is Number -> JsonPrimitive(value) is Boolean -> JsonPrimitive(value) else -> { - logger.debug("Skipping non-primitive enrichment attribute: $key") + logger.debug("Skipping non-primitive context entry: $key") continue } } payload.putInContext(key, jsonValue) } } catch (e: Exception) { - logger.error("ProfileEnrichmentProvider failed: ${e.message}") + logger.error("IdentifyContextProvider failed: ${e.message}") } } return payload diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 6f9ba024f..90088ce43 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -25,7 +25,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.ProfileEnrichmentPlugin +import io.customer.datapipelines.plugins.IdentifyContextPlugin import io.customer.datapipelines.plugins.ScreenFilterPlugin import io.customer.sdk.communication.Event import io.customer.sdk.communication.subscribe @@ -33,7 +33,7 @@ import io.customer.sdk.core.di.AndroidSDKComponent import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.profileEnrichmentRegistry +import io.customer.sdk.core.pipeline.identifyContextRegistry import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Logger import io.customer.sdk.data.model.CustomAttributes @@ -130,7 +130,7 @@ class CustomerIO private constructor( // Add plugin to filter events based on SDK configuration analytics.add(ScreenFilterPlugin(moduleConfig.screenViewUse)) - analytics.add(ProfileEnrichmentPlugin(SDKComponent.profileEnrichmentRegistry, logger)) + analytics.add(IdentifyContextPlugin(SDKComponent.identifyContextRegistry, logger)) analytics.add(ApplicationLifecyclePlugin()) // Register this instance as DataPipeline so modules can send track events directly @@ -325,6 +325,9 @@ class CustomerIO private constructor( override val userId: String? get() = analytics.userId() + override val isUserIdentified: Boolean + get() = !analytics.userId().isNullOrEmpty() + @Deprecated("Use setDeviceAttributes() function instead") @set:JvmName("setDeviceAttributesDeprecated") override var deviceAttributes: CustomAttributes diff --git a/location/src/main/kotlin/io/customer/location/LocationTracker.kt b/location/src/main/kotlin/io/customer/location/LocationTracker.kt index 0d0fbe304..24ad9329c 100644 --- a/location/src/main/kotlin/io/customer/location/LocationTracker.kt +++ b/location/src/main/kotlin/io/customer/location/LocationTracker.kt @@ -3,7 +3,7 @@ package io.customer.location import io.customer.location.store.LocationPreferenceStore import io.customer.location.sync.LocationSyncFilter import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.ProfileEnrichmentProvider +import io.customer.sdk.core.pipeline.IdentifyContextProvider import io.customer.sdk.core.util.Logger import io.customer.sdk.util.EventNames @@ -11,8 +11,8 @@ import io.customer.sdk.util.EventNames * Coordinates all location state management: persistence, restoration, * profile enrichment, and sending location track events. * - * Implements [ProfileEnrichmentProvider] to enrich identify events with - * the latest location. Sends location track events directly via + * Implements [IdentifyContextProvider] to enrich identify event context + * with the latest location. Sends location track events directly via * [DataPipeline], applying the userId gate and sync filter locally. * * Tracks the last known userId to detect profile switches and reset @@ -23,7 +23,7 @@ internal class LocationTracker( private val locationPreferenceStore: LocationPreferenceStore, private val locationSyncFilter: LocationSyncFilter, private val logger: Logger -) : ProfileEnrichmentProvider { +) : IdentifyContextProvider { @Volatile private var lastLocation: LocationCoordinates? = null @@ -31,8 +31,8 @@ internal class LocationTracker( @Volatile private var lastKnownUserId: String? = null - override fun getProfileEnrichmentAttributes(): Map? { - val location = lastLocation ?: return null + override fun getIdentifyContext(): Map { + val location = lastLocation ?: return emptyMap() return mapOf( "location_latitude" to location.latitude, "location_longitude" to location.longitude @@ -115,7 +115,7 @@ internal class LocationTracker( */ private fun trySendLocationTrack(latitude: Double, longitude: Double) { val pipeline = dataPipeline ?: return - if (pipeline.userId.isNullOrEmpty()) return + if (!pipeline.isUserIdentified) return if (!locationSyncFilter.filterAndRecord(latitude, longitude)) return logger.debug("Sending location track: lat=$latitude, lng=$longitude") diff --git a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt index 8f7868854..96030e924 100644 --- a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt +++ b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt @@ -10,7 +10,7 @@ import io.customer.sdk.communication.subscribe import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.profileEnrichmentRegistry +import io.customer.sdk.core.pipeline.identifyContextRegistry import io.customer.sdk.core.util.Logger /** @@ -46,6 +46,7 @@ class ModuleLocation @JvmOverloads constructor( ) : CustomerIOModule { override val moduleName: String = MODULE_NAME + @Volatile private var _locationServices: LocationServices? = null /** @@ -76,8 +77,8 @@ class ModuleLocation @JvmOverloads constructor( locationTracker.restorePersistedLocation() - // Register as ProfileEnrichmentProvider for identify enrichment - SDKComponent.profileEnrichmentRegistry.register(locationTracker) + // Register as IdentifyContextProvider so location is added to identify event context + SDKComponent.identifyContextRegistry.register(locationTracker) eventBus.subscribe { locationTracker.onReset() diff --git a/location/src/test/java/io/customer/location/LocationTrackerTest.kt b/location/src/test/java/io/customer/location/LocationTrackerTest.kt index 69caa6fa5..dda070f9d 100644 --- a/location/src/test/java/io/customer/location/LocationTrackerTest.kt +++ b/location/src/test/java/io/customer/location/LocationTrackerTest.kt @@ -8,9 +8,9 @@ import io.customer.sdk.util.EventNames import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.amshove.kluent.shouldBeEmpty import org.amshove.kluent.shouldBeEqualTo -import org.amshove.kluent.shouldBeNull -import org.amshove.kluent.shouldNotBeNull +import org.amshove.kluent.shouldNotBeEmpty import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -25,7 +25,7 @@ class LocationTrackerTest { @BeforeEach fun setup() { - every { dataPipeline.userId } returns "user-123" + every { dataPipeline.isUserIdentified } returns true every { syncFilter.filterAndRecord(any(), any()) } returns true tracker = LocationTracker(dataPipeline, store, syncFilter, logger) } @@ -52,17 +52,8 @@ class LocationTrackerTest { } @Test - fun givenLocationReceived_noUserId_expectTrackNotCalled() { - every { dataPipeline.userId } returns null - - tracker.onLocationReceived(37.7749, -122.4194) - - verify(exactly = 0) { dataPipeline.track(any(), any()) } - } - - @Test - fun givenLocationReceived_emptyUserId_expectTrackNotCalled() { - every { dataPipeline.userId } returns "" + fun givenLocationReceived_noUserIdentified_expectTrackNotCalled() { + every { dataPipeline.isUserIdentified } returns false tracker.onLocationReceived(37.7749, -122.4194) @@ -134,46 +125,46 @@ class LocationTrackerTest { tracker.restorePersistedLocation() - val attrs = tracker.getProfileEnrichmentAttributes() - attrs.shouldNotBeNull() - attrs["location_latitude"] shouldBeEqualTo 37.7749 - attrs["location_longitude"] shouldBeEqualTo -122.4194 + val context = tracker.getIdentifyContext() + context.shouldNotBeEmpty() + context["location_latitude"] shouldBeEqualTo 37.7749 + context["location_longitude"] shouldBeEqualTo -122.4194 } @Test - fun givenNoPersistedLatitude_expectNoEnrichment() { + fun givenNoPersistedLatitude_expectNoContext() { every { store.getCachedLatitude() } returns null tracker.restorePersistedLocation() - tracker.getProfileEnrichmentAttributes().shouldBeNull() + tracker.getIdentifyContext().shouldBeEmpty() } @Test - fun givenNoPersistedLongitude_expectNoEnrichment() { + fun givenNoPersistedLongitude_expectNoContext() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns null tracker.restorePersistedLocation() - tracker.getProfileEnrichmentAttributes().shouldBeNull() + tracker.getIdentifyContext().shouldBeEmpty() } - // -- getProfileEnrichmentAttributes -- + // -- getIdentifyContext -- @Test - fun givenNoLocation_expectReturnsNull() { - tracker.getProfileEnrichmentAttributes().shouldBeNull() + fun givenNoLocation_expectReturnsEmptyMap() { + tracker.getIdentifyContext().shouldBeEmpty() } @Test - fun givenLocationReceived_expectReturnsLocationMap() { + fun givenLocationReceived_expectReturnsLocationContext() { tracker.onLocationReceived(37.7749, -122.4194) - val attrs = tracker.getProfileEnrichmentAttributes() - attrs.shouldNotBeNull() - attrs["location_latitude"] shouldBeEqualTo 37.7749 - attrs["location_longitude"] shouldBeEqualTo -122.4194 + val context = tracker.getIdentifyContext() + context.shouldNotBeEmpty() + context["location_latitude"] shouldBeEqualTo 37.7749 + context["location_longitude"] shouldBeEqualTo -122.4194 } // -- onUserChanged -- @@ -226,6 +217,27 @@ class LocationTrackerTest { } } + @Test + fun givenUserChangedWithUserId_expectSyncFilterConsulted() { + every { store.getCachedLatitude() } returns 37.7749 + every { store.getCachedLongitude() } returns -122.4194 + + tracker.onUserChanged("user-a", "anon-1") + + verify { syncFilter.filterAndRecord(37.7749, -122.4194) } + } + + @Test + fun givenUserChangedWithUserId_filterRejects_expectNoTrack() { + every { store.getCachedLatitude() } returns 37.7749 + every { store.getCachedLongitude() } returns -122.4194 + every { syncFilter.filterAndRecord(any(), any()) } returns false + + tracker.onUserChanged("user-a", "anon-1") + + verify(exactly = 0) { dataPipeline.track(any(), any()) } + } + @Test fun givenUserChangedWithNullUserId_expectNoSync() { every { store.getCachedLatitude() } returns 37.7749 @@ -246,6 +258,6 @@ class LocationTrackerTest { verify { store.clearCachedLocation() } verify { syncFilter.clearSyncedData() } - tracker.getProfileEnrichmentAttributes().shouldBeNull() + tracker.getIdentifyContext().shouldBeEmpty() } } From 6750d5a832dffd6173f7a6ce61dcd9c535a2321e Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Wed, 25 Feb 2026 22:22:59 +0500 Subject: [PATCH 4/5] simplified --- .../io/customer/location/LocationTracker.kt | 59 ++++++++++--------- .../io/customer/location/ModuleLocation.kt | 11 +++- .../customer/location/LocationTrackerTest.kt | 56 ++++-------------- 3 files changed, 50 insertions(+), 76 deletions(-) diff --git a/location/src/main/kotlin/io/customer/location/LocationTracker.kt b/location/src/main/kotlin/io/customer/location/LocationTracker.kt index 24ad9329c..f17708b25 100644 --- a/location/src/main/kotlin/io/customer/location/LocationTracker.kt +++ b/location/src/main/kotlin/io/customer/location/LocationTracker.kt @@ -9,14 +9,24 @@ import io.customer.sdk.util.EventNames /** * Coordinates all location state management: persistence, restoration, - * profile enrichment, and sending location track events. + * identify context enrichment, and sending location track events. * - * Implements [IdentifyContextProvider] to enrich identify event context - * with the latest location. Sends location track events directly via - * [DataPipeline], applying the userId gate and sync filter locally. + * Location reaches the backend through two independent paths: * - * Tracks the last known userId to detect profile switches and reset - * the sync filter accordingly. + * 1. **Identify context enrichment** — implements [IdentifyContextProvider]. + * Every identify() call enriches the event context with the latest + * location coordinates. This is unfiltered — a new user always gets + * the device's current location on their profile immediately. + * + * 2. **"Location Update" track event** — sent via [DataPipeline.track]. + * Gated by a userId check and a sync filter (24h / 1km threshold) + * to avoid redundant events. This creates a discrete event in the + * user's activity timeline for journey/segment triggers. + * + * Profile switch handling is intentionally not tracked here. + * On clearIdentify(), [onReset] clears all state (cache, persistence, + * sync filter). On identify(), the new user's profile receives the + * location via path 1 regardless of the sync filter's state. */ internal class LocationTracker( private val dataPipeline: DataPipeline?, @@ -28,9 +38,6 @@ internal class LocationTracker( @Volatile private var lastLocation: LocationCoordinates? = null - @Volatile - private var lastKnownUserId: String? = null - override fun getIdentifyContext(): Map { val location = lastLocation ?: return emptyMap() return mapOf( @@ -65,32 +72,25 @@ internal class LocationTracker( } /** - * Called when the user identity changes (identify or clearIdentify). - * Detects profile switches to reset the sync filter, then attempts - * to sync the cached location for the new user. + * Called when a user is identified. Attempts to sync the cached + * location as a track event for the newly identified user. + * + * The identify event itself already carries location via + * [getIdentifyContext] — this method handles the supplementary + * "Location Update" track event, subject to the sync filter. */ - fun onUserChanged(userId: String?, anonymousId: String) { - val previousUserId = lastKnownUserId - lastKnownUserId = userId - - // Detect profile switch: previous user existed and differs from new user - if (previousUserId != null && previousUserId != userId) { - logger.debug("Profile switch detected ($previousUserId -> $userId), clearing sync filter") - locationSyncFilter.clearSyncedData() - } - - if (!userId.isNullOrEmpty()) { - syncCachedLocationIfNeeded() - } + fun onUserIdentified() { + syncCachedLocationIfNeeded() } /** * Clears all location state on identity reset (clearIdentify). - * Resets in-memory cache, persisted location, and sync filter. + * Resets in-memory cache, persisted location, and sync filter — + * similar to how device tokens and other per-user state are + * cleared on reset. */ fun onReset() { lastLocation = null - lastKnownUserId = null locationPreferenceStore.clearCachedLocation() locationSyncFilter.clearSyncedData() logger.debug("Location state reset") @@ -98,8 +98,9 @@ internal class LocationTracker( /** * Re-evaluates the cached location for sending. - * Called on identify (via [onUserChanged]) and on cold start to handle - * cases where a location was cached but not yet sent for the current user. + * Called on identify (via [onUserIdentified]) and on cold start + * (via replayed UserChangedEvent) to handle cases where a location + * was cached but not yet sent for the current user. */ internal fun syncCachedLocationIfNeeded() { val lat = locationPreferenceStore.getCachedLatitude() ?: return diff --git a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt index 96030e924..8962da3d3 100644 --- a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt +++ b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt @@ -77,15 +77,22 @@ class ModuleLocation @JvmOverloads constructor( locationTracker.restorePersistedLocation() - // Register as IdentifyContextProvider so location is added to identify event context + // Register as IdentifyContextProvider so location is added to identify event context. + // This ensures every identify() call carries the device's current location + // in the event context — the primary way location reaches a user's profile. SDKComponent.identifyContextRegistry.register(locationTracker) eventBus.subscribe { locationTracker.onReset() } + // On identify, attempt to send a supplementary "Location Update" track event. + // The identify event itself already carries location via context enrichment — + // this track event is for journey/segment triggers in the user's timeline. eventBus.subscribe { - locationTracker.onUserChanged(it.userId, it.anonymousId) + if (!it.userId.isNullOrEmpty()) { + locationTracker.onUserIdentified() + } } val locationProvider = FusedLocationProvider(context) diff --git a/location/src/test/java/io/customer/location/LocationTrackerTest.kt b/location/src/test/java/io/customer/location/LocationTrackerTest.kt index dda070f9d..c5aea8617 100644 --- a/location/src/test/java/io/customer/location/LocationTrackerTest.kt +++ b/location/src/test/java/io/customer/location/LocationTrackerTest.kt @@ -52,7 +52,7 @@ class LocationTrackerTest { } @Test - fun givenLocationReceived_noUserIdentified_expectTrackNotCalled() { + fun givenLocationReceived_noUserId_expectTrackNotCalled() { every { dataPipeline.isUserIdentified } returns false tracker.onLocationReceived(37.7749, -122.4194) @@ -167,47 +167,14 @@ class LocationTrackerTest { context["location_longitude"] shouldBeEqualTo -122.4194 } - // -- onUserChanged -- + // -- onUserIdentified -- @Test - fun givenProfileSwitch_expectClearsSyncFilter() { - every { store.getCachedLatitude() } returns null - - // First call sets lastKnownUserId - tracker.onUserChanged("user-a", "anon-1") - - // Switch to different user - tracker.onUserChanged("user-b", "anon-1") - - verify { syncFilter.clearSyncedData() } - } - - @Test - fun givenFirstIdentify_expectNoClearSyncFilter() { - every { store.getCachedLatitude() } returns null - - tracker.onUserChanged("user-a", "anon-1") - - // First identify should not clear (no previous user) - verify(exactly = 0) { syncFilter.clearSyncedData() } - } - - @Test - fun givenSameUserIdentify_expectNoClearSyncFilter() { - every { store.getCachedLatitude() } returns null - - tracker.onUserChanged("user-a", "anon-1") - tracker.onUserChanged("user-a", "anon-1") - - verify(exactly = 0) { syncFilter.clearSyncedData() } - } - - @Test - fun givenUserChangedWithUserId_expectSyncsCachedLocation() { + fun givenUserIdentified_withCachedLocation_expectSyncsCachedLocation() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 - tracker.onUserChanged("user-a", "anon-1") + tracker.onUserIdentified() verify { dataPipeline.track( @@ -218,32 +185,31 @@ class LocationTrackerTest { } @Test - fun givenUserChangedWithUserId_expectSyncFilterConsulted() { + fun givenUserIdentified_withCachedLocation_expectSyncFilterConsulted() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 - tracker.onUserChanged("user-a", "anon-1") + tracker.onUserIdentified() verify { syncFilter.filterAndRecord(37.7749, -122.4194) } } @Test - fun givenUserChangedWithUserId_filterRejects_expectNoTrack() { + fun givenUserIdentified_filterRejects_expectNoTrack() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 every { syncFilter.filterAndRecord(any(), any()) } returns false - tracker.onUserChanged("user-a", "anon-1") + tracker.onUserIdentified() verify(exactly = 0) { dataPipeline.track(any(), any()) } } @Test - fun givenUserChangedWithNullUserId_expectNoSync() { - every { store.getCachedLatitude() } returns 37.7749 - every { store.getCachedLongitude() } returns -122.4194 + fun givenUserIdentified_noCachedLocation_expectNoTrack() { + every { store.getCachedLatitude() } returns null - tracker.onUserChanged(null, "anon-1") + tracker.onUserIdentified() verify(exactly = 0) { dataPipeline.track(any(), any()) } } From 5c2a122929169dbb38dceb74330a9695d8e0f546 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Wed, 25 Feb 2026 22:46:50 +0500 Subject: [PATCH 5/5] cleanup --- .../core/pipeline/IdentifyContextProvider.kt | 20 --------- .../core/pipeline/IdentifyContextRegistry.kt | 41 ------------------- .../sdk/core/pipeline/IdentifyHook.kt | 25 +++++++++++ .../sdk/core/pipeline/IdentifyHookRegistry.kt | 41 +++++++++++++++++++ .../plugins/IdentifyContextPlugin.kt | 39 ++++++++++++++---- .../main/kotlin/io/customer/sdk/CustomerIO.kt | 4 +- .../io/customer/location/LocationTracker.kt | 40 +++++++++--------- .../io/customer/location/ModuleLocation.kt | 15 +++---- .../customer/location/LocationTrackerTest.kt | 10 +++-- 9 files changed, 131 insertions(+), 104 deletions(-) delete mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt delete mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHook.kt create mode 100644 core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHookRegistry.kt diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt deleted file mode 100644 index 854795000..000000000 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextProvider.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.customer.sdk.core.pipeline - -import io.customer.base.internal.InternalCustomerIOApi - -/** - * Interface for modules that contribute context entries to identify events. - * - * Implementations return a map of primitive-valued entries (String, Number, Boolean) - * that will be added to the identify event's context via `putInContext()`. - * Return an empty map when there is nothing to contribute. - * - * These are NOT profile traits/attributes — they are context-level enrichment - * data (e.g., location coordinates) attached to the identify event payload. - * - * This is an internal SDK contract — not intended for use by host app developers. - */ -@InternalCustomerIOApi -interface IdentifyContextProvider { - fun getIdentifyContext(): Map -} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt deleted file mode 100644 index a6fabaec6..000000000 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyContextRegistry.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.customer.sdk.core.pipeline - -import io.customer.base.internal.InternalCustomerIOApi -import io.customer.sdk.core.di.SDKComponent - -/** - * Thread-safe registry of [IdentifyContextProvider] instances. - * - * Modules register providers during initialization. The datapipelines module - * queries all providers when enriching identify event context. - * - * Cleared automatically when [SDKComponent.reset] clears singletons. - * - * This is an internal SDK contract — not intended for use by host app developers. - */ -@InternalCustomerIOApi -class IdentifyContextRegistry { - private val providers = mutableListOf() - - @Synchronized - fun register(provider: IdentifyContextProvider) { - if (provider !in providers) { - providers.add(provider) - } - } - - @Synchronized - fun getAll(): List = providers.toList() - - @Synchronized - fun clear() { - providers.clear() - } -} - -/** - * Singleton accessor for [IdentifyContextRegistry] via [SDKComponent]. - */ -@InternalCustomerIOApi -val SDKComponent.identifyContextRegistry: IdentifyContextRegistry - get() = singleton { IdentifyContextRegistry() } diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHook.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHook.kt new file mode 100644 index 000000000..5d6f9714f --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHook.kt @@ -0,0 +1,25 @@ +package io.customer.sdk.core.pipeline + +import io.customer.base.internal.InternalCustomerIOApi + +/** + * Hook for modules that participate in the identify event lifecycle. + * + * [getIdentifyContext] returns context entries (String, Number, Boolean) + * added to the identify event's context via `putInContext()`. Return an + * empty map when there is nothing to contribute. These are context-level + * enrichment data (e.g., location coordinates), NOT profile traits. + * + * [resetContext] is called synchronously during `analytics.reset()` + * (clearIdentify flow). Implementations must clear any cached data + * here to prevent stale context from enriching a subsequent identify. + * Full cleanup (persistence, filters) can happen asynchronously via + * EventBus ResetEvent. + * + * This is an internal SDK contract — not intended for use by host app developers. + */ +@InternalCustomerIOApi +interface IdentifyHook { + fun getIdentifyContext(): Map + fun resetContext() {} +} diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHookRegistry.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHookRegistry.kt new file mode 100644 index 000000000..f3fcd85f3 --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/IdentifyHookRegistry.kt @@ -0,0 +1,41 @@ +package io.customer.sdk.core.pipeline + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.sdk.core.di.SDKComponent + +/** + * Thread-safe registry of [IdentifyHook] instances. + * + * Modules register hooks during initialization. The datapipelines module + * queries all hooks when enriching identify event context and on reset. + * + * Cleared automatically when [SDKComponent.reset] clears singletons. + * + * This is an internal SDK contract — not intended for use by host app developers. + */ +@InternalCustomerIOApi +class IdentifyHookRegistry { + private val hooks = mutableListOf() + + @Synchronized + fun register(hook: IdentifyHook) { + if (hook !in hooks) { + hooks.add(hook) + } + } + + @Synchronized + fun getAll(): List = hooks.toList() + + @Synchronized + fun clear() { + hooks.clear() + } +} + +/** + * Singleton accessor for [IdentifyHookRegistry] via [SDKComponent]. + */ +@InternalCustomerIOApi +val SDKComponent.identifyHookRegistry: IdentifyHookRegistry + get() = singleton { IdentifyHookRegistry() } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt index 7cfa7dfcb..a0239b2d2 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt @@ -6,29 +6,50 @@ 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.core.pipeline.IdentifyContextRegistry +import io.customer.sdk.core.pipeline.IdentifyHookRegistry import io.customer.sdk.core.util.Logger import kotlinx.serialization.json.JsonPrimitive /** - * Generic Segment enrichment plugin that queries all registered - * [IdentifyContextProvider][io.customer.sdk.core.pipeline.IdentifyContextProvider] - * instances and adds their entries to the identify event context. + * Segment enrichment plugin that delegates to registered [IdentifyHook] + * instances for both identify enrichment and reset lifecycle. * - * This plugin has zero knowledge of specific modules — providers + * On identify: queries all hooks for context entries and adds them + * to the event context via `putInContext()`. + * + * On reset: propagates synchronously to all hooks so they clear + * cached state before a subsequent identify() picks up stale values. + * + * This plugin has zero knowledge of specific modules — hooks * manage their own state and return primitive-valued maps. */ internal class IdentifyContextPlugin( - private val registry: IdentifyContextRegistry, + private val registry: IdentifyHookRegistry, private val logger: Logger ) : EventPlugin { override val type: Plugin.Type = Plugin.Type.Enrichment override lateinit var analytics: Analytics + /** + * Called synchronously by analytics.reset() during clearIdentify(). + * Propagates to all hooks so they clear cached data before a subsequent + * identify() can pick up stale values. + */ + override fun reset() { + super.reset() + for (hook in registry.getAll()) { + try { + hook.resetContext() + } catch (e: Exception) { + logger.error("IdentifyHook reset failed: ${e.message}") + } + } + } + override fun identify(payload: IdentifyEvent): BaseEvent { - for (provider in registry.getAll()) { + for (hook in registry.getAll()) { try { - val context = provider.getIdentifyContext() + val context = hook.getIdentifyContext() if (context.isEmpty()) continue for ((key, value) in context) { val jsonValue = when (value) { @@ -43,7 +64,7 @@ internal class IdentifyContextPlugin( payload.putInContext(key, jsonValue) } } catch (e: Exception) { - logger.error("IdentifyContextProvider failed: ${e.message}") + logger.error("IdentifyHook failed: ${e.message}") } } return payload diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 90088ce43..fbf548fd2 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -33,7 +33,7 @@ import io.customer.sdk.core.di.AndroidSDKComponent import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.identifyContextRegistry +import io.customer.sdk.core.pipeline.identifyHookRegistry import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Logger import io.customer.sdk.data.model.CustomAttributes @@ -130,7 +130,7 @@ class CustomerIO private constructor( // Add plugin to filter events based on SDK configuration analytics.add(ScreenFilterPlugin(moduleConfig.screenViewUse)) - analytics.add(IdentifyContextPlugin(SDKComponent.identifyContextRegistry, logger)) + analytics.add(IdentifyContextPlugin(SDKComponent.identifyHookRegistry, logger)) analytics.add(ApplicationLifecyclePlugin()) // Register this instance as DataPipeline so modules can send track events directly diff --git a/location/src/main/kotlin/io/customer/location/LocationTracker.kt b/location/src/main/kotlin/io/customer/location/LocationTracker.kt index f17708b25..43564f3d3 100644 --- a/location/src/main/kotlin/io/customer/location/LocationTracker.kt +++ b/location/src/main/kotlin/io/customer/location/LocationTracker.kt @@ -3,7 +3,7 @@ package io.customer.location import io.customer.location.store.LocationPreferenceStore import io.customer.location.sync.LocationSyncFilter import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.IdentifyContextProvider +import io.customer.sdk.core.pipeline.IdentifyHook import io.customer.sdk.core.util.Logger import io.customer.sdk.util.EventNames @@ -13,7 +13,7 @@ import io.customer.sdk.util.EventNames * * Location reaches the backend through two independent paths: * - * 1. **Identify context enrichment** — implements [IdentifyContextProvider]. + * 1. **Identify context enrichment** — implements [IdentifyHook]. * Every identify() call enriches the event context with the latest * location coordinates. This is unfiltered — a new user always gets * the device's current location on their profile immediately. @@ -24,16 +24,17 @@ import io.customer.sdk.util.EventNames * user's activity timeline for journey/segment triggers. * * Profile switch handling is intentionally not tracked here. - * On clearIdentify(), [onReset] clears all state (cache, persistence, - * sync filter). On identify(), the new user's profile receives the - * location via path 1 regardless of the sync filter's state. + * On clearIdentify(), [resetContext] clears all state (cache, persistence, + * sync filter) synchronously during analytics.reset(). On identify(), the + * new user's profile receives the location via path 1 regardless of the + * sync filter's state. */ internal class LocationTracker( private val dataPipeline: DataPipeline?, private val locationPreferenceStore: LocationPreferenceStore, private val locationSyncFilter: LocationSyncFilter, private val logger: Logger -) : IdentifyContextProvider { +) : IdentifyHook { @Volatile private var lastLocation: LocationCoordinates? = null @@ -46,6 +47,20 @@ internal class LocationTracker( ) } + /** + * Called synchronously by analytics.reset() during clearIdentify. + * Clears all location state: in-memory cache, persisted coordinates, + * and sync filter — similar to how device tokens and other per-user + * state are cleared on reset. This runs before ResetEvent is published, + * guaranteeing no stale data is available for a subsequent identify(). + */ + override fun resetContext() { + lastLocation = null + locationPreferenceStore.clearCachedLocation() + locationSyncFilter.clearSyncedData() + logger.debug("Location state reset") + } + /** * Reads persisted cached location from the preference store and sets the * in-memory cache so that identify events have location context @@ -83,19 +98,6 @@ internal class LocationTracker( syncCachedLocationIfNeeded() } - /** - * Clears all location state on identity reset (clearIdentify). - * Resets in-memory cache, persisted location, and sync filter — - * similar to how device tokens and other per-user state are - * cleared on reset. - */ - fun onReset() { - lastLocation = null - locationPreferenceStore.clearCachedLocation() - locationSyncFilter.clearSyncedData() - logger.debug("Location state reset") - } - /** * Re-evaluates the cached location for sending. * Called on identify (via [onUserIdentified]) and on cold start diff --git a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt index 8962da3d3..9ab52197a 100644 --- a/location/src/main/kotlin/io/customer/location/ModuleLocation.kt +++ b/location/src/main/kotlin/io/customer/location/ModuleLocation.kt @@ -10,7 +10,7 @@ import io.customer.sdk.communication.subscribe import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.pipeline.DataPipeline -import io.customer.sdk.core.pipeline.identifyContextRegistry +import io.customer.sdk.core.pipeline.identifyHookRegistry import io.customer.sdk.core.util.Logger /** @@ -77,14 +77,11 @@ class ModuleLocation @JvmOverloads constructor( locationTracker.restorePersistedLocation() - // Register as IdentifyContextProvider so location is added to identify event context. - // This ensures every identify() call carries the device's current location - // in the event context — the primary way location reaches a user's profile. - SDKComponent.identifyContextRegistry.register(locationTracker) - - eventBus.subscribe { - locationTracker.onReset() - } + // Register as IdentifyHook so location is added to identify event context + // and cleared synchronously during analytics.reset(). This ensures every + // identify() call carries the device's current location in the event context — + // the primary way location reaches a user's profile. + SDKComponent.identifyHookRegistry.register(locationTracker) // On identify, attempt to send a supplementary "Location Update" track event. // The identify event itself already carries location via context enrichment — diff --git a/location/src/test/java/io/customer/location/LocationTrackerTest.kt b/location/src/test/java/io/customer/location/LocationTrackerTest.kt index c5aea8617..842c766b9 100644 --- a/location/src/test/java/io/customer/location/LocationTrackerTest.kt +++ b/location/src/test/java/io/customer/location/LocationTrackerTest.kt @@ -214,16 +214,18 @@ class LocationTrackerTest { verify(exactly = 0) { dataPipeline.track(any(), any()) } } - // -- onReset -- + // -- resetContext (synchronous, called by analytics.reset during clearIdentify) -- @Test - fun givenReset_expectClearsEverything() { + fun givenResetContext_expectClearsEverything() { tracker.onLocationReceived(37.7749, -122.4194) - tracker.onReset() + tracker.resetContext() + // In-memory location cleared — no stale data for next identify + tracker.getIdentifyContext().shouldBeEmpty() + // Persistence and sync filter also cleared synchronously verify { store.clearCachedLocation() } verify { syncFilter.clearSyncedData() } - tracker.getIdentifyContext().shouldBeEmpty() } }