Skip to content

Commit 76870d3

Browse files
committed
feat(paywalls): add state_condition to component overrides
1 parent 6a4a870 commit 76870d3

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

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

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

96+
/**
97+
* Reacts to the current value of a paywall state key (state-driven paywalls). Wire shape:
98+
* `{ "type": "state_condition", "operator": "=" | "!=", "name": "<key>", "value": <value> }`.
99+
*/
100+
@Serializable
101+
public data class State(
102+
public val operator: EqualityOperator,
103+
public val name: String,
104+
public val value: JsonPrimitive,
105+
) : Condition { override val isRule: Boolean get() = true }
106+
96107
@Serializable
97108
public object Unsupported : Condition
98109
}
@@ -113,6 +124,7 @@ internal object ConditionSerializer : SealedDeserializerWithDefault<Condition>(
113124
"promo_offer_condition" to { Condition.PromoOfferRule.serializer() },
114125
"selected_package_condition" to { Condition.SelectedPackage.serializer() },
115126
"variable_condition" to { Condition.Variable.serializer() },
127+
"state_condition" to { Condition.State.serializer() },
116128
),
117129
defaultValue = { Condition.Unsupported },
118130
)

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: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
// region state_declarations map
15+
16+
@Test
17+
fun `decodes a state_declarations map with all value types`() {
18+
@Language("json")
19+
val json = """
20+
{
21+
"planComparisonOpen": { "type": "boolean", "default": false },
22+
"activeSlide": { "type": "integer", "default": 0 },
23+
"discountMultiplier": { "type": "double", "default": 0.5 },
24+
"selectedFeatureTab": { "type": "string", "default": "billing" }
25+
}
26+
""".trimIndent()
27+
28+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
29+
30+
assertThat(actual).hasSize(4)
31+
assertThat(actual["planComparisonOpen"]?.type).isEqualTo(StateDeclaration.ValueType.BOOLEAN)
32+
assertThat(actual["planComparisonOpen"]?.defaultValue).isEqualTo(JsonPrimitive(false))
33+
assertThat(actual["activeSlide"]?.type).isEqualTo(StateDeclaration.ValueType.INTEGER)
34+
assertThat(actual["activeSlide"]?.defaultValue).isEqualTo(JsonPrimitive(0))
35+
assertThat(actual["discountMultiplier"]?.type).isEqualTo(StateDeclaration.ValueType.DOUBLE)
36+
assertThat(actual["discountMultiplier"]?.defaultValue).isEqualTo(JsonPrimitive(0.5))
37+
assertThat(actual["selectedFeatureTab"]?.type).isEqualTo(StateDeclaration.ValueType.STRING)
38+
assertThat(actual["selectedFeatureTab"]?.defaultValue).isEqualTo(JsonPrimitive("billing"))
39+
}
40+
41+
@Test
42+
fun `ignores unknown fields in a state declaration`() {
43+
@Language("json")
44+
val json = """{ "k": { "type": "string", "default": "v", "future_field": 42 } }"""
45+
46+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
47+
48+
assertThat(actual["k"]).isEqualTo(StateDeclaration(type = "string", defaultValue = JsonPrimitive("v")))
49+
}
50+
51+
@Test
52+
fun `drops a malformed entry without failing the rest of the map`() {
53+
@Language("json")
54+
val json = """
55+
{
56+
"good": { "type": "string", "default": "v" },
57+
"bad": { "type": "string", "default": { "nested": true } }
58+
}
59+
""".trimIndent()
60+
61+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, json)
62+
63+
assertThat(actual.keys).containsExactly("good")
64+
}
65+
66+
@Test
67+
fun `decodes a non-object state_declarations value as an empty map`() {
68+
val actual = JsonTools.json.decodeFromString(StateDeclarationMapSerializer, """"not_an_object"""")
69+
70+
assertThat(actual).isEmpty()
71+
}
72+
73+
// endregion
74+
75+
// region stateUpdates
76+
77+
@Test
78+
fun `decodes a set update with each literal type`() {
79+
assertThat(decodeUpdate("""{ "set": "k", "to": "billing" }"""))
80+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive("billing"))))
81+
assertThat(decodeUpdate("""{ "set": "k", "to": 3 }"""))
82+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(3))))
83+
assertThat(decodeUpdate("""{ "set": "k", "to": 0.5 }"""))
84+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(0.5))))
85+
assertThat(decodeUpdate("""{ "set": "k", "to": true }"""))
86+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(true))))
87+
}
88+
89+
@Test
90+
fun `decodes a set update with the value payload reference`() {
91+
assertThat(decodeUpdate("""{ "set": "activeSlide", "to": "${'$'}value" }"""))
92+
.isEqualTo(StateUpdate.Set("activeSlide", StateUpdateValue.PayloadReference))
93+
}
94+
95+
@Test
96+
fun `ignores unknown fields in a set update`() {
97+
assertThat(decodeUpdate("""{ "set": "k", "to": true, "op": "future" }"""))
98+
.isEqualTo(StateUpdate.Set("k", StateUpdateValue.Literal(JsonPrimitive(true))))
99+
}
100+
101+
@Test
102+
fun `decodes unknown or malformed update shapes as Unsupported`() {
103+
assertThat(decodeUpdate("""{ "toggle": "k" }""")).isEqualTo(StateUpdate.Unsupported)
104+
assertThat(decodeUpdate("""{ "set": "k" }""")).isEqualTo(StateUpdate.Unsupported)
105+
assertThat(decodeUpdate("""{ "to": true }""")).isEqualTo(StateUpdate.Unsupported)
106+
assertThat(decodeUpdate("""{ "set": "k", "to": [1, 2] }""")).isEqualTo(StateUpdate.Unsupported)
107+
assertThat(decodeUpdate(""""not_an_object"""")).isEqualTo(StateUpdate.Unsupported)
108+
}
109+
110+
@Test
111+
fun `degrades a malformed entry in a stateUpdates list without failing the others`() {
112+
@Language("json")
113+
val json = """
114+
[
115+
{ "set": "a", "to": true },
116+
{ "unknown": "shape" },
117+
{ "set": "b", "to": "${'$'}value" }
118+
]
119+
""".trimIndent()
120+
121+
val actual = JsonTools.json.decodeFromString<List<StateUpdate>>(json)
122+
123+
assertThat(actual).containsExactly(
124+
StateUpdate.Set("a", StateUpdateValue.Literal(JsonPrimitive(true))),
125+
StateUpdate.Unsupported,
126+
StateUpdate.Set("b", StateUpdateValue.PayloadReference),
127+
)
128+
}
129+
130+
@Test
131+
fun `decodes stateUpdates wired onto an interactive component`() {
132+
@Language("json")
133+
val json = """
134+
{
135+
"type": "button",
136+
"action": { "type": "restore_purchases" },
137+
"stack": { "type": "stack", "components": [] },
138+
"stateUpdates": [ { "set": "planComparisonOpen", "to": true } ]
139+
}
140+
""".trimIndent()
141+
142+
val actual = JsonTools.json.decodeFromString<ButtonComponent>(json)
143+
144+
assertThat(actual.stateUpdates).containsExactly(
145+
StateUpdate.Set("planComparisonOpen", StateUpdateValue.Literal(JsonPrimitive(true))),
146+
)
147+
}
148+
149+
@Test
150+
fun `decodes a component without stateUpdates as null`() {
151+
@Language("json")
152+
val json = """
153+
{
154+
"type": "button",
155+
"action": { "type": "restore_purchases" },
156+
"stack": { "type": "stack", "components": [] }
157+
}
158+
""".trimIndent()
159+
160+
val actual = JsonTools.json.decodeFromString<ButtonComponent>(json)
161+
162+
assertThat(actual.stateUpdates).isNull()
163+
}
164+
165+
// endregion
166+
167+
private fun decodeUpdate(@Language("json") json: String): StateUpdate =
168+
JsonTools.json.decodeFromString(json)
169+
}

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)