Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.revenuecat.purchases.common.events

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.PresentedOfferingContext
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
import com.revenuecat.purchases.customercenter.events.CustomerCenterDisplayMode
import com.revenuecat.purchases.customercenter.events.CustomerCenterEventType
Expand Down Expand Up @@ -99,6 +100,8 @@ internal sealed class BackendEvent : Event {
val darkMode: Boolean,
@SerialName("locale")
val localeIdentifier: String,
@SerialName("presented_offering_context")
val presentedOfferingContext: PresentedOfferingContextData? = null,
Comment thread
rickvdl marked this conversation as resolved.
@SerialName("exit_offer_type")
val exitOfferType: String? = null,
@SerialName("exit_offering_id")
Expand Down Expand Up @@ -151,6 +154,31 @@ internal sealed class BackendEvent : Event {
val resultingProductIdentifier: String? = null,
) : BackendEvent()

@Serializable
data class PresentedOfferingContextData(
Comment thread
tonidero marked this conversation as resolved.
@SerialName("placement_identifier")
val placementIdentifier: String? = null,
@SerialName("targeting_revision")
val targetingRevision: Int? = null,
@SerialName("targeting_rule_id")
val targetingRuleId: String? = null,
) {
companion object {
fun fromContext(
context: PresentedOfferingContext,
): PresentedOfferingContextData? {
if (context.placementIdentifier == null && context.targetingContext == null) {
return null
}
return PresentedOfferingContextData(
placementIdentifier = context.placementIdentifier,
targetingRevision = context.targetingContext?.revision,
targetingRuleId = context.targetingContext?.ruleId,
)
}
}
}

/**
* Represents an event related to a custom paywall.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ internal fun PaywallEvent.toBackendStoredEvent(
displayMode = data.displayMode,
darkMode = data.darkMode,
localeIdentifier = data.localeIdentifier,
presentedOfferingContext = BackendEvent.PresentedOfferingContextData.fromContext(
data.presentedOfferingContext,
),
exitOfferType = data.exitOfferType?.value,
exitOfferingID = data.exitOfferingIdentifier,
packageID = data.packageIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ internal data class PaywallStoredEvent(
displayMode = event.data.displayMode,
darkMode = event.data.darkMode,
localeIdentifier = event.data.localeIdentifier,
presentedOfferingContext = BackendEvent.PresentedOfferingContextData.fromContext(
event.data.presentedOfferingContext,
),
exitOfferType = event.data.exitOfferType?.value,
exitOfferingID = event.data.exitOfferingIdentifier,
packageID = event.data.packageIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import com.revenuecat.purchases.common.Delay
import com.revenuecat.purchases.common.Dispatcher
import com.revenuecat.purchases.common.HTTPClient
import com.revenuecat.purchases.common.SyncDispatcher
import com.revenuecat.purchases.PresentedOfferingContext
import com.revenuecat.purchases.common.events.BackendEvent
import com.revenuecat.purchases.common.events.BackendStoredEvent
import com.revenuecat.purchases.common.events.EventsRequest
import com.revenuecat.purchases.common.events.toBackendEvent
import com.revenuecat.purchases.common.events.toBackendStoredEvent
import com.revenuecat.purchases.paywalls.events.PaywallEvent
import com.revenuecat.purchases.common.networking.Endpoint
import com.revenuecat.purchases.common.networking.HTTPResult
import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes
Expand All @@ -33,6 +36,8 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.util.Date
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
Expand Down Expand Up @@ -60,6 +65,30 @@ class BackendPaywallEventTest {
)
).map { it.toBackendEvent() })

private val placementTargetingEventRequest = EventsRequest(listOf(
BackendStoredEvent.Paywalls(
BackendEvent.Paywalls(
id = "placement-id",
version = 1,
type = PaywallEventType.IMPRESSION.value,
appUserID = "appUserID",
sessionID = "sessionID",
offeringID = "offeringID",
paywallID = "paywallID",
paywallRevision = 5,
timestamp = 123456789,
displayMode = "full_screen",
darkMode = true,
localeIdentifier = "es_ES",
presentedOfferingContext = BackendEvent.PresentedOfferingContextData(
placementIdentifier = "home_banner",
targetingRevision = 3,
targetingRuleId = "rule_abc123",
),
)
)
).map { it.toBackendEvent() })

private val exitOfferEventRequest = EventsRequest(listOf(
BackendStoredEvent.Paywalls(
BackendEvent.Paywalls(
Expand Down Expand Up @@ -150,6 +179,44 @@ class BackendPaywallEventTest {
)
}

@Test
fun `postPaywallEvents posts events with placement and targeting correctly`() {
mockHttpResult()
backend.postEvents(
placementTargetingEventRequest,
baseURL = AppConfig.paywallEventsURL,
delay = Delay.DEFAULT,
onSuccessHandler = {},
onErrorHandler = { _, _ -> },
)
verifyCallWithBody(
"{" +
"\"events\":[" +
"{" +
"\"discriminator\":\"paywalls\"," +
"\"id\":\"placement-id\"," +
"\"version\":1," +
"\"type\":\"paywall_impression\"," +
"\"app_user_id\":\"appUserID\"," +
"\"session_id\":\"sessionID\"," +
"\"offering_id\":\"offeringID\"," +
"\"paywall_id\":\"paywallID\"," +
"\"paywall_revision\":5," +
"\"timestamp\":123456789," +
"\"display_mode\":\"full_screen\"," +
"\"dark_mode\":true," +
"\"locale\":\"es_ES\"," +
"\"presented_offering_context\":{" +
"\"placement_identifier\":\"home_banner\"," +
"\"targeting_revision\":3," +
"\"targeting_rule_id\":\"rule_abc123\"" +
"}" +
"}" +
"]" +
"}"
)
}

@Test
fun `postPaywallEvents posts exit offer events correctly`() {
mockHttpResult()
Expand Down Expand Up @@ -341,6 +408,59 @@ class BackendPaywallEventTest {
}
}

@Test
fun `toBackendStoredEvent preserves placement and targeting from PresentedOfferingContext`() {
val paywallEvent = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("298207f4-87af-4b57-a581-eb27bcc6e009"),
date = Date(1699270688884)
),
data = PaywallEvent.Data(
paywallIdentifier = "paywallID",
presentedOfferingContext = PresentedOfferingContext(
offeringIdentifier = "offeringID",
placementIdentifier = "home_banner",
targetingContext = PresentedOfferingContext.TargetingContext(
revision = 3,
ruleId = "rule_abc123",
),
),
paywallRevision = 5,
sessionIdentifier = UUID.fromString("315107f4-98bf-4b68-a582-eb27bcb6e111"),
displayMode = "footer",
localeIdentifier = "es_ES",
darkMode = true
),
type = PaywallEventType.IMPRESSION,
)

val storedEvent = paywallEvent.toBackendStoredEvent("testAppUserId")
assertThat(storedEvent).isNotNull
assertThat(storedEvent).isInstanceOf(BackendStoredEvent.Paywalls::class.java)

val backendEvent = (storedEvent as BackendStoredEvent.Paywalls).event
assertThat(backendEvent.offeringID).isEqualTo("offeringID")
assertThat(backendEvent.presentedOfferingContext).isEqualTo(
BackendEvent.PresentedOfferingContextData(
placementIdentifier = "home_banner",
targetingRevision = 3,
targetingRuleId = "rule_abc123",
)
)
}

@Test
fun `old stored BackendStoredEvent without presentedOfferingContext deserializes correctly`() {
val oldJson = """
{"discriminator":"paywalls","event":{"discriminator":"paywalls","id":"test-id","version":1,"type":"paywall_impression","app_user_id":"appUserID","session_id":"sessionID","offering_id":"offeringID","paywall_id":"paywallID","paywall_revision":5,"timestamp":123456789,"display_mode":"full_screen","dark_mode":true,"locale":"es_ES"}}
""".trimIndent()
val deserialized = JsonProvider.defaultJson.decodeFromString<BackendStoredEvent>(oldJson)
assertThat(deserialized).isInstanceOf(BackendStoredEvent.Paywalls::class.java)
val event = (deserialized as BackendStoredEvent.Paywalls).event
assertThat(event.presentedOfferingContext).isNull()
assertThat(event.offeringID).isEqualTo("offeringID")
}

private fun verifyCallWithBody(body: String) {
val expectedRequest: EventsRequest = JsonProvider.defaultJson.decodeFromString(body)
val expectedBody = JsonProvider.defaultJson.encodeToJsonElement(expectedRequest).asMap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.events

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.revenuecat.purchases.PresentedOfferingContext
import com.revenuecat.purchases.common.events.BackendEvent
import com.revenuecat.purchases.common.events.BackendStoredEvent
import com.revenuecat.purchases.common.events.toBackendStoredEvent
import kotlinx.serialization.encodeToString
Expand Down Expand Up @@ -139,6 +140,49 @@ class PaywallEventSerializationTests {
assertThat(decodedEvent).isEqualTo(exitOfferEvent)
}

@Test
fun `round trip serialization preserves placement and targeting in backend event`() {
val eventString = PaywallStoredEvent.json.encodeToString(impressionEvent)
val decodedEvent = PaywallStoredEvent.fromString(eventString)
val backendEvent = decodedEvent.toBackendEvent()

assertThat(backendEvent.presentedOfferingContext).isEqualTo(
BackendEvent.PresentedOfferingContextData(
placementIdentifier = "placementID",
targetingRevision = 5,
targetingRuleId = "ruleID",
)
)
}

@Test
fun `round trip serialization without placement produces null backend context`() {
val eventWithoutPlacement = PaywallStoredEvent(
event = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("298207f4-87af-4b57-a581-eb27bcc6e009"),
date = Date(1699270688884)
),
data = PaywallEvent.Data(
paywallIdentifier = "paywallID",
presentedOfferingContext = PresentedOfferingContext("offeringID"),
paywallRevision = 5,
sessionIdentifier = UUID.fromString("315107f4-98bf-4b68-a582-eb27bcb6e111"),
displayMode = "footer",
localeIdentifier = "es_ES",
darkMode = true
),
type = PaywallEventType.IMPRESSION,
),
userID = "testAppUserId",
)
val eventString = PaywallStoredEvent.json.encodeToString(eventWithoutPlacement)
val decodedEvent = PaywallStoredEvent.fromString(eventString)
val backendEvent = decodedEvent.toBackendEvent()

assertThat(backendEvent.presentedOfferingContext).isNull()
}

@Test
fun `can decode old cached event with offeringIdentifier string field`() {
// Old format with "offeringIdentifier" as a string field instead of "presentedOfferingContext" object
Expand Down Expand Up @@ -242,6 +286,82 @@ class PaywallEventSerializationTests {
assertThat(eventString).contains("\"presentedOfferingContext\":{\"offeringIdentifier\":\"offeringID\"")
}

@Test
fun `round trip serialization with placement only preserves placement in backend event`() {
val eventWithPlacementOnly = PaywallStoredEvent(
event = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("298207f4-87af-4b57-a581-eb27bcc6e009"),
date = Date(1699270688884)
),
data = PaywallEvent.Data(
paywallIdentifier = "paywallID",
presentedOfferingContext = PresentedOfferingContext(
offeringIdentifier = "offeringID",
placementIdentifier = "placementID",
targetingContext = null,
),
paywallRevision = 5,
sessionIdentifier = UUID.fromString("315107f4-98bf-4b68-a582-eb27bcb6e111"),
displayMode = "footer",
localeIdentifier = "es_ES",
darkMode = true
),
type = PaywallEventType.IMPRESSION,
),
userID = "testAppUserId",
)
val eventString = PaywallStoredEvent.json.encodeToString(eventWithPlacementOnly)
val decodedEvent = PaywallStoredEvent.fromString(eventString)
val backendEvent = decodedEvent.toBackendEvent()

assertThat(backendEvent.presentedOfferingContext).isEqualTo(
BackendEvent.PresentedOfferingContextData(
placementIdentifier = "placementID",
)
)
}

@Test
fun `round trip serialization with targeting only preserves targeting in backend event`() {
val eventWithTargetingOnly = PaywallStoredEvent(
event = PaywallEvent(
creationData = PaywallEvent.CreationData(
id = UUID.fromString("298207f4-87af-4b57-a581-eb27bcc6e009"),
date = Date(1699270688884)
),
data = PaywallEvent.Data(
paywallIdentifier = "paywallID",
presentedOfferingContext = PresentedOfferingContext(
offeringIdentifier = "offeringID",
placementIdentifier = null,
targetingContext = PresentedOfferingContext.TargetingContext(
revision = 7,
ruleId = "targetingRuleID",
),
),
paywallRevision = 5,
sessionIdentifier = UUID.fromString("315107f4-98bf-4b68-a582-eb27bcb6e111"),
displayMode = "footer",
localeIdentifier = "es_ES",
darkMode = true
),
type = PaywallEventType.IMPRESSION,
),
userID = "testAppUserId",
)
val eventString = PaywallStoredEvent.json.encodeToString(eventWithTargetingOnly)
val decodedEvent = PaywallStoredEvent.fromString(eventString)
val backendEvent = decodedEvent.toBackendEvent()

assertThat(backendEvent.presentedOfferingContext).isEqualTo(
BackendEvent.PresentedOfferingContextData(
targetingRevision = 7,
targetingRuleId = "targetingRuleID",
)
)
}

@Test
fun `old format with purchase error fields can be decoded`() {
val oldFormatJson = """
Expand Down