From 7812b653865bf6e81910e9b5aa6414429408472a Mon Sep 17 00:00:00 2001 From: Mansi-mParticle <159845845+Mansi-mParticle@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:28:24 -0400 Subject: [PATCH] feat: Implement Google EU Consent (#171) --- .../kotlin/com/mparticle/kits/AppboyKit.kt | 154 ++++++++++- .../com/mparticle/kits/AppboyKitTest.kt | 249 +++++++++++++++++- 2 files changed, 400 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/mparticle/kits/AppboyKit.kt b/src/main/kotlin/com/mparticle/kits/AppboyKit.kt index f5a626a..5816b7b 100644 --- a/src/main/kotlin/com/mparticle/kits/AppboyKit.kt +++ b/src/main/kotlin/com/mparticle/kits/AppboyKit.kt @@ -5,6 +5,7 @@ import android.app.Application.ActivityLifecycleCallbacks import android.content.Context import android.content.Intent import android.os.Handler +import android.util.Log import com.braze.Braze import com.braze.BrazeActivityLifecycleCallbackListener import com.braze.BrazeUser @@ -23,11 +24,13 @@ import com.mparticle.commerce.CommerceEvent import com.mparticle.commerce.Impression import com.mparticle.commerce.Product import com.mparticle.commerce.Promotion +import com.mparticle.consent.ConsentState import com.mparticle.identity.MParticleUser import com.mparticle.internal.Logger import com.mparticle.kits.CommerceEventUtils.OnAttributeExtracted import com.mparticle.kits.KitIntegration.* import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import java.math.BigDecimal import java.text.SimpleDateFormat @@ -38,7 +41,7 @@ import kotlin.collections.HashMap * mParticle client-side Appboy integration */ open class AppboyKit : KitIntegration(), AttributeListener, CommerceListener, - KitIntegration.EventListener, PushListener, IdentityListener { + KitIntegration.EventListener, PushListener, IdentityListener, KitIntegration.UserAttributeListener { var enableTypeDetection = false var bundleCommerceEvents = false @@ -106,7 +109,10 @@ open class AppboyKit : KitIntegration(), AttributeListener, CommerceListener, if (user != null) { updateUser(user) } - + val userConsentState = currentUser?.consentState + userConsentState?.let { + setConsent(currentUser.consentState) + } return null } @@ -357,7 +363,146 @@ open class AppboyKit : KitIntegration(), AttributeListener, CommerceListener, }) } + override fun onIncrementUserAttribute( + key: String?, + incrementedBy: Number?, + value: String?, + user: FilteredMParticleUser? + ) { + } + + override fun onRemoveUserAttribute(key: String?, user: FilteredMParticleUser?) { + } + + override fun onSetUserAttribute(key: String?, value: Any?, user: FilteredMParticleUser?) { + } + + override fun onSetUserTag(key: String?, user: FilteredMParticleUser?) { + } + + override fun onSetUserAttributeList( + attributeKey: String?, + attributeValueList: MutableList?, + user: FilteredMParticleUser? + ) { + } + + override fun onSetAllUserAttributes( + userAttributes: MutableMap?, + userAttributeLists: MutableMap>?, + user: FilteredMParticleUser? + ) { + } + override fun supportsAttributeLists(): Boolean = true + override fun onConsentStateUpdated( + oldState: ConsentState, + newState: ConsentState, + user: FilteredMParticleUser + ) { + setConsent(newState) + } + + private fun setConsent(consentState: ConsentState) { + val clientConsentSettings = parseToNestedMap(consentState.toString()) + + parseConsentMapping(settings[consentMappingSDK]).iterator().forEach { currentConsent -> + val isConsentAvailable = + searchKeyInNestedMap(clientConsentSettings, key = currentConsent.key) + + if (isConsentAvailable != null) { + val isConsentGranted: Boolean = + JSONObject(isConsentAvailable.toString()).opt("consented") as Boolean + + when (currentConsent.value) { + "google_ad_user_data" -> setConsentValueToBraze( + KEY_GOOGLE_AD_USER_DATA, isConsentGranted + ) + + "google_ad_personalization" -> setConsentValueToBraze( + KEY_GOOGLE_AD_PERSONALIZATION, isConsentGranted + ) + + } + } + } + } + + private fun setConsentValueToBraze(key: String, value: Boolean) { + Braze.getInstance(context).getCurrentUser(object : IValueCallback { + override fun onSuccess(brazeUser: BrazeUser) { + brazeUser.setCustomUserAttribute(key, value) + } + + override fun onError() { + super.onError() + } + }) + } + + private fun parseConsentMapping(json: String?): Map { + if (json.isNullOrEmpty()) { + return emptyMap() + } + val jsonWithFormat = json.replace("\\", "") + + return try { + JSONArray(jsonWithFormat) + .let { jsonArray -> + (0 until jsonArray.length()) + .associate { + val jsonObject = jsonArray.getJSONObject(it) + val map = jsonObject.getString("map") + val value = jsonObject.getString("value") + map to value + } + } + } catch (jse: JSONException) { + Logger.warning(jse, "The Braze kit threw an exception while searching for the configured consent purpose mapping in the current user's consent status.") + emptyMap() + } + } + + private fun parseToNestedMap(jsonString: String): Map { + val topLevelMap = mutableMapOf() + try { + val jsonObject = JSONObject(jsonString) + + for (key in jsonObject.keys()) { + val value = jsonObject.get(key) + if (value is JSONObject) { + topLevelMap[key] = parseToNestedMap(value.toString()) + } else { + topLevelMap[key] = value + } + } + } catch (e: Exception) { + Logger.error(e, "The Braze kit was unable to parse the user's ConsentState, consent may not be set correctly on the Braze SDK") + } + return topLevelMap + } + + private fun searchKeyInNestedMap(map: Map<*, *>, key: Any): Any? { + if (map.isNullOrEmpty()) { + return null + } + try { + for ((mapKey, mapValue) in map) { + if (mapKey.toString().equals(key.toString(), ignoreCase = true)) { + return mapValue + } + if (mapValue is Map<*, *>) { + val foundValue = searchKeyInNestedMap(mapValue, key) + if (foundValue != null) { + return foundValue + } + } + } + } catch (e: Exception) { + Logger.error(e, "The Braze kit threw an exception while searching for the configured consent purpose mapping in the current user's consent status.") + } + return null + } protected open fun queueDataFlush() { dataFlushRunnable?.let { dataFlushHandler.removeCallbacks(it) } @@ -944,6 +1089,11 @@ open class AppboyKit : KitIntegration(), AttributeListener, CommerceListener, private const val UNSUBSCRIBED = "unsubscribed" private const val SUBSCRIBED = "subscribed" + //Constants for Read Consent + private const val consentMappingSDK = "consentMappingSDK" + private const val KEY_GOOGLE_AD_USER_DATA = "\$google_ad_user_data" + private const val KEY_GOOGLE_AD_PERSONALIZATION = "\$google_ad_personalization" + const val CUSTOM_ATTRIBUTES_KEY = "Attributes" const val PRODUCT_KEY = "products" const val PROMOTION_KEY = "promotions" diff --git a/src/test/kotlin/com/mparticle/kits/AppboyKitTest.kt b/src/test/kotlin/com/mparticle/kits/AppboyKitTest.kt index fb3e524..75a7cd9 100644 --- a/src/test/kotlin/com/mparticle/kits/AppboyKitTest.kt +++ b/src/test/kotlin/com/mparticle/kits/AppboyKitTest.kt @@ -12,12 +12,15 @@ import com.mparticle.commerce.Impression import com.mparticle.commerce.Product import com.mparticle.commerce.Promotion import com.mparticle.commerce.TransactionAttributes +import com.mparticle.consent.ConsentState +import com.mparticle.consent.GDPRConsent import com.mparticle.identity.IdentityApi import com.mparticle.identity.MParticleUser import com.mparticle.kits.mocks.MockAppboyKit import com.mparticle.kits.mocks.MockContextApplication import com.mparticle.kits.mocks.MockKitConfiguration import com.mparticle.kits.mocks.MockUser +import junit.framework.TestCase import org.json.JSONArray import org.json.JSONObject import org.junit.Assert @@ -26,6 +29,7 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import java.lang.reflect.Method import java.math.BigDecimal import java.util.Calendar import java.util.Locale @@ -39,6 +43,12 @@ class AppboyKitTests { @Mock private val mTypeFilters: SparseBooleanArray? = null + @Mock + lateinit var filteredMParticleUser: FilteredMParticleUser + + @Mock + lateinit var user: MParticleUser + private val kit: AppboyKit get() = AppboyKit() @@ -47,6 +57,7 @@ class AppboyKitTests { MockitoAnnotations.initMocks(this) Braze.clearPurchases() Braze.clearEvents() + Braze.currentUser.customUserAttributes.clear() MParticle.setInstance(Mockito.mock(MParticle::class.java)) Mockito.`when`(MParticle.getInstance()!!.Identity()).thenReturn( Mockito.mock( @@ -54,7 +65,7 @@ class AppboyKitTests { ) ) braze = Braze - braze.currentUser.getCustomAttribute().clear() + } @Test @@ -1043,4 +1054,240 @@ class AppboyKitTests { } Assert.assertEquals("testEvent", outputKey) } + + @Test + fun testParseToNestedMap_When_JSON_Is_INVALID() { + val kit = MockAppboyKit() + var jsonInput = + "{'GDPR':{'marketing':'{:false,'timestamp':1711038269644:'Test consent','location':'17 Cherry Tree Lane','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}','performance':'{'consented':true,'timestamp':1711038269644,'document':'parental_consent_agreement_v2','location':'17 Cherry Tree Lan 3','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}'},'CCPA':'{'consented':true,'timestamp':1711038269644,'document':'ccpa_consent_agreement_v3','location':'17 Cherry Tree Lane','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}'}" + + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "parseToNestedMap", + String::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, jsonInput) + Assert.assertEquals(mutableMapOf(), result) + } + + @Test + fun testParseToNestedMap_When_JSON_Is_Empty() { + val kit = MockAppboyKit() + var jsonInput = "" + + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "parseToNestedMap", + String::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, jsonInput) + Assert.assertEquals(mutableMapOf(), result) + } + + @Test + fun testSearchKeyInNestedMap_When_Input_Key_Is_Empty_String() { + val kit = MockAppboyKit() + val map = mapOf( + "FeatureEnabled" to true, + "settings" to mapOf( + "darkMode" to false, + "notifications" to mapOf( + "email" to false, + "push" to true, + "lastUpdated" to 1633046400000L + ) + ) + ) + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "searchKeyInNestedMap", Map::class.java, + Any::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, map, "") + Assert.assertEquals(null, result) + } + + @Test + fun testSearchKeyInNestedMap_When_Input_Is_Empty_Map() { + val kit = MockAppboyKit() + val emptyMap: Map = emptyMap() + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "searchKeyInNestedMap", Map::class.java, + Any::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, emptyMap, "1") + Assert.assertEquals(null, result) + } + + @Test + fun testParseConsentMapping_When_Input_Is_Empty_Json() { + val kit = MockAppboyKit() + val emptyJson = "" + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "parseConsentMapping", + String::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, emptyJson) + Assert.assertEquals(emptyMap(), result) + } + + @Test + fun testParseConsentMapping_When_Input_Is_Invalid_Json() { + val kit = MockAppboyKit() + var jsonInput = + "{'GDPR':{'marketing':'{:false,'timestamp':1711038269644:'Test consent','location':'17 Cherry Tree Lane','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}','performance':'{'consented':true,'timestamp':1711038269644,'document':'parental_consent_agreement_v2','location':'17 Cherry Tree Lan 3','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}'},'CCPA':'{'consented':true,'timestamp':1711038269644,'document':'ccpa_consent_agreement_v3','location':'17 Cherry Tree Lane','hardware_id':'IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702'}'}" + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "parseConsentMapping", + String::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, jsonInput) + Assert.assertEquals(emptyMap(), result) + } + + @Test + fun testParseConsentMapping_When_Input_Is_NULL() { + val kit = MockAppboyKit() + val method: Method = AppboyKit::class.java.getDeclaredMethod( + "parseConsentMapping", + String::class.java + ) + method.isAccessible = true + val result = method.invoke(kit, null) + Assert.assertEquals(emptyMap(), result) + } + + @Test + fun onConsentStateUpdatedTest() { + val kit = MockAppboyKit() + val currentUser = braze.currentUser + kit.configuration = MockKitConfiguration() + val map = java.util.HashMap() + + map["consentMappingSDK"] = + " [{\\\"jsmap\\\":null,\\\"map\\\":\\\"Performance\\\",\\\"maptype\\\":\\\"ConsentPurposes\\\",\\\"value\\\":\\\"google_ad_user_data\\\"},{\\\"jsmap\\\":null,\\\"map\\\":\\\"Marketing\\\",\\\"maptype\\\":\\\"ConsentPurposes\\\",\\\"value\\\":\\\"google_ad_personalization\\\"}]" + + + var kitConfiguration = + MockKitConfiguration.createKitConfiguration(JSONObject().put("as", map.toMutableMap())) + kit.configuration = kitConfiguration + + val marketingConsent = GDPRConsent.builder(false) + .document("Test consent") + .location("17 Cherry Tree Lane") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + val state = ConsentState.builder() + .addGDPRConsentState("Marketing", marketingConsent) + .build() + filteredMParticleUser = FilteredMParticleUser.getInstance(user, kit) + + kit.onConsentStateUpdated(state, state, filteredMParticleUser) + TestCase.assertEquals(false, currentUser.getCustomUserAttribute()["\$google_ad_personalization"]) + + } + + @Test + fun onConsentStateUpdatedTest_When_Both_The_consents_Are_True() { + val kit = MockAppboyKit() + val currentUser = braze.currentUser + kit.configuration = MockKitConfiguration() + val map = java.util.HashMap() + + map["consentMappingSDK"] = + " [{\\\"jsmap\\\":null,\\\"map\\\":\\\"Performance\\\",\\\"maptype\\\":\\\"ConsentPurposes\\\",\\\"value\\\":\\\"google_ad_user_data\\\"},{\\\"jsmap\\\":null,\\\"map\\\":\\\"Marketing\\\",\\\"maptype\\\":\\\"ConsentPurposes\\\",\\\"value\\\":\\\"google_ad_personalization\\\"}]" + + + var kitConfiguration = + MockKitConfiguration.createKitConfiguration(JSONObject().put("as", map.toMutableMap())) + kit.configuration = kitConfiguration + + val marketingConsent = GDPRConsent.builder(true) + .document("Test consent") + .location("17 Cherry Tree Lane") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val performanceConsent = GDPRConsent.builder(true) + .document("parental_consent_agreement_v2") + .location("17 Cherry Tree Lan 3") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val state = ConsentState.builder() + .addGDPRConsentState("Marketing", marketingConsent) + .addGDPRConsentState("Performance", performanceConsent) + .build() + filteredMParticleUser = FilteredMParticleUser.getInstance(user, kit) + + kit.onConsentStateUpdated(state, state, filteredMParticleUser) + TestCase.assertEquals(true, currentUser.getCustomUserAttribute()["\$google_ad_user_data"]) + TestCase.assertEquals(true, currentUser.getCustomUserAttribute()["\$google_ad_personalization"]) + } + + @Test + fun onConsentStateUpdatedTest_When_No_DATA_From_Server() { + val kit = MockAppboyKit() + val currentUser = braze.currentUser + kit.configuration = MockKitConfiguration() + val marketingConsent = GDPRConsent.builder(true) + .document("Test consent") + .location("17 Cherry Tree Lane") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val performanceConsent = GDPRConsent.builder(true) + .document("parental_consent_agreement_v2") + .location("17 Cherry Tree Lan 3") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val state = ConsentState.builder() + .addGDPRConsentState("Marketing", marketingConsent) + .addGDPRConsentState("Performance", performanceConsent) + .build() + filteredMParticleUser = FilteredMParticleUser.getInstance(user, kit) + kit.onConsentStateUpdated(state, state, filteredMParticleUser) + TestCase.assertEquals(0, currentUser.getCustomUserAttribute().size) + } + + @Test + fun testOnConsentStateUpdatedTest_No_consentMappingSDK() { + val kit = MockAppboyKit() + val currentUser = braze.currentUser + kit.configuration = MockKitConfiguration() + val map = java.util.HashMap() + map["includeEnrichedUserAttributes"] = "True" + map["userIdentificationType"] = "MPID" + map["ABKDisableAutomaticLocationCollectionKey"] = "False" + map["defaultAdPersonalizationConsentSDK"] = "Denied" + + kit.configuration = + KitConfiguration.createKitConfiguration(JSONObject().put("as", map.toMutableMap())) + + val marketingConsent = GDPRConsent.builder(true) + .document("Test consent") + .location("17 Cherry Tree Lane") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val performanceConsent = GDPRConsent.builder(true) + .document("parental_consent_agreement_v2") + .location("17 Cherry Tree Lan 3") + .hardwareId("IDFA:a5d934n0-232f-4afc-2e9a-3832d95zc702") + .build() + + val state = ConsentState.builder() + .addGDPRConsentState("Marketing", marketingConsent) + .addGDPRConsentState("Performance", performanceConsent) + .build() + filteredMParticleUser = FilteredMParticleUser.getInstance(user, kit) + + kit.onConsentStateUpdated(state, state, filteredMParticleUser) + + TestCase.assertEquals(0, currentUser.getCustomUserAttribute().size) + + } }