diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendEvent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendEvent.kt index 5e1066ed9f..6ef5a716fd 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendEvent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendEvent.kt @@ -111,6 +111,44 @@ 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, + @SerialName("origin_index") + val originIndex: Int? = null, + @SerialName("destination_index") + val destinationIndex: Int? = null, + @SerialName("origin_context_name") + val originContextName: String? = null, + @SerialName("destination_context_name") + val destinationContextName: String? = null, + @SerialName("default_index") + val defaultIndex: Int? = null, + @SerialName("origin_package_id") + val originPackageIdentifier: String? = null, + @SerialName("destination_package_id") + val destinationPackageIdentifier: String? = null, + @SerialName("default_package_id") + val defaultPackageIdentifier: String? = null, + @SerialName("origin_product_id") + val originProductIdentifier: String? = null, + @SerialName("destination_product_id") + val destinationProductIdentifier: String? = null, + @SerialName("default_product_id") + val defaultProductIdentifier: String? = null, + @SerialName("current_package_id") + val currentPackageIdentifier: String? = null, + @SerialName("resulting_package_id") + val resultingPackageIdentifier: String? = null, + @SerialName("current_product_id") + val currentProductIdentifier: String? = null, + @SerialName("resulting_product_id") + val resultingProductIdentifier: String? = null, ) : BackendEvent() /** diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendStoredEvent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendStoredEvent.kt index a3030e16ad..852d39c85d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendStoredEvent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/events/BackendStoredEvent.kt @@ -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.toBackendComponentFields import com.revenuecat.purchases.paywalls.events.PaywallEventType import com.revenuecat.purchases.utils.Event import kotlinx.serialization.SerialName @@ -86,6 +87,7 @@ internal fun PaywallEvent.toBackendStoredEvent( // WIP: We should implement support for these events in the backend. return null } + val backendComponentFields = componentInteraction.toBackendComponentFields() return BackendStoredEvent.Paywalls( BackendEvent.Paywalls( id = creationData.id.toString(), @@ -106,6 +108,25 @@ internal fun PaywallEvent.toBackendStoredEvent( productID = data.productIdentifier, errorCode = data.errorCode, errorMessage = data.errorMessage, + componentType = backendComponentFields.componentType, + componentName = backendComponentFields.componentName, + componentValue = backendComponentFields.componentValue, + componentUrl = backendComponentFields.componentUrl, + originIndex = backendComponentFields.originIndex, + destinationIndex = backendComponentFields.destinationIndex, + originContextName = backendComponentFields.originContextName, + destinationContextName = backendComponentFields.destinationContextName, + defaultIndex = backendComponentFields.defaultIndex, + originPackageIdentifier = backendComponentFields.originPackageIdentifier, + destinationPackageIdentifier = backendComponentFields.destinationPackageIdentifier, + defaultPackageIdentifier = backendComponentFields.defaultPackageIdentifier, + originProductIdentifier = backendComponentFields.originProductIdentifier, + destinationProductIdentifier = backendComponentFields.destinationProductIdentifier, + defaultProductIdentifier = backendComponentFields.defaultProductIdentifier, + currentPackageIdentifier = backendComponentFields.currentPackageIdentifier, + resultingPackageIdentifier = backendComponentFields.resultingPackageIdentifier, + currentProductIdentifier = backendComponentFields.currentProductIdentifier, + resultingProductIdentifier = backendComponentFields.resultingProductIdentifier, ), ) } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt index c517e737aa..3e4dffa714 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt @@ -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 diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt index 1294f63b90..44cd8f64dd 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt @@ -68,6 +68,8 @@ public class CarouselComponent( public val autoAdvance: AutoAdvancePages? = null, @get:JvmSynthetic public val overrides: List> = emptyList(), + @get:JvmSynthetic + public val name: String? = null, ) : PaywallComponent { @Poko diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt index 9370688fe2..cf889a1aee 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt @@ -26,4 +26,7 @@ public class PackageComponent( @Serializable(with = ResilientPromoOfferConfigSerializer::class) @SerialName("play_store_offer") public val playStoreOffer: PromoOfferConfig? = null, + @get:JvmSynthetic + @SerialName("name") + public val name: String? = null, ) : PaywallComponent diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TabsComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TabsComponent.kt index 13d724ebc7..40b6402a7a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TabsComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TabsComponent.kt @@ -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 @@ -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 @@ -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 diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TextComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TextComponent.kt index d627bf53b7..b738f2d5d4 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TextComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TextComponent.kt @@ -71,6 +71,8 @@ constructor( public val margin: Padding = zero, @get:JvmSynthetic public val overrides: List> = emptyList(), + @get:JvmSynthetic + public val name: String? = null, ) : PaywallComponent @InternalRevenueCatAPI diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallEvent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallEvent.kt index df85849dac..0d3591c29b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallEvent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallEvent.kt @@ -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 @@ -17,6 +18,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.encodeStructure import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNames import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.util.Date @@ -57,8 +59,68 @@ 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 component (tabs, carousel, non-purchase button, etc.). + */ + COMPONENT_INTERACTION("paywall_component_interaction"), +} + +/** + * Component categories for [PaywallEventType.COMPONENT_INTERACTION]. Wire values match iOS `ControlType`. + */ +@InternalRevenueCatAPI +@Serializable +public enum class PaywallComponentType { + @SerialName("tab") + TAB, + + @SerialName("switch") + SWITCH, + + @SerialName("carousel") + CAROUSEL, + + @SerialName("button") + BUTTON, + + @SerialName("text") + TEXT, + + @SerialName("package") + PACKAGE, + + @SerialName("package_selection_sheet") + PACKAGE_SELECTION_SHEET, } +/** + * Payload for [PaywallEventType.COMPONENT_INTERACTION]. + */ +@InternalRevenueCatAPI +@Serializable +public data class PaywallComponentInteractionData( + public val componentType: PaywallComponentType, + public val componentName: String? = null, + public val componentValue: String, + public val componentUrl: String? = null, + public val originIndex: Int? = null, + public val destinationIndex: Int? = null, + public val originContextName: String? = null, + public val destinationContextName: String? = null, + public val defaultIndex: Int? = null, + public val originPackageIdentifier: String? = null, + public val destinationPackageIdentifier: String? = null, + public val defaultPackageIdentifier: String? = null, + public val originProductIdentifier: String? = null, + public val destinationProductIdentifier: String? = null, + public val defaultProductIdentifier: String? = null, + public val currentPackageIdentifier: String? = null, + public val resultingPackageIdentifier: String? = null, + public val currentProductIdentifier: String? = null, + public val resultingProductIdentifier: String? = null, +) + /** * Types of exit offers. Meant for RevenueCatUI use. */ @@ -80,6 +142,8 @@ public data class PaywallEvent( public val creationData: CreationData, public val data: Data, public val type: PaywallEventType, + @JsonNames("controlInteraction") + public val componentInteraction: PaywallComponentInteractionData? = null, ) : FeatureEvent { override val isPriorityEvent: Boolean get() = type == PaywallEventType.IMPRESSION @@ -281,3 +345,66 @@ internal object PaywallEventDataSerializer : KSerializer { ) } } + +@InternalRevenueCatAPI +internal fun PaywallComponentType.toWireString(): String = when (this) { + PaywallComponentType.TAB -> "tab" + PaywallComponentType.SWITCH -> "switch" + PaywallComponentType.CAROUSEL -> "carousel" + PaywallComponentType.BUTTON -> "button" + PaywallComponentType.TEXT -> "text" + PaywallComponentType.PACKAGE -> "package" + PaywallComponentType.PACKAGE_SELECTION_SHEET -> "package_selection_sheet" +} + +/** + * Flattened component-interaction values for [BackendEvent.Paywalls] (shared by stored-event paths). + */ +@InternalRevenueCatAPI +internal data class BackendPaywallComponentFields( + val componentType: String? = null, + val componentName: String? = null, + val componentValue: String? = null, + val componentUrl: String? = null, + val originIndex: Int? = null, + val destinationIndex: Int? = null, + val originContextName: String? = null, + val destinationContextName: String? = null, + val defaultIndex: Int? = null, + val originPackageIdentifier: String? = null, + val destinationPackageIdentifier: String? = null, + val defaultPackageIdentifier: String? = null, + val originProductIdentifier: String? = null, + val destinationProductIdentifier: String? = null, + val defaultProductIdentifier: String? = null, + val currentPackageIdentifier: String? = null, + val resultingPackageIdentifier: String? = null, + val currentProductIdentifier: String? = null, + val resultingProductIdentifier: String? = null, +) + +@InternalRevenueCatAPI +internal fun PaywallComponentInteractionData?.toBackendComponentFields(): BackendPaywallComponentFields { + val interaction = this ?: return BackendPaywallComponentFields() + return BackendPaywallComponentFields( + componentType = interaction.componentType.toWireString(), + componentName = interaction.componentName, + componentValue = interaction.componentValue, + componentUrl = interaction.componentUrl, + originIndex = interaction.originIndex, + destinationIndex = interaction.destinationIndex, + originContextName = interaction.originContextName, + destinationContextName = interaction.destinationContextName, + defaultIndex = interaction.defaultIndex, + originPackageIdentifier = interaction.originPackageIdentifier, + destinationPackageIdentifier = interaction.destinationPackageIdentifier, + defaultPackageIdentifier = interaction.defaultPackageIdentifier, + originProductIdentifier = interaction.originProductIdentifier, + destinationProductIdentifier = interaction.destinationProductIdentifier, + defaultProductIdentifier = interaction.defaultProductIdentifier, + currentPackageIdentifier = interaction.currentPackageIdentifier, + resultingPackageIdentifier = interaction.resultingPackageIdentifier, + currentProductIdentifier = interaction.currentProductIdentifier, + resultingProductIdentifier = interaction.resultingProductIdentifier, + ) +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallStoredEvent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallStoredEvent.kt index 819825c3d8..7a2af80f18 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallStoredEvent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/events/PaywallStoredEvent.kt @@ -22,6 +22,7 @@ internal data class PaywallStoredEvent( @OptIn(InternalRevenueCatAPI::class) fun toBackendEvent(): BackendEvent.Paywalls { + val backendComponentFields = event.componentInteraction.toBackendComponentFields() return BackendEvent.Paywalls( id = event.creationData.id.toString(), version = BackendEvent.PAYWALL_EVENT_SCHEMA_VERSION, @@ -37,6 +38,29 @@ 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 = backendComponentFields.componentType, + componentName = backendComponentFields.componentName, + componentValue = backendComponentFields.componentValue, + componentUrl = backendComponentFields.componentUrl, + originIndex = backendComponentFields.originIndex, + destinationIndex = backendComponentFields.destinationIndex, + originContextName = backendComponentFields.originContextName, + destinationContextName = backendComponentFields.destinationContextName, + defaultIndex = backendComponentFields.defaultIndex, + originPackageIdentifier = backendComponentFields.originPackageIdentifier, + destinationPackageIdentifier = backendComponentFields.destinationPackageIdentifier, + defaultPackageIdentifier = backendComponentFields.defaultPackageIdentifier, + originProductIdentifier = backendComponentFields.originProductIdentifier, + destinationProductIdentifier = backendComponentFields.destinationProductIdentifier, + defaultProductIdentifier = backendComponentFields.defaultProductIdentifier, + currentPackageIdentifier = backendComponentFields.currentPackageIdentifier, + resultingPackageIdentifier = backendComponentFields.resultingPackageIdentifier, + currentProductIdentifier = backendComponentFields.currentProductIdentifier, + resultingProductIdentifier = backendComponentFields.resultingProductIdentifier, ) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt index 7ec845ec10..d06b03b4cf 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt @@ -187,6 +187,29 @@ internal class PackageComponentTests(@Suppress("UNUSED_PARAMETER") name: String, ) ), ), + arrayOf( + "optional name", + Args( + json = """ + { + "type": "package", + "package_id": "${"$"}rc_weekly", + "is_selected_by_default": true, + "stack": { + "type": "stack", + "components": [] + }, + "name": "hero_package" + } + """.trimIndent(), + expected = PackageComponent( + packageId = "${"$"}rc_weekly", + isSelectedByDefault = true, + stack = StackComponent(components = emptyList()), + name = "hero_package", + ) + ), + ), ) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventSerializationTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventSerializationTests.kt index e20e7107e1..5ab3d25c35 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventSerializationTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventSerializationTests.kt @@ -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 @@ -277,4 +278,174 @@ class PaywallEventSerializationTests { assertThat(decodedEvent.event.data.errorMessage).isEqualTo("Purchase failed") assertThat(decodedEvent.event.type).isEqualTo(PaywallEventType.PURCHASE_ERROR) } + + @Test + fun `can encode and decode component 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.COMPONENT_INTERACTION, + componentInteraction = PaywallComponentInteractionData( + componentType = PaywallComponentType.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(json) + assertThat(decoded).isEqualTo(stored) + assertThat(decoded.event.componentInteraction?.componentType).isEqualTo(PaywallComponentType.BUTTON) + assertThat(decoded.event.componentInteraction?.componentUrl).isEqualTo("https://example.com/terms") + } + + @Test + fun `decodes stored event json using legacy controlInteraction property name`() { + 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.COMPONENT_INTERACTION, + componentInteraction = PaywallComponentInteractionData( + componentType = PaywallComponentType.BUTTON, + componentName = "terms", + componentValue = "navigate_to_terms", + componentUrl = "https://example.com/terms", + ), + ), + userID = "testAppUserId", + ) + val json = PaywallStoredEvent.json.encodeToString(stored) + val legacyJson = json.replace("componentInteraction", "controlInteraction") + val decoded = PaywallStoredEvent.json.decodeFromString(legacyJson) + assertThat(decoded).isEqualTo(stored) + } + + @Test + fun `toBackendStoredEvent maps component 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.COMPONENT_INTERACTION, + componentInteraction = PaywallComponentInteractionData( + componentType = PaywallComponentType.TAB, + componentName = "tabs_main", + componentValue = "annual", + ), + ) + val backend = event.toBackendStoredEvent("uid")!!.event + assertThat(backend.type).isEqualTo("paywall_component_interaction") + assertThat(backend.componentType).isEqualTo("tab") + assertThat(backend.componentName).isEqualTo("tabs_main") + assertThat(backend.componentValue).isEqualTo("annual") + assertThat(backend.componentUrl).isNull() + } + + @Test + fun `can encode and decode component interaction event with extended package fields`() { + 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.COMPONENT_INTERACTION, + componentInteraction = PaywallComponentInteractionData( + componentType = PaywallComponentType.PACKAGE, + componentName = "hero_pkg", + componentValue = "monthly", + originPackageIdentifier = "annual", + destinationPackageIdentifier = "monthly", + defaultPackageIdentifier = "annual", + originProductIdentifier = "com.annual", + destinationProductIdentifier = "com.monthly", + defaultProductIdentifier = "com.annual", + ), + ), + userID = "testAppUserId", + ) + val json = PaywallStoredEvent.json.encodeToString(stored) + val decoded = PaywallStoredEvent.json.decodeFromString(json) + assertThat(decoded).isEqualTo(stored) + } + + @Test + fun `toBackendStoredEvent maps extended component 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.COMPONENT_INTERACTION, + componentInteraction = PaywallComponentInteractionData( + componentType = PaywallComponentType.PACKAGE_SELECTION_SHEET, + componentName = "pkg_sheet", + componentValue = "close", + currentPackageIdentifier = "monthly", + resultingPackageIdentifier = "annual", + currentProductIdentifier = "com.monthly", + resultingProductIdentifier = "com.annual", + ), + ) + val backend = event.toBackendStoredEvent("uid")!!.event + assertThat(backend.componentType).isEqualTo("package_selection_sheet") + assertThat(backend.currentPackageIdentifier).isEqualTo("monthly") + assertThat(backend.resultingPackageIdentifier).isEqualTo("annual") + assertThat(backend.currentProductIdentifier).isEqualTo("com.monthly") + assertThat(backend.resultingProductIdentifier).isEqualTo("com.annual") + } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventsRequestSerializationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventsRequestSerializationTest.kt index 0161b2fd7b..96322d576f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventsRequestSerializationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/events/PaywallEventsRequestSerializationTest.kt @@ -66,4 +66,118 @@ class PaywallEventsRequestSerializationTest { val decodedRequest = JsonProvider.defaultJson.decodeFromString(requestString) Assertions.assertThat(decodedRequest).isEqualTo(request) } + + @Test + fun `can encode paywall component interaction event with component fields`() { + val controlRequest = EventsRequest( + listOf( + BackendStoredEvent.Paywalls( + BackendEvent.Paywalls( + id = "cid", + version = 1, + type = PaywallEventType.COMPONENT_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_component_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\"" + + "}" + + "]" + + "}", + ) + } + + @Test + fun `can encode paywall component interaction with package lifecycle fields`() { + val controlRequest = EventsRequest( + listOf( + BackendStoredEvent.Paywalls( + BackendEvent.Paywalls( + id = "cid", + version = 1, + type = PaywallEventType.COMPONENT_INTERACTION.value, + appUserID = "user", + sessionID = "sess", + offeringID = "off", + paywallID = "pw", + paywallRevision = 1, + timestamp = 100L, + displayMode = "fullscreen", + darkMode = false, + localeIdentifier = "en_US", + componentType = "package_selection_sheet", + componentName = "sheet_a", + componentValue = "close", + componentUrl = null, + currentPackageIdentifier = "monthly", + resultingPackageIdentifier = "annual", + currentProductIdentifier = "com.monthly", + resultingProductIdentifier = "com.annual", + ), + ), + ).map { it.toBackendEvent() }, + ) + val requestString = JsonProvider.defaultJson.encodeToString(controlRequest) + Assertions.assertThat(requestString).isEqualTo( + "{" + + "\"events\":[" + + "{" + + "\"discriminator\":\"paywalls\"," + + "\"id\":\"cid\"," + + "\"version\":1," + + "\"type\":\"paywall_component_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\":\"package_selection_sheet\"," + + "\"component_name\":\"sheet_a\"," + + "\"component_value\":\"close\"," + + "\"current_package_id\":\"monthly\"," + + "\"resulting_package_id\":\"annual\"," + + "\"current_product_id\":\"com.monthly\"," + + "\"resulting_product_id\":\"com.annual\"" + + "}" + + "]" + + "}", + ) + } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index aceec5b133..46a135b0ec 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.paywalls.components.ButtonComponent +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.UIConstant.defaultAnimation import com.revenuecat.purchases.ui.revenuecatui.components.LoadedPaywallComponents import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction @@ -44,7 +45,10 @@ import com.revenuecat.purchases.ui.revenuecatui.defaultpaywall.DefaultPaywallVie import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional import com.revenuecat.purchases.ui.revenuecatui.fonts.PaywallTheme import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalActivity +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallLegacyComponentInteraction import com.revenuecat.purchases.ui.revenuecatui.helpers.getActivity import com.revenuecat.purchases.ui.revenuecatui.helpers.isInPreviewMode import com.revenuecat.purchases.ui.revenuecatui.helpers.toResourceProvider @@ -76,6 +80,12 @@ internal fun InternalPaywall( val state = viewModel.state.collectAsStateWithLifecycle().value + val componentInteractionTracker = remember(viewModel) { + PaywallComponentInteractionTracker { data -> + viewModel.trackComponentInteraction(data) + } + } + PaywallTheme(fontProvider = options.fontProvider) { AnimatedVisibility( visible = state is PaywallState.Loading || state is PaywallState.Error, @@ -90,43 +100,45 @@ internal fun InternalPaywall( } } - PaywallTheme(fontProvider = options.fontProvider) { + CompositionLocalProvider(LocalPaywallComponentInteractionTracker provides componentInteractionTracker) { + PaywallTheme(fontProvider = options.fontProvider) { + AnimatedVisibility( + visible = state is PaywallState.Loaded.Legacy, + enter = fadeIn(animationSpec = defaultAnimation()), + exit = fadeOut(animationSpec = defaultAnimation()), + ) { + if (state is PaywallState.Loaded.Legacy) { + LoadedPaywall(state = state, viewModel = viewModel) + } else { + Logger.e( + "State is not loaded while transitioning animation. This may happen if state changes from " + + "being loaded to a different state. This should not happen.", + ) + } + } + } + + // V2 Paywalls set custom fonts on the dashboard, so we don't want to use FontProvider here to set the fonts. AnimatedVisibility( - visible = state is PaywallState.Loaded.Legacy, + visible = state is PaywallState.Loaded.Components, enter = fadeIn(animationSpec = defaultAnimation()), exit = fadeOut(animationSpec = defaultAnimation()), ) { - if (state is PaywallState.Loaded.Legacy) { - LoadedPaywall(state = state, viewModel = viewModel) + if (state is PaywallState.Loaded.Components) { + viewModel.trackPaywallImpressionIfNeeded() + LoadedPaywallComponents( + state = state, + clickHandler = rememberPaywallActionHandler(viewModel), + ) } else { Logger.e( - "State is not loaded while transitioning animation. This may happen if state changes from " + - "being loaded to a different state. This should not happen.", + "State is not loaded while transitioning animation. This may happen if state changes " + + "from being loaded to a different state. This should not happen.", ) } } } - // V2 Paywalls set custom fonts on the dashboard, so we don't want to use FontProvider here to set the fonts. - AnimatedVisibility( - visible = state is PaywallState.Loaded.Components, - enter = fadeIn(animationSpec = defaultAnimation()), - exit = fadeOut(animationSpec = defaultAnimation()), - ) { - if (state is PaywallState.Loaded.Components) { - viewModel.trackPaywallImpressionIfNeeded() - LoadedPaywallComponents( - state = state, - clickHandler = rememberPaywallActionHandler(viewModel), - ) - } else { - Logger.e( - "State is not Loaded.Components while transitioning animation. This may happen if state changes " + - "from being loaded to a different state. This should not happen.", - ) - } - } - when (state) { is PaywallState.Loading -> {} @@ -178,6 +190,11 @@ private fun LoadedPaywall(state: PaywallState.Loaded.Legacy, viewModel: PaywallV viewModel.purchaseSelectedPackage(activity) }, onRestore = { + viewModel.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.RESTORE_BUTTON_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.RESTORE_PURCHASES, + ) viewModel.restorePurchases() }, ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt index 28116f6a94..5652895669 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + package com.revenuecat.purchases.ui.revenuecatui import android.app.Activity @@ -21,6 +23,8 @@ import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.TestStoreProduct import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.paywalls.events.ExitOfferType +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.composables.CloseButton @@ -189,6 +193,7 @@ private class LoadingViewModel( override fun trackPaywallImpressionIfNeeded() = Unit override fun trackExitOffer(exitOfferType: ExitOfferType, exitOfferingIdentifier: String) = Unit + override fun trackComponentInteraction(data: PaywallComponentInteractionData) = Unit override fun refreshStateIfLocaleChanged() = Unit override fun refreshStateIfColorsChanged(colorScheme: ColorScheme, isDarkMode: Boolean) = Unit diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt index f7e29398d1..fb9069e941 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.components @@ -57,7 +58,11 @@ import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow +import com.revenuecat.purchases.ui.revenuecatui.helpers.paywallPackageSelectionSheetClose +import com.revenuecat.purchases.ui.revenuecatui.helpers.paywallPackageSelectionSheetOpen import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState import java.net.URL import java.util.Date @@ -75,7 +80,10 @@ internal fun LoadedPaywallComponents( val style = state.stack val footerComponentStyle = state.stickyFooter val background = rememberBackgroundStyle(state.background) - val onClick: suspend (PaywallAction) -> Unit = { action: PaywallAction -> handleClick(action, state, clickHandler) } + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current + val onClick: suspend (PaywallAction) -> Unit = { action: PaywallAction -> + handleClick(action, state, clickHandler, componentInteractionTracker) + } SimpleBottomSheetScaffold( sheetState = state.sheet, modifier = modifier.background(background), @@ -109,19 +117,28 @@ private suspend fun handleClick( action: PaywallAction, state: PaywallState.Loaded.Components, externalClickHandler: suspend (PaywallAction.External) -> Unit, + componentInteractionTracker: PaywallComponentInteractionTracker, ) { when (action) { is PaywallAction.External -> externalClickHandler(action) is PaywallAction.Internal -> when (action) { is PaywallAction.Internal.NavigateTo -> when (action.destination) { - is PaywallAction.Internal.NavigateTo.Destination.Sheet -> - state.sheet.show(action.destination.sheet, state) { - handleClick( - it, - state, - externalClickHandler, - ) + is PaywallAction.Internal.NavigateTo.Destination.Sheet -> { + val sheet = action.destination.sheet + componentInteractionTracker.track( + paywallPackageSelectionSheetOpen( + sheetComponentName = sheet.name, + rootSelectedPackage = state.selectedPackageInfo?.rcPackage, + ), + ) + state.sheet.show( + sheet, + state, + componentInteractionTracker, + ) { + handleClick(it, state, externalClickHandler, componentInteractionTracker) } + } } } } @@ -133,6 +150,7 @@ private suspend fun handleClick( private fun SimpleSheetState.show( sheet: ButtonComponentStyle.Action.NavigateTo.Destination.Sheet, state: PaywallState.Loaded.Components, + componentInteractionTracker: PaywallComponentInteractionTracker, onClick: suspend (PaywallAction) -> Unit, ) { show( @@ -153,6 +171,15 @@ private fun SimpleSheetState.show( ) }, onDismiss = { + val sheetSelected = state.selectedPackageInfo + val resulting = state.peekSelectedPackageInfoAfterSheetDismiss() + componentInteractionTracker.track( + paywallPackageSelectionSheetClose( + sheetComponentName = sheet.name, + sheetSelectedPackage = sheetSelected?.rcPackage, + resultingRootPackage = resulting?.rcPackage, + ), + ) state.resetToDefaultPackage() }, ) @@ -180,6 +207,7 @@ private fun LoadedPaywallComponents_BottomSheet_NullSize_Preview() { state.sheet.show( sheet = previewBottomSheet(size = null), state = state, + componentInteractionTracker = PaywallComponentInteractionTracker { _ -> }, onClick = { }, ) @@ -199,6 +227,7 @@ private fun LoadedPaywallComponents_BottomSheet_FitSize_Preview() { state.sheet.show( sheet = previewBottomSheet(size = Size(width = Fit, height = Fit)), state = state, + componentInteractionTracker = PaywallComponentInteractionTracker { _ -> }, onClick = { }, ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt index b20effa7be..1f3daabe6e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.components.button @@ -27,6 +28,8 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import com.revenuecat.purchases.paywalls.components.CountdownComponent +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.paywalls.components.properties.CornerRadiuses import com.revenuecat.purchases.paywalls.components.properties.Dimension import com.revenuecat.purchases.paywalls.components.properties.FlexDistribution.START @@ -51,6 +54,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.stack.rememberUpdated import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker import kotlinx.coroutines.launch import kotlin.math.min import kotlin.math.roundToInt @@ -89,6 +93,7 @@ internal fun ButtonComponentView( ) val coroutineScope = rememberCoroutineScope() + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current // Whether there's an action in progress anywhere on the paywall. val anyActionInProgress by state::actionInProgress // Whether this button's action is in progress. @@ -133,10 +138,24 @@ internal fun ButtonComponentView( ) }, modifier = modifier.clickable(enabled = !anyActionInProgress) { + val paywallAction = buttonState.action + if (!style.action.isPurchaseRelated()) { + val urlForEvent = paywallAction.navigateToUrlForComponentInteraction() + style.action.componentInteraction(urlForEvent)?.let { interaction -> + componentInteractionTracker.track( + PaywallComponentInteractionData( + componentType = PaywallComponentType.BUTTON, + componentName = style.componentName, + componentValue = interaction.value, + componentUrl = interaction.url, + ), + ) + } + } myActionInProgress = true state.update(actionInProgress = true) coroutineScope.launch { - onClick(buttonState.action) + onClick(paywallAction) myActionInProgress = false state.update(actionInProgress = false) } @@ -236,6 +255,15 @@ private val Color.brightness: Float green * COEFFICIENT_LUMINANCE_GREEN + blue * COEFFICIENT_LUMINANCE_BLUE +private fun PaywallAction.navigateToUrlForComponentInteraction(): String? = + when (this) { + is PaywallAction.External.NavigateTo -> when (val dest = destination) { + is PaywallAction.External.NavigateTo.Destination.Url -> dest.url + else -> null + } + else -> null + } + @Preview @Composable private fun ButtonComponentView_Preview_Default() { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/PaywallButtonComponentInteraction.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/PaywallButtonComponentInteraction.kt new file mode 100644 index 0000000000..ba54fc922d --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/PaywallButtonComponentInteraction.kt @@ -0,0 +1,57 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.ui.revenuecatui.components.button + +import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponentStyle + +/** + * Maps non-purchase button actions to `component_value` strings. + */ +internal data class ButtonComponentInteraction( + val value: String, + val url: String? = null, +) + +@Suppress("CyclomaticComplexMethod") +internal fun ButtonComponentStyle.Action.componentInteraction(localeUrl: String?): ButtonComponentInteraction? = + when (this) { + is ButtonComponentStyle.Action.RestorePurchases -> + ButtonComponentInteraction(value = "restore_purchases") + is ButtonComponentStyle.Action.NavigateBack -> + ButtonComponentInteraction(value = "navigate_back") + is ButtonComponentStyle.Action.NavigateTo -> destination.componentInteraction(localeUrl) + is ButtonComponentStyle.Action.PurchasePackage, + is ButtonComponentStyle.Action.WebCheckout, + is ButtonComponentStyle.Action.WebProductSelection, + is ButtonComponentStyle.Action.CustomWebCheckout, + -> null + } + +@Suppress("CyclomaticComplexMethod") +private fun ButtonComponentStyle.Action.NavigateTo.Destination.componentInteraction( + localeUrl: String?, +): ButtonComponentInteraction = + when (this) { + is ButtonComponentStyle.Action.NavigateTo.Destination.CustomerCenter -> + ButtonComponentInteraction(value = "navigate_to_customer_center") + is ButtonComponentStyle.Action.NavigateTo.Destination.Url -> + ButtonComponentInteraction(value = componentInteractionValue, url = localeUrl) + is ButtonComponentStyle.Action.NavigateTo.Destination.Sheet -> + ButtonComponentInteraction(value = "navigate_to_sheet") + } + +/** + * True for purchase / web checkout actions — these must not emit `paywall_component_interaction` + */ +internal fun ButtonComponentStyle.Action.isPurchaseRelated(): Boolean = + when (this) { + is ButtonComponentStyle.Action.PurchasePackage, + is ButtonComponentStyle.Action.WebCheckout, + is ButtonComponentStyle.Action.WebProductSelection, + is ButtonComponentStyle.Action.CustomWebCheckout, + -> true + is ButtonComponentStyle.Action.RestorePurchases, + is ButtonComponentStyle.Action.NavigateBack, + is ButtonComponentStyle.Action.NavigateTo, + -> false + } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/CarouselComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/CarouselComponentView.kt index e8e22c9d22..8798028e05 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/CarouselComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/CarouselComponentView.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) @file:Suppress("TooManyFunctions") package com.revenuecat.purchases.ui.revenuecatui.components.carousel @@ -23,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,6 +34,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.revenuecat.purchases.paywalls.components.CarouselComponent +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.paywalls.components.CountdownComponent import com.revenuecat.purchases.paywalls.components.properties.Dimension import com.revenuecat.purchases.paywalls.components.properties.FlexDistribution @@ -61,8 +65,10 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentS import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean import androidx.compose.ui.unit.lerp as lerpUnit @Suppress("LongMethod") @@ -99,8 +105,40 @@ internal fun CarouselComponentView( } } + val skipProgrammaticPageTracking = remember { AtomicBoolean(false) } + carouselState.autoAdvance?.let { autoAdvance -> - EnableAutoAdvance(autoAdvance, pagerState, carouselState.loop, pageCount) + EnableAutoAdvance( + autoAdvance, + pagerState, + carouselState.loop, + pageCount, + skipProgrammaticPageTracking, + ) + } + + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current + if (pageCount > 0) { + LaunchedEffect(pagerState, pageCount, style.componentName, componentInteractionTracker) { + var previousPage = pagerState.currentPage + snapshotFlow { pagerState.currentPage }.collect { page -> + if (page != previousPage) { + if (skipProgrammaticPageTracking.getAndSet(false)) { + // Auto-advance scroll; do not emit component interaction. + } else { + val logicalPage = page % pageCount + componentInteractionTracker.track( + PaywallComponentInteractionData( + componentType = PaywallComponentType.CAROUSEL, + componentName = style.componentName, + componentValue = logicalPage.toString(), + ), + ) + } + previousPage = page + } + } + } } Column( @@ -287,16 +325,18 @@ private fun EnableAutoAdvance( pagerState: PagerState, shouldLoop: Boolean, pageCount: Int, + skipProgrammaticPageTracking: AtomicBoolean, ) { LaunchedEffect(Unit) { while (true) { delay(autoAdvance.msTimePerPage.toLong()) if (pagerState.isScrollInProgress) continue - val nextPage = if (shouldLoop) { - pagerState.currentPage + 1 - } else { - (pagerState.currentPage + 1) % pageCount - } + val nextPage = nextAutoAdvanceTargetPage( + shouldLoop = shouldLoop, + pageCount = pageCount, + currentPage = pagerState.currentPage, + ) ?: continue + skipProgrammaticPageTracking.set(true) try { pagerState.animateScrollToPage( page = nextPage, @@ -305,12 +345,32 @@ private fun EnableAutoAdvance( ), ) } catch (_: CancellationException) { + skipProgrammaticPageTracking.set(false) // Do nothing, so we continue scrolling on the next loop } } } } +/** + * Next pager index for carousel auto-advance, or `null` when no scroll should run + * (empty carousel, or non-loop already on the last page). + */ +internal fun nextAutoAdvanceTargetPage( + shouldLoop: Boolean, + pageCount: Int, + currentPage: Int, +): Int? { + if (pageCount <= 0) return null + return if (shouldLoop) { + currentPage + 1 + } else if (currentPage >= pageCount - 1) { + null + } else { + currentPage + 1 + } +} + private fun getInitialPage(carouselState: CarouselComponentState) = if (carouselState.loop) { // When looping, we use a very large number of pages to allow for "infinite" scrolling // We need to calculate the initial page index in the middle of that large number of pages to make the carousel diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt index a30ae5de5b..c790ee5626 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.components.pkg @@ -10,6 +11,8 @@ import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentV import com.revenuecat.purchases.ui.revenuecatui.components.style.PackageComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker +import com.revenuecat.purchases.ui.revenuecatui.helpers.paywallPackageRowSelection @JvmSynthetic @Composable @@ -19,6 +22,7 @@ internal fun PackageComponentView( clickHandler: suspend (PaywallAction) -> Unit, modifier: Modifier = Modifier, ) { + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current StackComponentView( style = style.stackComponentStyle, state = state, @@ -29,7 +33,17 @@ internal fun PackageComponentView( modifier = modifier.conditional(style.isSelectable) { clickable( enabled = state.selectedPackageInfo?.uniqueId != style.uniqueId, - ) { state.update(selectedPackageUniqueId = style.uniqueId) } + ) { + componentInteractionTracker.track( + paywallPackageRowSelection( + componentName = style.componentName, + destination = style.rcPackage, + origin = state.selectedPackageInfo?.rcPackage, + defaultPackage = state.defaultPackageForPackageRowAnalytics(), + ), + ) + state.update(selectedPackageUniqueId = style.uniqueId) + } }, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt index 8437df2ff7..51fdd0f301 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ButtonComponentStyle.kt @@ -19,6 +19,8 @@ internal data class ButtonComponentStyle( val action: Action, @get:JvmSynthetic val transition: PaywallTransition? = null, + @get:JvmSynthetic + val componentName: String? = null, ) : ComponentStyle { internal sealed interface Action { @@ -57,6 +59,8 @@ internal data class ButtonComponentStyle( data class Url( @get:JvmSynthetic val urls: NonEmptyMap, @get:JvmSynthetic val method: ButtonComponent.UrlMethod, + /** Wire `component_value` for paywall component interaction (terms vs privacy vs generic link). */ + @get:JvmSynthetic val componentInteractionValue: String = "navigate_to_url", ) : Destination @Immutable diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/CarouselComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/CarouselComponentStyle.kt index fd43063f40..080a8b4533 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/CarouselComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/CarouselComponentStyle.kt @@ -80,6 +80,8 @@ internal data class CarouselComponentStyle( override val offerEligibility: OfferEligibility? = null, @get:JvmSynthetic val overrides: List>, + @get:JvmSynthetic + val componentName: String? = null, ) : ComponentStyle, PackageContext { @Immutable data class PageControlStyles( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt index 89eb447278..75b590f266 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt @@ -12,6 +12,8 @@ internal data class PackageComponentStyle( @get:JvmSynthetic val isSelectedByDefault: Boolean, @get:JvmSynthetic + val componentName: String? = null, + @get:JvmSynthetic val stackComponentStyle: StackComponentStyle, @get:JvmSynthetic val isSelectable: Boolean, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 4ce24e5da7..57b19b9558 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -125,6 +125,10 @@ internal class StyleFactory( * If this is non-null, it means the branch currently being built is inside a tab component. */ var tabIndex: Int? = null, + /** + * When building a [TabsComponent] subtree, holds that component's dashboard `name` for tab control analytics. + */ + var enclosingTabsComponentName: String? = null, /** * If this is non-null, it means the branch currently being built is inside a countdown component. */ @@ -324,6 +328,19 @@ internal class StyleFactory( return result } + fun withTabsComponentName( + name: String?, + block: StyleFactoryScope.() -> T, + ): T { + val previous = enclosingTabsComponentName + enclosingTabsComponentName = name + try { + return block() + } finally { + enclosingTabsComponentName = previous + } + } + /** * Records that this branch of the tree is in a countdown with the provided [countdownDate] and [countFrom]. */ @@ -538,6 +555,7 @@ internal class StyleFactory( stackComponentStyle = stack, action = action, transition = component.transition, + componentName = component.name, ) } } @@ -584,6 +602,7 @@ internal class StyleFactory( stackComponentStyle = stack, rcPackage = rcPackage, isSelectedByDefault = component.isSelectedByDefault, + componentName = component.name, isSelectable = purchaseButtons == 0, resolvedOffer = resolvedOffer, ) @@ -600,6 +619,7 @@ internal class StyleFactory( ButtonComponentStyle( stackComponentStyle = stack, action = action, + componentName = null, ) } @@ -692,14 +712,17 @@ internal class StyleFactory( is ButtonComponent.Destination.PrivacyPolicy -> buttonComponentStyleUrlDestination( destination.urlLid, destination.method, + componentInteractionValue = "navigate_to_privacy_policy", ) is ButtonComponent.Destination.Terms -> buttonComponentStyleUrlDestination( destination.urlLid, destination.method, + componentInteractionValue = "navigate_to_terms", ) is ButtonComponent.Destination.Url -> buttonComponentStyleUrlDestination( destination.urlLid, destination.method, + componentInteractionValue = "navigate_to_url", ) is ButtonComponent.Destination.Sheet -> createStackComponentStyle(destination.stack) @@ -722,9 +745,14 @@ internal class StyleFactory( private fun buttonComponentStyleUrlDestination( urlLid: LocalizationKey, method: ButtonComponent.UrlMethod, + componentInteractionValue: String, ) = localizations.stringForAllLocales(urlLid).map { urls -> - ButtonComponentStyle.Action.NavigateTo.Destination.Url(urls, method) + ButtonComponentStyle.Action.NavigateTo.Destination.Url( + urls = urls, + method = method, + componentInteractionValue = componentInteractionValue, + ) }.map { urlDestination -> when (urlDestination.method) { ButtonComponent.UrlMethod.IN_APP_BROWSER, @@ -846,6 +874,7 @@ internal class StyleFactory( countFrom = countFrom, variableLocalizations = variableLocalizations, overrides = presentedOverrides, + componentName = component.name, ) } @@ -1075,6 +1104,7 @@ internal class StyleFactory( tabIndex = tabControlIndex, offerEligibility = offerEligibility, overrides = presentedOverrides, + componentName = component.name, ) } @@ -1085,7 +1115,15 @@ internal class StyleFactory( // Button control doesn't have a default tab. defaultTabIndex = 0 createStackComponentStyle(component.stack) - .map { stack -> TabControlButtonComponentStyle(tabIndex = component.tabIndex, stack = stack) } + .map { stack -> + TabControlButtonComponentStyle( + tabIndex = component.tabIndex, + tabId = component.tabId, + stack = stack, + tabsComponentName = enclosingTabsComponentName, + tabButtonName = component.name, + ) + } } private fun StyleFactoryScope.createTabControlToggleComponentStyle( @@ -1103,13 +1141,15 @@ internal class StyleFactory( thumbColorOff = thumbColorOff, trackColorOn = trackColorOn, trackColorOff = trackColorOff, + componentName = component.name, ) } private fun StyleFactoryScope.createTabsComponentStyle( component: TabsComponent, ): Result> = - createTabsComponentStyleTabControl(component.control).flatMap { control -> + withTabsComponentName(component.name) { + createTabsComponentStyleTabControl(component.control).flatMap { control -> // Find the index of the defaultTabId. component.defaultTabId ?.takeUnless { it.isBlank() } @@ -1143,6 +1183,7 @@ internal class StyleFactory( ) } } + } private fun StyleFactoryScope.createTabsComponentStyleTabControl( componentControl: TabsComponent.TabControl, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt index 7edd440e7e..d5716ce62e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TabsComponentStyle.kt @@ -20,7 +20,13 @@ internal data class TabControlButtonComponentStyle( @get:JvmSynthetic val tabIndex: Int, @get:JvmSynthetic + val tabId: String, + @get:JvmSynthetic val stack: StackComponentStyle, + @get:JvmSynthetic + val tabsComponentName: String? = null, + @get:JvmSynthetic + val tabButtonName: String? = null, ) : ComponentStyle { override val visible: Boolean = stack.visible override val size: Size = stack.size @@ -36,6 +42,8 @@ internal class TabControlToggleComponentStyle( val trackColorOn: ColorStyles, @get:JvmSynthetic val trackColorOff: ColorStyles, + @get:JvmSynthetic + val componentName: String? = null, ) : ComponentStyle { override val visible: Boolean = true override val size: Size = Size(width = Fit, height = Fit) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TextComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TextComponentStyle.kt index be5536587b..003ab55dcd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TextComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/TextComponentStyle.kt @@ -85,4 +85,6 @@ internal class TextComponentStyle( val variableLocalizations: NonEmptyMap>, @get:JvmSynthetic val overrides: List>, + @get:JvmSynthetic + val componentName: String? = null, ) : ComponentStyle, PackageContext diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlButtonView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlButtonView.kt index b61ee0b96d..f001509e0e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlButtonView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlButtonView.kt @@ -1,13 +1,17 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.components.tabs import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView import com.revenuecat.purchases.ui.revenuecatui.components.style.TabControlButtonComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker @Composable internal fun TabControlButtonView( @@ -15,11 +19,22 @@ internal fun TabControlButtonView( state: PaywallState.Loaded.Components, modifier: Modifier = Modifier, ) { + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current StackComponentView( style = style.stack, state = state, // We act like a button, so we're handling the click already. clickHandler = { }, - modifier = modifier.clickable { state.update(selectedTabIndex = style.tabIndex) }, + modifier = modifier.clickable { + val componentValue = style.tabButtonName ?: style.tabId + componentInteractionTracker.track( + PaywallComponentInteractionData( + componentType = PaywallComponentType.TAB, + componentName = style.tabsComponentName, + componentValue = componentValue, + ), + ) + state.update(selectedTabIndex = style.tabIndex) + }, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt index b7bb3be6bd..08630c51c4 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlToggleView.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.components.tabs @@ -14,6 +15,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size import com.revenuecat.purchases.ui.revenuecatui.components.previewEmptyState import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle @@ -24,6 +27,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.TabControlToggl import com.revenuecat.purchases.ui.revenuecatui.composables.Switch import com.revenuecat.purchases.ui.revenuecatui.composables.SwitchDefaults import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker @Composable internal fun TabControlToggleView( @@ -32,10 +36,20 @@ internal fun TabControlToggleView( modifier: Modifier = Modifier, ) { val checked by remember { derivedStateOf { state.selectedTabIndex > 0 } } + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current Switch( checked = checked, - onCheckedChange = { state.update(selectedTabIndex = if (it) 1 else 0) }, + onCheckedChange = { + state.update(selectedTabIndex = if (it) 1 else 0) + componentInteractionTracker.track( + PaywallComponentInteractionData( + componentType = PaywallComponentType.SWITCH, + componentName = style.componentName, + componentValue = if (it) "on" else "off", + ), + ) + }, modifier = modifier .size(style.size), colors = SwitchDefaults.colors( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentView.kt index b76fb8bbf6..18567ba482 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabsComponentView.kt @@ -130,6 +130,7 @@ private fun TabsComponentView_Preview() { children = listOf( TabControlButtonComponentStyle( tabIndex = 0, + tabId = "t0", stack = previewStackComponentStyle( children = listOf( previewTextComponentStyle( @@ -144,6 +145,7 @@ private fun TabsComponentView_Preview() { ), TabControlButtonComponentStyle( tabIndex = 1, + tabId = "t1", stack = previewStackComponentStyle( children = listOf( previewTextComponentStyle( @@ -158,6 +160,7 @@ private fun TabsComponentView_Preview() { ), TabControlButtonComponentStyle( tabIndex = 2, + tabId = "t2", stack = previewStackComponentStyle( children = listOf( previewTextComponentStyle( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index 02f1e61ad8..2ba9397dcd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -1,4 +1,5 @@ @file:JvmSynthetic +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) @file:Suppress("TooManyFunctions") package com.revenuecat.purchases.ui.revenuecatui.components.text @@ -9,6 +10,7 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -18,8 +20,12 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.sp import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.paywalls.components.properties.FontWeight import com.revenuecat.purchases.paywalls.components.properties.HorizontalAlignment import com.revenuecat.purchases.paywalls.components.properties.Padding @@ -42,6 +48,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessor import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessorV2 import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull +import com.revenuecat.purchases.ui.revenuecatui.helpers.LocalPaywallComponentInteractionTracker @Composable internal fun TextComponentView( @@ -82,21 +89,40 @@ internal fun TextComponentView( } if (textState.visible) { - Markdown( - text = text, - modifier = modifier - .size(textState.size, horizontalAlignment = textState.horizontalAlignment) - .padding(textState.margin) - .applyIfNotNull(backgroundColorStyle) { background(it) } - .padding(textState.padding), - color = color, - fontSize = textState.fontSize.sp, - fontWeight = textState.fontWeight, - fontFamily = textState.fontFamily, - horizontalAlignment = textState.horizontalAlignment, - textAlign = textState.textAlign, - style = textStyle, - ) + val uriHandler = LocalUriHandler.current + val componentInteractionTracker = LocalPaywallComponentInteractionTracker.current + val trackingUriHandler = remember(uriHandler, componentInteractionTracker, style.componentName) { + object : UriHandler { + override fun openUri(uri: String) { + componentInteractionTracker.track( + PaywallComponentInteractionData( + componentType = PaywallComponentType.TEXT, + componentName = style.componentName, + componentValue = "navigate_to_url", + componentUrl = uri, + ), + ) + uriHandler.openUri(uri) + } + } + } + CompositionLocalProvider(LocalUriHandler provides trackingUriHandler) { + Markdown( + text = text, + modifier = modifier + .size(textState.size, horizontalAlignment = textState.horizontalAlignment) + .padding(textState.margin) + .applyIfNotNull(backgroundColorStyle) { background(it) } + .padding(textState.padding), + color = color, + fontSize = textState.fontSize.sp, + fontWeight = textState.fontWeight, + fontFamily = textState.fontFamily, + horizontalAlignment = textState.horizontalAlignment, + textAlign = textState.textAlign, + style = textStyle, + ) + } } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Footer.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Footer.kt index 9ab3121d21..3a010d8bc0 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Footer.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/Footer.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.revenuecat.purchases.paywalls.PaywallData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.UIConstant @@ -50,6 +51,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.data.testdata.templates.template2 import com.revenuecat.purchases.ui.revenuecatui.extensions.openUriOrElse import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallLegacyComponentInteraction import java.net.URL @Composable @@ -114,7 +116,14 @@ private fun Footer( color = color, childModifier = childModifier, R.string.all_plans, - action = allPlansTapped, + action = { + viewModel.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.ALL_PLANS_BUTTON_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.TOGGLE_ALL_PLANS, + ) + allPlansTapped() + }, ) if (configuration.displayRestorePurchases || @@ -131,7 +140,14 @@ private fun Footer( childModifier = childModifier, R.string.restore_purchases, R.string.restore, - ) { viewModel.restorePurchases() } + ) { + viewModel.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.RESTORE_BUTTON_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.RESTORE_PURCHASES, + ) + viewModel.restorePurchases() + } if (configuration.termsOfServiceURL != null || configuration.privacyURL != null) { Separator(color = color) @@ -145,6 +161,13 @@ private fun Footer( R.string.terms_and_conditions, R.string.terms, ) { + val urlString = it.toString() + viewModel.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.TERMS_LINK_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.NAVIGATE_TO_TERMS, + componentUrl = urlString, + ) openURL(context, it) } @@ -160,6 +183,13 @@ private fun Footer( R.string.privacy_policy, R.string.privacy, ) { + val urlString = it.toString() + viewModel.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.PRIVACY_LINK_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.NAVIGATE_TO_PRIVACY_POLICY, + componentUrl = urlString, + ) openURL(context, it) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 12aea758e6..9d0f41786e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -330,6 +330,30 @@ internal sealed interface PaywallState { ?: selectedPackageByTab[selectedTabIndex] } + fun peekDefaultPackageUniqueIdAfterSheetDismiss(): String? = + packages.packagesByTab[selectedTabIndex]?.firstOrNull { it.isSelectedByDefault }?.uniqueId + ?: initialSelectedPackageOutsideTabs + ?: selectedPackageByTab[selectedTabIndex] + + fun peekSelectedPackageInfoAfterSheetDismiss(): SelectedPackageInfo? { + val uid = peekDefaultPackageUniqueIdAfterSheetDismiss() ?: return null + val info = findPackageInfoByUniqueId(uid) ?: return null + return SelectedPackageInfo( + rcPackage = info.pkg, + resolvedOffer = info.resolvedOffer, + uniqueId = uid, + offerEligibility = calculateOfferEligibility(info.resolvedOffer, info.pkg), + ) + } + + /** + * Default package for the current tab / root context (aligned with [resetToDefaultPackage]). + */ + fun defaultPackageForPackageRowAnalytics(): Package? { + val uid = peekDefaultPackageUniqueIdAfterSheetDismiss() ?: return null + return findPackageInfoByUniqueId(uid)?.pkg + } + private fun LocaleList.toLocaleId(): LocaleId { val preferredOverride = purchases.preferredUILocaleOverride val deviceLocales = map { it.toLocaleId() }.plus(locales.head) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 539214301d..3fe4976245 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + package com.revenuecat.purchases.ui.revenuecatui.data import android.app.Activity @@ -22,6 +24,8 @@ import com.revenuecat.purchases.models.GoogleStoreProduct import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.paywalls.components.common.ProductChangeConfig import com.revenuecat.purchases.paywalls.events.ExitOfferType +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.paywalls.events.PaywallEvent import com.revenuecat.purchases.paywalls.events.PaywallEventType import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue @@ -79,6 +83,23 @@ internal interface PaywallViewModel { fun selectPackage(packageToSelect: TemplateConfiguration.PackageInfo) fun trackPaywallImpressionIfNeeded() fun trackExitOffer(exitOfferType: ExitOfferType, exitOfferingIdentifier: String) + fun trackComponentInteraction(data: PaywallComponentInteractionData) + + fun trackComponentInteraction( + componentType: PaywallComponentType, + componentName: String?, + componentValue: String, + componentUrl: String? = null, + ) { + trackComponentInteraction( + PaywallComponentInteractionData( + componentType = componentType, + componentName = componentName, + componentValue = componentValue, + componentUrl = componentUrl, + ), + ) + } fun closePaywall(result: PaywallResult? = null) fun getWebCheckoutUrl(launchWebCheckout: PaywallAction.External.LaunchWebCheckout): String? @@ -326,6 +347,21 @@ internal class PaywallViewModelImpl( purchases.track(event) } + override fun trackComponentInteraction(data: PaywallComponentInteractionData) { + val eventData = paywallPresentationData + if (eventData == null) { + Logger.e("Paywall event data is null, not tracking paywall component interaction") + return + } + val event = PaywallEvent( + creationData = PaywallEvent.CreationData(UUID.randomUUID(), Date()), + data = eventData, + type = PaywallEventType.COMPONENT_INTERACTION, + componentInteraction = data, + ) + purchases.track(event) + } + @Suppress("NestedBlockDepth", "CyclomaticComplexMethod", "LongMethod") override suspend fun handleRestorePurchases() { if (verifyNoActionInProgressOrStartAction()) { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt index 14f43067d3..556221c652 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + package com.revenuecat.purchases.ui.revenuecatui.data.testdata import android.app.Activity @@ -23,6 +25,8 @@ import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.events.ExitOfferType +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.ui.revenuecatui.PaywallMode import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions import com.revenuecat.purchases.ui.revenuecatui.R @@ -569,6 +573,15 @@ internal class MockViewModel( trackExitOfferParams.add(Pair(exitOfferType, exitOfferingIdentifier)) } + var trackComponentInteractionCallCount = 0 + private set + val trackComponentInteractionParams = mutableListOf() + + override fun trackComponentInteraction(data: PaywallComponentInteractionData) { + trackComponentInteractionCallCount++ + trackComponentInteractionParams.add(data) + } + var refreshStateIfLocaleChangedCallCount = 0 private set override fun refreshStateIfLocaleChanged() { @@ -711,3 +724,4 @@ internal class MockViewModel( private const val MILLIS_2025_01_25 = 1737763200000 } } + diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionFactories.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionFactories.kt new file mode 100644 index 0000000000..afb73c661b --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionFactories.kt @@ -0,0 +1,69 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.ui.revenuecatui.helpers + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData +import com.revenuecat.purchases.paywalls.events.PaywallComponentType + +@InternalRevenueCatAPI +internal fun paywallPackageSelectionSheetOpen( + sheetComponentName: String?, + rootSelectedPackage: Package?, +): PaywallComponentInteractionData = PaywallComponentInteractionData( + componentType = PaywallComponentType.PACKAGE_SELECTION_SHEET, + componentName = sheetComponentName, + componentValue = "open", + currentPackageIdentifier = rootSelectedPackage?.identifier, + currentProductIdentifier = rootSelectedPackage?.product?.id, +) + +@InternalRevenueCatAPI +internal fun paywallPackageSelectionSheetClose( + sheetComponentName: String?, + sheetSelectedPackage: Package?, + resultingRootPackage: Package?, +): PaywallComponentInteractionData = PaywallComponentInteractionData( + componentType = PaywallComponentType.PACKAGE_SELECTION_SHEET, + componentName = sheetComponentName, + componentValue = "close", + currentPackageIdentifier = sheetSelectedPackage?.identifier, + resultingPackageIdentifier = resultingRootPackage?.identifier, + currentProductIdentifier = sheetSelectedPackage?.product?.id, + resultingProductIdentifier = resultingRootPackage?.product?.id, +) + +@InternalRevenueCatAPI +internal fun paywallPackageRowSelection( + componentName: String? = null, + destination: Package, + origin: Package?, + defaultPackage: Package?, +): PaywallComponentInteractionData = PaywallComponentInteractionData( + componentType = PaywallComponentType.PACKAGE, + componentName = componentName, + componentValue = destination.identifier, + originPackageIdentifier = origin?.identifier, + destinationPackageIdentifier = destination.identifier, + defaultPackageIdentifier = defaultPackage?.identifier, + originProductIdentifier = origin?.product?.id, + destinationProductIdentifier = destination.product.id, + defaultProductIdentifier = defaultPackage?.product?.id, +) + +@InternalRevenueCatAPI +internal fun paywallTierSelection( + tierDisplayName: String, + componentName: String? = null, + originPackage: Package?, + destinationPackage: Package?, +): PaywallComponentInteractionData = PaywallComponentInteractionData( + componentType = PaywallComponentType.TAB, + componentName = componentName, + componentValue = tierDisplayName, + originPackageIdentifier = originPackage?.identifier, + destinationPackageIdentifier = destinationPackage?.identifier, + originProductIdentifier = originPackage?.product?.id, + destinationProductIdentifier = destinationPackage?.product?.id, +) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionLocal.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionLocal.kt new file mode 100644 index 0000000000..a0a642414f --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallComponentInteractionLocal.kt @@ -0,0 +1,36 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.ui.revenuecatui.helpers + +import androidx.compose.runtime.staticCompositionLocalOf +import com.revenuecat.purchases.paywalls.events.PaywallComponentInteractionData + +/** + * Tracks paywall component interactions for analytics. + */ +internal fun interface PaywallComponentInteractionTracker { + fun track(data: PaywallComponentInteractionData) +} + +internal val LocalPaywallComponentInteractionTracker = + staticCompositionLocalOf { + PaywallComponentInteractionTracker { _ -> } + } + +/** + * V1 template footer / tier control `component_name` constants. + */ +internal object PaywallLegacyComponentInteraction { + const val ALL_PLANS_BUTTON_NAME = "all_plans_button" + const val RESTORE_BUTTON_NAME = "restore_button" + const val TERMS_LINK_NAME = "terms_link" + const val PRIVACY_LINK_NAME = "privacy_link" + const val TIER_SELECTOR_NAME = "tier_selector" + + object Value { + const val TOGGLE_ALL_PLANS = "toggle_all_plans" + const val RESTORE_PURCHASES = "restore_purchases" + const val NAVIGATE_TO_TERMS = "navigate_to_terms" + const val NAVIGATE_TO_PRIVACY_POLICY = "navigate_to_privacy_policy" + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template7.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template7.kt index fee5a2a35d..628b67192e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template7.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/Template7.kt @@ -1,4 +1,5 @@ @file:Suppress("TooManyFunctions") +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) package com.revenuecat.purchases.ui.revenuecatui.templates @@ -77,6 +78,8 @@ import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfigura import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockViewModel import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.extensions.conditional +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallLegacyComponentInteraction +import com.revenuecat.purchases.ui.revenuecatui.helpers.paywallTierSelection import com.revenuecat.purchases.ui.revenuecatui.extensions.offerEligibility import com.revenuecat.purchases.ui.revenuecatui.extensions.packageButtonActionInProgressOpacityAnimation import com.revenuecat.purchases.ui.revenuecatui.extensions.packageButtonColorAnimation @@ -89,6 +92,13 @@ private object Template7UIConstants { const val headerAspectRatio = 2f } +/** + * `component_value` for Template 7 tier component interaction). + */ +@JvmSynthetic +internal fun tierSelectorComponentInteractionValue(tier: TemplateConfiguration.TierInfo): String = + tier.name.takeUnless { it.isBlank() } ?: "" + @Composable internal fun Template7( state: PaywallState.Loaded.Legacy, @@ -113,6 +123,19 @@ internal fun Template7( val colorForTier = state.templateConfiguration.getCurrentColorsForTier(tier = selectedTier) + val onTierSelected: (TemplateConfiguration.TierInfo) -> Unit = { tier -> + viewModel.trackComponentInteraction( + paywallTierSelection( + tierDisplayName = tierSelectorComponentInteractionValue(tier), + componentName = PaywallLegacyComponentInteraction.TIER_SELECTOR_NAME, + originPackage = selectedTier.defaultPackage.rcPackage, + destinationPackage = tier.defaultPackage.rcPackage, + ), + ) + selectedTier = tier + state.selectPackage(tier.defaultPackage) + } + Column( Modifier.background(colorForTier.background), ) { @@ -122,10 +145,8 @@ internal fun Template7( viewModel, allTiers, selectedTier, - ) { - selectedTier = it - state.selectPackage(selectedTier.defaultPackage) - } + onTierSelected, + ) } else { Template7PortraitContent( state, @@ -133,10 +154,8 @@ internal fun Template7( packageSelectorVisible, allTiers, selectedTier, - ) { - selectedTier = it - state.selectPackage(selectedTier.defaultPackage) - } + onTierSelected, + ) } PurchaseButton(state, viewModel, colors = colorForTier) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/NextAutoAdvanceTargetPageTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/NextAutoAdvanceTargetPageTest.kt new file mode 100644 index 0000000000..c02d44994a --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/carousel/NextAutoAdvanceTargetPageTest.kt @@ -0,0 +1,31 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.carousel + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class NextAutoAdvanceTargetPageTest { + + @Test + fun `non-loop advances until last page then returns null`() { + assertThat(nextAutoAdvanceTargetPage(shouldLoop = false, pageCount = 3, currentPage = 0)).isEqualTo(1) + assertThat(nextAutoAdvanceTargetPage(shouldLoop = false, pageCount = 3, currentPage = 1)).isEqualTo(2) + assertThat(nextAutoAdvanceTargetPage(shouldLoop = false, pageCount = 3, currentPage = 2)).isNull() + } + + @Test + fun `non-loop single page never advances`() { + assertThat(nextAutoAdvanceTargetPage(shouldLoop = false, pageCount = 1, currentPage = 0)).isNull() + } + + @Test + fun `loop always increments`() { + assertThat(nextAutoAdvanceTargetPage(shouldLoop = true, pageCount = 3, currentPage = 0)).isEqualTo(1) + assertThat(nextAutoAdvanceTargetPage(shouldLoop = true, pageCount = 3, currentPage = 2)).isEqualTo(3) + } + + @Test + fun `empty carousel returns null`() { + assertThat(nextAutoAdvanceTargetPage(shouldLoop = false, pageCount = 0, currentPage = 0)).isNull() + assertThat(nextAutoAdvanceTargetPage(shouldLoop = true, pageCount = 0, currentPage = 0)).isNull() + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index fba56030d2..96768534f9 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) + package com.revenuecat.purchases.ui.revenuecatui.data import android.app.Activity @@ -31,6 +33,7 @@ import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConf import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import com.revenuecat.purchases.paywalls.events.PaywallComponentType import com.revenuecat.purchases.paywalls.events.PaywallEvent import com.revenuecat.purchases.paywalls.events.PaywallEventType import com.revenuecat.purchases.ui.revenuecatui.OfferingSelection @@ -49,6 +52,7 @@ import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvid import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData.copy import com.revenuecat.purchases.ui.revenuecatui.extensions.copy +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallLegacyComponentInteraction import com.revenuecat.purchases.ui.revenuecatui.helpers.ResolvedOffer import com.revenuecat.purchases.ui.revenuecatui.helpers.UiConfig import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf @@ -1278,6 +1282,127 @@ class PaywallViewModelTest { verifyEventTracked(PaywallEventType.IMPRESSION, 1) } + @Test + fun `trackComponentInteraction restore matches legacy footer spec`() { + val model = create() + model.trackPaywallImpressionIfNeeded() + model.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.RESTORE_BUTTON_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.RESTORE_PURCHASES, + ) + verify(exactly = 1) { + purchases.track( + withArg { event -> + val paywallEvent = event as PaywallEvent + assertThat(paywallEvent.type).isEqualTo(PaywallEventType.COMPONENT_INTERACTION) + val ci = requireNotNull(paywallEvent.componentInteraction) + assertThat(ci.componentType).isEqualTo(PaywallComponentType.BUTTON) + assertThat(ci.componentName).isEqualTo("restore_button") + assertThat(ci.componentValue).isEqualTo("restore_purchases") + assertThat(ci.componentUrl).isNull() + }, + ) + } + } + + @Test + fun `trackComponentInteraction all plans matches legacy footer spec`() { + val model = create() + model.trackPaywallImpressionIfNeeded() + model.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.ALL_PLANS_BUTTON_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.TOGGLE_ALL_PLANS, + ) + verify(exactly = 1) { + purchases.track( + withArg { event -> + val paywallEvent = event as PaywallEvent + assertThat(paywallEvent.type).isEqualTo(PaywallEventType.COMPONENT_INTERACTION) + val ci = requireNotNull(paywallEvent.componentInteraction) + assertThat(ci.componentName).isEqualTo("all_plans_button") + assertThat(ci.componentValue).isEqualTo("toggle_all_plans") + assertThat(ci.componentUrl).isNull() + }, + ) + } + } + + @Test + fun `trackComponentInteraction terms link matches legacy footer spec`() { + val model = create() + model.trackPaywallImpressionIfNeeded() + val url = "https://example.com/terms" + model.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.TERMS_LINK_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.NAVIGATE_TO_TERMS, + componentUrl = url, + ) + verify(exactly = 1) { + purchases.track( + withArg { event -> + val paywallEvent = event as PaywallEvent + assertThat(paywallEvent.type).isEqualTo(PaywallEventType.COMPONENT_INTERACTION) + val ci = requireNotNull(paywallEvent.componentInteraction) + assertThat(ci.componentName).isEqualTo("terms_link") + assertThat(ci.componentValue).isEqualTo("navigate_to_terms") + assertThat(ci.componentUrl).isEqualTo(url) + }, + ) + } + } + + @Test + fun `trackComponentInteraction privacy link matches legacy footer spec`() { + val model = create() + model.trackPaywallImpressionIfNeeded() + val url = "https://example.com/privacy" + model.trackComponentInteraction( + componentType = PaywallComponentType.BUTTON, + componentName = PaywallLegacyComponentInteraction.PRIVACY_LINK_NAME, + componentValue = PaywallLegacyComponentInteraction.Value.NAVIGATE_TO_PRIVACY_POLICY, + componentUrl = url, + ) + verify(exactly = 1) { + purchases.track( + withArg { event -> + val paywallEvent = event as PaywallEvent + assertThat(paywallEvent.type).isEqualTo(PaywallEventType.COMPONENT_INTERACTION) + val ci = requireNotNull(paywallEvent.componentInteraction) + assertThat(ci.componentName).isEqualTo("privacy_link") + assertThat(ci.componentValue).isEqualTo("navigate_to_privacy_policy") + assertThat(ci.componentUrl).isEqualTo(url) + }, + ) + } + } + + @Test + fun `trackComponentInteraction tier selector matches legacy spec`() { + val model = create() + model.trackPaywallImpressionIfNeeded() + model.trackComponentInteraction( + componentType = PaywallComponentType.TAB, + componentName = PaywallLegacyComponentInteraction.TIER_SELECTOR_NAME, + componentValue = "Premium", + ) + verify(exactly = 1) { + purchases.track( + withArg { event -> + val paywallEvent = event as PaywallEvent + assertThat(paywallEvent.type).isEqualTo(PaywallEventType.COMPONENT_INTERACTION) + val ci = requireNotNull(paywallEvent.componentInteraction) + assertThat(ci.componentType).isEqualTo(PaywallComponentType.TAB) + assertThat(ci.componentName).isEqualTo("tier_selector") + assertThat(ci.componentValue).isEqualTo("Premium") + assertThat(ci.componentUrl).isNull() + }, + ) + } + } + @Test fun `trackPaywallImpression multiple times in a row only tracks once`() { val model = create() diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/TierSelectorComponentInteractionValueTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/TierSelectorComponentInteractionValueTest.kt new file mode 100644 index 0000000000..f5e5668208 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/templates/TierSelectorComponentInteractionValueTest.kt @@ -0,0 +1,40 @@ +package com.revenuecat.purchases.ui.revenuecatui.templates + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PackageType +import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration +import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData +import com.revenuecat.purchases.ui.revenuecatui.helpers.getPackageInfoForTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class TierSelectorComponentInteractionValueTest { + + private fun tier(name: String, id: String): TemplateConfiguration.TierInfo { + val rcPackage = TestData.template2Offering.availablePackages.first { it.packageType == PackageType.MONTHLY } + val pkg = rcPackage.getPackageInfoForTest() + return TemplateConfiguration.TierInfo( + name = name, + id = id, + defaultPackage = pkg, + packages = listOf(pkg), + ) + } + + @Test + fun `uses non-blank name`() { + assertThat(tierSelectorComponentInteractionValue(tier("Premium", "premium_id"))).isEqualTo("Premium") + } + + @Test + fun `blank name matches empty string fallback`() { + assertThat(tierSelectorComponentInteractionValue(tier("", "tier_abc"))).isEmpty() + } + + @Test + fun `whitespace-only name matches empty string fallback`() { + assertThat(tierSelectorComponentInteractionValue(tier(" ", "id_only"))).isEmpty() + } +}