Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ class PaywallView(

private fun recreateWebview() {
val oldWebView = webView
oldWebView.messageHandler.resetForWebViewReload()
oldWebView.detach(this)
oldWebView.destroyView()
webView =
Expand All @@ -1015,7 +1016,6 @@ class PaywallView(
})
webView.attach(this)
webView.delegate = this
webView.messageHandler.handle(PaywallMessage.PaywallOpen)
loadWebView()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -97,7 +98,84 @@ class PaywallMessageHandler(
}

var messageHandler: PaywallMessageHandlerDelegate? = null
private val queue: Queue<PaywallMessage> = 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<PendingMessage>()
private val outboundJobs = mutableSetOf<Job>()
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
}
Comment on lines +166 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the event name is inferred by exclusion, so anything that is not PaywallOpen ships as paywall_close. Safe today (both callers are gated by the is PaywallOpen, is PaywallClose -> branch in handle), but adding a third lifecycle message to that branch later would silently mislabel it rather than fail the build.

Suggested change
val eventName =
if (pending.message is PaywallMessage.PaywallOpen) {
SuperwallEvents.PaywallOpen.rawName
} else {
SuperwallEvents.PaywallClose.rawName
}
val eventName =
when (pending.message) {
is PaywallMessage.PaywallOpen -> SuperwallEvents.PaywallOpen.rawName
is PaywallMessage.PaywallClose -> SuperwallEvents.PaywallClose.rawName
else -> return@withContext
}

pass(eventName = eventName, paywall = paywall)
}
}
}
}
}
}

@JavascriptInterface
fun postMessage(message: String) {
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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)
}
}
Expand All @@ -229,7 +291,7 @@ class PaywallMessageHandler(
}

is PaywallMessage.TransactionComplete -> {
ioScope.launch {
enqueueOutbound {
pass(
SuperwallEvents.TransactionComplete.rawName,
paywall,
Expand All @@ -239,7 +301,7 @@ class PaywallMessageHandler(
}

is PaywallMessage.TrialStarted -> {
ioScope.launch {
enqueueOutbound {
pass(
eventName = SuperwallEvents.FreeTrialStart.rawName,
paywall = paywall,
Expand Down Expand Up @@ -376,6 +438,7 @@ class PaywallMessageHandler(
)

withContext(Dispatchers.Main) {
currentCoroutineContext().ensureActive()
messageHandler?.evaluate(templateScript) { error ->
if (error != null) {
Logger.debug(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Loading