diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3b0206f..9d8fcf13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.10 + +## Potentially impactful changes +- Billing errors on register are now wrapped in `SubscriptionStatusTimeout` errors +- This is used to clarify that the status is timing out due to billing errors. If you are depending on the `NoPaywallView` to distinguish between these, ensure you are checking for the proper status. + +## Enhancements +- Adds onboarding analytics +- Adds prioritized preloading support +- Improved error handling + +## Fixes +- Prevents paywalls from dismissing on return from deep link +- Fixes deadlock in `SerialTaskManager` +- Fix issues with bottom sheet on certain Samsung devices + ## 2.7.9 ## Fixes diff --git a/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt b/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt index a236a7e40..2863302b0 100644 --- a/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt +++ b/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt @@ -107,11 +107,11 @@ fun PaywallComposable( viewToRender }, onRelease = { - viewToRender.beforeOnDestroy() + viewToRender.beforeOnDestroy(forceCleanup = true) viewToRender.encapsulatingActivity = null CoroutineScope(Dispatchers.Main).launch { - viewToRender.destroyed() + viewToRender.destroyed(forceCleanup = true) viewToRender.cleanup() } }, 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 ca265a39b..e8ca22f3e 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 413d4aaf8..61dca8428 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 df25d204a..b4e640578 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/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt index ca3286699..e26aadb47 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay class PaywallPreload( val factory: Factory, @@ -52,9 +53,35 @@ class PaywallPreload( preloadingDisabled = config.preloadingDisabled, ) val confirmedAssignments = storage.getConfirmedAssignments() + + // If there's a prioritized campaign, preload its paywalls first. + var remainingTriggers = triggers + val prioritizedCampaignId = config.prioritizedCampaignId + if (prioritizedCampaignId != null) { + val prioritizedTriggers = + triggers.filter { trigger -> + trigger.rules.any { it.experimentGroupId == prioritizedCampaignId } + }.toSet() + if (prioritizedTriggers.isNotEmpty()) { + val prioritizedIds = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = prioritizedTriggers, + confirmedAssignments = confirmedAssignments, + unconfirmedAssignments = assignments.unconfirmedAssignments, + expressionEvaluator = expressionEvaluator, + ) + preloadPaywalls(paywallIdentifiers = prioritizedIds) + remainingTriggers = triggers - prioritizedTriggers + + // Delay before preloading the rest to avoid resource contention. + delay(5000) + } + } + + // Then preload all remaining paywalls. val paywallIds = ConfigLogic.getAllActiveTreatmentPaywallIds( - triggers = triggers, + triggers = remainingTriggers, confirmedAssignments = confirmedAssignments, unconfirmedAssignments = assignments.unconfirmedAssignments, expressionEvaluator = expressionEvaluator, diff --git a/superwall/src/main/java/com/superwall/sdk/misc/SerialTaskManager.kt b/superwall/src/main/java/com/superwall/sdk/misc/SerialTaskManager.kt index ceebf53d2..127a2d239 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/SerialTaskManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/SerialTaskManager.kt @@ -1,50 +1,32 @@ package com.superwall.sdk.misc +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import kotlinx.coroutines.* -import java.util.* -import java.util.LinkedList -import java.util.Queue +import kotlinx.coroutines.channels.Channel class SerialTaskManager( - private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)), + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) { - private val taskQueue: Queue Unit> = LinkedList() - private var currentTask: Deferred? = null + private val channel = Channel Unit>(Channel.UNLIMITED) - fun addTask(task: suspend () -> Unit) { + init { coroutineScope.launch { - // Wait for the current task to finish if there is one - currentTask?.await() - - // Add the task to the queue - taskQueue.offer(task) - - // If there's only one task in the queue, start executing it - if (taskQueue.size == 1) { - executeNextTask() + for (task in channel) { + task() } } } - private suspend fun executeNextTask() { - // Check if there are tasks in the queue - if (taskQueue.isEmpty()) { - return - } - - // Get the next task from the queue - val nextTask = taskQueue.poll() ?: return - - // Run the task and wait for it to complete - currentTask = - coroutineScope.async { - nextTask() - } - currentTask?.await() - - // After the task completes, recursively execute the next task - if (taskQueue.isNotEmpty()) { - executeNextTask() + fun addTask(task: suspend () -> Unit) { + val result = channel.trySend(task) + if (result.isFailure) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.superwallCore, + message = "SerialTaskManager: failed to enqueue task — channel closed", + ) } } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt index 0dcb2a6d7..7a7e568be 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt @@ -29,6 +29,7 @@ data class Config( @SerialName("build_id") val buildId: String, @SerialName("bundle_id_config") val bundleIdConfig: String? = null, @SerialName("test_mode_user_ids") val testModeUserIds: List? = null, + @SerialName("prioritized_campaign_id") val prioritizedCampaignId: String? = null, ) : SerializableEntity { init { locales = localizationConfig.locales.map { it.locale }.toSet() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt index ebcfba12e..fc60aa969 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt @@ -17,38 +17,76 @@ sealed class PaywallPresentationRequestStatus( */ sealed class PaywallPresentationRequestStatusReason( val description: String, + val info: String, ) : Throwable() { /** Trying to present paywall when debugger is launched. */ - class DebuggerPresented : PaywallPresentationRequestStatusReason("debugger_presented") + class DebuggerPresented : + PaywallPresentationRequestStatusReason( + "debugger_presented", + "The paywall debugger is currently presented. Dismiss it before presenting a paywall.", + ) /** There's already a paywall presented. */ - class PaywallAlreadyPresented : PaywallPresentationRequestStatusReason("paywall_already_presented") + class PaywallAlreadyPresented : + PaywallPresentationRequestStatusReason( + "paywall_already_presented", + "A paywall is already being presented. Dismiss it before presenting another one.", + ) /** The user is in a holdout group. */ data class Holdout( val experiment: Experiment, - ) : PaywallPresentationRequestStatusReason("holdout") + ) : PaywallPresentationRequestStatusReason( + "holdout", + "The user is in a holdout group for experiment '${experiment.id}'.", + ) /** No rules defined in the campaign for the event matched. */ - class NoAudienceMatch : PaywallPresentationRequestStatusReason("no_rule_match") + class NoAudienceMatch : + PaywallPresentationRequestStatusReason( + "no_rule_match", + "The user did not match any rules configured for this placement.", + ) /** The event provided was not found in any campaign on the dashboard. */ - class PlacementNotFound : PaywallPresentationRequestStatusReason("event_not_found") + class PlacementNotFound : + PaywallPresentationRequestStatusReason( + "event_not_found", + "The placement was not found in any campaign on the Superwall dashboard.", + ) /** There was an error getting the paywall view. */ - class NoPaywallView : PaywallPresentationRequestStatusReason("no_paywall_view_controller") + class NoPaywallView( + reason: String = "The paywall view could not be created. Check that the Android System WebView is enabled on the device.", + ) : PaywallPresentationRequestStatusReason( + "no_paywall_view_controller", + reason, + ) /** There isn't a view to present the paywall on. */ - class NoPresenter : PaywallPresentationRequestStatusReason("no_presenter") + class NoPresenter : + PaywallPresentationRequestStatusReason( + "no_presenter", + "There is no Activity to present the paywall on. Make sure an Activity is visible before registering a placement.", + ) /** The config hasn't been retrieved from the server in time. */ - class NoConfig : PaywallPresentationRequestStatusReason("no_config") + class NoConfig : + PaywallPresentationRequestStatusReason( + "no_config", + "The Superwall config could not be retrieved in time. Check your network connection and that your API key is correct.", + ) /** - * The entitlement status timed out. + * The entitlement status timed out or Google Play Billing is not available. * This happens when the entitlementStatus stays unknown for more than 5 seconds. */ - class SubscriptionStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout") + class SubscriptionStatusTimeout( + reason: String = "The entitlement status stayed 'unknown' for over 5 seconds. Ensure that Google Play Billing is available and if you're using a custom purchase controller, ensure you're setting the entitlement status on time.", + ) : PaywallPresentationRequestStatusReason( + "subscription_status_timeout", + reason, + ) } typealias PresentationPipelineError = PaywallPresentationRequestStatusReason diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt index 2f60a3ad9..b8cb83265 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt @@ -59,7 +59,9 @@ internal suspend fun evaluateRules( val paywallId = request.presentationInfo.identifier ?: return Result.failure( - PaywallPresentationRequestStatusReason.NoPaywallView(), + PaywallPresentationRequestStatusReason.NoPaywallView( + "No paywall identifier provided for debugger presentation." + ), ) Result.success( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt index 6f9644f20..c45dbc059 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.paywall.presentation.internal.operators +import com.superwall.sdk.billing.BillingError import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope @@ -53,7 +54,7 @@ internal suspend fun getPaywallView( return try { val isForPresentation = request.flags.type != PresentationRequestType.GetImplicitPresentationResult && - request.flags.type != PresentationRequestType.GetPresentationResult + request.flags.type != PresentationRequestType.GetPresentationResult val delegate = request.flags.type.paywallViewDelegateAdapter val webviewExists = webViewExists() @@ -77,11 +78,12 @@ internal suspend fun getPaywallView( scope = LogScope.paywallPresentation, message = "Paywalls cannot be presented because the Android System WebView has been disabled" + - " by the user.", + " by the user.", ) Result.failure(PaywallPresentationRequestStatusReason.NoPaywallView()) } } catch (e: Throwable) { + Result.failure(presentationFailure(e, debugInfo, paywallStatePublisher)) } } @@ -99,5 +101,14 @@ private suspend fun presentationFailure( error = error, ) paywallStatePublisher?.emit(PaywallState.PresentationError(error)) - return PaywallPresentationRequestStatusReason.NoPaywallView() + if (error is BillingError) { + return PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout( + "Google Play Billing is not available or has an error: ${error.message}" + ) + } + return PaywallPresentationRequestStatusReason.NoPaywallView( + "The paywall view could not be created: ${error.message}" + ) } + + diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt index 61503c55c..92d228c94 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt @@ -36,6 +36,6 @@ internal suspend fun Superwall.logErrors( Logger.debug( logLevel = LogLevel.info, scope = LogScope.paywallPresentation, - message = "Skipped paywall presentation: ${error.message}, ${error.stackTraceToString()}", + message = "Skipped paywall presentation: ${if (error is PaywallPresentationRequestStatusReason) "${error.description} - ${error.info}" else error.message}, ${error.stackTraceToString()}", ) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index bf0c823f5..ac6c0ae24 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -398,8 +398,8 @@ class PaywallView( } } - fun beforeOnDestroy() { - if (state.isBrowserViewPresented) { + fun beforeOnDestroy(forceCleanup: Boolean = false) { + if (state.isBrowserViewPresented && !forceCleanup) { return } factory.updatePaywallInfo(info) @@ -408,8 +408,8 @@ class PaywallView( .willDismissPaywall(info) } - suspend fun destroyed() { - if (state.isBrowserViewPresented) { + suspend fun destroyed(forceCleanup: Boolean = false) { + if (state.isBrowserViewPresented && !forceCleanup) { return } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 9a18a892d..6e30a0a21 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -35,6 +35,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.children +import androidx.core.view.doOnLayout import androidx.core.view.updateLayoutParams import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback @@ -564,30 +565,21 @@ class SuperwallPaywallActivity : AppCompatActivity() { } } bottomSheetBehavior.skipCollapsed = true + // Start hidden so the sheet slides up from the bottom + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - val setState = { - if (!isModal) { - // Expanded by default - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED - } else { - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED - } + val targetState = if (!isModal) { + BottomSheetBehavior.STATE_HALF_EXPANDED + } else { + BottomSheetBehavior.STATE_EXPANDED } - // Check if we need to delay state change for Samsung devices on Android 14 - val isSamsungAndroid14 = - Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE && - ( - Build.MANUFACTURER.equals("samsung", ignoreCase = true) || - Build.BRAND.equals("samsung", ignoreCase = true) - ) - - if (isSamsungAndroid14) { - // Post state change to next frame after layout is complete - // This fixes timing issues on Samsung devices with Android 14 - content.post { setState() } - } else { - setState() + // Wait for layout to complete before expanding, so the slide-up + // animation runs correctly on all devices (including Samsung). + content.doOnLayout { + content.post { + bottomSheetBehavior.state = targetState + } } content.invalidate() var currentWebViewScroll = 0 @@ -713,7 +705,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.beforeOnDestroy() + paywallVc.beforeOnDestroy(forceCleanup = isFinishing) } } @@ -723,7 +715,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.destroyed() + paywallVc.destroyed(forceCleanup = isFinishing) } } 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 000000000..e19cd8034 --- /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 153f9a526..7f1e544fe 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 c02e76b34..e8e951848 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 7c30d2ee0..48bbff7f5 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 bf0182d5b..858d73f10 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) + } + } + } + } } diff --git a/version.env b/version.env index e0e7deb06..2e14af5fe 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.9 +SUPERWALL_VERSION=2.7.10