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/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index ec50def2a..fd9384d2c 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 cb7a5980c..42fbf3527 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.1
+
+## 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. 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
## Enhancements
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
index 86d047bee..9b3b2228d 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -54,6 +54,7 @@ import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.MainScope
import com.superwall.sdk.misc.primitives.DebugInterceptor
import com.superwall.sdk.misc.primitives.SequentialActor
+import com.superwall.sdk.misc.sha256Hex
import com.superwall.sdk.models.config.ComputedPropertyRequest
import com.superwall.sdk.models.config.FeatureFlags
import com.superwall.sdk.models.entitlements.SubscriptionStatus
@@ -142,7 +143,6 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.lang.ref.WeakReference
import java.nio.charset.StandardCharsets
-import java.security.MessageDigest
import java.util.Date
import com.superwall.sdk.paywall.presentation.internal.dismiss as internalDismiss
@@ -500,11 +500,8 @@ class DependencyContainer(
storage = storage,
options = { options },
ioScope = ioScope,
- stringToSha = {
- val bytes = this.toString().toByteArray()
- val md = MessageDigest.getInstance("SHA-256")
- val digest = md.digest(bytes)
- digest.fold("", { str, it -> str + "%02x".format(it) })
+ stringToSha = { userId ->
+ checkNotNull(userId.sha256Hex()) { "SHA-256 is unavailable" }
},
notifyUserChange = {
delegate().userAttributesDidChange(it)
diff --git a/superwall/src/main/java/com/superwall/sdk/misc/String+SHA256.kt b/superwall/src/main/java/com/superwall/sdk/misc/String+SHA256.kt
index a4e9314f8..3d3277677 100644
--- a/superwall/src/main/java/com/superwall/sdk/misc/String+SHA256.kt
+++ b/superwall/src/main/java/com/superwall/sdk/misc/String+SHA256.kt
@@ -12,6 +12,11 @@ fun String.sha256(): ByteArray? =
null
}
+fun String.sha256Hex(): String? =
+ sha256()?.joinToString(separator = "") { byte ->
+ "%02x".format(byte.toInt() and 0xff)
+ }
+
fun String.sha256MappedToRange(): Int? {
val hashBytes = this.sha256() ?: return null
diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt
index 16b9a188c..470ec246a 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt
@@ -122,6 +122,12 @@ data class Paywall(
*/
@kotlinx.serialization.Transient()
var state: Map = emptyMap(),
+ /**
+ * A unique identifier minted for each distinct presentation of this paywall, used to
+ * correlate the events tracked during that presentation (e.g. page views).
+ */
+ @kotlinx.serialization.Transient()
+ var presentationId: String? = null,
@SerialName("url_config")
val urlConfig: PaywallWebviewUrl.Config? = null,
@Serializable
@@ -275,6 +281,7 @@ data class Paywall(
buildId = buildId,
isScrollEnabled = isScrollEnabled ?: true,
state = state,
+ presentationId = presentationId,
)
companion object {
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt
index e3eb99b63..ef34ec278 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt
@@ -31,3 +31,13 @@ sealed class PaywallCloseReason {
else -> true
}
}
+
+val PaywallCloseReason.description: String
+ get() =
+ when (this) {
+ is PaywallCloseReason.SystemLogic -> "systemLogic"
+ is PaywallCloseReason.ForNextPaywall -> "forNextPaywall"
+ is PaywallCloseReason.WebViewFailedToLoad -> "webViewFailedToLoad"
+ is PaywallCloseReason.ManualClose -> "manualClose"
+ is PaywallCloseReason.None -> "none"
+ }
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt
index 008657cf9..7f4907b8f 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt
@@ -64,6 +64,7 @@ data class PaywallInfo(
@Serializable(with = AnyMapSerializer::class)
val state: Map = emptyMap(),
val customerInfo: CustomerInfo = CustomerInfo.empty(),
+ val presentationId: String? = null,
) {
constructor(
databaseId: String,
@@ -99,6 +100,7 @@ data class PaywallInfo(
isScrollEnabled: Boolean,
state: Map = emptyMap(),
customerInfo: CustomerInfo = CustomerInfo.empty(),
+ presentationId: String? = null,
) : this(
databaseId = databaseId,
identifier = identifier,
@@ -187,6 +189,7 @@ data class PaywallInfo(
isScrollEnabled = isScrollEnabled,
state = state,
customerInfo = customerInfo,
+ presentationId = presentationId,
)
fun eventParams(
@@ -220,6 +223,10 @@ data class PaywallInfo(
"variant_id" to experiment?.variant?.id,
"is_scroll_enabled" to isScrollEnabled,
"state" to state,
+ "presentation_id" to presentationId,
+ "close_reason" to closeReason.description,
+ "cache_key" to cacheKey,
+ "build_id" to buildId,
)
val customerParams = customerInfo.toParams()
if (customerParams.isNotEmpty()) {
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
index 0f099e806..86084070b 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
@@ -181,6 +181,7 @@ class PaywallRequestManager(
return@withContext paywall.copy(
experiment = request.responseIdentifiers.experiment,
presentationSourceType = request.presentationSourceType,
+ presentationId = java.util.UUID.randomUUID().toString(),
)
}
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/analytics/internal/trackable/InternalSuperwallEventTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt
index 48bbff7f5..2f58c5dcc 100644
--- a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt
@@ -958,6 +958,7 @@ class InternalSuperwallEventTest {
presentation = PaywallPresentationInfo(PaywallPresentationStyle.Modal, 0),
buildId = "build_1",
cacheKey = "cache_1",
+ presentationId = "presentation_1",
)
private fun stubStoreProduct(
@@ -1033,6 +1034,7 @@ class InternalSuperwallEventTest {
And("paywall info params are also included") {
assertEquals(paywallInfo.identifier, params["paywall_identifier"])
+ assertEquals(paywallInfo.presentationId, params["presentation_id"])
}
And("the superwall placement is paywall_page_view") {
diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
index 90c4cbec8..ab2b19f06 100644
--- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
@@ -331,6 +331,7 @@ class IdentityManagerTest {
runTest {
Given("passIdentifiersToPlayStore is disabled") {
val testOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = false }
+ every { storage.read(AppUserId) } returns "user-123"
val manager =
IdentityManager(
@@ -352,7 +353,7 @@ class IdentityManagerTest {
}
Then("it returns the sha of the userId") {
- assertTrue(externalId.startsWith("sha256-of-"))
+ assertEquals("sha256-of-user-123", externalId)
}
}
}
diff --git a/superwall/src/test/java/com/superwall/sdk/misc/StringSHA256Test.kt b/superwall/src/test/java/com/superwall/sdk/misc/StringSHA256Test.kt
new file mode 100644
index 000000000..42d359a4a
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/misc/StringSHA256Test.kt
@@ -0,0 +1,16 @@
+package com.superwall.sdk.misc
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class StringSHA256Test {
+ @Test
+ fun `sha256Hex hashes the string value deterministically`() {
+ assertEquals(
+ "fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8",
+ "user-123".sha256Hex(),
+ )
+ assertNotEquals("user-123".sha256Hex(), "user-456".sha256Hex())
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt
index 1cdaaf989..b127a5a80 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt
@@ -123,6 +123,33 @@ class PaywallInfoTest {
assertNotNull(params["paywall_response_load_start_time"])
}
+ @Test
+ fun eventParams_includesPresentationIdCloseReasonCacheKeyAndBuildId() {
+ val info =
+ PaywallInfo.empty().copy(
+ presentationId = "presentation-123",
+ closeReason = PaywallCloseReason.ManualClose,
+ cacheKey = "cache-456",
+ buildId = "build-789",
+ )
+
+ val params = info.eventParams()
+
+ assertEquals("presentation-123", params["presentation_id"])
+ assertEquals("manualClose", params["close_reason"])
+ assertEquals("cache-456", params["cache_key"])
+ assertEquals("build-789", params["build_id"])
+ }
+
+ @Test
+ fun eventParams_omitsPresentationId_whenNull() {
+ val info = PaywallInfo.empty().copy(presentationId = null)
+
+ val params = info.eventParams()
+
+ assertFalse(params.containsKey("presentation_id"))
+ }
+
private fun createProductItem(
name: String,
productIdentifier: String,
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt
index 1d873b701..329503488 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt
@@ -320,6 +320,72 @@ class PaywallRequestManagerTest {
assertEquals(sourceType, (result as Either.Success).value.presentationSourceType)
}
+ @Test
+ fun test_getPaywall_setsPresentationId() =
+ runTest {
+ val paywall = Paywall.stub().copy(identifier = "test_paywall")
+ val request =
+ mockk {
+ every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "test_paywall")
+ every { eventData } returns null
+ every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null)
+ every { isDebuggerLaunched } returns false
+ every { presentationSourceType } returns null
+ }
+
+ coEvery { network.getPaywall(any(), any()) } returns Either.Success(paywall)
+ coEvery { storeManager.getProducts(any(), any(), any()) } returns
+ mockk {
+ every { productItems } returns emptyList()
+ every { productsByFullId } returns emptyMap()
+ every { this@mockk.paywall } returns null
+ }
+
+ val result = requestManager.getPaywall(request)
+
+ assertTrue(result is Either.Success)
+ val presentationId = (result as Either.Success).value.presentationId
+ assertNotNull(presentationId)
+ assertTrue(presentationId!!.isNotBlank())
+ }
+
+ @Test
+ fun test_getPaywall_generatesNewPresentationId_onEachCall() =
+ runTest {
+ val paywall = Paywall.stub().copy(identifier = "test_paywall")
+ val request =
+ mockk {
+ every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "test_paywall")
+ every { eventData } returns null
+ every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null)
+ every { isDebuggerLaunched } returns false
+ every { presentationSourceType } returns null
+ }
+
+ coEvery { network.getPaywall(any(), any()) } returns Either.Success(paywall)
+ coEvery { storeManager.getProducts(any(), any(), any()) } returns
+ mockk {
+ every { productItems } returns emptyList()
+ every { productsByFullId } returns emptyMap()
+ every { this@mockk.paywall } returns null
+ }
+
+ // First call
+ val result1 = requestManager.getPaywall(request)
+ // Second call hits the request-hash cache, but should still mint a fresh presentation ID
+ val result2 = requestManager.getPaywall(request)
+
+ assertTrue(result1 is Either.Success)
+ assertTrue(result2 is Either.Success)
+ val presentationId1 = (result1 as Either.Success).value.presentationId
+ val presentationId2 = (result2 as Either.Success).value.presentationId
+ assertNotNull(presentationId1)
+ assertNotNull(presentationId2)
+ assertTrue(presentationId1 != presentationId2)
+ // Network should only be called once due to caching
+ coVerify(exactly = 1) { network.getPaywall(any(), any()) }
+ }
+
@Test
fun test_resetCache_clearsPaywallCache() =
runTest {
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,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/version.env b/version.env
index adafaa0b1..46d6905c8 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.8.0
+SUPERWALL_VERSION=2.8.1