diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 32dcc03..cebd62f 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -42,7 +42,7 @@ android { dependencies { implementation("com.segment:sovran-kotlin:1.3.1") - implementation("com.segment.analytics.kotlin:android:1.16.1") + implementation("com.segment.analytics.kotlin:android:1.16.3") 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/ConsentBlocker.kt b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt index 69eceeb..5bc5935 100644 --- a/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt +++ b/lib/src/main/java/com/segment/analytics/kotlin/destinations/consent/ConsentBlocker.kt @@ -9,10 +9,10 @@ import com.segment.analytics.kotlin.destinations.consent.Constants.EVENT_SEGMENT import com.segment.analytics.kotlin.destinations.consent.Constants.SEGMENT_IO_KEY import kotlinx.serialization.json.JsonObject import sovran.kotlin.SynchronousStore +import com.segment.analytics.kotlin.destinations.consent.Constants.CATEGORY_PREFERENCE_KEY +import com.segment.analytics.kotlin.destinations.consent.Constants.CONSENT_KEY -internal const val CONSENT_SETTINGS = "consent" -internal const val CATEGORY_PREFERENCE = "categoryPreference" open class ConsentBlocker( var destinationKey: String, @@ -30,7 +30,7 @@ open class ConsentBlocker( if (requiredConsentCategories != null && requiredConsentCategories.isNotEmpty()) { - val consentJsonArray = getConsentCategoriesFromEvent(event) + val consentJsonArray = getConsentedCategoriesFromEvent(event) // Look for a missing consent category requiredConsentCategories.forEach { @@ -53,13 +53,17 @@ open class ConsentBlocker( return event } - private fun getConsentCategoriesFromEvent(event: BaseEvent): Set { + /** + * Returns the set of consented categories in the event. Only categories with set to 'true' + * will be returned. + */ + internal fun getConsentedCategoriesFromEvent(event: BaseEvent): Set { val consentJsonArray = HashSet() - val consentSettingsJson = event.context[CONSENT_SETTINGS] + val consentSettingsJson = event.context[CONSENT_KEY] if (consentSettingsJson != null) { val consentJsonObject = (consentSettingsJson as JsonObject) - val categoryPreferenceJson = consentJsonObject[CATEGORY_PREFERENCE] + val categoryPreferenceJson = consentJsonObject[CATEGORY_PREFERENCE_KEY] if (categoryPreferenceJson != null) { val categoryPreferenceJsonObject = categoryPreferenceJson as JsonObject categoryPreferenceJsonObject.forEach { category, consentGiven -> @@ -83,4 +87,22 @@ open class ConsentBlocker( } -class SegmentConsentBlocker(store: SynchronousStore): ConsentBlocker(SEGMENT_IO_KEY, store) {} +class SegmentConsentBlocker(store: SynchronousStore): ConsentBlocker(SEGMENT_IO_KEY, store) { + override fun execute(event: BaseEvent): BaseEvent? { + + val currentState = store.currentState(ConsentState::class) + val hasUnmappedDestinations = currentState?.hasUnmappedDestinations + + // IF we have no unmapped destinations and we have not consented to any categories block (drop) + // the event. + if (hasUnmappedDestinations == false) { + val consentedCategoriesSet = getConsentedCategoriesFromEvent(event) + if (consentedCategoriesSet.isEmpty()) { + // Drop the event + return null + } + } + + return event + } +} 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 index 018f7af..c3b9269 100644 --- 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 @@ -8,6 +8,7 @@ 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.ALL_CATEGORIES_KEY 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 @@ -88,6 +89,7 @@ class ConsentManager( val destinationMapping = mutableMapOf>() var hasUnmappedDestinations = true + val allCategories = mutableListOf() var enabledAtSegment = true // Add all mappings @@ -110,8 +112,13 @@ class ConsentManager( it.jsonObject.getBoolean(HAS_UNMAPPED_DESTINATIONS_KEY) ?.let { serverHasUnmappedDestinations -> println("hasUnmappedDestinations jsonElement: $serverHasUnmappedDestinations") - hasUnmappedDestinations = serverHasUnmappedDestinations == true + hasUnmappedDestinations = serverHasUnmappedDestinations } + + val allCategoriesJson = it.jsonObject.get(ALL_CATEGORIES_KEY) + allCategoriesJson?.let { + it.jsonObject.values.forEach { jsonElement -> allCategories.add(jsonElement.toString())} + } } } catch (t: Throwable) { println("Couldn't parse settings object to check for 'hasUnmappedDestinations'") @@ -126,7 +133,7 @@ class ConsentManager( println("Couldn't parse settings object to check if 'enabledAtSegment'.") } - return ConsentState(destinationMapping, hasUnmappedDestinations, enabledAtSegment) + return ConsentState(destinationMapping, hasUnmappedDestinations, allCategories, enabledAtSegment) } override fun execute(event: BaseEvent): BaseEvent? { 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 index a3c6d3b..bccc5c6 100644 --- 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 @@ -3,7 +3,7 @@ object Constants { const val EVENT_SEGMENT_CONSENT_PREFERENCE = "Segment Consent Preference Updated" const val CONSENT_SETTINGS_KEY = "consentSettings" const val CONSENT_KEY = "consent" - const val CATEGORY_PREFERENCE_KEY = "categoryPreference" + const val CATEGORY_PREFERENCE_KEY = "categoryPreferences" const val CATEGORIES_KEY = "categories" const val ALL_CATEGORIES_KEY = "allCategories" const val HAS_UNMAPPED_DESTINATIONS_KEY = "hasUnmappedDestinations" 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 index 1b9b473..48af23f 100644 --- 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 @@ -7,11 +7,12 @@ import sovran.kotlin.State class ConsentState( var destinationCategoryMap: Map>, var hasUnmappedDestinations: Boolean, + var allCategories: List, var enabledAtSegment: Boolean ) : State { companion object { - val defaultState = ConsentState(mutableMapOf(), true, true) + val defaultState = ConsentState(mutableMapOf(), true, mutableListOf(),true) } } @@ -20,7 +21,7 @@ class UpdateConsentStateActionFull(var value: ConsentState) : Action>) : Action { override fun reduce(state: ConsentState): ConsentState { - val newState = ConsentState(mappings, state.hasUnmappedDestinations, state.enabledAtSegment) + val newState = ConsentState(mappings, state.hasUnmappedDestinations, state.allCategories, state.enabledAtSegment) return newState } } @@ -39,7 +40,7 @@ class UpdateConsentStateActionHasUnmappedDestinations(var hasUnmappedDestination // New state override any old state. val newState = ConsentState( - state.destinationCategoryMap, hasUnmappedDestinations, state.enabledAtSegment + state.destinationCategoryMap, hasUnmappedDestinations, state.allCategories, state.enabledAtSegment ) return newState @@ -51,7 +52,7 @@ class UpdateConsentStateActionEnabledAtSegment(var enabledAtSegment: Boolean) : // New state override any old state. val newState = ConsentState( - state.destinationCategoryMap, state.hasUnmappedDestinations, enabledAtSegment + state.destinationCategoryMap, state.hasUnmappedDestinations, state.allCategories, enabledAtSegment ) return newState diff --git a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt index 5965ba8..6dcf0f1 100644 --- a/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt +++ b/lib/src/test/kotlin/com/segment/analytics/kotlin/destinations/consent/ConsentBlockerTests.kt @@ -19,15 +19,15 @@ class ConsentBlockerTests { store.provide(ConsentState.defaultState) var mappings: MutableMap> = HashMap() mappings["foo"] = arrayOf("cat1", "cat2") - val state = ConsentState(mappings, false, true) + val state = ConsentState(mappings, false, mutableListOf(),true) store.dispatch(UpdateConsentStateActionFull(state), ConsentState::class) val blockingPlugin = ConsentBlocker("foo", store) // All categories correct var stampedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") stampedEvent.context = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { - put(CATEGORY_PREFERENCE, buildJsonObject { + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { put("cat1", JsonPrimitive(true)) put("cat2", JsonPrimitive(true)) }) @@ -38,8 +38,8 @@ class ConsentBlockerTests { stampedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") stampedEvent.context = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { - put(CATEGORY_PREFERENCE, buildJsonObject { + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { put("cat1", JsonPrimitive(true)) put("cat2", JsonPrimitive(true)) put("cat3", JsonPrimitive(true)) @@ -56,7 +56,7 @@ class ConsentBlockerTests { store.provide(ConsentState.defaultState) var mappings: MutableMap> = HashMap() mappings["foo"] = arrayOf("cat1", "cat2") - val state = ConsentState(mappings, false, true) + val state = ConsentState(mappings, false, mutableListOf(),true) store.dispatch(UpdateConsentStateActionFull(state), ConsentState::class) val blockingPlugin = ConsentBlocker("foo", store) @@ -68,7 +68,7 @@ class ConsentBlockerTests { // Context with empty consentSettings unstamppedEvent.context = buildJsonObject { - put(CONSENT_SETTINGS, emptyJsonObject) + put(Constants.CONSENT_KEY, emptyJsonObject) } processedEvent = blockingPlugin.execute(unstamppedEvent) assertNull(processedEvent) @@ -76,8 +76,8 @@ class ConsentBlockerTests { // Stamped Event with all categories false var stamppedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") stamppedEvent.context = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { - put(CATEGORY_PREFERENCE, buildJsonObject { + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { put("cat1", JsonPrimitive(false)) put("cat2", JsonPrimitive(false)) }) @@ -101,8 +101,8 @@ class ConsentBlockerTests { var stamppedEvent = TrackEvent(properties = emptyJsonObject, event = "MyEvent") stamppedEvent.context = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { - put(CATEGORY_PREFERENCE, buildJsonObject { + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { put("cat1", JsonPrimitive(false)) put("cat2", JsonPrimitive(false)) }) 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 index 993913f..06cc636 100644 --- 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 @@ -15,9 +15,11 @@ import io.mockk.mockkStatic import io.mockk.spyk import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import org.junit.Before import org.junit.jupiter.api.Assertions.* import org.junit.Test @@ -36,6 +38,25 @@ class ConsentManagerTests { private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) + fun createConsentEvent(name: String, consentMap: Map, properties: Properties = emptyJsonObject, context: AnalyticsContext = emptyJsonObject): BaseEvent { + var event = TrackEvent(properties, name) + event.context = buildJsonObject { + // Add all context items + context.forEach { prop, elem -> put(prop, elem) } + + // Add (potentially overriding from context) the consentMap values + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { + consentMap.forEach { category, isConsented -> + put(category, JsonPrimitive(isConsented)) + } + }) + }) + } + + return event + } + @Before fun setUp() { appContext = spyk(InstrumentationRegistry.getInstrumentation().targetContext) @@ -78,12 +99,13 @@ class ConsentManagerTests { val consentManager = ConsentManager(store, cp) consentManager.start() + // Refactor var event = TrackEvent(emptyJsonObject, "MyEvent") event.context = emptyJsonObject val expectedContext = buildJsonObject { - put(CONSENT_SETTINGS, buildJsonObject { - put(CATEGORY_PREFERENCE, buildJsonObject { + put(Constants.CONSENT_KEY, buildJsonObject { + put(Constants.CATEGORY_PREFERENCE_KEY, buildJsonObject { put("cat1", JsonPrimitive(true)) put("cat2", JsonPrimitive(false)) }) @@ -130,15 +152,15 @@ class ConsentManagerTests { val integrations = buildJsonObject { put(KEY_SEGMENTIO, buildJsonObject { put("apiKey", JsonPrimitive("foo")) - put("consentSettings", buildJsonObject { - put("categories", buildJsonArray { "foo" }) + put(Constants.CONSENT_SETTINGS_KEY, buildJsonObject { + put(Constants.CATEGORIES_KEY, buildJsonArray { "foo" }) }) }) put(KEY_TEST_DESTINATION, buildJsonObject { put("apiKey", JsonPrimitive("foo")) - put("consentSettings", buildJsonObject { - put("categories", buildJsonArray { "foo" }) + put(Constants.CONSENT_SETTINGS_KEY, buildJsonObject { + put(Constants.CATEGORIES_KEY, buildJsonArray { "foo" }) }) }) } @@ -147,7 +169,14 @@ class ConsentManagerTests { integrations = integrations, plan = buildJsonObject { put("foo", JsonPrimitive("bar")) }, middlewareSettings = buildJsonObject { put("foo", JsonPrimitive("bar")) }, - edgeFunction = buildJsonObject { put("foo", JsonPrimitive("bar")) } + edgeFunction = buildJsonObject { put("foo", JsonPrimitive("bar")) }, + consentSettings = buildJsonObject { + put(Constants.ALL_CATEGORIES_KEY, buildJsonArray { + add(JsonPrimitive("foo")) + }) + + put(Constants.HAS_UNMAPPED_DESTINATIONS_KEY, JsonPrimitive(false)) + } ) consentManager.update(settings, Plugin.UpdateType.Refresh) @@ -166,4 +195,55 @@ class ConsentManagerTests { assertEquals(1, testConsentBlockers?.size) } + @Test + fun `SegmentConsentBlocker does not block when we have unmapped destinations and no consent rule`() { + val store = SynchronousStore() + + // We _have_ unmapped destinations and there are no rules for the for the segment destination + // so we should ALLOW the event to proceed. + store.provide(ConsentState(mutableMapOf(), true, mutableListOf(),true)) + var event = createConsentEvent("MyConsentEvent", mapOf( "foo" to false)) + var segmentBlocker = SegmentConsentBlocker(store) + var resultingEvent = segmentBlocker.execute(event) + assertNotNull(resultingEvent) + } + + @Test + fun `SegmentConsentBlocker blocks when we have no unmapped destinations and event has no consent`() { + val store = SynchronousStore() + + // We have NO unmapped destinations and there are no rules for the for the segment destination + // so we should BLOCK the event to proceed. + store.provide(ConsentState(mutableMapOf(), false, mutableListOf(),true)) + var event = createConsentEvent("MyConsentEvent", mapOf( "foo" to false)) + var segmentBlocker = SegmentConsentBlocker(store) + var resultingEvent = segmentBlocker.execute(event) + assertNull(resultingEvent) + } + + @Test + fun `SegmentConsentBlocker blocks when event missing required consent`() { + val store = SynchronousStore() + + // We _have_ unmapped destinations but there are required consent categories for the + // segment destination so we should BLOCK the event to proceed. + store.provide(ConsentState(mutableMapOf("Segment.io" to arrayOf("foo")), false, mutableListOf(),true)) + var event = createConsentEvent("MyConsentEvent", mapOf( "foo" to false)) + var segmentBlocker = SegmentConsentBlocker(store) + var resultingEvent = segmentBlocker.execute(event) + assertNull(resultingEvent) + } + + @Test + fun `SegmentConsentBlocker does not block when event has required consent`() { + val store = SynchronousStore() + + // We _have_ unmapped destinations but there are required consent categories for the + // segment destination so we should BLOCK the event to proceed. + store.provide(ConsentState(mutableMapOf("Segment.io" to arrayOf("foo")), false, mutableListOf(),true)) + var event = createConsentEvent("MyConsentEvent", mapOf( "foo" to true)) + var segmentBlocker = SegmentConsentBlocker(store) + var resultingEvent = segmentBlocker.execute(event) + assertNotNull(resultingEvent) + } } \ No newline at end of file diff --git a/testapp/build.gradle.kts b/testapp/build.gradle.kts index a9e814a..bd21888 100644 --- a/testapp/build.gradle.kts +++ b/testapp/build.gradle.kts @@ -39,7 +39,7 @@ android { dependencies { implementation(project(mapOf("path" to ":lib"))) implementation("com.segment:sovran-kotlin:1.3.1") - implementation("com.segment.analytics.kotlin:android:1.16.1") + implementation("com.segment.analytics.kotlin:android:1.16.3") implementation("androidx.core:core-ktx:1.7.0") implementation("androidx.appcompat:appcompat:1.4.1") implementation("com.google.android.material:material:1.6.0")