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..0d315fbb1 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.8.0
## Enhancements
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/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 {