diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 01318ccb1..98b75ff22 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index eb67a0eb4..a08d53e80 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1f68eae7..e91a7bbeb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
## Unreleased
+- Fix multi-page paywalls only reporting the entry page view.
+- Fix an active paywall not being reopened after its webview process crashes and is recreated. Recovery cancels messages for the old webview and sends the open after the replacement loads, only if the same presentation is still active.
- Fix prices not showing when product/offers are fetched from cache
- Fix a JSON null in placement parameters or user attributes reaching audience filters as the text `"null"`, so a filter checking whether a field is null never matched.
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 c041e571d..d33dddb42 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
@@ -1007,6 +1007,7 @@ class PaywallView(
private fun recreateWebview() {
val oldWebView = webView
+ oldWebView.messageHandler.resetForWebViewReload()
oldWebView.detach(this)
oldWebView.destroyView()
webView =
@@ -1015,7 +1016,6 @@ class PaywallView(
})
webView.attach(this)
webView.delegate = this
- webView.messageHandler.handle(PaywallMessage.PaywallOpen)
loadWebView()
}
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 1446d9f89..8f8a32d67 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
@@ -32,6 +32,9 @@ import com.superwall.sdk.storage.core_data.convertToJsonElement
import com.superwall.sdk.utilities.withErrorTracking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -41,8 +44,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import java.net.URI
import java.util.Date
-import java.util.LinkedList
-import java.util.Queue
import kotlin.coroutines.resume
interface PaywallStateDelegate {
@@ -97,7 +98,84 @@ class PaywallMessageHandler(
}
var messageHandler: PaywallMessageHandlerDelegate? = null
- private val queue: Queue = LinkedList()
+
+ private data class PendingMessage(
+ val message: PaywallMessage,
+ val shouldSend: () -> Boolean = { true },
+ )
+
+ // Reserve the order synchronously; template construction can suspend before the
+ // main-thread evaluation. Never bypass an unfinished send: late templates reset
+ // the runtime even if an open has already arrived. Reload cancels obsolete work.
+ // Permission and callback replies remain independent.
+ private val outboundLock = Any()
+ private val queue = ArrayDeque()
+ private val outboundJobs = mutableSetOf()
+ private var lastOutbound: Job? = null
+
+ private fun enqueueOutbound(block: suspend () -> Unit) {
+ synchronized(outboundLock) {
+ val previous = lastOutbound
+ val job =
+ ioScope.launch {
+ previous?.join()
+ block()
+ }
+ lastOutbound = job
+ outboundJobs.add(job)
+ job.invokeOnCompletion {
+ synchronized(outboundLock) {
+ outboundJobs.remove(job)
+ if (lastOutbound === job) lastOutbound = null
+ }
+ }
+ }
+ }
+
+ // Called on main before replacing the WebView. Cancel work for the old document
+ // and only restore an open if that same presentation is still active at delivery.
+ internal fun resetForWebViewReload() {
+ synchronized(outboundLock) {
+ outboundJobs.toList().forEach { it.cancel() }
+ lastOutbound = null
+ queue.clear()
+ val state = messageHandler?.state
+ messageHandler?.updateState(PaywallViewState.Updates.SetPaywallJsVersion(null))
+ if (state?.isPresented == true && !state.closedForBackground) {
+ // SetLastOpen replaces this object for each new presentation.
+ val lastOpen = state.lastOpen
+ queue.addLast(
+ PendingMessage(PaywallMessage.PaywallOpen) {
+ val current = messageHandler?.state
+ current?.isPresented == true && !current.closedForBackground && current.lastOpen === lastOpen
+ },
+ )
+ }
+ }
+ }
+
+ private fun sendLifecycleMessage(pending: PendingMessage) {
+ synchronized(outboundLock) {
+ if (!isWebViewLoaded) {
+ queue.addLast(pending)
+ } else {
+ val paywall = messageHandler?.state?.paywall ?: return
+ enqueueOutbound {
+ withContext(Dispatchers.Main) {
+ if (pending.shouldSend()) {
+ val eventName =
+ if (pending.message is PaywallMessage.PaywallOpen) {
+ SuperwallEvents.PaywallOpen.rawName
+ } else {
+ SuperwallEvents.PaywallClose.rawName
+ }
+ pass(eventName = eventName, paywall = paywall)
+ }
+ }
+ }
+ }
+ }
+ }
@JavascriptInterface
fun postMessage(message: String) {
@@ -140,19 +218,18 @@ class PaywallMessageHandler(
) { "!! PaywallMessageHandler: Paywall: $paywall, delegeate: $messageHandler" }
when (message) {
is PaywallMessage.TemplateParamsAndUserAttributes ->
- ioScope.launch { passTemplatesToWebView(paywall) }
+ enqueueOutbound { passTemplatesToWebView(paywall) }
is PaywallMessage.OnReady -> {
- messageHandler?.updateState(
- PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion),
- )
- val loadedAt = Date()
- Logger.debug(
- LogLevel.debug,
- LogScope.superwallCore,
- "!! PaywallMessageHandler: Ready !!",
- )
- ioScope.launch { didLoadWebView(paywall, loadedAt) }
+ // Publishing readiness and reserving initialization must be atomic
+ // with lifecycle sends from other threads.
+ synchronized(outboundLock) {
+ messageHandler?.updateState(
+ PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion),
+ )
+ val loadedAt = Date()
+ enqueueOutbound { didLoadWebView(paywall, loadedAt) }
+ }
}
is PaywallMessage.Close -> {
@@ -177,49 +254,34 @@ class PaywallMessageHandler(
shouldDismiss = message.shouldDismiss,
)
- is PaywallMessage.PaywallOpen -> {
- if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
- queue.offer(message)
- } else {
- ioScope.launch {
- pass(eventName = SuperwallEvents.PaywallOpen.rawName, paywall = paywall)
- }
- }
- }
-
- is PaywallMessage.PaywallClose -> {
- if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
- queue.offer(message)
- } else {
- ioScope.launch {
- val eventName = SuperwallEvents.PaywallClose.rawName
- pass(eventName = eventName, paywall = paywall)
- }
- }
+ is PaywallMessage.PaywallOpen,
+ is PaywallMessage.PaywallClose,
+ -> {
+ sendLifecycleMessage(PendingMessage(message))
}
is PaywallMessage.BackButtonPressed ->
- ioScope.launch {
+ enqueueOutbound {
pass(eventName = "back_button_input", paywall = paywall)
}
is PaywallMessage.Custom -> handleCustomEvent(message.data)
is PaywallMessage.CustomPlacement -> handleCustomPlacement(message.name, message.params)
is PaywallMessage.RestoreFailed ->
- ioScope.launch {
+ enqueueOutbound {
pass(SuperwallEvents.RestoreFail.rawName, paywall)
}
is PaywallMessage.RequestReview -> handleRequestReview(message)
is PaywallMessage.TransactionStart -> {
- ioScope.launch {
+ enqueueOutbound {
pass(eventName = SuperwallEvents.TransactionStart.rawName, paywall = paywall)
}
}
is PaywallMessage.TransactionAbandon -> {
- ioScope.launch {
+ enqueueOutbound {
pass(eventName = SuperwallEvents.TransactionAbandon.rawName, paywall = paywall)
}
}
@@ -229,7 +291,7 @@ class PaywallMessageHandler(
}
is PaywallMessage.TransactionComplete -> {
- ioScope.launch {
+ enqueueOutbound {
pass(
SuperwallEvents.TransactionComplete.rawName,
paywall,
@@ -239,7 +301,7 @@ class PaywallMessageHandler(
}
is PaywallMessage.TrialStarted -> {
- ioScope.launch {
+ enqueueOutbound {
pass(
eventName = SuperwallEvents.FreeTrialStart.rawName,
paywall = paywall,
@@ -376,6 +438,7 @@ class PaywallMessageHandler(
)
withContext(Dispatchers.Main) {
+ currentCoroutineContext().ensureActive()
messageHandler?.evaluate(templateScript) { error ->
if (error != null) {
Logger.debug(
@@ -395,22 +458,24 @@ class PaywallMessageHandler(
paywall: Paywall,
loadedAt: Date,
) {
- ioScope.launch {
- val delegate = this@PaywallMessageHandler.messageHandler
- if (delegate != null) {
- delegate.updateState(PaywallViewState.Updates.WebLoadingEnded(loadedAt))
+ val delegate = this@PaywallMessageHandler.messageHandler
+ if (delegate != null) {
+ delegate.updateState(PaywallViewState.Updates.WebLoadingEnded(loadedAt))
- val paywallInfo = delegate.state.info
+ val paywallInfo = delegate.state.info
+ // Tracking talks to the network, so it stays off the outbound queue - only
+ // the messages the webview receives need to keep their order.
+ ioScope.launch {
val trackedEvent =
InternalSuperwallEvent.PaywallWebviewLoad(
state = InternalSuperwallEvent.PaywallWebviewLoad.State.Complete(),
paywallInfo = paywallInfo,
)
track(trackedEvent)
-
- val behavior = options.makeSuperwallOptions().eventTrackingBehavior
- passEventTrackingBehaviorToWebView(behavior)
}
+
+ val behavior = options.makeSuperwallOptions().eventTrackingBehavior
+ passEventTrackingBehaviorToWebView(behavior)
}
Logger.debug(
@@ -451,7 +516,11 @@ class PaywallMessageHandler(
message = { "Posting Message" },
)
- mainScope.launch {
+ // Awaited rather than launched, so this send completes before anything queued
+ // behind it - a `paywall_open` that arrives while the templates are still
+ // building must not reach the webview first.
+ withContext(Dispatchers.Main) {
+ currentCoroutineContext().ensureActive()
messageHandler?.evaluate(scriptSrc) { error ->
if (error != null) {
Logger.debug(
@@ -476,20 +545,22 @@ class PaywallMessageHandler(
}
}
+ private val isWebViewLoaded: Boolean
+ get() = messageHandler?.state?.paywall?.paywalljsVersion != null
+
fun flushPendingMessages() {
- ioScope.launch {
- mainScope.launch {
- flushPendingMessagesInternal()
- }
+ mainScope.launch {
+ flushPendingMessagesInternal()
}
}
private fun flushPendingMessagesInternal() {
- if (queue.isEmpty()) return
-
- val pending = queue.toList()
- queue.clear()
- pending.forEach { handle(it) }
+ synchronized(outboundLock) {
+ if (!isWebViewLoaded) return
+ while (queue.isNotEmpty()) {
+ sendLifecycleMessage(queue.removeFirst())
+ }
+ }
}
private fun openUrl(
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt
new file mode 100644
index 000000000..9fb74fd57
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt
@@ -0,0 +1,406 @@
+package com.superwall.sdk.paywall.view.webview.messaging
+
+import android.app.Activity
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
+import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.dependencies.OptionsFactory
+import com.superwall.sdk.dependencies.VariablesFactory
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.misc.MainScope
+import com.superwall.sdk.models.config.ComputedPropertyRequest
+import com.superwall.sdk.models.events.EventData
+import com.superwall.sdk.models.paywall.Paywall
+import com.superwall.sdk.models.product.ProductVariable
+import com.superwall.sdk.paywall.presentation.CustomCallbackRegistry
+import com.superwall.sdk.paywall.view.PaywallViewState
+import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
+import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
+import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.PermissionType
+import com.superwall.sdk.permissions.UserPermissions
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.util.Date
+
+/**
+ * The paywall runtime treats a `template_variables` message that lands after
+ * `paywall_open` as a fresh load: it marks the paywall as closed and then discards
+ * every subsequent `page_view`. Multi-page flows then look like every user dropped
+ * off on the first page. These tests pin the delivery order, with a template build
+ * that is deliberately slower than the open message it must precede.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class PaywallMessageOrderingTest {
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private class RecordingDelegate(
+ initial: PaywallViewState,
+ ) : PaywallMessageHandlerDelegate {
+ private var _state: PaywallViewState = initial
+ override val state: PaywallViewState
+ get() = _state
+
+ val evaluations = mutableListOf()
+
+ override fun updateState(update: PaywallViewState.Updates) {
+ _state = update.transform(_state)
+ }
+
+ override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) {}
+
+ override fun openDeepLink(url: String) {}
+
+ override fun presentBrowserInApp(url: String) {}
+
+ override fun presentBrowserExternal(url: String) {}
+
+ override fun evaluate(
+ code: String,
+ resultCallback: ((String?) -> Unit)?,
+ ) {
+ evaluations.add(code)
+ resultCallback?.invoke(null)
+ }
+
+ override fun presentPaymentSheet(url: String) {}
+ }
+
+ // Mirrors production, where building the variables hits the store and user
+ // attributes and so takes far longer than encoding a plain event.
+ private class SlowVariablesFactory(
+ private val fail: Boolean = false,
+ private val buildDelayMs: Long = TEMPLATE_BUILD_MS,
+ ) : VariablesFactory {
+ override suspend fun makeJsonVariables(
+ products: List?,
+ computedPropertyRequests: List,
+ event: EventData?,
+ ): JsonVariables {
+ delay(buildDelayMs)
+ if (fail) throw IllegalStateException("could not build the templates")
+ return JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap()))
+ }
+ }
+
+ private class FakeUserPermissions : UserPermissions {
+ override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus = PermissionStatus.GRANTED
+ }
+
+ private fun createHandler(
+ failTemplateBuild: Boolean = false,
+ buildDelayMs: Long = TEMPLATE_BUILD_MS,
+ ): PaywallMessageHandler =
+ PaywallMessageHandler(
+ factory = SlowVariablesFactory(fail = failTemplateBuild, buildDelayMs = buildDelayMs),
+ options =
+ object : OptionsFactory {
+ override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
+ },
+ track = { _: TrackableSuperwallEvent -> },
+ setAttributes = { },
+ getView = { null },
+ mainScope = MainScope(testDispatcher),
+ ioScope = IOScope(testDispatcher),
+ encodeToB64 = { it },
+ userPermissions = FakeUserPermissions(),
+ getActivity = { null },
+ customCallbackRegistry = CustomCallbackRegistry(),
+ )
+
+ private fun List.indexOfMessage(needle: String): Int = indexOfFirst { it.contains(needle) }
+
+ private fun assertTemplatesPrecedeOpen(evaluations: List) {
+ val templates = evaluations.indexOfMessage(TEMPLATE_VARIABLES)
+ val open = evaluations.indexOfMessage(PAYWALL_OPEN)
+ assertTrue("template_variables was never sent to the webview", templates >= 0)
+ assertTrue("paywall_open was never sent to the webview", open >= 0)
+ assertTrue(
+ "paywall_open (index $open) overtook template_variables (index $templates)",
+ templates < open,
+ )
+ }
+
+ @Test
+ fun templateVariablesReachWebviewBeforePaywallOpenOnAPreloadedPaywall() =
+ runTest {
+ Given("a paywall whose webview has already loaded") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("presentation sends the templates and the open follows immediately") {
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the templates reach the webview first") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun templateVariablesReachWebviewBeforePaywallOpenWhenPaywallIsNotPreloaded() =
+ runTest {
+ Given("a paywall whose webview has just reported it is ready") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("the open lands while the templates are still being built") {
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the templates reach the webview first") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun deferredPaywallOpenIsSentAfterTheTemplatesOnceTheWebviewLoads() =
+ runTest {
+ Given("an open deferred while the webview is being recreated") {
+ val state =
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ )
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("the replacement webview finishes loading") {
+ handler.resetForWebViewReload()
+ handler.flushPendingMessages()
+ advanceUntilIdle()
+ assertTrue(
+ "the deferred open was sent before the webview loaded",
+ delegate.evaluations.indexOfMessage(PAYWALL_OPEN) < 0,
+ )
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ Then("the open is delivered after the templates") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun aFailedSendDoesNotHoldBackTheMessagesQueuedBehindIt() =
+ runTest {
+ Given("a paywall whose template build throws") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler(failTemplateBuild = true)
+ handler.messageHandler = delegate
+
+ When("an open is queued behind the failing send") {
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the open is still delivered") {
+ assertTrue(
+ "paywall_open was lost behind a failed send",
+ delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun slowTemplatesCannotBeOvertakenOnAPreloadedPaywall() =
+ runTest {
+ val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"))
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler(buildDelayMs = 11_000L)
+ handler.messageHandler = delegate
+
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ handler.handle(PaywallMessage.TransactionStart)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertTrue(delegate.evaluations.indexOfMessage(PAYWALL_OPEN) < delegate.evaluations.indexOfMessage("transaction_start"))
+ }
+
+ @Test
+ fun slowInitializationFinishesBeforeOpeningANewPaywall() =
+ runTest {
+ for (openBeforeReady in listOf(false, true)) {
+ val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"))
+ val handler = createHandler(buildDelayMs = 11_000L)
+ handler.messageHandler = delegate
+
+ if (openBeforeReady) handler.handle(PaywallMessage.PaywallOpen)
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ if (!openBeforeReady) handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(PaywallLoadingState.Ready, delegate.state.loadingState)
+ }
+ }
+
+ @Test
+ fun recoveryDoesNotOpenCachedOrBackgroundedPaywalls() =
+ runTest {
+ for (presented in listOf(false, true)) {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = presented,
+ closedForBackground = presented,
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ handler.resetForWebViewReload()
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ assertTrue(delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES) >= 0)
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+ }
+ }
+
+ @Test
+ fun recoveryDoesNotReopenAPaywallDismissedOrBackgroundedWhileLoading() =
+ runTest {
+ for (update in listOf(PaywallViewState.Updates.CleanupAfterDestroy, PaywallViewState.Updates.SetClosedForBackground(true))) {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ handler.resetForWebViewReload()
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ // Put the recovery open on the outbound queue while initialization is
+ // still suspended, then dismiss before that open can be evaluated.
+ handler.flushPendingMessages()
+ runCurrent()
+ delegate.updateState(update)
+ advanceUntilIdle()
+
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+ }
+ }
+
+ @Test
+ fun recoveryOpenDoesNotCarryOverToANewPresentation() =
+ runTest {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+ handler.resetForWebViewReload()
+ delegate.updateState(PaywallViewState.Updates.CleanupAfterDestroy)
+ delegate.updateState(PaywallViewState.Updates.SetPresentedAndFinished)
+ delegate.updateState(PaywallViewState.Updates.SetLastOpen)
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(1, delegate.evaluations.count { it.contains(PAYWALL_OPEN) })
+ }
+
+ @Test
+ fun replacingWebViewCancelsOldTemplatesAndQueuedEvents() =
+ runTest {
+ val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"))
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.TransactionStart)
+ runCurrent()
+
+ handler.resetForWebViewReload()
+ assertNull(delegate.state.paywall.paywalljsVersion)
+ handler.handle(PaywallMessage.PaywallOpen)
+ handler.flushPendingMessages()
+ advanceUntilIdle()
+ assertTrue(delegate.evaluations.isEmpty())
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(1, delegate.evaluations.count { it.contains(TEMPLATE_VARIABLES) })
+ assertEquals(-1, delegate.evaluations.indexOfMessage("transaction_start"))
+ }
+
+ private companion object {
+ const val TEMPLATE_BUILD_MS = 500L
+ const val PAYWALL_JS_VERSION = "3.0.0"
+ const val TEMPLATE_VARIABLES = "template_variables"
+ const val PAYWALL_OPEN = "paywall_open"
+ }
+}