From 060a0669f17bb7879ecf23726fd1641ffc9db8e7 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 6 Aug 2026 14:00:52 +0000 Subject: [PATCH 1/6] fix: emit presentation_id, close_reason, cache_key, build_id on paywall events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superwall-Android never wrote presentation_id into outgoing paywall event payloads (paywall_page_view, paywall_open, paywall_close, etc.), which breaks any dashboard funnel that correlates a set of page views into one paywall session. Confirmed on live ClickHouse data: the field is 100% empty on Android across every SDK version, vs 0% empty on iOS. - PaywallCloseReason: add a `description` extension mirroring iOS's camelCase close-reason strings (systemLogic, forNextPaywall, webViewFailedToLoad, manualClose, none). - PaywallInfo: add `presentationId`, and serialize it alongside the already-modeled-but-never-emitted close_reason/cache_key/build_id in eventParams(). - Paywall: add a transient `presentationId` field, threaded through getInfo(). - PaywallRequestManager.updatePaywall: mint a fresh UUID presentationId on every getPaywall() call that results in a presentation (fresh fetch, in-flight-task reuse, and content-cache hit), so repeat presentations of a cached paywall get distinct, correlatable IDs. Trade-off: PaywallLoad.Complete/PaywallProductsLoad.* events track before updatePaywall runs, so they won't carry presentation_id — same existing timing gap as experiment_id/variant_id/presentation_source_type. paywall_open/paywall_page_view/paywall_close all fire after updatePaywall and reliably get a stable ID. --- CHANGELOG.md | 5 ++ .../superwall/sdk/models/paywall/Paywall.kt | 7 ++ .../presentation/PaywallCloseReason.kt | 10 +++ .../sdk/paywall/presentation/PaywallInfo.kt | 7 ++ .../paywall/request/PaywallRequestManager.kt | 1 + .../trackable/InternalSuperwallEventTest.kt | 2 + .../paywall/presentation/PaywallInfoTest.kt | 27 ++++++++ .../request/PaywallRequestManagerTest.kt | 66 +++++++++++++++++++ 8 files changed, 125 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7f5434e..32597d5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## Unreleased + +## Fixes +- 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/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/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/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 { From 9c2d6558ca68cffce5f2935388da415e86a0e701 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 7 Aug 2026 19:13:48 +0200 Subject: [PATCH 2/6] Fix Play Store user ID hashing --- .../sdk/dependencies/DependencyContainer.kt | 9 +++------ .../java/com/superwall/sdk/misc/String+SHA256.kt | 5 +++++ .../sdk/identity/IdentityManagerTest.kt | 2 +- .../com/superwall/sdk/misc/StringSHA256Test.kt | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/misc/StringSHA256Test.kt 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/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt index 90c4cbec8..c25184f61 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt @@ -352,7 +352,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()) + } +} From dd2f8ce2b0f7ebbcd3d98e0b395ad9aff7afa2ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 12:24:07 +0000 Subject: [PATCH 3/6] Inject device locale into paywall WebView at document start Translated paywalls rendered in the default language first, then visibly re-rendered once the template_variables message delivered deviceLocale to paywall.js (that message is gated on product/billing loading, so it can take seconds). The web runtime now reads window.__SW_DEVICE_PRELOAD__ at boot and seeds its locale from it, so inject that global before any page JavaScript runs: - Add DevicePreloadScript, a pure builder that serializes the payload with kotlinx.serialization so hostile locale strings cannot break out of the script, producing exactly: window.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"en_US"}; - Install it via WebViewCompat.addDocumentStartJavaScript (androidx.webkit, new dependency) when the WebView supports DOCUMENT_START_SCRIPT, and fall back to evaluateJavascript in WebViewClient.onPageStarted on older WebView versions. - The locale comes from PaywallViewState.locale, which is the same DeviceHelper.locale value later sent as deviceLocale in template_variables, so the later message is a visual no-op. - Unit-test the builder (exact output, quote escaping, longer and non-ASCII locales) and add a CHANGELOG entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 2 + superwall/build.gradle.kts | 3 + .../view/webview/DefaultWebviewClient.kt | 2 + .../view/webview/DevicePreloadScript.kt | 32 +++++++ .../sdk/paywall/view/webview/SWWebView.kt | 49 ++++++++++ .../view/webview/WebviewFallbackClient.kt | 3 +- .../view/webview/DevicePreloadScriptTest.kt | 96 +++++++++++++++++++ 8 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScript.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/webview/DevicePreloadScriptTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 32597d5fe..e997fa72c 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.7.23 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 268687245..32eff7609 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] billing_version = "8.0.0" browser_version = "1.8.0" +webkit_version = "1.12.1" gradle_plugin_version = "8.6.1" jna_version = "5.14.0@aar" kotlinxCoroutinesGuavaVersion = "1.9.0" @@ -61,6 +62,7 @@ revenue_cat = { module = "com.revenuecat.purchases:purchases", version.ref = "re # Browser browser = { module = "androidx.browser:browser", version.ref = "browser_version" } +webkit = { module = "androidx.webkit:webkit", version.ref = "webkit_version" } # Compose compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_version" } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index a176a768c..51904e60e 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -169,6 +169,9 @@ dependencies { // Browser implementation(libs.browser) + // WebView (document-start script injection) + implementation(libs.webkit) + // Core implementation(libs.core) implementation(libs.appcompat) 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..08fb7c800 --- /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 before any page JavaScript runs. + * + * 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..b23075554 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 @@ -25,6 +25,8 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.EditText import androidx.core.graphics.createBitmap +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent @@ -179,8 +181,53 @@ class SWWebView( private var lastWebViewClient: WebViewClient? = null private var lastLoadedUrl: String? = null + // The device preload script seeds `window.__SW_DEVICE_PRELOAD__` before any + // page JavaScript runs, so translated paywalls render in the device locale on + // first paint instead of waiting for the `template_variables` message. + private var devicePreloadScript: String? = null + private var documentStartScriptInstalled = false + + private fun currentDeviceLocale(): String? = + delegate?.state?.locale + ?: if (Superwall.initialized) { + Superwall.instance.dependencyContainer.deviceHelper.locale + } else { + null + } + + private fun installDevicePreloadScript() { + val locale = currentDeviceLocale() ?: return + val script = DevicePreloadScript.build(locale) + devicePreloadScript = script + if (documentStartScriptInstalled) { + return + } + try { + if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*")) + documentStartScriptInstalled = true + } + } catch (e: Throwable) { + // Fall back to injecting in onPageStarted via the webview client. + Logger.debug( + LogLevel.warn, + LogScope.paywallView, + "Failed to install document-start device preload script: ${e.message}", + ) + } + } + + // Fallback for WebView versions without document-start script support: + // inject as early as possible once the page starts loading. + private val onPageStartedPreloadHook: (WebView) -> Unit = { view -> + if (!documentStartScriptInstalled) { + devicePreloadScript?.let { view.evaluateJavascript(it, null) } + } + } + internal fun prepareWebview() { addJavascriptInterface(messageHandler, "SWAndroid") + installDevicePreloadScript() val webSettings = this.settings setWebContentsDebuggingEnabled(false) @@ -235,6 +282,7 @@ class SWWebView( } }, localResourceHandler = localResourceHandler, + onPageStartedHook = onPageStartedPreloadHook, ) this.webViewClient = client if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -300,6 +348,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, + ) + } + } + } + } +} From fce6a3781189ebb62514fd78ce0f72adfb274a3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:14:17 +0000 Subject: [PATCH 4/6] Fix failing externalAccountId sha test by seeding stored user ID The test asserted externalAccountId equals sha256-of-user-123 but never stubbed storage.read(AppUserId), so userId fell back to the generated anonymous alias. Stub the stored app user ID like the sibling test does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UL13jCN87cPLKtmZnTYUrb --- .../test/java/com/superwall/sdk/identity/IdentityManagerTest.kt | 1 + 1 file changed, 1 insertion(+) 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 c25184f61..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( From e36b5ac9ba46f6381cf3cdaefefc93c498e3da38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:50:07 +0000 Subject: [PATCH 5/6] Drop androidx.webkit; inject device preload via onPageStarted eval only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document-start script path required adding androidx.webkit as a new dependency for every SDK user. It bought little: the paywall runtime reads window.__SW_DEVICE_PRELOAD__ when its network-fetched bundle boots, so an evaluateJavascript from onPageStarted lands well before that — and since the web runtime now seeds exclusively from the preload global, a missed injection just means today's behavior (wait for template_variables), never a wrong translation. This matches how the SDK already injects JS (plain evaluateJavascript, like the selection/zoom scripts), just hooked at page start rather than template delivery, which would be too late. DevicePreloadScript and its tests are unchanged; the script is now built lazily in the hook so it always uses the freshest locale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg --- gradle/libs.versions.toml | 2 - superwall/build.gradle.kts | 3 -- .../view/webview/DevicePreloadScript.kt | 2 +- .../sdk/paywall/view/webview/SWWebView.kt | 43 ++++--------------- 4 files changed, 9 insertions(+), 41 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32eff7609..268687245 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,6 @@ [versions] billing_version = "8.0.0" browser_version = "1.8.0" -webkit_version = "1.12.1" gradle_plugin_version = "8.6.1" jna_version = "5.14.0@aar" kotlinxCoroutinesGuavaVersion = "1.9.0" @@ -62,7 +61,6 @@ revenue_cat = { module = "com.revenuecat.purchases:purchases", version.ref = "re # Browser browser = { module = "androidx.browser:browser", version.ref = "browser_version" } -webkit = { module = "androidx.webkit:webkit", version.ref = "webkit_version" } # Compose compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_version" } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index 51904e60e..a176a768c 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -169,9 +169,6 @@ dependencies { // Browser implementation(libs.browser) - // WebView (document-start script injection) - implementation(libs.webkit) - // Core implementation(libs.core) implementation(libs.appcompat) 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 index 08fb7c800..83d751f78 100644 --- 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 @@ -5,7 +5,7 @@ import kotlinx.serialization.json.put /** * Builds the JavaScript snippet that seeds the paywall web runtime with device - * data before any page JavaScript runs. + * 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 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 b23075554..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 @@ -25,8 +25,6 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.EditText import androidx.core.graphics.createBitmap -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent @@ -181,12 +179,12 @@ class SWWebView( private var lastWebViewClient: WebViewClient? = null private var lastLoadedUrl: String? = null - // The device preload script seeds `window.__SW_DEVICE_PRELOAD__` before any - // page JavaScript runs, so translated paywalls render in the device locale on - // first paint instead of waiting for the `template_variables` message. - private var devicePreloadScript: String? = null - private var documentStartScriptInstalled = false - + // 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) { @@ -195,39 +193,14 @@ class SWWebView( null } - private fun installDevicePreloadScript() { - val locale = currentDeviceLocale() ?: return - val script = DevicePreloadScript.build(locale) - devicePreloadScript = script - if (documentStartScriptInstalled) { - return - } - try { - if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { - WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*")) - documentStartScriptInstalled = true - } - } catch (e: Throwable) { - // Fall back to injecting in onPageStarted via the webview client. - Logger.debug( - LogLevel.warn, - LogScope.paywallView, - "Failed to install document-start device preload script: ${e.message}", - ) - } - } - - // Fallback for WebView versions without document-start script support: - // inject as early as possible once the page starts loading. private val onPageStartedPreloadHook: (WebView) -> Unit = { view -> - if (!documentStartScriptInstalled) { - devicePreloadScript?.let { view.evaluateJavascript(it, null) } + currentDeviceLocale()?.let { locale -> + view.evaluateJavascript(DevicePreloadScript.build(locale), null) } } internal fun prepareWebview() { addJavascriptInterface(messageHandler, "SWAndroid") - installDevicePreloadScript() val webSettings = this.settings setWebContentsDebuggingEnabled(false) From cee210e9e45d0b0fb791c64374fc5778d38e9ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 12:09:06 +0000 Subject: [PATCH 6/6] Prepare special release 2.7.24 Bump SUPERWALL_VERSION to 2.7.24 and stamp the changelog: this release is 2.7.23 plus the fixes backported from develop (translation first-paint, Play Store user ID hashing, presentation_id/close_reason/cache_key/build_id on paywall events), without the 2.8.0 changes (Billing 9, custom store products, minSdk 23). Also add a `publish` input to the Build, Test & Publish workflow so a manual workflow_dispatch on a non-main branch can opt into the full publish/tag/release flow, which was previously gated to pushes on main. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QQGVCTwCQwWYFVHCHosoFg --- .github/workflows/build+test+deploy.yml | 12 +++++++++--- CHANGELOG.md | 5 ++++- version.env | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) 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 e997fa72c..cec724ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. -## Unreleased +## 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 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