From 7d63808265000742e5b76de332d3fe020beb9873 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 27 Mar 2026 11:17:55 +0100 Subject: [PATCH] Add onboarding analytics support --- .../trackable/TrackableSuperwallEvent.kt | 22 +++ .../sdk/analytics/superwall/SuperwallEvent.kt | 10 ++ .../analytics/superwall/SuperwallEvents.kt | 1 + .../view/webview/messaging/PageViewData.kt | 12 ++ .../view/webview/messaging/PaywallMessage.kt | 19 ++ .../messaging/PaywallMessageHandler.kt | 12 ++ .../trackable/InternalSuperwallEventTest.kt | 88 +++++++++ .../view/webview/PaywallMessageHandlerTest.kt | 170 ++++++++++++++++++ 8 files changed, 334 insertions(+) create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PageViewData.kt diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt index ca265a39..e8ca22f3 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.analytics.internal.trackable import com.superwall.sdk.analytics.superwall.SuperwallEvent +import com.superwall.sdk.paywall.view.webview.messaging.PageViewData import com.superwall.sdk.analytics.superwall.TransactionProduct import com.superwall.sdk.config.models.Survey import com.superwall.sdk.config.models.SurveyOption @@ -494,6 +495,27 @@ sealed class InternalSuperwallEvent( } } + class PaywallPageView( + val paywallInfo: PaywallInfo, + val data: PageViewData, + ) : InternalSuperwallEvent(SuperwallEvent.PaywallPageView(paywallInfo, data)) { + override val audienceFilterParams: Map + get() = paywallInfo.audienceFilterParams() + + override suspend fun getSuperwallParameters(): HashMap { + val params = HashMap(paywallInfo.eventParams()) + params["page_node_id"] = data.pageNodeId + params["flow_position"] = data.flowPosition + params["page_name"] = data.pageName + params["navigation_node_id"] = data.navigationNodeId + params["navigation_type"] = data.navigationType + data.previousPageNodeId?.let { params["previous_page_node_id"] = it } + data.previousFlowPosition?.let { params["previous_flow_position"] = it } + data.timeOnPreviousPageMs?.let { params["time_on_previous_page_ms"] = it } + return params + } + } + class Transaction( val state: State, val paywallInfo: PaywallInfo, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt index 413d4aaf..61dca842 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt @@ -5,6 +5,7 @@ import com.superwall.sdk.config.models.SurveyOption import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.triggers.TriggerResult import com.superwall.sdk.paywall.presentation.PaywallInfo +import com.superwall.sdk.paywall.view.webview.messaging.PageViewData import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.view.webview.WebviewError @@ -138,6 +139,15 @@ sealed class SuperwallEvent { get() = "paywall_open" } + // / When a page view occurs in a multi-page paywall. + data class PaywallPageView( + val paywallInfo: PaywallInfo, + val data: PageViewData, + ) : SuperwallEvent() { + override val rawName: String + get() = SuperwallEvents.PaywallPageView.rawName + } + // / When a paywall is closed. data class PaywallClose( val paywallInfo: PaywallInfo, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt index df25d204..b4e64057 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt @@ -65,6 +65,7 @@ enum class SuperwallEvents( PermissionDenied("permission_denied"), PaywallPreloadStart("paywallPreload_start"), PaywallPreloadComplete("paywallPreload_complete"), + PaywallPageView("paywall_page_view"), TestModeModalOpen("testModeModal_open"), TestModeModalClose("testModeModal_close"), } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PageViewData.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PageViewData.kt new file mode 100644 index 00000000..e19cd803 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PageViewData.kt @@ -0,0 +1,12 @@ +package com.superwall.sdk.paywall.view.webview.messaging + +data class PageViewData( + val pageNodeId: String, + val flowPosition: Int, + val pageName: String, + val navigationNodeId: String, + val previousPageNodeId: String?, + val previousFlowPosition: Int?, + val navigationType: String, + val timeOnPreviousPageMs: Int?, +) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt index 153f9a52..7f1e544f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -129,6 +130,10 @@ sealed class PaywallMessage { val variables: Map?, ) : PaywallMessage() + data class PageView( + val data: PageViewData, + ) : PaywallMessage() + data class HapticFeedback( val hapticType: HapticType, ) : PaywallMessage() { @@ -256,6 +261,20 @@ private fun parsePaywallMessage(json: JsonObject): PaywallMessage { ) } + "page_view" -> + PaywallMessage.PageView( + PageViewData( + pageNodeId = json["page_node_id"]!!.jsonPrimitive.content, + flowPosition = json["flow_position"]!!.jsonPrimitive.int, + pageName = json["page_name"]!!.jsonPrimitive.content, + navigationNodeId = json["navigation_node_id"]!!.jsonPrimitive.content, + previousPageNodeId = json["previous_page_node_id"]?.jsonPrimitive?.contentOrNull, + previousFlowPosition = json["previous_flow_position"]?.jsonPrimitive?.intOrNull, + navigationType = json["type"]!!.jsonPrimitive.content, + timeOnPreviousPageMs = json["time_on_previous_page_ms"]?.jsonPrimitive?.intOrNull, + ), + ) + "haptic_feedback" -> { val style = json["haptic_type"]?.jsonPrimitive?.contentOrNull?.let { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index c02e76b3..e8e95184 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -264,6 +264,18 @@ class PaywallMessageHandler( is PaywallMessage.HapticFeedback -> triggerHapticFeedback(message.hapticType) + is PaywallMessage.PageView -> { + val paywallInfo = messageHandler?.state?.info ?: return + ioScope.launch { + track( + InternalSuperwallEvent.PaywallPageView( + paywallInfo = paywallInfo, + data = message.data, + ), + ) + } + } + else -> { Logger.debug( LogLevel.error, diff --git a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt index 7c30d2ee..48bbff7f 100644 --- a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt @@ -995,6 +995,94 @@ class InternalSuperwallEventTest { return StoreTransaction(transaction, configRequestId = "config", appSessionId = "session") } + @Test + fun paywallPageView_allFieldsMappedToSnakeCase() = + runTest { + Given("a PaywallPageView event with all fields") { + val paywallInfo = stubPaywallInfo() + val data = + com.superwall.sdk.paywall.view.webview.messaging.PageViewData( + pageNodeId = "node-123", + flowPosition = 2, + pageName = "Pricing", + navigationNodeId = "nav-456", + previousPageNodeId = "node-000", + previousFlowPosition = 1, + navigationType = "forward", + timeOnPreviousPageMs = 3500, + ) + val event = + InternalSuperwallEvent.PaywallPageView( + paywallInfo = paywallInfo, + data = data, + ) + + When("parameters are requested") { + val params = event.getSuperwallParameters() + + Then("all page view fields are mapped to snake_case keys") { + assertEquals("node-123", params["page_node_id"]) + assertEquals(2, params["flow_position"]) + assertEquals("Pricing", params["page_name"]) + assertEquals("nav-456", params["navigation_node_id"]) + assertEquals("forward", params["navigation_type"]) + assertEquals("node-000", params["previous_page_node_id"]) + assertEquals(1, params["previous_flow_position"]) + assertEquals(3500, params["time_on_previous_page_ms"]) + } + + And("paywall info params are also included") { + assertEquals(paywallInfo.identifier, params["paywall_identifier"]) + } + + And("the superwall placement is paywall_page_view") { + assertEquals("paywall_page_view", event.superwallPlacement.rawName) + } + } + } + } + + @Test + fun paywallPageView_optionalFieldsOmitted() = + runTest { + Given("a PaywallPageView event without optional fields") { + val paywallInfo = stubPaywallInfo() + val data = + com.superwall.sdk.paywall.view.webview.messaging.PageViewData( + pageNodeId = "node-first", + flowPosition = 0, + pageName = "Welcome", + navigationNodeId = "nav-001", + previousPageNodeId = null, + previousFlowPosition = null, + navigationType = "entry", + timeOnPreviousPageMs = null, + ) + val event = + InternalSuperwallEvent.PaywallPageView( + paywallInfo = paywallInfo, + data = data, + ) + + When("parameters are requested") { + val params = event.getSuperwallParameters() + + Then("required fields are present") { + assertEquals("node-first", params["page_node_id"]) + assertEquals(0, params["flow_position"]) + assertEquals("Welcome", params["page_name"]) + assertEquals("entry", params["navigation_type"]) + } + + And("optional fields are absent") { + assertFalse(params.containsKey("previous_page_node_id")) + assertFalse(params.containsKey("previous_flow_position")) + assertFalse(params.containsKey("time_on_previous_page_ms")) + } + } + } + } + private object NoopPresentationFactory : InternalSuperwallEvent.PresentationRequest.Factory { override suspend fun makeRuleAttributes( event: EventData?, diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt index bf0182d5..858d73f1 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt @@ -15,6 +15,7 @@ import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.paywall.view.PaywallViewState import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.webview.messaging.PageViewData import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate @@ -644,4 +645,173 @@ class PaywallMessageHandlerTest { } } } + + @Test + fun parsePageView_allFields() = + runTest { + Given("a wrapped message containing a page_view event with all fields") { + val json = + """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "page_view", + "page_node_id": "node-123", + "flow_position": 2, + "page_name": "Pricing", + "navigation_node_id": "nav-456", + "previous_page_node_id": "node-000", + "previous_flow_position": 1, + "type": "forward", + "time_on_previous_page_ms": 3500 + } + ] + } + } + """.trimIndent() + + When("the message is parsed") { + val result = parseWrappedPaywallMessages(json) + + Then("it returns a PageView message with correct data") { + assert(result.isSuccess) + val wrapped = result.getOrThrow() + assertEquals(1, wrapped.payload.messages.size) + val message = wrapped.payload.messages[0] + assert(message is PaywallMessage.PageView) + val pageView = (message as PaywallMessage.PageView).data + assertEquals("node-123", pageView.pageNodeId) + assertEquals(2, pageView.flowPosition) + assertEquals("Pricing", pageView.pageName) + assertEquals("nav-456", pageView.navigationNodeId) + assertEquals("node-000", pageView.previousPageNodeId) + assertEquals(1, pageView.previousFlowPosition) + assertEquals("forward", pageView.navigationType) + assertEquals(3500, pageView.timeOnPreviousPageMs) + } + } + } + } + + @Test + fun parsePageView_optionalFieldsNull() = + runTest { + Given("a wrapped message containing a page_view event without optional fields") { + val json = + """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "page_view", + "page_node_id": "node-first", + "flow_position": 0, + "page_name": "Welcome", + "navigation_node_id": "nav-001", + "type": "entry" + } + ] + } + } + """.trimIndent() + + When("the message is parsed") { + val result = parseWrappedPaywallMessages(json) + + Then("optional fields are null") { + assert(result.isSuccess) + val pageView = (result.getOrThrow().payload.messages[0] as PaywallMessage.PageView).data + assertEquals("node-first", pageView.pageNodeId) + assertEquals(0, pageView.flowPosition) + assertEquals("Welcome", pageView.pageName) + assertEquals("entry", pageView.navigationType) + assertEquals(null, pageView.previousPageNodeId) + assertEquals(null, pageView.previousFlowPosition) + assertEquals(null, pageView.timeOnPreviousPageMs) + } + } + } + } + + @Test + fun handlePageView_tracksInternalEvent() = + runTest { + Given("a PaywallMessageHandler with a delegate") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = FakeDelegate(state) + val trackedEvents = + mutableListOf() + val handler = + createHandler( + track = { event -> trackedEvents.add(event) }, + ) + handler.messageHandler = delegate + + When("a PageView message is handled") { + handler.handle( + PaywallMessage.PageView( + PageViewData( + pageNodeId = "node-abc", + flowPosition = 1, + pageName = "Checkout", + navigationNodeId = "nav-xyz", + previousPageNodeId = "node-prev", + previousFlowPosition = 0, + navigationType = "forward", + timeOnPreviousPageMs = 2000, + ), + ), + ) + advanceUntilIdle() + + Then("a PaywallPageView event is tracked") { + assertEquals(1, trackedEvents.size) + val event = + trackedEvents[0] + as com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.PaywallPageView + assertEquals("node-abc", event.data.pageNodeId) + assertEquals(1, event.data.flowPosition) + assertEquals("Checkout", event.data.pageName) + assertEquals("forward", event.data.navigationType) + assertEquals(2000, event.data.timeOnPreviousPageMs) + } + } + } + } + + @Test + fun parsePageView_missingRequiredField_fails() = + runTest { + Given("a wrapped message with page_view missing required page_node_id") { + val json = + """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "page_view", + "flow_position": 0, + "page_name": "Welcome", + "navigation_node_id": "nav-001", + "type": "entry" + } + ] + } + } + """.trimIndent() + + When("the message is parsed") { + val result = parseWrappedPaywallMessages(json) + + Then("parsing fails") { + assert(result.isFailure) + } + } + } + } }