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/.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 @@ -coverage45.4% \ No newline at end of file +coverage45.5% \ 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