diff --git a/src/main/kotlin/com/mparticle/kits/AppsFlyerKit.kt b/src/main/kotlin/com/mparticle/kits/AppsFlyerKit.kt index 0226a0b..d4cd230 100644 --- a/src/main/kotlin/com/mparticle/kits/AppsFlyerKit.kt +++ b/src/main/kotlin/com/mparticle/kits/AppsFlyerKit.kt @@ -29,6 +29,7 @@ import com.mparticle.MParticle import com.mparticle.commerce.CommerceEvent import com.mparticle.commerce.Product import com.mparticle.consent.ConsentState +import com.mparticle.identity.MParticleUser import com.mparticle.internal.Logger import com.mparticle.internal.MPUtility import org.json.JSONArray @@ -47,7 +48,8 @@ class AppsFlyerKit : KitIntegration.CommerceListener, AppsFlyerConversionListener, KitIntegration.ActivityListener, - KitIntegration.UserAttributeListener { + KitIntegration.UserAttributeListener, + KitIntegration.IdentityListener { override fun getInstance(): AppsFlyerLib = AppsFlyerLib.getInstance() override fun getName() = NAME @@ -74,6 +76,8 @@ class AppsFlyerKit : setIntegrationAttributes(integrationAttributes) AppsFlyerLib.getInstance().subscribeForDeepLink(deepLinkListener()) + updateCustomerUserIDIfNeededForUser(currentUser) + val messages: MutableList = ArrayList() messages.add( ReportingMessage( @@ -107,6 +111,7 @@ class AppsFlyerKit : ): List = emptyList() override fun logEvent(event: CommerceEvent): List { + val event = commerceEventWithAppsFlyerCustomerUserId(event) val messages: MutableList = LinkedList() val eventValues: MutableMap = HashMap() val productList = event.products @@ -215,9 +220,13 @@ class AppsFlyerKit : event.productAction == Product.PURCHASE override fun logEvent(event: MPEvent): List { - var hashMap: HashMap? = hashMapOf() - if (event.customAttributes?.isNotEmpty() == true) { - hashMap = event.customAttributes?.let { HashMap(it) } + val hashMap: HashMap = + event.customAttributes + ?.takeIf { it.isNotEmpty() } + ?.let { HashMap(it) } + ?: hashMapOf() + customerIdForAppsFlyer(currentUser)?.takeIf { it.isNotEmpty() }?.let { cid -> + hashMap[AF_CUSTOMER_USER_ID] = cid } instance.logEvent(context, event.eventName, hashMap) val messages: MutableList = LinkedList() @@ -307,7 +316,11 @@ class AppsFlyerKit : override fun removeUserIdentity(identityType: MParticle.IdentityType) { with(instance) { if (MParticle.IdentityType.CustomerId == identityType) { - setCustomerUserId("") + if (isUserIdentificationMPID() || isUserIdentificationCustomerId()) { + updateCustomerUserIDIfNeededForUser(currentUser) + } else { + setCustomerUserId("") + } } else if (MParticle.IdentityType.Email == identityType) { setUserEmails(AppsFlyerProperties.EmailsCryptType.NONE, "") } @@ -320,7 +333,9 @@ class AppsFlyerKit : ) { with(instance) { if (MParticle.IdentityType.CustomerId == identityType) { - setCustomerUserId(identity) + if (!isUserIdentificationMPID()) { + setCustomerUserId(identity) + } } else if (MParticle.IdentityType.Email == identityType) { setUserEmails(AppsFlyerProperties.EmailsCryptType.NONE, identity) } @@ -329,6 +344,71 @@ class AppsFlyerKit : override fun logout(): List = emptyList() + override fun onIdentifyCompleted( + mParticleUser: MParticleUser, + identityApiRequest: FilteredIdentityApiRequest, + ) { + updateCustomerUserIDIfNeededForUser(mParticleUser) + } + + override fun onLoginCompleted( + mParticleUser: MParticleUser, + identityApiRequest: FilteredIdentityApiRequest, + ) { + updateCustomerUserIDIfNeededForUser(mParticleUser) + } + + override fun onLogoutCompleted( + mParticleUser: MParticleUser, + identityApiRequest: FilteredIdentityApiRequest, + ) { + updateCustomerUserIDIfNeededForUser(mParticleUser) + } + + override fun onModifyCompleted( + mParticleUser: MParticleUser, + identityApiRequest: FilteredIdentityApiRequest, + ) { + updateCustomerUserIDIfNeededForUser(mParticleUser) + } + + override fun onUserIdentified(mParticleUser: MParticleUser) { + updateCustomerUserIDIfNeededForUser(mParticleUser) + } + + private fun isUserIdentificationMPID(): Boolean = USER_IDENTIFICATION_MPID == settings[USER_IDENTIFICATION_TYPE] + + private fun isUserIdentificationCustomerId(): Boolean = USER_IDENTIFICATION_CUSTOMER_ID == settings[USER_IDENTIFICATION_TYPE] + + private fun customerIdForAppsFlyer(user: MParticleUser?): String? { + if (user == null) { + return null + } + return when { + isUserIdentificationMPID() -> user.id.toString() + isUserIdentificationCustomerId() -> + user.getUserIdentities()[MParticle.IdentityType.CustomerId] + else -> user.id.toString() + } + } + + private fun updateCustomerUserIDIfNeededForUser(user: MParticleUser?) { + if (!isUserIdentificationMPID() && !isUserIdentificationCustomerId()) { + return + } + instance.setCustomerUserId(customerIdForAppsFlyer(user)) + } + + private fun commerceEventWithAppsFlyerCustomerUserId(event: CommerceEvent): CommerceEvent { + val cid = customerIdForAppsFlyer(currentUser)?.takeIf { it.isNotEmpty() } ?: return event + val newAttrs = HashMap() + event.customAttributes?.forEach { (key, value) -> + newAttrs[key] = value?.toString() ?: "" + } + newAttrs[AF_CUSTOMER_USER_ID] = cid + return CommerceEvent.Builder(event).customAttributes(newAttrs).build() + } + private fun parseToNestedMap(jsonString: String): Map { val topLevelMap = mutableMapOf() try { @@ -608,6 +688,19 @@ class AppsFlyerKit : } const val MANUAL_START = "manualStart" + + /** + * Kit setting: use [USER_IDENTIFICATION_MPID], [USER_IDENTIFICATION_CUSTOMER_ID], or omit for legacy (MPID). + * When set to [USER_IDENTIFICATION_MPID] or [USER_IDENTIFICATION_CUSTOMER_ID], AppsFlyer customer user ID + * is synced on identity changes and `setUserIdentity(CustomerId)` does not override MPID mode. + */ + const val USER_IDENTIFICATION_TYPE = "userIdentificationType" + + const val USER_IDENTIFICATION_MPID = "MPID" + const val USER_IDENTIFICATION_CUSTOMER_ID = "CustomerId" + + const val AF_CUSTOMER_USER_ID = "af_customer_user_id" + private const val CONSENT_MAPPING = "consentMapping" @Suppress("ktlint:standard:property-naming") diff --git a/src/test/kotlin/com/appsflyer/AppsFlyerLib.kt b/src/test/kotlin/com/appsflyer/AppsFlyerLib.kt index c5443c2..2605e3b 100644 --- a/src/test/kotlin/com/appsflyer/AppsFlyerLib.kt +++ b/src/test/kotlin/com/appsflyer/AppsFlyerLib.kt @@ -1,6 +1,8 @@ package com.appsflyer import android.content.Context +import com.appsflyer.AppsFlyerProperties +import java.util.HashMap class AppsFlyerLib { private var consentData: AppsFlyerConsent? = null @@ -11,6 +13,18 @@ class AppsFlyerLib { var customerUserId: String? = null private set + var setCustomerUserIdCallCount = 0 + private set + + var lastSetCustomerUserId: String? = null + private set + + var lastLogEventName: String? = null + private set + + var lastLogEventValues: Map? = null + private set + fun setConsentData(consent: AppsFlyerConsent) { consentData = consent } @@ -22,9 +36,27 @@ class AppsFlyerLib { } fun setCustomerUserId(id: String?) { + setCustomerUserIdCallCount++ + lastSetCustomerUserId = id customerUserId = id } + fun setUserEmails( + cryptType: AppsFlyerProperties.EmailsCryptType, + vararg emails: String, + ) {} + + fun logEvent( + context: Context, + name: String, + values: Map?, + ) { + lastLogEventName = name + lastLogEventValues = values?.let { HashMap(it) } + } + + fun anonymizeUser(optOut: Boolean) {} + fun init( devKey: String, conversionListener: Any?, diff --git a/src/test/kotlin/com/mparticle/kits/AppsflyerKitTests.kt b/src/test/kotlin/com/mparticle/kits/AppsflyerKitTests.kt index c79c7a6..e2dfbe3 100644 --- a/src/test/kotlin/com/mparticle/kits/AppsflyerKitTests.kt +++ b/src/test/kotlin/com/mparticle/kits/AppsflyerKitTests.kt @@ -15,8 +15,6 @@ import com.mparticle.identity.IdentityApi import com.mparticle.identity.MParticleUser import com.mparticle.internal.CoreCallbacks import com.mparticle.internal.CoreCallbacks.KitListener -import junit.framework.Assert.assertEquals -import junit.framework.TestCase import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -85,9 +83,8 @@ class AppsflyerKitTests { fun testOnKitCreate() { var e: Throwable? = null try { - val settings = HashMap() - settings["fake setting"] = "fake" - kit.onKitCreate(settings as Map, mock(Context::class.java)) + val settings = hashMapOf("fake setting" to "fake") + kit.onKitCreate(settings, mock(Context::class.java)) } catch (ex: Throwable) { e = ex } @@ -123,7 +120,7 @@ class AppsflyerKitTests { .Builder(Product.PURCHASE, product) .transactionAttributes(TransactionAttributes("foo")) .build() - assertEquals(mutableListOf("foo-sku"), AppsFlyerKit.generateProductIdList(event)) + Assert.assertEquals(mutableListOf("foo-sku"), AppsFlyerKit.generateProductIdList(event)) val product2 = Product.Builder("foo-name-2", "foo-sku-2", 50.0).build() val event2 = CommerceEvent @@ -131,7 +128,7 @@ class AppsflyerKitTests { .addProduct(product2) .transactionAttributes(TransactionAttributes("foo")) .build() - assertEquals( + Assert.assertEquals( mutableListOf("foo-sku", "foo-sku-2"), AppsFlyerKit.generateProductIdList(event2), ) @@ -143,7 +140,7 @@ class AppsflyerKitTests { .addProduct(product3) .transactionAttributes(TransactionAttributes("foo")) .build() - assertEquals( + Assert.assertEquals( mutableListOf("foo-sku", "foo-sku-2", "foo-sku-%2C3"), AppsFlyerKit.generateProductIdList(event3), ) @@ -183,19 +180,19 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(false, expectedConsentValue) + Assert.assertEquals(false, expectedConsentValue) val notExpectedConsentKey = afConsentResults.containsKey("hasConsentForDataUsage") - TestCase.assertEquals(false, notExpectedConsentKey) + Assert.assertEquals(false, notExpectedConsentKey) val notExpectedConsentKey2 = afConsentResults.containsKey("hasConsentForAdsPersonalization") - TestCase.assertEquals(false, notExpectedConsentKey2) + Assert.assertEquals(false, notExpectedConsentKey2) val notExpectedConsentKey3 = afConsentResults.containsKey("hasConsentForAdStorage") - TestCase.assertEquals(false, notExpectedConsentKey3) + Assert.assertEquals(false, notExpectedConsentKey3) } @Test @@ -232,19 +229,19 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val notExpectedConsentKey = afConsentResults.containsKey("hasConsentForDataUsage") - TestCase.assertEquals(false, notExpectedConsentKey) + Assert.assertEquals(false, notExpectedConsentKey) val notExpectedConsentKey2 = afConsentResults.containsKey("hasConsentForAdsPersonalization") - TestCase.assertEquals(false, notExpectedConsentKey2) + Assert.assertEquals(false, notExpectedConsentKey2) val notExpectedConsentKey3 = afConsentResults.containsKey("hasConsentForAdStorage") - TestCase.assertEquals(false, notExpectedConsentKey3) + Assert.assertEquals(false, notExpectedConsentKey3) } @Test @@ -281,22 +278,22 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val expectedConsentValue2 = afConsentResults .getValue("hasConsentForDataUsage") - TestCase.assertEquals(false, expectedConsentValue2) + Assert.assertEquals(false, expectedConsentValue2) val expectedConsentValue3 = afConsentResults .getValue("hasConsentForAdsPersonalization") - TestCase.assertEquals(true, expectedConsentValue3) + Assert.assertEquals(true, expectedConsentValue3) val expectedConsentValue4 = afConsentResults .getValue("hasConsentForAdStorage") - TestCase.assertEquals(true, expectedConsentValue4) + Assert.assertEquals(true, expectedConsentValue4) } @Test @@ -349,22 +346,22 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val expectedConsentValue2 = afConsentResults .getValue("hasConsentForDataUsage") - TestCase.assertEquals(true, expectedConsentValue2) + Assert.assertEquals(true, expectedConsentValue2) val expectedConsentValue3 = afConsentResults .getValue("hasConsentForAdsPersonalization") - TestCase.assertEquals(false, expectedConsentValue3) + Assert.assertEquals(false, expectedConsentValue3) val expectedConsentValue4 = afConsentResults .getValue("hasConsentForAdStorage") - TestCase.assertEquals(false, expectedConsentValue4) + Assert.assertEquals(false, expectedConsentValue4) } @Test @@ -410,22 +407,22 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val expectedConsentValue2 = afConsentResults .getValue("hasConsentForDataUsage") - TestCase.assertEquals(true, expectedConsentValue2) + Assert.assertEquals(true, expectedConsentValue2) val expectedConsentValue3 = afConsentResults .getValue("hasConsentForAdsPersonalization") - TestCase.assertEquals(true, expectedConsentValue3) + Assert.assertEquals(true, expectedConsentValue3) val expectedConsentValue4 = afConsentResults .getValue("hasConsentForAdStorage") - TestCase.assertEquals(true, expectedConsentValue4) + Assert.assertEquals(true, expectedConsentValue4) } @Test @@ -467,21 +464,21 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val expectedConsentValue2 = afConsentResults .getValue("hasConsentForDataUsage") - TestCase.assertEquals(true, expectedConsentValue2) + Assert.assertEquals(true, expectedConsentValue2) val expectedConsentValue3 = afConsentResults .getValue("hasConsentForAdsPersonalization") - TestCase.assertEquals(true, expectedConsentValue3) + Assert.assertEquals(true, expectedConsentValue3) val notExpectedConsentKey = afConsentResults.containsKey("hasConsentForAdStorage") - TestCase.assertEquals(false, notExpectedConsentKey) + Assert.assertEquals(false, notExpectedConsentKey) } @Test @@ -512,7 +509,7 @@ class AppsflyerKitTests { kit.onConsentStateUpdated(state, state, filteredMParticleUser) - TestCase.assertEquals(0, appsflyer.getConsentState().size) + Assert.assertEquals(0, appsflyer.getConsentState().size) } @Test @@ -556,22 +553,22 @@ class AppsflyerKitTests { val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(true, expectedConsentValue) + Assert.assertEquals(true, expectedConsentValue) val expectedConsentValue2 = afConsentResults .getValue("hasConsentForDataUsage") - TestCase.assertEquals(false, expectedConsentValue2) + Assert.assertEquals(false, expectedConsentValue2) val expectedConsentValue3 = afConsentResults .getValue("hasConsentForAdsPersonalization") - TestCase.assertEquals(false, expectedConsentValue3) + Assert.assertEquals(false, expectedConsentValue3) val expectedConsentValue4 = afConsentResults .getValue("hasConsentForAdStorage") - TestCase.assertEquals(true, expectedConsentValue4) + Assert.assertEquals(true, expectedConsentValue4) } @Test @@ -610,12 +607,12 @@ class AppsflyerKitTests { kit.onConsentStateUpdated(state, state, filteredMParticleUser) - TestCase.assertEquals(1, appsflyer.getConsentState().size) + Assert.assertEquals(1, appsflyer.getConsentState().size) val afConsentResults = appsflyer.getConsentState() val expectedConsentValue = afConsentResults .getValue("isUserSubjectToGDPR") - TestCase.assertEquals(false, expectedConsentValue) + Assert.assertEquals(false, expectedConsentValue) } @Test @@ -725,6 +722,129 @@ class AppsflyerKitTests { Assert.assertEquals(emptyMap(), result) } + // region userIdentificationType + + @Test + fun testSetUserIdentity_customerId_legacy_forwardsToAppsFlyer() { + setKitSettings(emptyMap()) + kit.setUserIdentity(MParticle.IdentityType.CustomerId, "ext-cust-1") + Assert.assertEquals(1, appsflyer.setCustomerUserIdCallCount) + Assert.assertEquals("ext-cust-1", appsflyer.lastSetCustomerUserId) + } + + @Test + fun testSetUserIdentity_customerId_customerIdMode_forwardsToAppsFlyer() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_CUSTOMER_ID), + ) + kit.setUserIdentity(MParticle.IdentityType.CustomerId, "ext-cust-2") + Assert.assertEquals(1, appsflyer.setCustomerUserIdCallCount) + Assert.assertEquals("ext-cust-2", appsflyer.lastSetCustomerUserId) + } + + @Test + fun testSetUserIdentity_customerId_mpidMode_doesNotCallSetCustomerUserId() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_MPID), + ) + kit.setUserIdentity(MParticle.IdentityType.CustomerId, "ext-ignored") + Assert.assertEquals(0, appsflyer.setCustomerUserIdCallCount) + Assert.assertNull(appsflyer.lastSetCustomerUserId) + } + + @Test + fun testSetUserIdentity_email_doesNotUseCustomerUserIdPath() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_MPID), + ) + kit.setUserIdentity(MParticle.IdentityType.Email, "a@b.com") + Assert.assertEquals(0, appsflyer.setCustomerUserIdCallCount) + } + + @Test + fun testSetUserIdentity_unsupportedIdentity_doesNotSetCustomerUserId() { + setKitSettings(emptyMap()) + kit.setUserIdentity(MParticle.IdentityType.Other, "x") + Assert.assertEquals(0, appsflyer.setCustomerUserIdCallCount) + } + + @Test + fun testOnIdentifyCompleted_mpidMode_setsCustomerUserIdFromMpid() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_MPID), + ) + val mpUser = mock(MParticleUser::class.java) + Mockito.`when`(mpUser.getId()).thenReturn(42L) + kit.onIdentifyCompleted(mpUser, mock(FilteredIdentityApiRequest::class.java)) + Assert.assertEquals(1, appsflyer.setCustomerUserIdCallCount) + Assert.assertEquals("42", appsflyer.lastSetCustomerUserId) + } + + @Test + fun testOnIdentifyCompleted_legacy_doesNotSyncCustomerUserId() { + setKitSettings(emptyMap()) + val mpUser = mock(MParticleUser::class.java) + kit.onIdentifyCompleted(mpUser, mock(FilteredIdentityApiRequest::class.java)) + Assert.assertEquals(0, appsflyer.setCustomerUserIdCallCount) + } + + @Test + fun testOnIdentifyCompleted_customerIdMode_usesExternalCustomerId() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_CUSTOMER_ID), + ) + val mpUser = mock(MParticleUser::class.java) + Mockito.`when`(mpUser.getUserIdentities()).thenReturn( + mapOf(MParticle.IdentityType.CustomerId to "cust-ext"), + ) + kit.onIdentifyCompleted(mpUser, mock(FilteredIdentityApiRequest::class.java)) + Assert.assertEquals(1, appsflyer.setCustomerUserIdCallCount) + Assert.assertEquals("cust-ext", appsflyer.lastSetCustomerUserId) + } + + @Test + fun customerIdForAppsFlyer_customerIdMode_returnsCustomerIdFromIdentities() { + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_CUSTOMER_ID), + ) + val mpUser = mock(MParticleUser::class.java) + Mockito.`when`(mpUser.getUserIdentities()).thenReturn( + mapOf(MParticle.IdentityType.CustomerId to "c-out"), + ) + // Same inputs as onIdentifyCompleted (raw MParticleUser). FilteredMParticleUser.getUserIdentities + // can NPE in unit tests when dataplan transform returns null; kit UI settings match production. + val customerIdMethod = + AppsFlyerKit::class.java.declaredMethods.first { + it.name == "customerIdForAppsFlyer" + } + customerIdMethod.isAccessible = true + val cid = customerIdMethod.invoke(kit, mpUser) as String? + Assert.assertEquals("c-out", cid) + } + + @Test + fun testCommerceEvent_includesAfCustomerUserId_mpidMode() { + val identityApi = mock(IdentityApi::class.java) + val mpUser = mock(MParticleUser::class.java) + Mockito.`when`(mpUser.getId()).thenReturn(555L) + Mockito.`when`(identityApi.getCurrentUser()).thenReturn(mpUser) + val mParticle = MParticle.getInstance()!! + Mockito.`when`(mParticle.Identity()).thenReturn(identityApi) + Mockito.`when`(mParticle.environment).thenReturn(MParticle.Environment.Production) + setKitSettings( + mapOf(AppsFlyerKit.USER_IDENTIFICATION_TYPE to AppsFlyerKit.USER_IDENTIFICATION_MPID), + ) + val product = Product.Builder("n", "sku1", 1.0).quantity(1.0).build() + val event = CommerceEvent.Builder(Product.ADD_TO_CART, product).build() + kit.logEvent(event) + Assert.assertEquals( + "555", + appsflyer.lastLogEventValues?.get(AppsFlyerKit.AF_CUSTOMER_USER_ID), + ) + } + + // endregion + // region manualStart tests @Test