diff --git a/core/api/core.api b/core/api/core.api index 7cdb6f307..8ee26aaea 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 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..26e0e83b6 --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -0,0 +1,17 @@ +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 isUserIdentified: Boolean + fun track(name: String, properties: Map) +} 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/api/datapipelines.api b/datapipelines/api/datapipelines.api index 26b782115..fcd8b077e 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; @@ -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/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/IdentifyContextPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt new file mode 100644 index 000000000..a0239b2d2 --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/IdentifyContextPlugin.kt @@ -0,0 +1,72 @@ +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.IdentifyHookRegistry +import io.customer.sdk.core.util.Logger +import kotlinx.serialization.json.JsonPrimitive + +/** + * Segment enrichment plugin that delegates to registered [IdentifyHook] + * instances for both identify enrichment and reset lifecycle. + * + * 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: 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 (hook in registry.getAll()) { + try { + val context = hook.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 context entry: $key") + continue + } + } + payload.putInContext(key, jsonValue) + } + } catch (e: Exception) { + logger.error("IdentifyHook failed: ${e.message}") + } + } + return payload + } +} 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/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 1d236f7ca..fbf548fd2 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.IdentifyContextPlugin 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.identifyHookRegistry 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(IdentifyContextPlugin(SDKComponent.identifyHookRegistry, 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() @@ -347,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/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/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") 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..43564f3d3 100644 --- a/location/src/main/kotlin/io/customer/location/LocationTracker.kt +++ b/location/src/main/kotlin/io/customer/location/LocationTracker.kt @@ -1,83 +1,141 @@ 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.IdentifyHook 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. + * identify context 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. + * Location reaches the backend through two independent paths: * - * 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. + * 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. + * + * 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(), [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 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 +) : IdentifyHook { + + @Volatile + private var lastLocation: LocationCoordinates? = null + + override fun getIdentifyContext(): Map { + val location = lastLocation ?: return emptyMap() + return mapOf( + "location_latitude" to location.latitude, + "location_longitude" to location.longitude + ) + } + + /** + * 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 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(location: Event.LocationData) { - logger.debug("Location update received: lat=${location.latitude}, lng=${location.longitude}") + fun onLocationReceived(latitude: Double, longitude: Double) { + logger.debug("Location update received: lat=$latitude, lng=$longitude") - locationCache?.lastLocation = location - locationPreferenceStore.saveCachedLocation(location.latitude, location.longitude) + lastLocation = LocationCoordinates(latitude = latitude, longitude = longitude) + locationPreferenceStore.saveCachedLocation(latitude, longitude) - logger.debug("Publishing TrackLocationEvent") - eventBus.publish(Event.TrackLocationEvent(location = location)) + trySendLocationTrack(latitude, longitude) } /** - * 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. + * Called when a user is identified. Attempts to sync the cached + * location as a track event for the newly identified user. * - * Datapipelines applies the sync filter, so this is safe to call - * unconditionally when a user is identified. + * The identify event itself already carries location via + * [getIdentifyContext] — this method handles the supplementary + * "Location Update" track event, subject to the sync filter. + */ + fun onUserIdentified() { + syncCachedLocationIfNeeded() + } + + /** + * Re-evaluates the cached location for sending. + * 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. */ - fun syncCachedLocationIfNeeded() { + 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.isUserIdentified) 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..9ab52197a 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.identifyHookRegistry import io.customer.sdk.core.util.Logger /** @@ -43,6 +46,7 @@ class ModuleLocation @JvmOverloads constructor( ) : CustomerIOModule { override val moduleName: String = MODULE_NAME + @Volatile private var _locationServices: LocationServices? = null /** @@ -64,19 +68,27 @@ 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() - eventBus.subscribe { - locationTracker.clearCachedLocation() - } + // 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 — + // this track event is for journey/segment triggers in the user's timeline. eventBus.subscribe { if (!it.userId.isNullOrEmpty()) { - locationTracker.syncCachedLocationIfNeeded() + locationTracker.onUserIdentified() } } 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..842c766b9 100644 --- a/location/src/test/java/io/customer/location/LocationTrackerTest.kt +++ b/location/src/test/java/io/customer/location/LocationTrackerTest.kt @@ -1,172 +1,231 @@ 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.shouldBeEmpty import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBeEmpty 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.isUserIdentified } returns true + 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.isUserIdentified } returns false - 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_filterRejects_expectTrackNotCalled() { + every { syncFilter.filterAndRecord(any(), any()) } returns false - 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 givenNullDataPipeline_expectNoException() { + val trackerWithNullPipeline = LocationTracker(null, store, syncFilter, logger) + + trackerWithNullPipeline.onLocationReceived(37.7749, -122.4194) - verify(exactly = 3) { eventBus.publish(any()) } + // 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 context = tracker.getIdentifyContext() + context.shouldNotBeEmpty() + context["location_latitude"] shouldBeEqualTo 37.7749 + context["location_longitude"] shouldBeEqualTo -122.4194 } @Test - fun givenNoPersistedLatitude_expectNoOp() { + fun givenNoPersistedLatitude_expectNoContext() { every { store.getCachedLatitude() } returns null tracker.restorePersistedLocation() - verify(exactly = 0) { locationCache.lastLocation = any() } + tracker.getIdentifyContext().shouldBeEmpty() } @Test - fun givenNoPersistedLongitude_expectNoOp() { + fun givenNoPersistedLongitude_expectNoContext() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns null tracker.restorePersistedLocation() - verify(exactly = 0) { locationCache.lastLocation = any() } + tracker.getIdentifyContext().shouldBeEmpty() } + // -- getIdentifyContext -- + @Test - fun givenNullLocationCache_expectNoException() { - val trackerWithNullCache = LocationTracker(null, store, logger, eventBus) + fun givenNoLocation_expectReturnsEmptyMap() { + tracker.getIdentifyContext().shouldBeEmpty() + } + + @Test + fun givenLocationReceived_expectReturnsLocationContext() { + tracker.onLocationReceived(37.7749, -122.4194) + + val context = tracker.getIdentifyContext() + context.shouldNotBeEmpty() + context["location_latitude"] shouldBeEqualTo 37.7749 + context["location_longitude"] shouldBeEqualTo -122.4194 + } + + // -- onUserIdentified -- + + @Test + fun givenUserIdentified_withCachedLocation_expectSyncsCachedLocation() { every { store.getCachedLatitude() } returns 37.7749 every { store.getCachedLongitude() } returns -122.4194 - // Should not throw - trackerWithNullCache.restorePersistedLocation() + tracker.onUserIdentified() + + verify { + dataPipeline.track( + name = EventNames.LOCATION_UPDATE, + properties = mapOf("latitude" to 37.7749, "longitude" to -122.4194) + ) + } } - // -- clearCachedLocation -- + @Test + fun givenUserIdentified_withCachedLocation_expectSyncFilterConsulted() { + every { store.getCachedLatitude() } returns 37.7749 + every { store.getCachedLongitude() } returns -122.4194 + + tracker.onUserIdentified() + + verify { syncFilter.filterAndRecord(37.7749, -122.4194) } + } @Test - fun clearCachedLocation_expectClearsStore() { - tracker.clearCachedLocation() + fun givenUserIdentified_filterRejects_expectNoTrack() { + every { store.getCachedLatitude() } returns 37.7749 + every { store.getCachedLongitude() } returns -122.4194 + every { syncFilter.filterAndRecord(any(), any()) } returns false - verify { store.clearCachedLocation() } + tracker.onUserIdentified() + + verify(exactly = 0) { dataPipeline.track(any(), any()) } } @Test - fun givenNullLocationCache_onLocationReceived_expectStillPersistsAndPublishes() { - val trackerWithNullCache = LocationTracker(null, store, logger, eventBus) - val location = Event.LocationData(37.7749, -122.4194) + fun givenUserIdentified_noCachedLocation_expectNoTrack() { + every { store.getCachedLatitude() } returns null - trackerWithNullCache.onLocationReceived(location) + tracker.onUserIdentified() - // 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(exactly = 0) { dataPipeline.track(any(), any()) } + } + + // -- resetContext (synchronous, called by analytics.reset during clearIdentify) -- + + @Test + fun givenResetContext_expectClearsEverything() { + tracker.onLocationReceived(37.7749, -122.4194) + + 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() } } } 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