Skip to content

Commit f1f5b18

Browse files
committed
feat(paywalls): add state_condition to component overrides
1 parent 90798ea commit f1f5b18

7 files changed

Lines changed: 242 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public class ButtonComponent(
2929
@get:JvmSynthetic public val transition: PaywallTransition? = null,
3030
@get:JvmSynthetic public val name: String? = null,
3131
@get:JvmSynthetic public val id: String? = null,
32-
@get:JvmSynthetic public val stateUpdates: List<StateUpdate>? = null,
32+
@get:JvmSynthetic @SerialName("state_updates") public val stateUpdates: List<StateUpdate>? = null,
3333
) : PaywallComponent {
3434

3535
@InternalRevenueCatAPI

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public class CarouselComponent(
7272
@get:JvmSynthetic
7373
public val name: String? = null,
7474
@get:JvmSynthetic
75+
@SerialName("state_updates")
7576
public val stateUpdates: List<StateUpdate>? = null,
7677
) : PaywallComponent {
7778

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ public class TabsComponent(
103103
@get:JvmSynthetic
104104
public val overrides: List<ComponentOverride<PartialTabsComponent>> = emptyList(),
105105
@get:JvmSynthetic
106+
@SerialName("state_updates")
106107
public val stateUpdates: List<StateUpdate>? = null,
107108
) : PaywallComponent {
108109
@InternalRevenueCatAPI

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ public class ComponentOverride<T : PartialComponent>(
9393
public val value: JsonPrimitive,
9494
) : Condition { override val isRule: Boolean get() = true }
9595

96+
@Serializable
97+
public data class State(
98+
public val operator: EqualityOperator,
99+
public val name: String,
100+
public val value: JsonPrimitive,
101+
) : Condition { override val isRule: Boolean get() = true }
102+
96103
@Serializable
97104
public object Unsupported : Condition
98105
}
@@ -113,6 +120,7 @@ internal object ConditionSerializer : SealedDeserializerWithDefault<Condition>(
113120
"promo_offer_condition" to { Condition.PromoOfferRule.serializer() },
114121
"selected_package_condition" to { Condition.SelectedPackage.serializer() },
115122
"variable_condition" to { Condition.Variable.serializer() },
123+
"state_condition" to { Condition.State.serializer() },
116124
),
117125
defaultValue = { Condition.Unsupported },
118126
)

purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ComponentOverridesTests.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,74 @@ internal class ComponentOverridesTests {
467467
value = JsonPrimitive("premium"),
468468
),
469469
),
470+
471+
// State condition with boolean value
472+
arrayOf(
473+
"""{ "type": "state_condition", "operator": "=", "name": "planComparisonOpen", "value": true }""",
474+
ComponentOverride.Condition.State(
475+
operator = ComponentOverride.EqualityOperator.EQUALS,
476+
name = "planComparisonOpen",
477+
value = JsonPrimitive(true),
478+
),
479+
),
480+
// State condition with integer value
481+
arrayOf(
482+
"""{ "type": "state_condition", "operator": "=", "name": "activeSlide", "value": 2 }""",
483+
ComponentOverride.Condition.State(
484+
operator = ComponentOverride.EqualityOperator.EQUALS,
485+
name = "activeSlide",
486+
value = JsonPrimitive(2),
487+
),
488+
),
489+
// State condition with double value
490+
arrayOf(
491+
"""{ "type": "state_condition", "operator": "=", "name": "discountMultiplier", "value": 0.5 }""",
492+
ComponentOverride.Condition.State(
493+
operator = ComponentOverride.EqualityOperator.EQUALS,
494+
name = "discountMultiplier",
495+
value = JsonPrimitive(0.5),
496+
),
497+
),
498+
// State condition with string value and not-equals
499+
arrayOf(
500+
"""{ "type": "state_condition", "operator": "!=", "name": "selectedFeatureTab", "value": "billing" }""",
501+
ComponentOverride.Condition.State(
502+
operator = ComponentOverride.EqualityOperator.NOT_EQUALS,
503+
name = "selectedFeatureTab",
504+
value = JsonPrimitive("billing"),
505+
),
506+
),
507+
// State condition with extra unknown fields deserializes successfully
508+
arrayOf(
509+
"""{ "type": "state_condition", "operator": "=", "name": "k", "value": true, "future_field": 1 }""",
510+
ComponentOverride.Condition.State(
511+
operator = ComponentOverride.EqualityOperator.EQUALS,
512+
name = "k",
513+
value = JsonPrimitive(true),
514+
),
515+
),
516+
// State condition with missing name / value / unknown operator falls back to Unsupported
517+
arrayOf(
518+
"""{ "type": "state_condition", "operator": "=", "value": true }""",
519+
ComponentOverride.Condition.Unsupported,
520+
),
521+
arrayOf(
522+
"""{ "type": "state_condition", "operator": "=", "name": "k" }""",
523+
ComponentOverride.Condition.Unsupported,
524+
),
525+
arrayOf(
526+
"""{ "type": "state_condition", "operator": ">", "name": "k", "value": 1 }""",
527+
ComponentOverride.Condition.Unsupported,
528+
),
529+
// State condition with non-scalar value falls back to Unsupported
530+
arrayOf(
531+
"""{ "type": "state_condition", "operator": "=", "name": "k", "value": [1, 2] }""",
532+
ComponentOverride.Condition.Unsupported,
533+
),
534+
arrayOf(
535+
"""{ "type": "state_condition", "operator": "=", "name": "k", "value": {"nested": true} }""",
536+
ComponentOverride.Condition.Unsupported,
537+
),
470538
// SelectedPackage with extra unknown fields deserializes successfully
471539
arrayOf(
472540
"""{ "type": "selected_package_condition", "operator": "in", "packages": ["a"], "future_field": true }""",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.revenuecat.purchases.paywalls.components.common
2+
3+
import com.revenuecat.purchases.InternalRevenueCatAPI
4+
import com.revenuecat.purchases.JsonTools
5+
import com.revenuecat.purchases.paywalls.components.ButtonComponent
6+
import org.assertj.core.api.Assertions.assertThat
7+
import org.intellij.lang.annotations.Language
8+
import org.junit.Test
9+
import kotlinx.serialization.json.JsonPrimitive
10+
11+
@OptIn(InternalRevenueCatAPI::class)
12+
internal class PaywallComponentStateTests {
13+
14+
@Test
15+
fun `decodes a state_declarations map with all value types`() {
16+
@Language("json")
17+
val json = """
18+
{
19+
"planComparisonOpen": { "type": "boolean", "default": false },
20+
"activeSlide": { "type": "integer", "default": 0 },
21+
"discountMultiplier": { "type": "double", "default": 0.5 },
22+
"selectedFeatureTab": { "type": "string", "default": "billing" }
23+
}
24+
""".trimIndent()
25+
26+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
27+
28+
assertThat(actual).hasSize(4)
29+
assertThat(actual["planComparisonOpen"]?.type).isEqualTo(StateDeclaration.ValueType.BOOLEAN)
30+
assertThat(actual["planComparisonOpen"]?.defaultValue).isEqualTo(JsonPrimitive(false))
31+
assertThat(actual["activeSlide"]?.type).isEqualTo(StateDeclaration.ValueType.INTEGER)
32+
assertThat(actual["activeSlide"]?.defaultValue).isEqualTo(JsonPrimitive(0))
33+
assertThat(actual["discountMultiplier"]?.type).isEqualTo(StateDeclaration.ValueType.DOUBLE)
34+
assertThat(actual["discountMultiplier"]?.defaultValue).isEqualTo(JsonPrimitive(0.5))
35+
assertThat(actual["selectedFeatureTab"]?.type).isEqualTo(StateDeclaration.ValueType.STRING)
36+
assertThat(actual["selectedFeatureTab"]?.defaultValue).isEqualTo(JsonPrimitive("billing"))
37+
}
38+
39+
@Test
40+
fun `ignores unknown fields in a state declaration`() {
41+
@Language("json")
42+
val json = """{ "k": { "type": "string", "default": "v", "future_field": 42 } }"""
43+
44+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
45+
46+
assertThat(actual["k"]).isEqualTo(StateDeclaration(type = "string", defaultValue = JsonPrimitive("v")))
47+
}
48+
49+
@Test
50+
fun `drops a malformed entry without failing the rest of the map`() {
51+
@Language("json")
52+
val json = """
53+
{
54+
"good": { "type": "string", "default": "v" },
55+
"bad": { "type": "string", "default": { "nested": true } }
56+
}
57+
""".trimIndent()
58+
59+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
60+
61+
assertThat(actual.keys).containsExactly("good")
62+
}
63+
64+
@Test
65+
fun `decodes a non-object state_declarations value as an empty map`() {
66+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, """"not_an_object"""")
67+
68+
assertThat(actual).isEmpty()
69+
}
70+
71+
@Test
72+
fun `decodes a set update with each literal type`() {
73+
assertThat(decodeUpdate("""{ "set": "k", "to": "billing" }"""))
74+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive("billing"))))
75+
assertThat(decodeUpdate("""{ "set": "k", "to": 3 }"""))
76+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(3))))
77+
assertThat(decodeUpdate("""{ "set": "k", "to": 0.5 }"""))
78+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(0.5))))
79+
assertThat(decodeUpdate("""{ "set": "k", "to": true }"""))
80+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(true))))
81+
}
82+
83+
@Test
84+
fun `decodes a set update with the value payload reference`() {
85+
assertThat(decodeUpdate("""{ "set": "activeSlide", "to": "${'$'}value" }"""))
86+
.isEqualTo(StateUpdate.Set("activeSlide", StateUpdateValue.PayloadReference))
87+
}
88+
89+
@Test
90+
fun `ignores unknown fields in a set update`() {
91+
assertThat(decodeUpdate("""{ "set": "k", "to": true, "op": "future" }"""))
92+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(true))))
93+
}
94+
95+
@Test
96+
fun `decodes unknown or malformed update shapes as Unsupported`() {
97+
assertThat(decodeUpdate("""{ "toggle": "k" }""")).isEqualTo(StateUpdate.Unsupported)
98+
assertThat(decodeUpdate("""{ "set": "k" }""")).isEqualTo(StateUpdate.Unsupported)
99+
assertThat(decodeUpdate("""{ "to": true }""")).isEqualTo(StateUpdate.Unsupported)
100+
assertThat(decodeUpdate("""{ "set": "k", "to": [1, 2] }""")).isEqualTo(StateUpdate.Unsupported)
101+
assertThat(decodeUpdate(""""not_an_object"""")).isEqualTo(StateUpdate.Unsupported)
102+
}
103+
104+
@Test
105+
fun `degrades a malformed entry in a stateUpdates list without failing the others`() {
106+
@Language("json")
107+
val json = """
108+
[
109+
{ "set": "a", "to": true },
110+
{ "unknown": "shape" },
111+
{ "set": "b", "to": "${'$'}value" }
112+
]
113+
""".trimIndent()
114+
115+
val actual = JsonTools.json.decodeFromString<List<StateUpdate>>(json)
116+
117+
assertThat(actual).containsExactly(
118+
StateUpdate.Set("a", StateUpdateValue.Literal(JsonPrimitive(true))),
119+
StateUpdate.Unsupported,
120+
StateUpdate.Set("b", StateUpdateValue.PayloadReference),
121+
)
122+
}
123+
124+
@Test
125+
fun `decodes stateUpdates wired onto an interactive component`() {
126+
@Language("json")
127+
val json = """
128+
{
129+
"type": "button",
130+
"action": { "type": "restore_purchases" },
131+
"stack": { "type": "stack", "components": [] },
132+
"state_updates": [ { "set": "planComparisonOpen", "to": true } ]
133+
}
134+
""".trimIndent()
135+
136+
val actual = JsonTools.json.decodeFromString<ButtonComponent>(json)
137+
138+
assertThat(actual.stateUpdates).containsExactly(
139+
StateUpdate.Set("planComparisonOpen", StateUpdateValue.Literal(JsonPrimitive(true))),
140+
)
141+
}
142+
143+
@Test
144+
fun `decodes a component without stateUpdates as null`() {
145+
@Language("json")
146+
val json = """
147+
{
148+
"type": "button",
149+
"action": { "type": "restore_purchases" },
150+
"stack": { "type": "stack", "components": [] }
151+
}
152+
""".trimIndent()
153+
154+
val actual = JsonTools.json.decodeFromString<ButtonComponent>(json)
155+
156+
assertThat(actual.stateUpdates).isNull()
157+
}
158+
159+
private fun decodeUpdate(@Language("json") json: String): StateUpdate =
160+
JsonTools.json.decodeFromString(json)
161+
}

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ private fun ComponentOverride.Condition.evaluate(
147147
is ComponentOverride.Condition.PromoOfferRule -> evaluate(offerEligibility)
148148
is ComponentOverride.Condition.SelectedPackage -> evaluate(conditionContext.selectedPackageId)
149149
is ComponentOverride.Condition.Variable -> evaluate(conditionContext.customVariables)
150+
// State condition evaluation lands in a later phase; until then it never applies its override.
151+
is ComponentOverride.Condition.State -> false
150152
ComponentOverride.Condition.Unsupported -> false
151153
}
152154

0 commit comments

Comments
 (0)