From 169a8448f571749f01ee747d3983aebf13e23d9a Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Wed, 27 May 2026 13:44:11 -0400 Subject: [PATCH] feat(rokt): add Shoppable Ads support Add Rokt kit facade APIs for payment extension registration and Shoppable Ads selection so host apps can wire optional payment providers through mParticle. Forward Shoppable Ads requests through the existing identity confirmation and attribute enrichment pipeline, pass dashboard stripePublishableKey into the Rokt SDK registration config, and cover the behavior with kit and facade tests. --- CHANGELOG.md | 4 + kits/rokt/rokt/README.md | 25 +++++ kits/rokt/rokt/build.gradle | 2 +- .../main/kotlin/com/mparticle/kits/Rokt.kt | 51 +++++++++ .../main/kotlin/com/mparticle/kits/RoktKit.kt | 42 ++++++- .../com/mparticle/kits/RoktKitBridge.kt | 10 ++ .../mparticle/kits/RoktKitRequestHelper.kt | 29 +++++ .../kotlin/com/mparticle/kits/RoktKitTests.kt | 105 ++++++++++++++++++ .../kotlin/com/mparticle/kits/RoktTest.kt | 68 ++++++++++++ 9 files changed, 332 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcfa57e4f..67ab0ccb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Add Rokt Shoppable Ads payment extension registration and selection APIs. + ### Changed - Add support for qualified alpha, beta, and release candidate versions in release workflows. diff --git a/kits/rokt/rokt/README.md b/kits/rokt/rokt/README.md index 072d4b31e..3936a9b04 100644 --- a/kits/rokt/rokt/README.md +++ b/kits/rokt/rokt/README.md @@ -35,6 +35,31 @@ Java consumers can use the kit helper: MParticleRokt.Rokt().selectPlacements("RoktExperience", attributes); ``` +### Shoppable Ads + +Add the optional Rokt payment extension dependency in your app, then register the extension after mParticle starts. The Rokt kit reads `stripePublishableKey` from the mParticle dashboard configuration and forwards it to the Rokt SDK during registration. + +Kotlin: + +```kotlin +import com.mparticle.MParticle +import com.mparticle.kits.rokt +import com.rokt.payment.extension.StripePaymentExtension + +MParticle.getInstance()?.rokt?.registerPaymentExtension(StripePaymentExtension()) +MParticle.getInstance()?.rokt?.selectShoppableAds( + identifier = "RoktShoppableExperience", + attributes = attributes, +) +``` + +Java: + +```java +MParticleRokt.Rokt().registerPaymentExtension(new StripePaymentExtension()); +MParticleRokt.Rokt().selectShoppableAds("RoktShoppableExperience", attributes); +``` + Compose integrations can receive native Rokt SDK events from `RoktLayout`: ```kotlin diff --git a/kits/rokt/rokt/build.gradle b/kits/rokt/rokt/build.gradle index cc343d190..8ba0d8744 100644 --- a/kits/rokt/rokt/build.gradle +++ b/kits/rokt/rokt/build.gradle @@ -82,7 +82,7 @@ dependencies { implementation 'androidx.annotation:annotation:1.5.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0' implementation 'androidx.compose.runtime:runtime' - api 'com.rokt:roktsdk:6.0.1-rc.1' + api 'com.rokt:roktsdk:6.0.1-rc.2' api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" testImplementation files('libs/java-json.jar') diff --git a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/Rokt.kt b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/Rokt.kt index 9876a865f..72354c919 100644 --- a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/Rokt.kt +++ b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/Rokt.kt @@ -7,6 +7,7 @@ import com.mparticle.internal.Logger import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent +import com.rokt.roktsdk.payment.PaymentExtension import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import java.lang.ref.WeakReference @@ -64,6 +65,56 @@ class Rokt internal constructor(private val mKitManager: KitManager) { flowOf() } + /** + * Register a payment extension for Shoppable Ads. + * + * The Rokt Kit adds mParticle dashboard configuration before forwarding to the Rokt SDK. + * + * @param paymentExtension The payment extension implementation to register + * @return true if the Rokt SDK accepts the payment extension configuration + */ + fun registerPaymentExtension(paymentExtension: PaymentExtension): Boolean = if (isEnabled()) { + val resolved = resolveRoktKit() + if (resolved != null) { + resolved.second.registerPaymentExtension(paymentExtension) + } else { + Logger.warning("Rokt Kit is not available. Make sure the Rokt Kit is included in your app.") + false + } + } else { + false + } + + /** + * Display a Rokt Shoppable Ads placement with the specified parameters. + * + * @param identifier The placement identifier + * @param attributes User attributes to pass to Rokt + * @param config Optional Rokt configuration + */ + @JvmOverloads + fun selectShoppableAds( + identifier: String, + attributes: Map = emptyMap(), + config: RoktConfig? = null, + ) { + if (isEnabled()) { + val resolved = resolveRoktKit() + if (resolved != null) { + val (kitIntegration, roktListener) = resolved + RoktKitRequestHelper.selectShoppableAds( + kitIntegration = kitIntegration, + roktListener = roktListener, + viewName = identifier, + attributes = HashMap(attributes), + config = config, + ) + } else { + Logger.warning("Rokt Kit is not available. Make sure the Rokt Kit is included in your app.") + } + } + } + /** * Notify Rokt that a purchase has been finalized. * diff --git a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt index 7539d0ce7..08a0ee069 100644 --- a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt +++ b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt @@ -29,6 +29,7 @@ import com.rokt.roktsdk.RoktEvent import com.rokt.roktsdk.RoktWidgetDimensionCallBack import com.rokt.roktsdk.Widget import com.rokt.roktsdk.logging.RoktLogLevel +import com.rokt.roktsdk.payment.PaymentExtension import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -230,6 +231,12 @@ class RoktKit : private fun prepareFinalAttributes( filterUser: FilteredMParticleUser?, attributes: Map, + ): Map = prepareFinalAttributesForEvent(filterUser, attributes, EVENT_NAME_SELECT_PLACEMENTS) + + private fun prepareFinalAttributesForEvent( + filterUser: FilteredMParticleUser?, + attributes: Map, + eventName: String, ): Map { val finalAttributes = mutableMapOf() @@ -251,7 +258,7 @@ class RoktKit : verifyHashedEmail(finalAttributes) - logSelectPlacementEvent(finalAttributes) + logRoktEvent(eventName, finalAttributes) return finalAttributes } @@ -279,6 +286,32 @@ class RoktKit : override fun events(identifier: String): Flow = Rokt.events(identifier) + override fun registerPaymentExtension(paymentExtension: PaymentExtension): Boolean { + val stripeKey = configuration?.settings?.get(STRIPE_PUBLISHABLE_KEY) + val paymentConfig = if (stripeKey.isNullOrEmpty()) { + emptyMap() + } else { + mapOf(STRIPE_KEY_ALIAS to stripeKey) + } + return Rokt.registerPaymentExtension(paymentExtension, paymentConfig) + } + + override fun selectShoppableAds( + viewName: String, + attributes: Map, + filterUser: FilteredMParticleUser?, + roktConfig: RoktConfig?, + ) { + val finalAttributes = prepareFinalAttributesForEvent(filterUser, attributes, EVENT_NAME_SELECT_SHOPPABLE_ADS) + + Rokt.selectShoppableAds( + identifier = viewName, + attributes = finalAttributes, + config = roktConfig, + eventCollector = null, + ) + } + override fun setWrapperSdkVersion(wrapperSdkVersion: WrapperSdkVersion) { val sdkFrameworkType = when (wrapperSdkVersion.sdk) { WrapperSdk.WrapperFlutter -> Flutter @@ -378,8 +411,8 @@ class RoktKit : } } - private fun logSelectPlacementEvent(attributes: Map) { - val event = MPEvent.Builder(EVENT_NAME_SELECT_PLACEMENTS, MParticle.EventType.Other) + private fun logRoktEvent(eventName: String, attributes: Map) { + val event = MPEvent.Builder(eventName, MParticle.EventType.Other) .customAttributes(attributes) .build() MParticle.getInstance()?.logEvent(event) @@ -423,8 +456,11 @@ class RoktKit : const val NAME = "Rokt" const val ROKT_ACCOUNT_ID = "accountId" const val HASHED_EMAIL_USER_IDENTITY_TYPE = "hashedEmailUserIdentityType" + const val STRIPE_PUBLISHABLE_KEY = "stripePublishableKey" + const val STRIPE_KEY_ALIAS = "stripeKey" const val MPID = "mpid" const val EVENT_NAME_SELECT_PLACEMENTS = "selectPlacements" + const val EVENT_NAME_SELECT_SHOPPABLE_ADS = "selectShoppableAds" const val NO_ROKT_ACCOUNT_ID = "No Rokt account ID provided, can't initialize kit." const val NO_APP_VERSION_FOUND = "No App version found, can't initialize kit." } diff --git a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitBridge.kt b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitBridge.kt index fb55f7316..7a36b0230 100644 --- a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitBridge.kt +++ b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitBridge.kt @@ -4,6 +4,7 @@ import android.graphics.Typeface import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent +import com.rokt.roktsdk.payment.PaymentExtension import kotlinx.coroutines.flow.Flow import java.lang.ref.WeakReference @@ -22,6 +23,15 @@ internal interface RoktKitBridge { fun enrichAttributes(attributes: MutableMap, user: FilteredMParticleUser?) + fun registerPaymentExtension(paymentExtension: PaymentExtension): Boolean + + fun selectShoppableAds( + viewName: String, + attributes: Map, + user: FilteredMParticleUser?, + config: RoktConfig?, + ) + fun purchaseFinalized(identifier: String, catalogItemId: String, success: Boolean) fun close() diff --git a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitRequestHelper.kt b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitRequestHelper.kt index a30d1d5a3..c7cdd7a64 100644 --- a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitRequestHelper.kt +++ b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKitRequestHelper.kt @@ -74,6 +74,35 @@ internal object RoktKitRequestHelper { } } + fun selectShoppableAds( + kitIntegration: KitIntegration, + roktListener: RoktKitBridge, + viewName: String, + attributes: Map, + config: RoktConfig?, + ) { + val mutableAttributes = attributes.toMutableMap() + val instance = MParticle.getInstance() + if (instance == null) { + Logger.warning("MParticle instance is null, cannot execute Rokt Shoppable Ads placement") + return + } + val user = instance.Identity().currentUser + 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, kitConfig) + roktListener.selectShoppableAds( + viewName, + finalAttributes, + FilteredMParticleUser.getInstance(user?.id ?: 0L, kitIntegration), + config, + ) + } + } + private fun getValueIgnoreCase(map: Map, searchKey: String): String? { for ((key, value) in map) { if (key.equals(searchKey, ignoreCase = true)) { diff --git a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt index bad941bfc..228e39df1 100644 --- a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt +++ b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt @@ -19,8 +19,10 @@ import com.mparticle.internal.CoreCallbacks.KitListener import com.mparticle.kits.mocks.MockKitConfiguration import com.rokt.roktsdk.FulfillmentAttributes import com.rokt.roktsdk.Rokt +import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent import com.rokt.roktsdk.logging.RoktLogLevel +import com.rokt.roktsdk.payment.PaymentExtension import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -1330,6 +1332,109 @@ class RoktKitTests { ) } + @Test + fun testRegisterPaymentExtensionPassesStripeKeyFromKitConfiguration() { + val paymentExtension = mock(PaymentExtension::class.java) + var registrationConfig: Map<*, *>? = null + Mockito.`when`(paymentExtension.id).thenReturn("capturing-payment-extension") + Mockito.doAnswer { + registrationConfig = it.arguments[0] as Map<*, *> + true + }.`when`(paymentExtension).onRegister(Mockito.anyMap()) + roktKit.configuration = MockKitConfiguration.createKitConfiguration( + JSONObject() + .put("as", JSONObject().put("stripePublishableKey", "pk_test_123")) + .put("hs", JSONObject()), + ) + + val result = roktKit.registerPaymentExtension(paymentExtension) + + assertTrue(result) + assertEquals(mapOf("stripeKey" to "pk_test_123"), registrationConfig) + } + + @Test + fun testRegisterPaymentExtensionPassesEmptyConfigWhenStripeKeyAbsent() { + val paymentExtension = mock(PaymentExtension::class.java) + var registrationConfig: Map<*, *>? = null + Mockito.`when`(paymentExtension.id).thenReturn("capturing-payment-extension") + Mockito.doAnswer { + registrationConfig = it.arguments[0] as Map<*, *> + false + }.`when`(paymentExtension).onRegister(Mockito.anyMap()) + roktKit.configuration = MockKitConfiguration.createKitConfiguration(JSONObject().put("hs", JSONObject())) + + val result = roktKit.registerPaymentExtension(paymentExtension) + + assertFalse(result) + assertTrue(registrationConfig?.isEmpty() == true) + } + + @Test + fun testSelectShoppableAdsInvokesRoktAndLogsEvent() { + mockkObject(Rokt) + try { + val capturedAttributesSlot = slot>() + every { + Rokt.selectPlacements( + any(), + capture(capturedAttributesSlot), + isNull(), + isNull(), + isNull(), + any(), + isNull(), + ) + } just runs + + val mockFilterUser = mock(FilteredMParticleUser::class.java) + val userIdentities = HashMap() + userIdentities[IdentityType.Email] = "test@example.com" + Mockito.`when`(mockFilterUser.userIdentities).thenReturn(userIdentities) + Mockito.`when`(mockFilterUser.id).thenReturn(9876L) + val userAttributes = HashMap() + userAttributes["user_key"] = "user_val" + Mockito.`when`(mockFilterUser.userAttributes).thenReturn(userAttributes) + roktKit.configuration = MockKitConfiguration.createKitConfiguration(JSONObject().put("hs", JSONObject())) + val config = RoktConfig.Builder().colorMode(RoktConfig.ColorMode.DARK).build() + + roktKit.selectShoppableAds( + viewName = "ShopView", + attributes = mapOf("attr1" to "val1"), + filterUser = mockFilterUser, + roktConfig = config, + ) + + assertEquals( + mapOf( + "user_key" to "user_val", + "attr1" to "val1", + "mpid" to "9876", + "email" to "test@example.com", + "adsExperience" to "shoppable", + ), + capturedAttributesSlot.captured, + ) + + val eventCaptor = ArgumentCaptor.forClass(MPEvent::class.java) + Mockito.verify(MParticle.getInstance()!!).logEvent(eventCaptor.capture()) + val loggedEvent = eventCaptor.value + assertEquals("selectShoppableAds", loggedEvent.eventName) + assertEquals(MParticle.EventType.Other, loggedEvent.eventType) + assertEquals( + mapOf( + "user_key" to "user_val", + "attr1" to "val1", + "mpid" to "9876", + "email" to "test@example.com", + ), + loggedEvent.customAttributes, + ) + } finally { + unmockkObject(Rokt) + } + } + @Test fun test_execute_logsMPEventWhenMParticleInstanceIsNull() { // Arrange diff --git a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktTest.kt b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktTest.kt index 0433b8a57..88af7f60c 100644 --- a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktTest.kt +++ b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktTest.kt @@ -10,6 +10,7 @@ import com.mparticle.internal.KitManager import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent +import com.rokt.roktsdk.payment.PaymentExtension import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList @@ -30,6 +31,7 @@ import org.powermock.core.classloader.annotations.PrepareForTest import org.powermock.modules.junit4.PowerMockRunner import java.lang.ref.WeakReference import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -228,6 +230,72 @@ class RoktTest { } } + @Test + fun testRegisterPaymentExtension_whenEnabled_delegatesToKitManager() { + configManager.enabled = true + val paymentExtension = org.mockito.Mockito.mock(PaymentExtension::class.java) + `when`(roktListener.registerPaymentExtension(paymentExtension)).thenReturn(true) + + val result = rokt.registerPaymentExtension(paymentExtension) + + assertTrue(result) + verify(roktListener).registerPaymentExtension(paymentExtension) + } + + @Test + fun testRegisterPaymentExtension_whenDisabled_returnsFalse() { + configManager.enabled = false + val paymentExtension = org.mockito.Mockito.mock(PaymentExtension::class.java) + + val result = rokt.registerPaymentExtension(paymentExtension) + + assertFalse(result) + verify(roktListener, never()).registerPaymentExtension(any()) + } + + @Test + fun testRegisterPaymentExtension_whenRoktKitMissing_returnsFalse() { + val paymentExtension = org.mockito.Mockito.mock(PaymentExtension::class.java) + `when`(kitManager.isKitActive(MParticle.ServiceProviders.ROKT)).thenReturn(false) + + val result = rokt.registerPaymentExtension(paymentExtension) + + assertFalse(result) + verify(roktListener, never()).registerPaymentExtension(any()) + } + + @Test + fun testSelectShoppableAds_whenEnabled_delegatesToKitManager() { + configManager.enabled = true + val attributes = mapOf("key" to "value") + val config = RoktConfig.Builder().colorMode(RoktConfig.ColorMode.DARK).build() + + rokt.selectShoppableAds( + identifier = "shoppableView", + attributes = attributes, + config = config, + ) + + verify(roktListener).selectShoppableAds( + eq("shoppableView"), + any(), + any(), + eq(config), + ) + } + + @Test + fun testSelectShoppableAds_whenDisabled_doesNotCallKitManager() { + configManager.enabled = false + + rokt.selectShoppableAds( + identifier = "shoppableView", + attributes = emptyMap(), + ) + + verify(roktListener, never()).selectShoppableAds(any(), any(), any(), any()) + } + @Test fun testSetSessionId_whenEnabled_delegatesToKitManager() { configManager.enabled = true