diff --git a/README.md b/README.md index 9139e09..46b93e1 100644 --- a/README.md +++ b/README.md @@ -142,32 +142,36 @@ Next you'll need to write some setup/init code where you have your Analytics setup: ```kotlin +// Setup Analytics analytics = Analytics(SEGMENT_WRITE_KEY, applicationContext) { - this.collectDeviceId = true - this.trackApplicationLifecycleEvents = true - this.trackDeepLinks = true - this.flushPolicies = listOf( - CountBasedFlushPolicy(5), // Flush after 5 events - FrequencyFlushPolicy(5000) // Flush after 5 Seconds - ) + this.collectDeviceId = true + this.trackApplicationLifecycleEvents = true + this.trackDeepLinks = true + this.flushPolicies = listOf( + CountBasedFlushPolicy(5), // Flush after 5 events + FrequencyFlushPolicy(5000) // Flush after 5 Seconds + ) } -// List of categories we care about; we will query the CMP SDK locally on the status -// of these categories when stamping an event with consent status. -val categories = listOf("C0001", "C0002") -val consentCategoryProvider = MyCmpConsentCategoryProvider(cmpSDK, categories) +// Add the myDestination plugin into the main timeline +val myDestinationPlugin = myDestinationPlugin() +analytics.add(myDestinationPlugin) + +// Create the Consent Category Provider that will get the status of consent categories +val consentCategoryProvider = MyConsentCategoryProvider(cmpSDK) val store = SynchronousStore() // Use only a Synchronous store here! + val consentPlugin = ConsentManagementPlugin(store, consentCategoryProvider) // Add the Consent Plugin directly to analytics analytics.add(consentPlugin) -// Add the myDestination plugin into the main timeline -val myDestinationPlugin = myDestinationPlugin() -analytics.add(myDestinationPlugin) +// Use the CMP SDK to get the list of consent categories. +consentCategoryProvider.setCategories(cmpSDK.getCategories()) -// Add the blocking plugin to this destination -webhookDestinationPlugin.add(ConsentBlockingPlugin("my-destination", store)) +// Once the categories have been set we can start processing events by starting +// the Consent Management plugin +consentPlugin.start() ``` ## Building your own integration diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 1ece00c..8368261 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -40,7 +40,7 @@ android { dependencies { implementation("com.segment:sovran-kotlin:1.3.1") - implementation("com.segment.analytics.kotlin:android:1.13.1") + implementation("com.segment.analytics.kotlin:android:1.14.0") implementation("androidx.multidex:multidex:2.0.1") implementation("androidx.core:core-ktx:1.10.1") diff --git a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPlugin.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt similarity index 87% rename from lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPlugin.kt rename to lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt index 2c2f7f7..5e27a0d 100644 --- a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPlugin.kt +++ b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt @@ -5,6 +5,8 @@ import com.segment.analytics.kotlin.core.BaseEvent import com.segment.analytics.kotlin.core.Settings import com.segment.analytics.kotlin.core.TrackEvent import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.destinations.consent.Constants.EVENT_SEGMENT_CONSENT_PREFERENCE +import com.segment.analytics.kotlin.destinations.consent.Constants.SEGMENT_IO_KEY import kotlinx.serialization.json.JsonObject import sovran.kotlin.SynchronousStore @@ -12,7 +14,7 @@ import sovran.kotlin.SynchronousStore internal const val CONSENT_SETTINGS = "consent" internal const val CATEGORY_PREFERENCE = "categoryPreference" -class ConsentBlockingPlugin( +open class ConsentBlocker( var destinationKey: String, var store: SynchronousStore, var allowSegmentPreferenceEvent: Boolean = true @@ -34,7 +36,7 @@ class ConsentBlockingPlugin( requiredConsentCategories.forEach { if (!consentJsonArray.contains(it)) { - if (allowSegmentPreferenceEvent && event is TrackEvent && event.event == ConsentManagementPlugin.EVENT_SEGMENT_CONSENT_PREFERENCE) { + if (allowSegmentPreferenceEvent && event is TrackEvent && event.event == EVENT_SEGMENT_CONSENT_PREFERENCE) { // IF event is the SEGMENT CONSENT PREFERENCE event let it through return event } else { @@ -51,8 +53,8 @@ class ConsentBlockingPlugin( return event } - private fun getConsentCategoriesFromEvent(event: BaseEvent): MutableList { - val consentJsonArray = mutableListOf() + private fun getConsentCategoriesFromEvent(event: BaseEvent): Set { + val consentJsonArray = HashSet() val consentSettingsJson = event.context[CONSENT_SETTINGS] if (consentSettingsJson != null) { @@ -78,4 +80,7 @@ class ConsentBlockingPlugin( override fun update(settings: Settings, type: Plugin.UpdateType) { super.update(settings, type) } -} \ No newline at end of file +} + + +class SegmentConsentBlocker(store: SynchronousStore): ConsentBlocker(SEGMENT_IO_KEY, store) {} diff --git a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPlugin.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPlugin.kt deleted file mode 100644 index 9e2b0b4..0000000 --- a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPlugin.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.segment.analytics.kotlin.destinations.consent - -import com.segment.analytics.kotlin.core.Analytics -import com.segment.analytics.kotlin.core.BaseEvent -import com.segment.analytics.kotlin.core.Settings -import com.segment.analytics.kotlin.core.platform.Plugin -import com.segment.analytics.kotlin.core.utilities.toJsonElement -import kotlinx.serialization.json.* -import sovran.kotlin.Action -import sovran.kotlin.State -import sovran.kotlin.SynchronousStore - - -data class ConsentState(var destinationCategoryMap: Map> = mapOf()) : State { -} - -class UpdateConsentStateAction(var value: Map>) : Action { - override fun reduce(state: ConsentState): ConsentState { - val newState = state.copy() - newState.destinationCategoryMap = value - - return newState - } -} - -class ConsentManagementPlugin( - private var store: SynchronousStore, - private var consentProvider: ConsentCategoryProvider -) : Plugin { - - - companion object { - // Note, because this event name starts with "Segment" it won't show up in the Segment - // debugger. To allow this event to be displayed in the Segment Debugger you can add a - // prefix like "X-" to the event name. - const val EVENT_SEGMENT_CONSENT_PREFERENCE = "Segment Consent Preference" - const val CONSENT_SETTINGS_KEY = "consentSettings" - const val CONSENT_KEY = "consent" - const val CATEGORY_PREFERENCE_KEY = "categoryPreference" - const val CATEGORIES_KEY = "categories" - const val ALL_CATEGORIES_KEY = "allCategories" - } - - - override lateinit var analytics: Analytics - override val type: Plugin.Type = Plugin.Type.Enrichment - - override fun setup(analytics: Analytics) { - super.setup(analytics) - - // Empty state - store.provide(ConsentState()) - } - - override fun update(settings: Settings, type: Plugin.UpdateType) { - - val state = consentStateFrom(settings.integrations) - - - val consentSettingsJson = settings.toJsonElement().jsonObject.get(CONSENT_SETTINGS_KEY) - consentSettingsJson?.let { - val allCategoriesJsonArray = it.jsonObject.get(ALL_CATEGORIES_KEY) as? JsonArray - val allCategories: MutableList = mutableListOf() - allCategoriesJsonArray?.forEach { - allCategories.add(it.toString()) - } - - this.consentProvider.setCategoryList(allCategories) - } - - - // Update the store - store.dispatch(UpdateConsentStateAction(state), ConsentState::class) - } - - private fun consentStateFrom(integrations: JsonObject): HashMap> { - val state = HashMap>() - - integrations.forEach { integrationName, integrationJson -> - // If the integration has the consent key: - integrationJson.jsonObject.get(CONSENT_SETTINGS_KEY)?.let { - - // Build list of categories required for this integration - val categories: MutableList = mutableListOf() - (it.jsonObject.get(CATEGORIES_KEY) as JsonArray).forEach { categoryJsonElement -> - categories.add(categoryJsonElement.toString().replace("\"", "").trim()) - } - - state[integrationName] = categories.toTypedArray() - } - } - return state - } - - override fun execute(event: BaseEvent): BaseEvent? { - - // Try to stamp consent on the event - stampEvent(event) - - return event - } - - /** - * Add the consent status to the event's context object. - */ - private fun stampEvent(event: BaseEvent) { - event.context = buildJsonObject { - event.context.forEach { key, json -> - put(key, json) - } - put(CONSENT_KEY, buildJsonObject { - put(CATEGORY_PREFERENCE_KEY, buildJsonObject { - val categories = consentProvider.getCategories() - categories.forEach { (category, status) -> - put(category, JsonPrimitive(status)) - } - }) - }) - } - } - - /** - * Notify the ConsentManagementPlugin that consent has changed. This will - * trigger the Segment Consent Preference event to be fired. - */ - fun notifyConsentChanged() { - analytics.track(EVENT_SEGMENT_CONSENT_PREFERENCE) - } -} \ No newline at end of file diff --git a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManager.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManager.kt new file mode 100644 index 0000000..4c9eb9c --- /dev/null +++ b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentManager.kt @@ -0,0 +1,180 @@ +package com.segment.analytics.kotlin.destinations.consent + +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.BaseEvent +import com.segment.analytics.kotlin.core.Settings +import com.segment.analytics.kotlin.core.platform.EventPlugin +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.utilities.getBoolean +import com.segment.analytics.kotlin.core.utilities.safeJsonObject +import com.segment.analytics.kotlin.core.utilities.toJsonElement +import com.segment.analytics.kotlin.destinations.consent.Constants.CATEGORIES_KEY +import com.segment.analytics.kotlin.destinations.consent.Constants.CATEGORY_PREFERENCE_KEY +import com.segment.analytics.kotlin.destinations.consent.Constants.CONSENT_KEY +import com.segment.analytics.kotlin.destinations.consent.Constants.CONSENT_SETTINGS_KEY +import com.segment.analytics.kotlin.destinations.consent.Constants.EVENT_SEGMENT_CONSENT_PREFERENCE +import com.segment.analytics.kotlin.destinations.consent.Constants.HAS_UNMAPPED_DESTINATIONS_KEY +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import sovran.kotlin.SynchronousStore +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean + + +class ConsentManager( + private var store: SynchronousStore, + private var consentProvider: ConsentCategoryProvider, + private var consentChange: (() -> Unit)? = null +) : EventPlugin { + + + override lateinit var analytics: Analytics + override val type: Plugin.Type = Plugin.Type.Enrichment + + // Event Queue + private var queuedEvents: Queue = LinkedList() + + // Flag for Event Queue + private var started = AtomicBoolean(false) + + init { + // IF no consentChanged function passed in, set a default. + if (consentChange == null) { + consentChange = { analytics.track(EVENT_SEGMENT_CONSENT_PREFERENCE) } + } + } + + override fun setup(analytics: Analytics) { + super.setup(analytics) + + // Empty state + store.provide(ConsentState.defaultState) + } + + override fun update(settings: Settings, type: Plugin.UpdateType) { + + val state = consentStateFrom(settings) + // Update the store + store.dispatch(UpdateConsentStateActionFull(state), ConsentState::class) + + + // Add Segment Destination blocker + analytics.find(Constants.SEGMENT_IO_KEY)?.let { segmentDestination -> + val existingBlocker = analytics.find(SegmentConsentBlocker::class) + if (existingBlocker == null) { + segmentDestination.add(SegmentConsentBlocker(store)) + } + } + + // Add Blocker to all other destinations + val destinationKeys = state.destinationCategoryMap.keys + for (key in destinationKeys) { + analytics.find(key)?.let { destination -> + if (destination.key != Constants.SEGMENT_IO_KEY) { + val existingBlockers = + destination.findAll(ConsentBlocker::class) + if (existingBlockers.isEmpty()) { + destination.add(ConsentBlocker(key, store)) + } + } + } + } + + } + + private fun consentStateFrom(settings: Settings): ConsentState { + + val destinationMapping = mutableMapOf>() + var hasUnmappedDestinations = true + var enabledAtSegment = true + + // Add all mappings + settings.integrations.forEach { integrationName, integrationJson -> + // If the integration has the consent key: + integrationJson.jsonObject[CONSENT_SETTINGS_KEY]?.let { + + // Build list of categories required for this integration + val categories: MutableList = mutableListOf() + (it.jsonObject.get(CATEGORIES_KEY) as JsonArray).forEach { categoryJsonElement -> + categories.add(categoryJsonElement.toString().replace("\"", "").trim()) + } + destinationMapping[integrationName] = categories.toTypedArray() + } + } + + // Set hasUnmappedDestinations + try { + settings.toJsonElement().jsonObject.get(CONSENT_SETTINGS_KEY)?.let { + it.jsonObject.getBoolean(HAS_UNMAPPED_DESTINATIONS_KEY) + ?.let { serverHasUnmappedDestinations -> + println("hasUnmappedDestinations jsonElement: $serverHasUnmappedDestinations") + hasUnmappedDestinations = serverHasUnmappedDestinations == true + } + } + } catch (t: Throwable) { + println("Couldn't parse settings object to check for 'hasUnmappedDestinations'") + } + + // Set enabledAtSegment + try { + settings.toJsonElement().jsonObject.get(CONSENT_SETTINGS_KEY)?.safeJsonObject.let { + enabledAtSegment = true + } + } catch (t: Throwable) { + println("Couldn't parse settings object to check if 'enabledAtSegment'.") + } + + return ConsentState(destinationMapping, hasUnmappedDestinations, enabledAtSegment) + } + + override fun execute(event: BaseEvent): BaseEvent? { + + return if (started.get()) { + // Stamp consent on the event + stampEvent(event) + event + } else { + queuedEvents.add(event) + null + } + } + + /** + * Add the consent status to the event's context object. + */ + private fun stampEvent(event: BaseEvent) { + event.context = buildJsonObject { + event.context.forEach { key, json -> + put(key, json) + } + put(CONSENT_KEY, buildJsonObject { + put(CATEGORY_PREFERENCE_KEY, buildJsonObject { + val categories = consentProvider.getCategories() + categories.forEach { (category, status) -> + put(category, JsonPrimitive(status)) + } + }) + }) + } + } + + /** + * Notify the ConsentManagementPlugin that consent has changed. This will + * trigger the Segment Consent Preference event to be fired. + */ + fun notifyConsentChanged() { + consentChange?.invoke() + } + + fun start() { + started.set(true) + + while (queuedEvents.isNotEmpty()) { + queuedEvents.poll()?.let { analytics.process(it) } + } + + queuedEvents.clear() + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/Constants.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/Constants.kt new file mode 100644 index 0000000..b1fd854 --- /dev/null +++ b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/Constants.kt @@ -0,0 +1,12 @@ +package com.segment.analytics.kotlin.destinations.consent + +object Constants { + const val EVENT_SEGMENT_CONSENT_PREFERENCE = "Segment Consent Preference" + const val CONSENT_SETTINGS_KEY = "consentSettings" + const val CONSENT_KEY = "consent" + const val CATEGORY_PREFERENCE_KEY = "categoryPreference" + const val CATEGORIES_KEY = "categories" + const val ALL_CATEGORIES_KEY = "allCategories" + const val HAS_UNMAPPED_DESTINATIONS_KEY = "hasUnmappedDestinations" + const val SEGMENT_IO_KEY = "Segment.io" +} \ No newline at end of file diff --git a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/State.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/State.kt new file mode 100644 index 0000000..1b9b473 --- /dev/null +++ b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/State.kt @@ -0,0 +1,59 @@ +package com.segment.analytics.kotlin.destinations.consent + +import sovran.kotlin.Action +import sovran.kotlin.State + + +class ConsentState( + var destinationCategoryMap: Map>, + var hasUnmappedDestinations: Boolean, + var enabledAtSegment: Boolean +) : State { + + companion object { + val defaultState = ConsentState(mutableMapOf(), true, true) + } +} + +class UpdateConsentStateActionFull(var value: ConsentState) : Action { + override fun reduce(state: ConsentState): ConsentState { + + // New state override any old state. + val newState = ConsentState( + value.destinationCategoryMap, value.hasUnmappedDestinations, value.enabledAtSegment + ) + + return newState + } +} + +class UpdateConsentStateActionMappings(var mappings: Map>) : Action { + override fun reduce(state: ConsentState): ConsentState { + val newState = ConsentState(mappings, state.hasUnmappedDestinations, state.enabledAtSegment) + return newState + } +} + +class UpdateConsentStateActionHasUnmappedDestinations(var hasUnmappedDestinations: Boolean) : Action { + override fun reduce(state: ConsentState): ConsentState { + + // New state override any old state. + val newState = ConsentState( + state.destinationCategoryMap, hasUnmappedDestinations, state.enabledAtSegment + ) + + return newState + } +} + +class UpdateConsentStateActionEnabledAtSegment(var enabledAtSegment: Boolean) : Action { + override fun reduce(state: ConsentState): ConsentState { + + // New state override any old state. + val newState = ConsentState( + state.destinationCategoryMap, state.hasUnmappedDestinations, enabledAtSegment + ) + + return newState + } +} diff --git a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPluginTests.kt b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt similarity index 81% rename from lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPluginTests.kt rename to lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt index 7f93607..5965ba8 100644 --- a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockingPluginTests.kt +++ b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt @@ -11,16 +11,17 @@ import org.junit.jupiter.api.TestInstance import sovran.kotlin.SynchronousStore @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ConsentBlockingPluginTests { +class ConsentBlockerTests { @Test fun `allow when consent available`() { val store = SynchronousStore() - store.provide(ConsentState()) - var state: MutableMap> = HashMap() - state["foo"] = arrayOf("cat1", "cat2") - store.dispatch(UpdateConsentStateAction(state), ConsentState::class) - val blockingPlugin = ConsentBlockingPlugin("foo", store) + store.provide(ConsentState.defaultState) + var mappings: MutableMap> = HashMap() + mappings["foo"] = arrayOf("cat1", "cat2") + val state = ConsentState(mappings, false, true) + store.dispatch(UpdateConsentStateActionFull(state), ConsentState::class) + val blockingPlugin = ConsentBlocker("foo", store) // All categories correct var stampedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") @@ -52,11 +53,12 @@ class ConsentBlockingPluginTests { @Test fun `blocks when missing consent`() { val store = SynchronousStore() - store.provide(ConsentState()) - var state: MutableMap> = HashMap() - state["foo"] = arrayOf("cat1", "cat2") - store.dispatch(UpdateConsentStateAction(state), ConsentState::class) - val blockingPlugin = ConsentBlockingPlugin("foo", store) + store.provide(ConsentState.defaultState) + var mappings: MutableMap> = HashMap() + mappings["foo"] = arrayOf("cat1", "cat2") + val state = ConsentState(mappings, false, true) + store.dispatch(UpdateConsentStateActionFull(state), ConsentState::class) + val blockingPlugin = ConsentBlocker("foo", store) // Empty context var unstamppedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") @@ -88,8 +90,8 @@ class ConsentBlockingPluginTests { @Test fun `block when nothing in store`() { val store = SynchronousStore() - store.provide(ConsentState()) - val blockingPlugin = ConsentBlockingPlugin("foo", store) + store.provide(ConsentState.defaultState) + val blockingPlugin = ConsentBlocker("foo", store) // Empty context var unstamppedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") diff --git a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPluginTests.kt b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPluginTests.kt deleted file mode 100644 index 9eefa72..0000000 --- a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagementPluginTests.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.segment.analytics.kotlin.destinations.consent - -import com.segment.analytics.kotlin.core.TrackEvent -import com.segment.analytics.kotlin.core.emptyJsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import sovran.kotlin.SynchronousStore - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ConsentManagementPluginTests { - - @Test - fun `stamps events`() { - val store = SynchronousStore() - store.provide(ConsentState()) - - val cp = object : ConsentCategoryProvider { - - override fun setCategoryList(categories: List) { - // NO OP - } - - override fun getCategories(): Map { - var categories = HashMap() - - categories.put("cat1", true) - categories.put("cat2", false) - - return categories - } - } - - - val consentManagementPlugin = ConsentManagementPlugin(store, cp) - - var event = TrackEvent(emptyJsonObject, "MyEvent") - event.context = emptyJsonObject - - val expectedContext = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { put(CATEGORY_PREFERENCE, buildJsonObject { - put("cat1", JsonPrimitive(true)) - put("cat2", JsonPrimitive(false)) - }) }) - } - - var processedEvent = consentManagementPlugin.execute(event) - - assertEquals(expectedContext, processedEvent?.context) - } -} \ No newline at end of file diff --git a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagerTests.kt b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagerTests.kt new file mode 100644 index 0000000..993913f --- /dev/null +++ b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentManagerTests.kt @@ -0,0 +1,169 @@ +package com.segment.analytics.kotlin.destinations.consent + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.platform.app.InstrumentationRegistry +import com.segment.analytics.kotlin.android.AndroidStorageProvider +import com.segment.analytics.kotlin.android.plugins.getUniqueID +import com.segment.analytics.kotlin.core.* +import com.segment.analytics.kotlin.core.platform.DestinationPlugin +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.utilities.LenientJson +import com.segment.analytics.kotlin.core.utilities.toJsonElement +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.spyk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import org.junit.Before +import org.junit.jupiter.api.Assertions.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import sovran.kotlin.SynchronousStore + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ConsentManagerTests { + + lateinit var appContext: Context + lateinit var analytics: Analytics + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + appContext = spyk(InstrumentationRegistry.getInstrumentation().targetContext) + val sharedPreferences: SharedPreferences = MemorySharedPreferences() + every { appContext.getSharedPreferences(any(), any()) } returns sharedPreferences + mockkStatic("com.segment.analytics.kotlin.android.plugins.AndroidContextPluginKt") + every { getUniqueID() } returns "unknown" + + analytics = testAnalytics( + Configuration( + writeKey = "123", + application = appContext, + storageProvider = AndroidStorageProvider + ), + testScope, testDispatcher + ) + } + + @Test + fun `stamps events`() { + val store = SynchronousStore() + store.provide(ConsentState.defaultState) + + val cp = object : ConsentCategoryProvider { + + override fun setCategoryList(categories: List) { + // NO OP + } + + override fun getCategories(): Map { + var categories = HashMap() + + categories.put("cat1", true) + categories.put("cat2", false) + + return categories + } + } + + val consentManager = ConsentManager(store, cp) + consentManager.start() + + var event = TrackEvent(emptyJsonObject, "MyEvent") + event.context = emptyJsonObject + + val expectedContext = buildJsonObject { + put(CONSENT_SETTINGS, buildJsonObject { + put(CATEGORY_PREFERENCE, buildJsonObject { + put("cat1", JsonPrimitive(true)) + put("cat2", JsonPrimitive(false)) + }) + }) + } + + var processedEvent = consentManager.execute(event) + + assertEquals(expectedContext, processedEvent?.context) + } + + @Test + fun `setups up blockers`() { + val store = SynchronousStore() + store.provide(ConsentState.defaultState) + + val cp = object : ConsentCategoryProvider { + + override fun setCategoryList(categories: List) { + // NO OP + } + + override fun getCategories(): Map { + var categories = HashMap() + + categories.put("cat1", true) + categories.put("cat2", false) + + return categories + } + } + + val KEY_TEST_DESTINATION = "TestDestination" + analytics.add(object : DestinationPlugin() { + override val key: String = KEY_TEST_DESTINATION + override lateinit var analytics: Analytics + }) + + val consentManager = ConsentManager(store, cp) + + analytics.add(consentManager) + + val KEY_SEGMENTIO = "Segment.io" + val integrations = buildJsonObject { + put(KEY_SEGMENTIO, buildJsonObject { + put("apiKey", JsonPrimitive("foo")) + put("consentSettings", buildJsonObject { + put("categories", buildJsonArray { "foo" }) + }) + }) + + put(KEY_TEST_DESTINATION, buildJsonObject { + put("apiKey", JsonPrimitive("foo")) + put("consentSettings", buildJsonObject { + put("categories", buildJsonArray { "foo" }) + }) + }) + } + + val settings = Settings( + integrations = integrations, + plan = buildJsonObject { put("foo", JsonPrimitive("bar")) }, + middlewareSettings = buildJsonObject { put("foo", JsonPrimitive("bar")) }, + edgeFunction = buildJsonObject { put("foo", JsonPrimitive("bar")) } + ) + + consentManager.update(settings, Plugin.UpdateType.Refresh) + + val segmentDestination = analytics.find(KEY_SEGMENTIO) + val segmentConsentBlockers = segmentDestination?.findAll(SegmentConsentBlocker::class) + + assertNotNull(segmentConsentBlockers) + assertEquals(1, segmentConsentBlockers?.size) + + + val testDestination = analytics.find(KEY_TEST_DESTINATION) + val testConsentBlockers = testDestination?.findAll(ConsentBlocker::class) + + assertNotNull(testConsentBlockers) + assertEquals(1, testConsentBlockers?.size) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/Mocks.kt b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/Mocks.kt new file mode 100644 index 0000000..4c90bb1 --- /dev/null +++ b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/Mocks.kt @@ -0,0 +1,188 @@ +package com.segment.analytics.kotlin.destinations.consent + +import android.content.SharedPreferences +import androidx.annotation.Nullable +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.Configuration +import com.segment.analytics.kotlin.core.CoroutineConfiguration +import io.mockk.every +import io.mockk.spyk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import sovran.kotlin.Store +import kotlin.coroutines.CoroutineContext + + + +fun testAnalytics(configuration: Configuration, testScope: TestScope, testDispatcher: TestDispatcher): Analytics { + return object : Analytics(configuration, TestCoroutineConfiguration(testScope, testDispatcher)) {} +} + + + + +fun spyStore(scope: CoroutineScope, dispatcher: CoroutineDispatcher): Store { + val store = spyk(Store()) + every { store getProperty "sovranScope" } propertyType CoroutineScope::class returns scope + every { store getProperty "syncQueue" } propertyType CoroutineContext::class returns dispatcher + every { store getProperty "updateQueue" } propertyType CoroutineContext::class returns dispatcher + return store +} + + + + +class TestCoroutineConfiguration( + val testScope: TestScope, + val testDispatcher: TestDispatcher +) : CoroutineConfiguration { + + override val store: Store = + spyStore(testScope, testDispatcher) + + override val analyticsScope: CoroutineScope + get() = testScope + + override val analyticsDispatcher: CoroutineDispatcher + get() = testDispatcher + + override val networkIODispatcher: CoroutineDispatcher + get() = testDispatcher + + override val fileIODispatcher: CoroutineDispatcher + get() = testDispatcher +} + + + + +/** + * Mock implementation of shared preference, which just saves data in memory using map. + */ +class MemorySharedPreferences : SharedPreferences { + internal val preferenceMap: HashMap = HashMap() + private val preferenceEditor: MockSharedPreferenceEditor + override fun getAll(): Map { + return preferenceMap + } + + @Nullable + override fun getString(s: String, @Nullable s1: String?): String? { + return try { + preferenceMap[s] as String? + } catch(ex: Exception) { + s1 + } + } + + @Nullable + override fun getStringSet(s: String, @Nullable set: Set?): Set? { + return try { + preferenceMap[s] as Set? + } catch(ex: Exception) { + set + } + } + + override fun getInt(s: String, i: Int): Int { + return try { + preferenceMap[s] as Int + } catch(ex: Exception) { + i + } + } + + override fun getLong(s: String, l: Long): Long { + return try { + preferenceMap[s] as Long + } catch(ex: Exception) { + l + } + } + + override fun getFloat(s: String, v: Float): Float { + return try { + preferenceMap[s] as Float + } catch(ex: Exception) { + v + } + } + + override fun getBoolean(s: String, b: Boolean): Boolean { + return try { + preferenceMap[s] as Boolean + } catch(ex: Exception) { + b + } + } + + override fun contains(s: String): Boolean { + return preferenceMap.containsKey(s) + } + + override fun edit(): SharedPreferences.Editor { + return preferenceEditor + } + + override fun registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener) {} + override fun unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener) {} + class MockSharedPreferenceEditor(private val preferenceMap: HashMap) : + SharedPreferences.Editor { + override fun putString(s: String, @Nullable s1: String?): SharedPreferences.Editor { + preferenceMap[s] = s1 + return this + } + + override fun putStringSet( + s: String, + @Nullable set: Set? + ): SharedPreferences.Editor { + preferenceMap[s] = set + return this + } + + override fun putInt(s: String, i: Int): SharedPreferences.Editor { + preferenceMap[s] = i + return this + } + + override fun putLong(s: String, l: Long): SharedPreferences.Editor { + preferenceMap[s] = l + return this + } + + override fun putFloat(s: String, v: Float): SharedPreferences.Editor { + preferenceMap[s] = v + return this + } + + override fun putBoolean(s: String, b: Boolean): SharedPreferences.Editor { + preferenceMap[s] = b + return this + } + + override fun remove(s: String): SharedPreferences.Editor { + preferenceMap.remove(s) + return this + } + + override fun clear(): SharedPreferences.Editor { + preferenceMap.clear() + return this + } + + override fun commit(): Boolean { + return true + } + + override fun apply() { + // Nothing to do, everything is saved in memory. + } + } + + init { + preferenceEditor = MockSharedPreferenceEditor(preferenceMap) + } +} \ No newline at end of file