From a2a3343f03d2983ee1a876864de680e97be27925 Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker Date: Fri, 26 Jun 2026 15:27:22 -0400 Subject: [PATCH] fix: Wait for User Attributes to persist before Rokt selectPlacements forwards to the kit --- CHANGELOG.md | 7 +- .../com/mparticle/kits/RoktKitApiImpl.kt | 85 +++++++++++++------ .../com/mparticle/kits/KitManagerImplTest.kt | 11 +++ .../com/mparticle/kits/RoktKitApiImplTest.kt | 57 +++++++++++++ 4 files changed, 131 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27eb72c50..adbf7e5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,15 @@ ## [5.80.0](https://github.com/mParticle/mparticle-android-sdk/compare/v5.79.2...v5.80.0) (2026-06-25) - ### Features -* add device-based consent to override MPID-scoped consent ([#726](https://github.com/mParticle/mparticle-android-sdk/issues/726)) ([e92d352](https://github.com/mParticle/mparticle-android-sdk/commit/e92d3522a350be90a1f15eb3b81d5e6f089f1aed)) +- add device-based consent to override MPID-scoped consent ([#726](https://github.com/mParticle/mparticle-android-sdk/issues/726)) ([e92d352](https://github.com/mParticle/mparticle-android-sdk/commit/e92d3522a350be90a1f15eb3b81d5e6f089f1aed)) ## Unreleased +### Fixed + +- Restore async wait for user attributes to persist before Rokt `selectPlacements` delegates to the kit, fixing a regression where placement attributes could be missing in mParticle and Rokt. + ### Added - Add device-based consent APIs (`MParticle.getDeviceConsentState()`, `MParticle.setDeviceConsentState()`) and `MParticleOptions.Builder.deviceBasedConsentEnabled()` so consent can be stored and applied at the device level, overriding MPID-based consent for kit forwarding rules and event uploads. diff --git a/android-kit-base/src/main/kotlin/com/mparticle/kits/RoktKitApiImpl.kt b/android-kit-base/src/main/kotlin/com/mparticle/kits/RoktKitApiImpl.kt index 68fd73d4c..138ca9c75 100644 --- a/android-kit-base/src/main/kotlin/com/mparticle/kits/RoktKitApiImpl.kt +++ b/android-kit-base/src/main/kotlin/com/mparticle/kits/RoktKitApiImpl.kt @@ -4,6 +4,7 @@ import android.graphics.Typeface import com.mparticle.MParticle import com.mparticle.MpRoktEventCallback import com.mparticle.RoktEvent +import com.mparticle.TypedUserAttributeListener import com.mparticle.identity.IdentityApi import com.mparticle.identity.IdentityApiRequest import com.mparticle.identity.MParticleUser @@ -50,17 +51,20 @@ internal class RoktKitApiImpl(private val roktListener: KitIntegration.RoktListe val kitConfig = kitIntegration.configuration confirmEmail(email, hashedEmail, user, instance.Identity(), kitConfig) { - val finalAttributes = prepareAttributes(mutableAttributes, user) - roktListener.selectPlacements( - viewName, - finalAttributes, - mpRoktEventCallback, - placeHolders, - fontTypefaces, - FilteredMParticleUser.getInstance(user?.id ?: 0L, kitIntegration), - config, - options, - ) + val finalAttributes = applyPlacementAttributeMapping(mutableAttributes) + setRoktAttributesOnUser(finalAttributes, user) { + ensureSandboxMode(finalAttributes) + roktListener.selectPlacements( + viewName, + finalAttributes, + mpRoktEventCallback, + placeHolders, + fontTypefaces, + FilteredMParticleUser.getInstance(user?.id ?: 0L, kitIntegration), + config, + options, + ) + } } } catch (e: Exception) { Logger.warning("Failed to call execute for Rokt Kit: ${e.message}") @@ -115,16 +119,19 @@ internal class RoktKitApiImpl(private val roktListener: KitIntegration.RoktListe return } val user = instance.Identity().currentUser - val email = mutableAttributes["email"] + val email = getValueIgnoreCase(mutableAttributes, "email") val hashedEmail = getValueIgnoreCase(mutableAttributes, "emailsha256") val kitConfig = kitIntegration.configuration confirmEmail(email, hashedEmail, user, instance.Identity(), kitConfig) { - val finalAttributes = prepareAttributes(mutableAttributes, user) - roktListener.enrichAttributes( - finalAttributes, - FilteredMParticleUser.getInstance(user?.id ?: 0L, kitIntegration), - ) + val finalAttributes = applyPlacementAttributeMapping(mutableAttributes) + setRoktAttributesOnUser(finalAttributes, user) { + ensureSandboxMode(finalAttributes) + roktListener.enrichAttributes( + finalAttributes, + FilteredMParticleUser.getInstance(user?.id ?: 0L, kitIntegration), + ) + } } } catch (e: Exception) { Logger.warning("Failed to call prepareAttributesAsync for Rokt Kit: ${e.message}") @@ -142,7 +149,7 @@ internal class RoktKitApiImpl(private val roktListener: KitIntegration.RoktListe return null } - private fun prepareAttributes(finalAttributes: MutableMap, user: MParticleUser?): MutableMap { + private fun applyPlacementAttributeMapping(attributes: MutableMap): MutableMap { val kitConfig = kitIntegration.configuration val jsonArray = try { kitConfig?.placementAttributesMapping ?: org.json.JSONArray() @@ -155,27 +162,51 @@ internal class RoktKitApiImpl(private val roktListener: KitIntegration.RoktListe val obj = jsonArray.optJSONObject(i) ?: continue val mapFrom = obj.optString("map") val mapTo = obj.optString("value") - if (finalAttributes.containsKey(mapFrom)) { - val value = finalAttributes.remove(mapFrom) + if (attributes.containsKey(mapFrom)) { + val value = attributes.remove(mapFrom) if (value != null) { - finalAttributes[mapTo] = value + attributes[mapTo] = value } } } + return attributes + } + private fun ensureSandboxMode(finalAttributes: MutableMap) { + if (!finalAttributes.containsKey(Constants.MessageKey.SANDBOX_MODE_ROKT)) { + finalAttributes[Constants.MessageKey.SANDBOX_MODE_ROKT] = + Objects.toString(MPUtility.isDevEnv(), "false") + } + } + + /** + * Persists placement attributes on the mParticle user, then invokes [onReady] after the + * attributes have been applied. This ensures the Rokt kit reads an up-to-date user profile + * when merging attributes for the placement. + */ + private fun setRoktAttributesOnUser(finalAttributes: Map, user: MParticleUser?, onReady: Runnable) { val objectAttributes = mutableMapOf() for ((key, value) in finalAttributes) { if (key != Constants.MessageKey.SANDBOX_MODE_ROKT) { objectAttributes[key] = value } } - user?.setUserAttributes(objectAttributes) - - if (!finalAttributes.containsKey(Constants.MessageKey.SANDBOX_MODE_ROKT)) { - finalAttributes[Constants.MessageKey.SANDBOX_MODE_ROKT] = - Objects.toString(MPUtility.isDevEnv(), "false") + if (user != null) { + user.setUserAttributes(objectAttributes) + user.getUserAttributes( + object : TypedUserAttributeListener { + override fun onUserAttributesReceived( + userAttributes: Map, + userAttributeLists: Map?>, + mpid: Long, + ) { + onReady.run() + } + }, + ) + } else { + onReady.run() } - return finalAttributes } private fun confirmEmail( diff --git a/android-kit-base/src/test/kotlin/com/mparticle/kits/KitManagerImplTest.kt b/android-kit-base/src/test/kotlin/com/mparticle/kits/KitManagerImplTest.kt index b4001ba63..8a6407223 100644 --- a/android-kit-base/src/test/kotlin/com/mparticle/kits/KitManagerImplTest.kt +++ b/android-kit-base/src/test/kotlin/com/mparticle/kits/KitManagerImplTest.kt @@ -10,6 +10,7 @@ import com.mparticle.MParticle import com.mparticle.MParticleOptions import com.mparticle.MpRoktEventCallback import com.mparticle.RoktEvent +import com.mparticle.TypedUserAttributeListener import com.mparticle.WrapperSdk import com.mparticle.WrapperSdkVersion import com.mparticle.commerce.CommerceEvent @@ -1277,6 +1278,7 @@ class KitManagerImplTest { @Test fun testRokt_selectPlacements_with_PlacementOptions() { val mockUser = mock(MParticleUser::class.java) + stubUserAttributesCallback(mockUser) `when`(mockIdentity!!.currentUser).thenReturn(mockUser) val manager: KitManagerImpl = MockKitManagerImpl() @@ -1315,6 +1317,7 @@ class KitManagerImplTest { @Test fun testRokt_selectPlacements_without_PlacementOptions() { val mockUser = mock(MParticleUser::class.java) + stubUserAttributesCallback(mockUser) `when`(mockIdentity!!.currentUser).thenReturn(mockUser) val manager: KitManagerImpl = MockKitManagerImpl() @@ -1760,6 +1763,14 @@ class KitManagerImplTest { } } + private fun stubUserAttributesCallback(user: MParticleUser) { + `when`(user.getUserAttributes(any())).thenAnswer { invocation -> + val listener = invocation.arguments[0] as TypedUserAttributeListener + listener.onUserAttributesReceived(emptyMap(), emptyMap(), 0L) + null + } + } + internal inner class MockProvider( val config: KitConfiguration, ) : KitIntegration(), diff --git a/android-kit-base/src/test/kotlin/com/mparticle/kits/RoktKitApiImplTest.kt b/android-kit-base/src/test/kotlin/com/mparticle/kits/RoktKitApiImplTest.kt index 65643eba5..2f27fa440 100644 --- a/android-kit-base/src/test/kotlin/com/mparticle/kits/RoktKitApiImplTest.kt +++ b/android-kit-base/src/test/kotlin/com/mparticle/kits/RoktKitApiImplTest.kt @@ -2,6 +2,7 @@ package com.mparticle.kits import com.mparticle.MParticle import com.mparticle.identity.IdentityApi +import com.mparticle.identity.MParticleUser import com.mparticle.internal.MPUtility import com.mparticle.mock.MockMParticle import com.mparticle.rokt.PlacementOptions @@ -15,6 +16,7 @@ import org.junit.Test import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.mock +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.Mockito.withSettings @@ -82,6 +84,61 @@ class RoktKitApiImplTest { assertEquals(MPUtility.isDevEnv().toString(), captured["sandbox"]) } + @Test + fun testSelectPlacements_waitsForUserAttributesBeforeDelegating() { + val kitConfig = KitConfiguration.createKitConfiguration(JSONObject().put("id", 42)) + val kitIntegration = + mock( + KitIntegration::class.java, + withSettings().extraInterfaces(KitIntegration.RoktListener::class.java), + ) + `when`(kitIntegration.configuration).thenReturn(kitConfig) + val roktListener = kitIntegration as KitIntegration.RoktListener + val roktApi = RoktKitApiImpl(roktListener, kitIntegration) + + val identityApi = mock(IdentityApi::class.java) + val user = mock(MParticleUser::class.java) + `when`(user.id).thenReturn(12345L) + `when`(identityApi.currentUser).thenReturn(user) + val instance = MockMParticle() + instance.setIdentityApi(identityApi) + MParticle.setInstance(instance) + + var capturedListener: com.mparticle.TypedUserAttributeListener? = null + `when`(user.getUserAttributes(any())).thenAnswer { invocation -> + capturedListener = invocation.arguments[0] as com.mparticle.TypedUserAttributeListener + null + } + + val attributes = mapOf("country" to "US") + roktApi.selectPlacements("Test", attributes, null, null, null, null, null) + + verify(user).setUserAttributes(any()) + verify(roktListener, never()).selectPlacements( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + + capturedListener!!.onUserAttributesReceived(emptyMap(), emptyMap(), 12345L) + + verify(roktListener).selectPlacements( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + @Test fun testSelectPlacements_passesPlacementOptions() { val kitConfig = KitConfiguration.createKitConfiguration(JSONObject().put("id", 42))