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