Skip to content
Draft
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
Expand Up @@ -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.
Expand Down Expand Up @@ -91,6 +93,8 @@ internal fun <T : PartialComponent, P : PresentedPartial<P>> List<ComponentOverr
internal class ConditionContext(
val selectedPackageId: String?,
val customVariables: Map<String, CustomVariableValue>,
val stateValues: Map<String, JsonPrimitive> = emptyMap(),
val stateDefaults: Map<String, JsonPrimitive> = emptyMap(),
)

/**
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<String, JsonPrimitive>,
stateDefaults: Map<String, JsonPrimitive>,
): 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<ComponentOverride.Condition>
get() = when (this) {
ScreenCondition.COMPACT -> setOf(ComponentOverride.Condition.Compact)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St
val expected: LocalizedTextPartial?,
val selectedPackageId: String? = null,
val customVariables: Map<String, CustomVariableValue> = emptyMap(),
val stateValues: Map<String, JsonPrimitive> = emptyMap(),
val stateDefaults: Map<String, JsonPrimitive> = emptyMap(),
)

@Suppress("LargeClass")
Expand Down Expand Up @@ -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<String, JsonPrimitive> = emptyMap(),
stateDefaults: Map<String, JsonPrimitive> = 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}")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
),
)

Expand Down