diff --git a/.github/workflows/build+test+deploy.yml b/.github/workflows/build+test+deploy.yml index 4829b929a..da990732f 100644 --- a/.github/workflows/build+test+deploy.yml +++ b/.github/workflows/build+test+deploy.yml @@ -11,6 +11,11 @@ on: push: branches: [ "main" ] workflow_dispatch: + inputs: + publish: + description: "Publish to Maven Central, tag, and create a GitHub release from this ref. Use for special releases cut from non-main branches (e.g. backport releases)." + type: boolean + default: false permissions: contents: write @@ -57,9 +62,10 @@ jobs: path: "superwall/build/version.json" prop_path: "version" - # Only try to publish if we're on main + # Only try to publish on a push to main, or on a manual dispatch that + # explicitly opted into publishing (special releases off non-main branches). - name: Check if tag exists - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.publish == true) id: check-tag run: | EXISTS=$(git tag -l | grep -Fxq "${{steps.version.outputs.prop}}" && echo 'true' || echo 'false') @@ -99,7 +105,7 @@ jobs: fi - name: Tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.publish == true) run: | if [ "${{ steps.check-tag.outputs.tag-exists }}" == "false" ]; then sudo git config --global user.name 'Jake' diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7f5434e..cec724ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.24 + +This is a special patch release: it is 2.7.23 plus the fixes below, and does not include the changes shipped in 2.8.0 (Google Play Billing Library 9, custom store products, minSdk 23). Use it if you want these fixes but are not ready to take the 2.8.0 upgrade. + +## Fixes +- Paywalls with translations now render in the user's language on first paint instead of briefly showing the default language. +- Fix the hashed user ID (`externalAccountId`) attached to Play Store purchases: the SDK was hashing an internal object reference instead of the user ID, producing a hash that didn't match the user and changed between app launches. +- 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.7.23 ## Fixes 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 886f5eca7..076c21ce8 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 @@ -499,11 +499,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 4cb102c72..7b23432af 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 @@ -272,6 +278,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 8199fe731..d1708b38d 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 @@ -180,6 +180,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 7e3df4474..66dfad3ce 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.23 +SUPERWALL_VERSION=2.7.24