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 @@ -branches36.3% \ No newline at end of file +branches36.2% \ 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, + ) + } + } + } + } +}