Skip to content

Commit fb5632d

Browse files
authored
feat(paywalls): add state-driven paywalls data layer (PWENG-57) (#3656)
Decodes the new server-driven state schema from `PaywallComponentsData`: - `state_declarations`: a top-level map of named, typed (string/bool/int/double) variables with declared defaults. - `state_updates` on interactive components (tabs, carousels, buttons): write operations that fire when the component is triggered. Supports literal values and a `$value` payload reference for the triggering component's identity. - `state_condition` in `ComponentOverride.Condition`: evaluates a named variable against an expected value with `EQUALS`/`NOT_EQUALS` operators. Pure data model — no runtime behavior. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are additive JSON models and resilient decoders with no purchase/auth logic; the UI stub means state-based overrides are inert until a later runtime phase. > > **Overview** > Adds **decode-only** support for server-driven paywall **state**: top-level `state_declarations` on `PaywallComponentsData`, optional `state_updates` on **buttons**, **carousels**, and **tabs**, and a new **`state_condition`** override type. > > **`state_declarations`** maps names to typed defaults (`boolean` / `integer` / `double` / `string`) via `StateDeclaration` and a tolerant `StateDeclarationMapSerializer` that drops bad entries instead of failing the whole paywall. > > **`state_updates`** decode as `StateUpdate.Set` with literal `to` values or the `"$value"` payload reference; unknown shapes become `Unsupported`. > > **`state_condition`** compares a named state key with `=` / `!=` (scalar primitives only; null/non-scalar → `Unsupported`). It is marked **`isRule`** like other conditional rules. > > In **revenuecatui**, `ComponentOverride.Condition.State` is wired to **always evaluate false** until runtime state exists—overrides gated only on state will not apply yet. > > Tests cover deserialization for declarations, updates, conditions, and component wiring. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cc087cd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 3183def commit fb5632d

13 files changed

Lines changed: 525 additions & 5 deletions

File tree

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.revenuecat.purchases.paywalls.components.ButtonComponent.Action
77
import com.revenuecat.purchases.paywalls.components.ButtonComponent.Destination
88
import com.revenuecat.purchases.paywalls.components.ButtonComponent.UrlMethod
99
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
10+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
1011
import com.revenuecat.purchases.paywalls.components.properties.Size
1112
import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault
1213
import dev.drewhamilton.poko.Poko
@@ -28,6 +29,7 @@ public class ButtonComponent(
2829
@get:JvmSynthetic public val transition: PaywallTransition? = null,
2930
@get:JvmSynthetic public val name: String? = null,
3031
@get:JvmSynthetic public val id: String? = null,
32+
@get:JvmSynthetic @SerialName("state_updates") public val stateUpdates: List<StateUpdate>? = null,
3133
) : PaywallComponent {
3234

3335
@InternalRevenueCatAPI

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.revenuecat.purchases.InternalRevenueCatAPI
55
import com.revenuecat.purchases.paywalls.components.CarouselComponent.PageControl
66
import com.revenuecat.purchases.paywalls.components.common.Background
77
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
8+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
89
import com.revenuecat.purchases.paywalls.components.properties.Border
910
import com.revenuecat.purchases.paywalls.components.properties.ColorScheme
1011
import com.revenuecat.purchases.paywalls.components.properties.Padding
@@ -70,6 +71,9 @@ public class CarouselComponent(
7071
public val overrides: List<ComponentOverride<PartialCarouselComponent>> = emptyList(),
7172
@get:JvmSynthetic
7273
public val name: String? = null,
74+
@get:JvmSynthetic
75+
@SerialName("state_updates")
76+
public val stateUpdates: List<StateUpdate>? = null,
7377
) : PaywallComponent {
7478

7579
@Poko

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TabsComponent.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
66
import com.revenuecat.purchases.InternalRevenueCatAPI
77
import com.revenuecat.purchases.paywalls.components.common.Background
88
import com.revenuecat.purchases.paywalls.components.common.ComponentOverride
9+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
910
import com.revenuecat.purchases.paywalls.components.properties.Border
1011
import com.revenuecat.purchases.paywalls.components.properties.ColorScheme
1112
import com.revenuecat.purchases.paywalls.components.properties.Padding
@@ -101,6 +102,9 @@ public class TabsComponent(
101102
public val defaultTabId: String? = null,
102103
@get:JvmSynthetic
103104
public val overrides: List<ComponentOverride<PartialTabsComponent>> = emptyList(),
105+
@get:JvmSynthetic
106+
@SerialName("state_updates")
107+
public val stateUpdates: List<StateUpdate>? = null,
104108
) : PaywallComponent {
105109
@InternalRevenueCatAPI
106110
@Poko

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ComponentOverride.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.revenuecat.purchases.utils.serializers.SealedDeserializerWithDefault
77
import dev.drewhamilton.poko.Poko
88
import kotlinx.serialization.SerialName
99
import kotlinx.serialization.Serializable
10+
import kotlinx.serialization.json.JsonNull
1011
import kotlinx.serialization.json.JsonPrimitive
1112

1213
@InternalRevenueCatAPI
@@ -93,6 +94,20 @@ public class ComponentOverride<T : PartialComponent>(
9394
public val value: JsonPrimitive,
9495
) : Condition { override val isRule: Boolean get() = true }
9596

97+
@Serializable
98+
public data class State(
99+
public val operator: EqualityOperator,
100+
public val name: String,
101+
public val value: JsonPrimitive,
102+
) : Condition {
103+
override val isRule: Boolean get() = true
104+
105+
init {
106+
// Parity with iOS, where a null value fails ConditionValue decoding: fall back to Unsupported.
107+
require(value !is JsonNull) { "State condition value must not be null" }
108+
}
109+
}
110+
96111
@Serializable
97112
public object Unsupported : Condition
98113
}
@@ -113,6 +128,7 @@ internal object ConditionSerializer : SealedDeserializerWithDefault<Condition>(
113128
"promo_offer_condition" to { Condition.PromoOfferRule.serializer() },
114129
"selected_package_condition" to { Condition.SelectedPackage.serializer() },
115130
"variable_condition" to { Condition.Variable.serializer() },
131+
"state_condition" to { Condition.State.serializer() },
116132
),
117133
defaultValue = { Condition.Unsupported },
118134
)

purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/PaywallComponentsData.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ public class PaywallComponentsData(
4848
@get:JvmSynthetic
4949
@SerialName("automatically_scale_font_size")
5050
public val automaticallyScaleFontSize: Boolean = true,
51+
@get:JvmSynthetic
52+
@Serializable(with = StateDeclarationMapSerializer::class)
53+
@SerialName("state_declarations")
54+
public val stateDeclarations: Map<String, StateDeclaration>? = null,
5155
)
5256

5357
@OptIn(InternalRevenueCatAPI::class)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.revenuecat.purchases.paywalls.components.common
2+
3+
import com.revenuecat.purchases.InternalRevenueCatAPI
4+
import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault
5+
import dev.drewhamilton.poko.Poko
6+
import kotlinx.serialization.KSerializer
7+
import kotlinx.serialization.SerialName
8+
import kotlinx.serialization.Serializable
9+
import kotlinx.serialization.descriptors.SerialDescriptor
10+
import kotlinx.serialization.encoding.Decoder
11+
import kotlinx.serialization.encoding.Encoder
12+
import kotlinx.serialization.json.JsonDecoder
13+
import kotlinx.serialization.json.JsonNull
14+
import kotlinx.serialization.json.JsonObject
15+
import kotlinx.serialization.json.JsonPrimitive
16+
17+
/**
18+
* A named state key declared in a paywall's top-level `state_declarations` map (state-driven paywalls).
19+
*/
20+
@InternalRevenueCatAPI
21+
@Poko
22+
@Serializable
23+
public class StateDeclaration(
24+
@get:JvmSynthetic public val type: ValueType,
25+
@get:JvmSynthetic @SerialName("default") public val defaultValue: JsonPrimitive,
26+
) {
27+
28+
/** Declared type of a state key. Unrecognized wire types decode to [UNKNOWN]. */
29+
@Serializable(with = ValueTypeSerializer::class)
30+
public enum class ValueType {
31+
// SerialNames are handled by the ValueTypeSerializer.
32+
BOOLEAN,
33+
INTEGER,
34+
DOUBLE,
35+
STRING,
36+
UNKNOWN,
37+
}
38+
}
39+
40+
@OptIn(InternalRevenueCatAPI::class)
41+
internal object ValueTypeSerializer : EnumDeserializerWithDefault<StateDeclaration.ValueType>(
42+
defaultValue = StateDeclaration.ValueType.UNKNOWN,
43+
)
44+
45+
/**
46+
* Decodes the top-level `state_declarations` map resiliently: individual malformed entries are dropped and a
47+
* non-object value decodes to an empty map, so a broken declaration never fails the whole paywall.
48+
*/
49+
@OptIn(InternalRevenueCatAPI::class)
50+
internal object StateDeclarationMapSerializer : KSerializer<Map<String, StateDeclaration>> {
51+
override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor
52+
53+
@Suppress("ReturnCount")
54+
override fun deserialize(decoder: Decoder): Map<String, StateDeclaration> {
55+
val jsonDecoder = decoder as? JsonDecoder ?: return emptyMap()
56+
val jsonObject = jsonDecoder.decodeJsonElement() as? JsonObject ?: return emptyMap()
57+
return jsonObject.mapNotNull { (key, element) ->
58+
try {
59+
val declaration = jsonDecoder.json.decodeFromJsonElement(StateDeclaration.serializer(), element)
60+
// Parity with iOS, where a null default fails ConditionValue decoding: drop the declaration.
61+
declaration.takeIf { it.defaultValue !is JsonNull }?.let { key to it }
62+
} catch (_: IllegalArgumentException) {
63+
null
64+
}
65+
}.toMap()
66+
}
67+
68+
override fun serialize(encoder: Encoder, value: Map<String, StateDeclaration>) {
69+
throw NotImplementedError("Serialization is not implemented because it is not needed.")
70+
}
71+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.revenuecat.purchases.paywalls.components.common
2+
3+
import com.revenuecat.purchases.InternalRevenueCatAPI
4+
import dev.drewhamilton.poko.Poko
5+
import kotlinx.serialization.KSerializer
6+
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.descriptors.SerialDescriptor
8+
import kotlinx.serialization.encoding.Decoder
9+
import kotlinx.serialization.encoding.Encoder
10+
import kotlinx.serialization.json.JsonDecoder
11+
import kotlinx.serialization.json.JsonObject
12+
import kotlinx.serialization.json.JsonPrimitive
13+
14+
/**
15+
* A declarative state mutation on an interactive component (state-driven paywalls). Decode-only in this phase: only
16+
* `set` is defined, and unknown or malformed shapes decode to [Unsupported] so future operations stay additive.
17+
*/
18+
@InternalRevenueCatAPI
19+
@Serializable(with = StateUpdateSerializer::class)
20+
public sealed interface StateUpdate {
21+
22+
/** Wire shape: `{ "set": "<key>", "to": <value | "$value"> }`. */
23+
@Poko
24+
public class Set(
25+
@get:JvmSynthetic public val key: String,
26+
@get:JvmSynthetic public val value: StateUpdateValue,
27+
) : StateUpdate
28+
29+
public object Unsupported : StateUpdate
30+
}
31+
32+
/**
33+
* The `to` value of a [StateUpdate.Set]: a [Literal] value or a [PayloadReference] (the wire sentinel `"$value"`).
34+
*/
35+
@InternalRevenueCatAPI
36+
public sealed interface StateUpdateValue {
37+
38+
@Poko
39+
public class Literal(@get:JvmSynthetic public val value: JsonPrimitive) : StateUpdateValue
40+
41+
public object PayloadReference : StateUpdateValue
42+
43+
public companion object {
44+
public const val PAYLOAD_REFERENCE_TOKEN: String = "\$value"
45+
}
46+
}
47+
48+
@OptIn(InternalRevenueCatAPI::class)
49+
internal object StateUpdateSerializer : KSerializer<StateUpdate> {
50+
private const val KEY_SET = "set"
51+
private const val KEY_TO = "to"
52+
53+
override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor
54+
55+
@Suppress("ReturnCount")
56+
override fun deserialize(decoder: Decoder): StateUpdate {
57+
val jsonDecoder = decoder as? JsonDecoder ?: return StateUpdate.Unsupported
58+
val jsonObject = jsonDecoder.decodeJsonElement() as? JsonObject ?: return StateUpdate.Unsupported
59+
val key = (jsonObject[KEY_SET] as? JsonPrimitive)?.takeIf { it.isString }?.content
60+
?: return StateUpdate.Unsupported
61+
val toElement = jsonObject[KEY_TO] as? JsonPrimitive ?: return StateUpdate.Unsupported
62+
val value = if (toElement.isString && toElement.content == StateUpdateValue.PAYLOAD_REFERENCE_TOKEN) {
63+
StateUpdateValue.PayloadReference
64+
} else {
65+
StateUpdateValue.Literal(toElement)
66+
}
67+
return StateUpdate.Set(key = key, value = value)
68+
}
69+
70+
override fun serialize(encoder: Encoder, value: StateUpdate) {
71+
throw NotImplementedError("Serialization is not implemented because it is not needed.")
72+
}
73+
}

purchases/src/test/java/com/revenuecat/purchases/paywalls/components/ButtonComponentTests.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.revenuecat.purchases.paywalls.components
33
import com.revenuecat.purchases.ColorAlias
44
import com.revenuecat.purchases.JsonTools
55
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
6+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
7+
import com.revenuecat.purchases.paywalls.components.common.StateUpdateValue
68
import com.revenuecat.purchases.paywalls.components.properties.ColorInfo
79
import com.revenuecat.purchases.paywalls.components.properties.ColorScheme
810
import com.revenuecat.purchases.paywalls.components.properties.Size
@@ -12,6 +14,7 @@ import org.junit.Test
1214
import org.junit.experimental.runners.Enclosed
1315
import org.junit.runner.RunWith
1416
import org.junit.runners.Parameterized
17+
import kotlinx.serialization.json.JsonPrimitive
1518

1619
@RunWith(Enclosed::class)
1720
internal class ButtonComponentTests {
@@ -57,7 +60,10 @@ internal class ButtonComponentTests {
5760
"type": "text"
5861
}
5962
]
60-
}
63+
},
64+
"state_updates": [
65+
{ "set": "planComparisonOpen", "to": true }
66+
]
6167
}
6268
""".trimIndent(),
6369
expected = ButtonComponent(
@@ -70,7 +76,13 @@ internal class ButtonComponentTests {
7076
name = "Text",
7177
)
7278
),
73-
)
79+
),
80+
stateUpdates = listOf(
81+
StateUpdate.Set(
82+
key = "planComparisonOpen",
83+
value = StateUpdateValue.Literal(JsonPrimitive(true)),
84+
),
85+
),
7486
)
7587
),
7688
),

purchases/src/test/java/com/revenuecat/purchases/paywalls/components/CarouselComponentTests.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.revenuecat.purchases.paywalls.components
33
import com.revenuecat.purchases.ColorAlias
44
import com.revenuecat.purchases.JsonTools
55
import com.revenuecat.purchases.paywalls.components.common.Background
6+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
7+
import com.revenuecat.purchases.paywalls.components.common.StateUpdateValue
68
import com.revenuecat.purchases.paywalls.components.properties.Border
79
import com.revenuecat.purchases.paywalls.components.properties.ColorInfo
810
import com.revenuecat.purchases.paywalls.components.properties.ColorScheme
@@ -195,7 +197,10 @@ internal class CarouselComponentTests {
195197
"components": []
196198
}
197199
],
198-
"visible": false
200+
"visible": false,
201+
"state_updates": [
202+
{ "set": "activeSlide", "to": "${'$'}value" }
203+
]
199204
}
200205
""".trimIndent(),
201206
expected = CarouselComponent(
@@ -291,7 +296,10 @@ internal class CarouselComponentTests {
291296
msTimePerPage = 1000,
292297
msTransitionTime = 500,
293298
transitionType = CarouselComponent.AutoAdvancePages.TransitionType.FADE,
294-
)
299+
),
300+
stateUpdates = listOf(
301+
StateUpdate.Set(key = "activeSlide", value = StateUpdateValue.PayloadReference),
302+
),
295303
)
296304
),
297305
),

purchases/src/test/java/com/revenuecat/purchases/paywalls/components/TabsComponentTests.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import com.revenuecat.purchases.ColorAlias
44
import com.revenuecat.purchases.JsonTools
55
import com.revenuecat.purchases.paywalls.components.common.Background
66
import com.revenuecat.purchases.paywalls.components.common.LocalizationKey
7+
import com.revenuecat.purchases.paywalls.components.common.StateUpdate
8+
import com.revenuecat.purchases.paywalls.components.common.StateUpdateValue
79
import com.revenuecat.purchases.paywalls.components.properties.Border
810
import com.revenuecat.purchases.paywalls.components.properties.ColorInfo
911
import com.revenuecat.purchases.paywalls.components.properties.ColorScheme
@@ -270,7 +272,10 @@ internal class TabsComponentTests {
270272
"x": 23.6,
271273
"y": 45.2
272274
},
273-
"visible": false
275+
"visible": false,
276+
"state_updates": [
277+
{ "set": "selectedFeatureTab", "to": "${'$'}value" }
278+
]
274279
}
275280
""".trimIndent(),
276281
expected = TabsComponent(
@@ -321,6 +326,9 @@ internal class TabsComponentTests {
321326
y = 45.2
322327
),
323328
name = "Tabs",
329+
stateUpdates = listOf(
330+
StateUpdate.Set(key = "selectedFeatureTab", value = StateUpdateValue.PayloadReference),
331+
),
324332
)
325333
),
326334
),

0 commit comments

Comments
 (0)