Skip to content
Merged
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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
## Unreleased

## Fixes
- Paywalls with translations now render in the user's language on first paint instead of briefly showing the default language.
- Paywall analytics events (`paywall_open`, `paywall_page_view`, `paywall_close`, etc.) now include a `presentation_id`, a unique identifier minted for each paywall presentation. Previously this field was always empty on Android, which broke dashboard funnels that correlate a paywall's page views into a single session. Also adds the previously-missing `close_reason`, `cache_key`, and `build_id` fields to these events, matching the data already sent by the iOS SDK.

## 2.8.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal open class DefaultWebviewClient(
private val ioScope: CoroutineScope,
private val onWebViewCrash: (view: WebView, RenderProcessGoneDetail) -> Unit = { v, d -> },
private val localResourceHandler: LocalResourceHandler? = null,
private val onPageStartedHook: (WebView) -> Unit = {},
) : WebViewClient() {
val webviewClientEvents: MutableSharedFlow<WebviewClientEvent> =
MutableSharedFlow(extraBufferCapacity = 10, replay = 2)
Expand All @@ -45,6 +46,7 @@ internal open class DefaultWebviewClient(
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
view?.let(onPageStartedHook)
}

override fun onPageFinished(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.superwall.sdk.paywall.view.webview

import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
* Builds the JavaScript snippet that seeds the paywall web runtime with device
* data as soon as the page starts loading.
*
* The web runtime reads `window.__SW_DEVICE_PRELOAD__` at boot and uses
* `deviceLocale` to render translations on first paint, instead of waiting for
* the `template_variables` message (which is gated on product/billing loading).
* The locale value must be identical to the `deviceLocale` the SDK later sends
* in `template_variables`, so that message is a visual no-op.
*/
internal object DevicePreloadScript {
/**
* Returns a one-line script of the form:
* `window.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"en_US"};`
*
* The payload is serialized with kotlinx.serialization so hostile locale
* strings (quotes, backslashes, etc.) are escaped and cannot break out of
* the JSON literal.
*/
fun build(deviceLocale: String): String {
val payload =
buildJsonObject {
put("deviceLocale", deviceLocale)
}
return "window.__SW_DEVICE_PRELOAD__ = $payload;"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ class SWWebView(
private var lastWebViewClient: WebViewClient? = null
private var lastLoadedUrl: String? = null

// The device preload script seeds `window.__SW_DEVICE_PRELOAD__` as soon as
// the page starts loading, so translated paywalls render in the device locale
// on first paint instead of waiting for the `template_variables` message. The
// paywall runtime reads the global when its (network-fetched) bundle boots,
// so an onPageStarted injection lands well before it; if it ever misses, the
// runtime just falls back to waiting for `template_variables` as before.
private fun currentDeviceLocale(): String? =
delegate?.state?.locale
?: if (Superwall.initialized) {
Superwall.instance.dependencyContainer.deviceHelper.locale
} else {
null
}

private val onPageStartedPreloadHook: (WebView) -> Unit = { view ->
currentDeviceLocale()?.let { locale ->
view.evaluateJavascript(DevicePreloadScript.build(locale), null)
}
}

internal fun prepareWebview() {
addJavascriptInterface(messageHandler, "SWAndroid")

Expand Down Expand Up @@ -235,6 +255,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Expand Down Expand Up @@ -300,6 +321,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ internal class WebviewFallbackClient(
private val stopLoading: () -> Unit,
private val onCrashed: (view: WebView, RenderProcessGoneDetail) -> Unit,
localResourceHandler: LocalResourceHandler? = null,
) : DefaultWebviewClient("", ioScope, onCrashed, localResourceHandler) {
onPageStartedHook: (WebView) -> Unit = {},
) : DefaultWebviewClient("", ioScope, onCrashed, localResourceHandler, onPageStartedHook) {
private class MaxAttemptsReachedException : Exception("Max attempts reached")

private var failureCount = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.superwall.sdk.paywall.view.webview

import com.superwall.sdk.Given
import com.superwall.sdk.Then
import com.superwall.sdk.When
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Test

class DevicePreloadScriptTest {
private fun payloadOf(script: String): JsonObject {
val prefix = "window.__SW_DEVICE_PRELOAD__ = "
assertEquals(prefix, script.take(prefix.length))
assertEquals(";", script.takeLast(1))
val json = script.removePrefix(prefix).removeSuffix(";")
return Json.decodeFromString(JsonObject.serializer(), json)
}

@Test
fun `builds exact preload script for a simple locale`() {
Given("a simple device locale") {
val locale = "en_US"
When("building the preload script") {
val script = DevicePreloadScript.build(locale)
Then("it matches the exact one-liner the web runtime expects") {
assertEquals(
"window.__SW_DEVICE_PRELOAD__ = {\"deviceLocale\":\"en_US\"};",
script,
)
}
}
}
}

@Test
fun `escapes hostile locale strings so they cannot break out of the script`() {
Given("a hostile locale string containing quotes and JS") {
val locale = "en\"};alert(1);//"
When("building the preload script") {
val script = DevicePreloadScript.build(locale)
Then("the quote is escaped inside the JSON literal") {
assertEquals(
"window.__SW_DEVICE_PRELOAD__ = {\"deviceLocale\":\"en\\\"};alert(1);//\"};",
script,
)
}
Then("the payload round-trips back to the original value") {
assertEquals(
locale,
payloadOf(script)["deviceLocale"]!!.jsonPrimitive.content,
)
}
}
}
}

@Test
fun `handles longer non-ASCII locales`() {
Given("a longer locale with script and region subtags") {
val locale = "zh_Hans_CN"
When("building the preload script") {
val script = DevicePreloadScript.build(locale)
Then("it matches the exact one-liner") {
assertEquals(
"window.__SW_DEVICE_PRELOAD__ = {\"deviceLocale\":\"zh_Hans_CN\"};",
script,
)
}
Then("the payload round-trips back to the original value") {
assertEquals(
locale,
payloadOf(script)["deviceLocale"]!!.jsonPrimitive.content,
)
}
}
}
}

@Test
fun `preserves non-ASCII characters`() {
Given("a locale string containing non-ASCII characters") {
val locale = "ja_JP_日本"
When("building the preload script") {
val script = DevicePreloadScript.build(locale)
Then("the payload round-trips back to the original value") {
assertEquals(
locale,
payloadOf(script)["deviceLocale"]!!.jsonPrimitive.content,
)
}
}
}
}
}
Loading