From 712e6449937118fbbad0205932acdb0942687e14 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 20 Mar 2026 19:47:13 +0100 Subject: [PATCH 01/11] Adds prioritized campaigns support --- .../superwall/sdk/config/PaywallPreload.kt | 25 +++++++++++++++++++ .../com/superwall/sdk/models/config/Config.kt | 1 + 2 files changed, 26 insertions(+) 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 ca328669..fa48b745 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,6 +53,30 @@ class PaywallPreload( preloadingDisabled = config.preloadingDisabled, ) val confirmedAssignments = storage.getConfirmedAssignments() + + // If there's a prioritized campaign, preload its paywalls first. + 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) + + // Delay before preloading the rest to avoid resource contention. + delay(5000) + } + } + + // Then preload all remaining paywalls. val paywallIds = ConfigLogic.getAllActiveTreatmentPaywallIds( triggers = triggers, 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 0dcb2a6d..7a7e568b 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() From 4b794c093f6911b9e2dab9d0b4e4143ffd0e6acd Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 23 Mar 2026 12:31:18 +0100 Subject: [PATCH 02/11] Improve error messages for billing errors --- .../PaywallPresentationRequestStatus.kt | 60 +++++++++++++++---- .../internal/operators/EvaluateRules.kt | 4 +- .../internal/operators/GetPaywallVC.kt | 12 +++- .../internal/operators/LogErrors.kt | 2 +- 4 files changed, 64 insertions(+), 14 deletions(-) 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 ebcfba12..c436491a 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") + /** Trying to prbroesent paywall when debugger is launched. */ + 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 2f60a3ad..b8cb8326 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 6f9644f2..742fa5b4 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 @@ -82,6 +83,7 @@ internal suspend fun getPaywallView( Result.failure(PaywallPresentationRequestStatusReason.NoPaywallView()) } } catch (e: Throwable) { + Result.failure(presentationFailure(e, debugInfo, paywallStatePublisher)) } } @@ -99,5 +101,13 @@ 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: ${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 61503c55..92d228c9 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()}", ) } From 06d54a91d14eca0caff595fea674f3dfd46b067f Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 1 Apr 2026 11:59:54 +0200 Subject: [PATCH 03/11] Add guard to prevent pw from dismissing on deep link --- .../java/com/superwall/sdk/paywall/view/PaywallView.kt | 8 ++++---- .../sdk/paywall/view/SuperwallPaywallActivity.kt | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 bf0c823f..fa2b06ea 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(isActivityFinishing: Boolean = false) { + if (state.isBrowserViewPresented && !isActivityFinishing) { return } factory.updatePaywallInfo(info) @@ -408,8 +408,8 @@ class PaywallView( .willDismissPaywall(info) } - suspend fun destroyed() { - if (state.isBrowserViewPresented) { + suspend fun destroyed(isActivityFinishing: Boolean = false) { + if (state.isBrowserViewPresented && !isActivityFinishing) { 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 9a18a892..aff6ec22 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 @@ -713,7 +713,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.beforeOnDestroy() + paywallVc.beforeOnDestroy(isActivityFinishing = isFinishing) } } @@ -723,7 +723,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.destroyed() + paywallVc.destroyed(isActivityFinishing = isFinishing) } } From 869619eb7f811054f5ace0d426f5c8fa6eb2eba5 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 1 Apr 2026 12:34:12 +0200 Subject: [PATCH 04/11] Fix compose reference --- .../java/com/superwall/sdk/compose/PaywallComposable.kt | 4 ++-- .../java/com/superwall/sdk/paywall/view/PaywallView.kt | 8 ++++---- .../sdk/paywall/view/SuperwallPaywallActivity.kt | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) 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 a236a7e4..2863302b 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/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index fa2b06ea..ac6c0ae2 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(isActivityFinishing: Boolean = false) { - if (state.isBrowserViewPresented && !isActivityFinishing) { + fun beforeOnDestroy(forceCleanup: Boolean = false) { + if (state.isBrowserViewPresented && !forceCleanup) { return } factory.updatePaywallInfo(info) @@ -408,8 +408,8 @@ class PaywallView( .willDismissPaywall(info) } - suspend fun destroyed(isActivityFinishing: Boolean = false) { - if (state.isBrowserViewPresented && !isActivityFinishing) { + 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 aff6ec22..47e0ab6a 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 @@ -713,7 +713,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.beforeOnDestroy(isActivityFinishing = isFinishing) + paywallVc.beforeOnDestroy(forceCleanup = isFinishing) } } @@ -723,7 +723,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { val paywallVc = paywallView() ?: return mainScope.launch { - paywallVc.destroyed(isActivityFinishing = isFinishing) + paywallVc.destroyed(forceCleanup = isFinishing) } } From 7d63808265000742e5b76de332d3fe020beb9873 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 27 Mar 2026 11:17:55 +0100 Subject: [PATCH 05/11] 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) + } + } + } + } } From a6ccdd7ac5f01e3a2e61da671d7a1146d9ff6b7c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 2 Apr 2026 10:44:14 +0200 Subject: [PATCH 06/11] Fix treatment of bottom sheet for samsungs --- .../paywall/view/SuperwallPaywallActivity.kt | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) 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 47e0ab6a..6e30a0a2 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 From a472380e86be2bbb1fb82fb7e3c6f01dc7366d96 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Sat, 4 Apr 2026 19:34:03 +0200 Subject: [PATCH 07/11] Ensure serial task manager never blocks by migrating to channel --- .../superwall/sdk/misc/SerialTaskManager.kt | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) 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 ceebf53d..127a2d23 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", + ) } } } From 60b1b32183d8874c8b21e2e9809e26f1a95572e0 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 6 Apr 2026 13:30:31 +0200 Subject: [PATCH 08/11] Update version & Changelog --- CHANGELOG.md | 13 +++++++++++++ version.env | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3b0206..93bb49f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.10 + +## Enhancements +- Adds onboarding analytics +- Adds prioritized preloading support +- Improves error messages for billing errors +- Improved error handling + +## Fixes +- Prevents paywalls from dismissing on return from deep link +- Fixes deadlock in `SerialTaskManager` +- Fix issues with bottom sheet on certain + ## 2.7.9 ## Fixes diff --git a/version.env b/version.env index e0e7deb0..2e14af5f 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.9 +SUPERWALL_VERSION=2.7.10 From 8b701370f8e0f59bcfafbd807534dc3435f08a8b Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 6 Apr 2026 13:31:32 +0200 Subject: [PATCH 09/11] Fix changelog message --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93bb49f2..cfffe581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Fixes - Prevents paywalls from dismissing on return from deep link - Fixes deadlock in `SerialTaskManager` -- Fix issues with bottom sheet on certain +- Fix issues with bottom sheet on certain Samsung devices ## 2.7.9 From e948e9a52a97ec31a3fae5bd2948a895982bd517 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 6 Apr 2026 13:42:22 +0200 Subject: [PATCH 10/11] Fix double preload bug --- .../main/java/com/superwall/sdk/config/PaywallPreload.kt | 4 +++- .../internal/PaywallPresentationRequestStatus.kt | 2 +- .../presentation/internal/operators/GetPaywallVC.kt | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) 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 fa48b745..e26aadb4 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -55,6 +55,7 @@ class PaywallPreload( 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 = @@ -70,6 +71,7 @@ class PaywallPreload( expressionEvaluator = expressionEvaluator, ) preloadPaywalls(paywallIdentifiers = prioritizedIds) + remainingTriggers = triggers - prioritizedTriggers // Delay before preloading the rest to avoid resource contention. delay(5000) @@ -79,7 +81,7 @@ class PaywallPreload( // 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/paywall/presentation/internal/PaywallPresentationRequestStatus.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt index c436491a..fc60aa96 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 @@ -19,7 +19,7 @@ sealed class PaywallPresentationRequestStatusReason( val description: String, val info: String, ) : Throwable() { - /** Trying to prbroesent paywall when debugger is launched. */ + /** Trying to present paywall when debugger is launched. */ class DebuggerPresented : PaywallPresentationRequestStatusReason( "debugger_presented", 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 742fa5b4..c45dbc05 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 @@ -54,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() @@ -78,7 +78,7 @@ 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()) } @@ -101,10 +101,11 @@ private suspend fun presentationFailure( error = error, ) paywallStatePublisher?.emit(PaywallState.PresentationError(error)) - if(error is BillingError) + if (error is BillingError) { return PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout( - "Google Play Billing is not available: ${error.message}" + "Google Play Billing is not available or has an error: ${error.message}" ) + } return PaywallPresentationRequestStatusReason.NoPaywallView( "The paywall view could not be created: ${error.message}" ) From dbfd0bd8b5a2dc4c78f152826ca0a09acda9bf09 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 6 Apr 2026 13:45:48 +0200 Subject: [PATCH 11/11] Changelog update --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffe581..9d8fcf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 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 -- Improves error messages for billing errors - Improved error handling ## Fixes