diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 2ea735435..95275ca45 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/CHANGELOG.md b/CHANGELOG.md
index 0d315fbb1..f29a7a2e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt
index f5a166339..d460dd44e 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt
@@ -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 =
MutableSharedFlow(extraBufferCapacity = 10, replay = 2)
@@ -45,6 +46,7 @@ internal open class DefaultWebviewClient(
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
+ view?.let(onPageStartedHook)
}
override fun onPageFinished(
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScript.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScript.kt
new file mode 100644
index 000000000..83d751f78
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScript.kt
@@ -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;"
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
index cfcfb0c0f..730c8151f 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
@@ -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")
@@ -235,6 +255,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
+ onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -300,6 +321,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
+ onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt
index 15585db81..2f08a7015 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt
@@ -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
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScriptTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScriptTest.kt
new file mode 100644
index 000000000..3ef928e97
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScriptTest.kt
@@ -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,
+ )
+ }
+ }
+ }
+ }
+}