Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ internal sealed class BackendEvent : Event {
val errorCode: Int? = null,
@SerialName("error_message")
val errorMessage: String? = null,
@SerialName("component_type")
val componentType: String? = null,
@SerialName("component_name")
val componentName: String? = null,
@SerialName("component_value")
val componentValue: String? = null,
@SerialName("component_url")
val componentUrl: String? = null,
) : BackendEvent()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.revenuecat.purchases.customercenter.events.CustomerCenterImpressionEv
import com.revenuecat.purchases.customercenter.events.CustomerCenterSurveyOptionChosenEvent
import com.revenuecat.purchases.paywalls.events.CustomPaywallEvent
import com.revenuecat.purchases.paywalls.events.PaywallEvent
import com.revenuecat.purchases.paywalls.events.toWireString
import com.revenuecat.purchases.paywalls.events.PaywallEventType
import com.revenuecat.purchases.utils.Event
import kotlinx.serialization.SerialName
Expand Down Expand Up @@ -106,6 +107,10 @@ internal fun PaywallEvent.toBackendStoredEvent(
productID = data.productIdentifier,
errorCode = data.errorCode,
errorMessage = data.errorMessage,
componentType = controlInteraction?.componentType?.toWireString(),
componentName = controlInteraction?.componentName,
componentValue = controlInteraction?.componentValue,
componentUrl = controlInteraction?.componentUrl,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class ButtonComponent(
@get:JvmSynthetic public val action: Action,
@get:JvmSynthetic public val stack: StackComponent,
@get:JvmSynthetic public val transition: PaywallTransition? = null,
@get:JvmSynthetic public val name: String? = null,
) : PaywallComponent {

@InternalRevenueCatAPI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public class CarouselComponent(
public val autoAdvance: AutoAdvancePages? = null,
@get:JvmSynthetic
public val overrides: List<ComponentOverride<PartialCarouselComponent>> = emptyList(),
@get:JvmSynthetic
public val name: String? = null,
) : PaywallComponent {

@Poko
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public class TabControlButtonComponent(
public val tabId: String,
@get:JvmSynthetic
public val stack: StackComponent,
@get:JvmSynthetic
public val name: String? = null,
) : PaywallComponent

@InternalRevenueCatAPI
Expand All @@ -43,6 +45,8 @@ public class TabControlToggleComponent(
@SerialName("default_value")
public val defaultValue: Boolean,
@get:JvmSynthetic
public val name: String? = null,
@get:JvmSynthetic
@SerialName("thumb_color_on")
public val thumbColorOn: ColorScheme,
@get:JvmSynthetic
Expand All @@ -67,6 +71,8 @@ public object TabControlComponent : PaywallComponent
@SerialName("tabs")
@Immutable
public class TabsComponent(
@get:JvmSynthetic
public val name: String? = null,
@get:JvmSynthetic
public val visible: Boolean? = null,
@get:JvmSynthetic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ constructor(
public val margin: Padding = zero,
@get:JvmSynthetic
public val overrides: List<ComponentOverride<PartialTextComponent>> = emptyList(),
@get:JvmSynthetic
public val name: String? = null,
) : PaywallComponent

@InternalRevenueCatAPI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.revenuecat.purchases.common.events.FeatureEvent
import com.revenuecat.purchases.utils.serializers.DateSerializer
import com.revenuecat.purchases.utils.serializers.UUIDSerializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.nullable
Expand Down Expand Up @@ -57,8 +58,47 @@ public enum class PaywallEventType(public val value: String) {
* An exit offer will be shown to the user.
*/
EXIT_OFFER("paywall_exit_offer"),

/**
* User interacted with a paywall control (tabs, carousel, non-purchase button, etc.).
*/
CONTROL_INTERACTION("paywall_control_interaction"),
}

/**
* Control categories for [PaywallEventType.CONTROL_INTERACTION]. Wire values match iOS `ControlType`.
*/
@InternalRevenueCatAPI
@Serializable
public enum class PaywallControlType {
@SerialName("tab")
TAB,

@SerialName("switch")
SWITCH,

@SerialName("carousel")
CAROUSEL,

@SerialName("button")
BUTTON,

@SerialName("text")
TEXT,
}

/**
* Payload for [PaywallEventType.CONTROL_INTERACTION].
*/
@InternalRevenueCatAPI
@Serializable
public data class PaywallControlInteractionData(
public val componentType: PaywallControlType,
public val componentName: String? = null,
public val componentValue: String,
public val componentUrl: String? = null,
)

/**
* Types of exit offers. Meant for RevenueCatUI use.
*/
Expand All @@ -80,6 +120,7 @@ public data class PaywallEvent(
public val creationData: CreationData,
public val data: Data,
public val type: PaywallEventType,
public val controlInteraction: PaywallControlInteractionData? = null,
) : FeatureEvent {

override val isPriorityEvent: Boolean get() = type == PaywallEventType.IMPRESSION
Expand Down Expand Up @@ -281,3 +322,12 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
)
}
}

@InternalRevenueCatAPI
internal fun PaywallControlType.toWireString(): String = when (this) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm since PaywallControlType is serializable, should we instead just serialize the enum value? (Alternatively, not sure if we just want to make the type not serializable and keep this. Just keeping both means we need to keep serialization logic in both places.)

PaywallControlType.TAB -> "tab"
PaywallControlType.SWITCH -> "switch"
PaywallControlType.CAROUSEL -> "carousel"
PaywallControlType.BUTTON -> "button"
PaywallControlType.TEXT -> "text"
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ internal data class PaywallStoredEvent(
localeIdentifier = event.data.localeIdentifier,
exitOfferType = event.data.exitOfferType?.value,
exitOfferingID = event.data.exitOfferingIdentifier,
packageID = event.data.packageIdentifier,
productID = event.data.productIdentifier,
errorCode = event.data.errorCode,
errorMessage = event.data.errorMessage,
componentType = event.controlInteraction?.componentType?.toWireString(),
componentName = event.controlInteraction?.componentName,
componentValue = event.controlInteraction?.componentValue,
componentUrl = event.controlInteraction?.componentUrl,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.events

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.revenuecat.purchases.PresentedOfferingContext
import com.revenuecat.purchases.common.events.toBackendStoredEvent
import kotlinx.serialization.encodeToString
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
Expand Down Expand Up @@ -277,4 +278,69 @@ class PaywallEventSerializationTests {
assertThat(decodedEvent.event.data.errorMessage).isEqualTo("Purchase failed")
assertThat(decodedEvent.event.type).isEqualTo(PaywallEventType.PURCHASE_ERROR)
}

@Test
fun `can encode and decode control interaction event`() {
val stored = PaywallStoredEvent(
event = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("598207f4-97af-4b57-a581-eb27bcc6e444"),
date = Date(1699270689111),
),
data = PaywallEvent.Data(
paywallIdentifier = "paywallID",
presentedOfferingContext = PresentedOfferingContext("offeringID"),
paywallRevision = 2,
sessionIdentifier = UUID.fromString("615107f4-98bf-4b68-a582-eb27bcb6e444"),
displayMode = "fullscreen",
localeIdentifier = "en_US",
darkMode = false,
),
type = PaywallEventType.CONTROL_INTERACTION,
controlInteraction = PaywallControlInteractionData(
componentType = PaywallControlType.BUTTON,
componentName = "terms",
componentValue = "navigate_to_terms",
componentUrl = "https://example.com/terms",
),
),
userID = "testAppUserId",
)
val json = PaywallStoredEvent.json.encodeToString(stored)
val decoded = PaywallStoredEvent.json.decodeFromString<PaywallStoredEvent>(json)
assertThat(decoded).isEqualTo(stored)
assertThat(decoded.event.controlInteraction?.componentType).isEqualTo(PaywallControlType.BUTTON)
assertThat(decoded.event.controlInteraction?.componentUrl).isEqualTo("https://example.com/terms")
}

@Test
fun `toBackendStoredEvent maps control interaction fields`() {
val event = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("598207f4-97af-4b57-a581-eb27bcc6e444"),
date = Date(1699270689111),
),
data = PaywallEvent.Data(
paywallIdentifier = "pw",
presentedOfferingContext = PresentedOfferingContext("off"),
paywallRevision = 1,
sessionIdentifier = UUID.fromString("615107f4-98bf-4b68-a582-eb27bcb6e444"),
displayMode = "footer",
localeIdentifier = "en_US",
darkMode = true,
),
type = PaywallEventType.CONTROL_INTERACTION,
controlInteraction = PaywallControlInteractionData(
componentType = PaywallControlType.TAB,
componentName = "tabs_main",
componentValue = "annual",
),
)
val backend = event.toBackendStoredEvent("uid")!!.event
assertThat(backend.type).isEqualTo("paywall_control_interaction")
assertThat(backend.componentType).isEqualTo("tab")
assertThat(backend.componentName).isEqualTo("tabs_main")
assertThat(backend.componentValue).isEqualTo("annual")
assertThat(backend.componentUrl).isNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,57 @@ class PaywallEventsRequestSerializationTest {
val decodedRequest = JsonProvider.defaultJson.decodeFromString<EventsRequest>(requestString)
Assertions.assertThat(decodedRequest).isEqualTo(request)
}

@Test
fun `can encode paywall control interaction event with component fields`() {
val controlRequest = EventsRequest(
listOf(
BackendStoredEvent.Paywalls(
BackendEvent.Paywalls(
id = "cid",
version = 1,
type = PaywallEventType.CONTROL_INTERACTION.value,
appUserID = "user",
sessionID = "sess",
offeringID = "off",
paywallID = "pw",
paywallRevision = 1,
timestamp = 100L,
displayMode = "fullscreen",
darkMode = false,
localeIdentifier = "en_US",
componentType = "button",
componentName = "restore_cta",
componentValue = "restore_purchases",
componentUrl = null,
),
),
).map { it.toBackendEvent() },
)
val requestString = JsonProvider.defaultJson.encodeToString(controlRequest)
Assertions.assertThat(requestString).isEqualTo(
"{" +
"\"events\":[" +
"{" +
"\"discriminator\":\"paywalls\"," +
"\"id\":\"cid\"," +
"\"version\":1," +
"\"type\":\"paywall_control_interaction\"," +
"\"app_user_id\":\"user\"," +
"\"session_id\":\"sess\"," +
"\"offering_id\":\"off\"," +
"\"paywall_id\":\"pw\"," +
"\"paywall_revision\":1," +
"\"timestamp\":100," +
"\"display_mode\":\"fullscreen\"," +
"\"dark_mode\":false," +
"\"locale\":\"en_US\"," +
"\"component_type\":\"button\"," +
"\"component_name\":\"restore_cta\"," +
"\"component_value\":\"restore_purchases\"" +
"}" +
"]" +
"}",
)
}
}
Loading