diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt index 46236a586c..2bbd3c2389 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt @@ -11,8 +11,10 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList import com.revenuecat.purchases.ui.revenuecatui.helpers.Result import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrElse import dev.drewhamilton.poko.Poko +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull +import kotlin.math.abs /** * Partial components transformed and ready for presentation. @@ -91,6 +93,8 @@ internal fun > List, + val stateValues: Map = emptyMap(), + val stateDefaults: Map = emptyMap(), ) /** @@ -147,8 +151,7 @@ private fun ComponentOverride.Condition.evaluate( is ComponentOverride.Condition.PromoOfferRule -> evaluate(offerEligibility) is ComponentOverride.Condition.SelectedPackage -> evaluate(conditionContext.selectedPackageId) is ComponentOverride.Condition.Variable -> evaluate(conditionContext.customVariables) - // State condition evaluation lands in a later phase; until then it never applies its override. - is ComponentOverride.Condition.State -> false + is ComponentOverride.Condition.State -> evaluate(conditionContext.stateValues, conditionContext.stateDefaults) ComponentOverride.Condition.Unsupported -> false } @@ -199,6 +202,35 @@ private fun ComponentOverride.Condition.Variable.matchesValue( else -> false } +private const val STATE_NUMBER_COMPARISON_EPSILON = 1e-10 + +private fun ComponentOverride.Condition.State.evaluate( + stateValues: Map, + stateDefaults: Map, +): Boolean { + // An undeclared key (absent from both the store and the declared defaults) never applies its override, + // regardless of operator. + val current = stateValues[name] ?: stateDefaults[name] ?: return false + val matches = matchesValue(current) + return when (operator) { + ComponentOverride.EqualityOperator.EQUALS -> matches + ComponentOverride.EqualityOperator.NOT_EQUALS -> !matches + } +} + +private fun ComponentOverride.Condition.State.matchesValue(current: JsonPrimitive): Boolean = when { + value.isString || current.isString -> + value.isString && current.isString && value.content == current.content + value.booleanOrNull != null || current.booleanOrNull != null -> + value.booleanOrNull == current.booleanOrNull + else -> { + val expectedNumber = value.doubleOrNull + val currentNumber = current.doubleOrNull + expectedNumber != null && currentNumber != null && + abs(expectedNumber - currentNumber) < STATE_NUMBER_COMPARISON_EPSILON + } +} + private val ScreenCondition.applicableConditions: Set get() = when (this) { ScreenCondition.COMPACT -> setOf(ComponentOverride.Condition.Compact) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/BuildPresentedPartialTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/BuildPresentedPartialTests.kt index 04f021b919..3c39ae4591 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/BuildPresentedPartialTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/BuildPresentedPartialTests.kt @@ -49,6 +49,8 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St val expected: LocalizedTextPartial?, val selectedPackageId: String? = null, val customVariables: Map = emptyMap(), + val stateValues: Map = emptyMap(), + val stateDefaults: Map = emptyMap(), ) @Suppress("LargeClass") @@ -194,6 +196,27 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St return overrides } + private fun stateCondition( + operator: ComponentOverride.EqualityOperator, + name: String, + value: JsonPrimitive, + ) = ComponentOverride.Condition.State(operator = operator, name = name, value = value) + + private fun stateArgs( + condition: ComponentOverride.Condition.State, + stateValues: Map = emptyMap(), + stateDefaults: Map = emptyMap(), + expected: LocalizedTextPartial?, + ) = Args( + availableOverrides = listOf(PresentedOverride(listOf(condition), selectedPartial)), + windowSize = COMPACT, + offerEligibility = Ineligible, + state = DEFAULT, + stateValues = stateValues, + stateDefaults = stateDefaults, + expected = expected, + ) + @Suppress("LongMethod") @JvmStatic @Parameterized.Parameters(name = "{0}") @@ -1169,6 +1192,91 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St ), ), + // State condition tests + arrayOf( + "state boolean equals: applies when the stored value matches", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)), + stateValues = mapOf("open" to JsonPrimitive(true)), + expected = selectedPartial, + ), + ), + arrayOf( + "state boolean equals: does not apply when the stored value differs", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)), + stateValues = mapOf("open" to JsonPrimitive(false)), + expected = null, + ), + ), + arrayOf( + "state string not-equals: applies when the stored value differs", + stateArgs( + condition = stateCondition( + ComponentOverride.EqualityOperator.NOT_EQUALS, "tab", JsonPrimitive("billing"), + ), + stateValues = mapOf("tab" to JsonPrimitive("usage")), + expected = selectedPartial, + ), + ), + arrayOf( + "state integer equals: applies when the stored value matches", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)), + stateValues = mapOf("slide" to JsonPrimitive(2)), + expected = selectedPartial, + ), + ), + arrayOf( + "state number: integer condition matches a double-typed store value", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)), + stateValues = mapOf("slide" to JsonPrimitive(2.0)), + expected = selectedPartial, + ), + ), + arrayOf( + "state: type mismatch evaluates as not equal", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)), + stateValues = mapOf("slide" to JsonPrimitive("2")), + expected = null, + ), + ), + arrayOf( + "state: missing value falls back to the declared default", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)), + stateDefaults = mapOf("open" to JsonPrimitive(true)), + expected = selectedPartial, + ), + ), + arrayOf( + "state: stored value wins over the declared default", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)), + stateValues = mapOf("open" to JsonPrimitive(true)), + stateDefaults = mapOf("open" to JsonPrimitive(false)), + expected = selectedPartial, + ), + ), + arrayOf( + "state: undeclared key with equals never applies", + stateArgs( + condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "ghost", JsonPrimitive(true)), + expected = null, + ), + ), + arrayOf( + "state: undeclared key with not-equals never applies", + stateArgs( + condition = stateCondition( + ComponentOverride.EqualityOperator.NOT_EQUALS, "ghost", JsonPrimitive(true), + ), + expected = null, + ), + ), + // IntroOffer (legacy object) tests arrayOf( "intro_offer: should apply when eligible", @@ -2057,6 +2165,8 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St conditionContext = ConditionContext( selectedPackageId = args.selectedPackageId, customVariables = args.customVariables, + stateValues = args.stateValues, + stateDefaults = args.stateDefaults, ), )