diff --git a/android-core/src/main/kotlin/com/mparticle/rokt/RoktSession.kt b/android-core/src/main/kotlin/com/mparticle/rokt/RoktSession.kt new file mode 100644 index 000000000..7276bddd0 --- /dev/null +++ b/android-core/src/main/kotlin/com/mparticle/rokt/RoktSession.kt @@ -0,0 +1,13 @@ +package com.mparticle.rokt + +/** + * A Rokt session suitable for handoff between native and non-native integrations. + * + * Includes the session id and short-lived session token used to authorize offers and events. + * + * @param sessionId The Rokt session identifier. Must be non-empty when passed to [com.mparticle.kits.Rokt.setSession]. + * @param sessionToken The Rokt session token. Must be non-empty when passed to [com.mparticle.kits.Rokt.setSession]. + * @param expiresAt Optional Unix epoch milliseconds when [sessionToken] expires. The underlying Rokt SDK applies its default expiry when + * this value is omitted or already in the past. + */ +data class RoktSession @JvmOverloads constructor(val sessionId: String, val sessionToken: String, val expiresAt: Long? = null) diff --git a/android-core/src/test/java/com/mparticle/rokt/RoktSessionJavaTest.java b/android-core/src/test/java/com/mparticle/rokt/RoktSessionJavaTest.java new file mode 100644 index 000000000..f2274f215 --- /dev/null +++ b/android-core/src/test/java/com/mparticle/rokt/RoktSessionJavaTest.java @@ -0,0 +1,21 @@ +package com.mparticle.rokt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import java.util.Arrays; +import org.junit.Test; + +public class RoktSessionJavaTest { + @Test + public void supportsRequiredTokenAndOptionalExpiryConstructors() { + RoktSession sessionWithoutExpiry = new RoktSession("session-id", "session-token"); + RoktSession sessionWithExpiry = new RoktSession("session-id", "session-token", 123L); + + assertEquals("session-token", sessionWithoutExpiry.getSessionToken()); + assertEquals(Long.valueOf(123L), sessionWithExpiry.getExpiresAt()); + assertFalse( + Arrays.stream(RoktSession.class.getConstructors()) + .anyMatch(constructor -> constructor.getParameterCount() == 1)); + } +} diff --git a/kits/rokt/rokt/README.md b/kits/rokt/rokt/README.md index 5e66f1f33..5fa6aff15 100644 --- a/kits/rokt/rokt/README.md +++ b/kits/rokt/rokt/README.md @@ -35,6 +35,39 @@ Java consumers can use the kit helper: MParticleRokt.Rokt().selectPlacements("RoktExperience", attributes); ``` +### Session handoff + +Use a token-backed session to preserve Rokt continuity when moving between native and non-native experiences. Both the session ID and token are required; expiry is optional. + +Kotlin: + +```kotlin +import com.mparticle.MParticle +import com.mparticle.kits.rokt +import com.mparticle.rokt.RoktSession + +MParticle.getInstance()?.rokt?.setSession( + RoktSession( + sessionId = sessionId, + sessionToken = sessionToken, + expiresAt = expiresAt, + ), +) + +val currentSession = MParticle.getInstance()?.rokt?.getSession() +``` + +Java: + +```java +import com.mparticle.rokt.RoktSession; + +MParticleRokt.Rokt().setSession(new RoktSession(sessionId, sessionToken, expiresAt)); +RoktSession currentSession = MParticleRokt.Rokt().getSession(); +``` + +Pass `expiresAt` as Unix epoch milliseconds when the value is available, or use the two-argument constructor to omit it. When expiry is omitted or already in the past, Rokt Android SDK 6.1.1 applies its standard 30-minute session expiry. Blank session IDs or tokens are ignored. + ### 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. 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 81b7e73fc..736a5d101 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 @@ -4,6 +4,7 @@ import android.graphics.Typeface import com.mparticle.MParticle import com.mparticle.internal.KitManager import com.mparticle.internal.Logger +import com.mparticle.rokt.RoktSession import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent @@ -147,6 +148,40 @@ class Rokt internal constructor(private val mKitManager: KitManager) { } } + /** + * Set the session to use for the next execute call. + * + * Use this when you have a session from a non-native integration (e.g. WebView) + * and want the session to stay consistent across integrations. Call before the next + * selectPlacements. + * + * Both [RoktSession.sessionId] and [RoktSession.sessionToken] must be non-empty. The optional + * [RoktSession.expiresAt] value is passed to the Rokt SDK, which applies its default expiry when + * the value is omitted or already in the past. + * + * @param session The session id, session token, and optional expiry. + */ + fun setSession(session: RoktSession) { + MParticle.logRoktApiUsage("ROKT_SET_SESSION") + if (isEnabled()) { + resolveRoktKit()?.second?.setSession(session) + } + } + + /** + * Get the current session (id + token) for use within a non-native integration e.g. WebView. + * + * @return The session, or null if disabled, no session is present, or the token has expired. + */ + fun getSession(): RoktSession? { + MParticle.logRoktApiUsage("ROKT_GET_SESSION") + return if (isEnabled()) { + resolveRoktKit()?.second?.getSession() + } else { + null + } + } + /** * Set the session id to use for the next execute call. * @@ -154,9 +189,11 @@ class Rokt internal constructor(private val mKitManager: KitManager) { * e.g. WebView, and you want the session to be consistent across integrations. * * **Note:** Empty strings are ignored and will not update the session. + * Prefer [setSession] so the session token is also applied for offers and events. * * @param sessionId The session id to be set. Must be a non-empty string. */ + @Deprecated("Use setSession to set session id and session token.") fun setSessionId(sessionId: String) { MParticle.logRoktApiUsage("ROKT_SET_SESSION_ID") if (isEnabled()) { @@ -167,8 +204,11 @@ class Rokt internal constructor(private val mKitManager: KitManager) { /** * Get the session id to use within a non-native integration e.g. WebView. * + * Prefer [getSession] to also read the session token. + * * @return The session id or null if no session is present or SDK is not initialized. */ + @Deprecated("Use getSession to read session id and session token.") fun getSessionId(): String? { MParticle.logRoktApiUsage("ROKT_GET_SESSION_ID") return if (isEnabled()) { 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 81aafe141..686684108 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 @@ -19,6 +19,7 @@ import com.mparticle.kits.KitIntegration.CommerceListener import com.mparticle.kits.KitIntegration.IdentityListener import com.mparticle.kits.KitIntegration.RoktListener import com.mparticle.rokt.RoktApiDiagnosticsForwarder +import com.mparticle.rokt.RoktSession import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.Rokt import com.rokt.roktsdk.Rokt.SdkFrameworkType.Android @@ -39,6 +40,7 @@ import kotlinx.coroutines.launch import java.lang.ref.WeakReference import java.math.BigDecimal import java.net.URL +import com.rokt.roktsdk.RoktSession as NativeRoktSession const val ROKT_ATTRIBUTE_SANDBOX_MODE: String = "sandbox" @@ -337,13 +339,49 @@ class RoktKit : Rokt.close() } + /** + * Set the session to use for the next execute call. + * + * Non-empty [RoktSession.sessionId] and [RoktSession.sessionToken] values seed session + * continuity through [Rokt.setSession]. Blank values are ignored. + */ + override fun setSession(session: RoktSession) { + val sessionId = session.sessionId.trim() + val sessionToken = session.sessionToken.trim() + if (sessionId.isEmpty() || sessionToken.isEmpty()) { + return + } + Rokt.setSession( + NativeRoktSession( + sessionId = sessionId, + sessionToken = sessionToken, + expiresAt = session.expiresAt, + ), + ) + } + + /** + * Get the current session (id + token) for WebView / non-native handoff. + */ + override fun getSession(): RoktSession? { + val session = Rokt.getSession() ?: return null + return RoktSession( + sessionId = session.sessionId, + sessionToken = session.sessionToken, + expiresAt = session.expiresAt, + ) + } + /** * Set the session id to use for the next execute call. * This is useful for cases where you have a session id from a non-native integration, * e.g. WebView, and you want the session to be consistent across integrations. * + * Prefer [setSession] so the session token is also applied for offers and events. + * * @param sessionId The session id to be set. Must be a non-empty string. */ + @Deprecated("Use setSession to set session id and session token.") override fun setSessionId(sessionId: String) { Rokt.setSessionId(sessionId) } @@ -351,8 +389,11 @@ class RoktKit : /** * Get the session id to use within a non-native integration e.g. WebView. * + * Prefer [getSession] to also read the session token. + * * @return The session id or null if no session is present. */ + @Deprecated("Use getSession to read session id and session token.") override fun getSessionId(): String? = Rokt.getSessionId() override fun enrichAttributes(attributes: MutableMap, user: FilteredMParticleUser?) { 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 7a36b0230..945381b07 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 @@ -1,6 +1,7 @@ package com.mparticle.kits import android.graphics.Typeface +import com.mparticle.rokt.RoktSession import com.rokt.roktsdk.PlacementOptions import com.rokt.roktsdk.RoktConfig import com.rokt.roktsdk.RoktEvent @@ -36,6 +37,10 @@ internal interface RoktKitBridge { fun close() + fun setSession(session: RoktSession) + + fun getSession(): RoktSession? + fun setSessionId(sessionId: String) fun getSessionId(): String? 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 64b34db54..34d5cf7ec 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 @@ -42,6 +42,7 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -1499,6 +1500,72 @@ class RoktKitTests { unmockkObject(Rokt) } + @Test + fun testSetSession_delegatesToRoktSdk() { + mockkObject(Rokt) + every { Rokt.setSession(any()) } just runs + + roktKit.setSession(com.mparticle.rokt.RoktSession("sid", "jwt", 123L)) + + verify { + Rokt.setSession( + match { + it.sessionId == "sid" && it.sessionToken == "jwt" && it.expiresAt == 123L + }, + ) + } + verify(exactly = 0) { Rokt.setSessionId(any()) } + unmockkObject(Rokt) + } + + @Test + fun testSetSession_blankTokenIsIgnored() { + mockkObject(Rokt) + + roktKit.setSession(com.mparticle.rokt.RoktSession("sid", " ")) + + verify(exactly = 0) { Rokt.setSession(any()) } + verify(exactly = 0) { Rokt.setSessionId(any()) } + unmockkObject(Rokt) + } + + @Test + fun testSetSession_blankIdIsIgnored() { + mockkObject(Rokt) + + roktKit.setSession(com.mparticle.rokt.RoktSession(" ", "jwt")) + + verify(exactly = 0) { Rokt.setSession(any()) } + verify(exactly = 0) { Rokt.setSessionId(any()) } + unmockkObject(Rokt) + } + + @Test + fun testGetSession_mapsValueFromRoktSdk() { + mockkObject(Rokt) + every { Rokt.getSession() } returns com.rokt.roktsdk.RoktSession("sid", "jwt", 456L) + + val result = roktKit.getSession() + + assertEquals("sid", result?.sessionId) + assertEquals("jwt", result?.sessionToken) + assertEquals(456L, result?.expiresAt) + verify { Rokt.getSession() } + unmockkObject(Rokt) + } + + @Test + fun testGetSession_whenNativeSessionIsAbsent_returnsNull() { + mockkObject(Rokt) + every { Rokt.getSession() } returns null + + val result = roktKit.getSession() + + assertNull(result) + verify { Rokt.getSession() } + unmockkObject(Rokt) + } + @Test fun testSetSessionId_delegatesToRoktSdk() { mockkObject(Rokt) 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 88af7f60c..6c254db14 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 @@ -296,6 +296,60 @@ class RoktTest { verify(roktListener, never()).selectShoppableAds(any(), any(), any(), any()) } + @Test + fun testSetSession_whenEnabled_delegatesToKitManager() { + configManager.enabled = true + val session = com.mparticle.rokt.RoktSession("sid", "jwt", 123L) + rokt.setSession(session) + verify(roktListener).setSession(session) + } + + @Test + fun testSetSession_whenDisabled_doesNotCallKitManager() { + configManager.enabled = false + val session = com.mparticle.rokt.RoktSession("sid", "jwt", 123L) + rokt.setSession(session) + verify(roktListener, never()).setSession(any()) + } + + @Test + fun testSetSession_whenRoktKitMissing_doesNotCallKitManager() { + `when`(kitManager.isKitActive(MParticle.ServiceProviders.ROKT)).thenReturn(false) + val session = com.mparticle.rokt.RoktSession("sid", "jwt") + + rokt.setSession(session) + + verify(roktListener, never()).setSession(any()) + } + + @Test + fun testGetSession_whenEnabled_delegatesToKitManager() { + configManager.enabled = true + val expected = com.mparticle.rokt.RoktSession("sid", "jwt", 123L) + `when`(roktListener.getSession()).thenReturn(expected) + val result = rokt.getSession() + verify(roktListener).getSession() + assertEquals(expected, result) + } + + @Test + fun testGetSession_whenDisabled_returnsNull() { + configManager.enabled = false + val result = rokt.getSession() + verify(roktListener, never()).getSession() + assertNull(result) + } + + @Test + fun testGetSession_whenRoktKitMissing_returnsNull() { + `when`(kitManager.isKitActive(MParticle.ServiceProviders.ROKT)).thenReturn(false) + + val result = rokt.getSession() + + verify(roktListener, never()).getSession() + assertNull(result) + } + @Test fun testSetSessionId_whenEnabled_delegatesToKitManager() { configManager.enabled = true