From bff893536f58899ba525b1833760e7be5f429733 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 26 Mar 2026 13:38:46 +0100 Subject: [PATCH 1/8] feat: support package-level visibility via visible property and overrides Mirrors khepri#18819 which adds `visible` and `overrides` to the Package and PartialPackage schema, allowing packages to be hidden by Rules. - Add `visible: Boolean?` and `overrides: List>` to `PackageComponent` - Add `PartialPackageComponent` with `visible: Boolean?` - Add `PresentedPackagePartial` following the existing partial pattern - `PackageComponentStyle` now implements `PackageContext` and carries package-level `visible`, `overrides`, and `offerEligibility` - `PackageComponentState` evaluates overrides at render time - `PackageComponentView` gates rendering on the resolved visibility - `containsUnsupportedCondition` updated to check package overrides - Unit + UI tests for deserialization, style creation, and visibility behavior Co-Authored-By: Claude Sonnet 4.6 --- .../paywalls/components/PackageComponent.kt | 14 ++ .../components/PackageComponentTests.kt | 81 ++++++++ .../components/PresentedPackagePartial.kt | 30 +++ .../components/pkg/PackageComponentState.kt | 107 ++++++++++ .../components/pkg/PackageComponentView.kt | 4 + .../components/style/PackageComponentStyle.kt | 25 ++- .../components/style/StyleFactory.kt | 17 +- .../helpers/OfferingToStateMapper.kt | 2 +- .../components/VisibilityConditionTests.kt | 187 ++++++++++++++++++ .../components/style/StyleFactoryTests.kt | 63 ++++++ .../ContainsUnsupportedConditionTests.kt | 18 ++ 11 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPackagePartial.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentState.kt diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt index 9370688fe2..072a098854 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.components import androidx.compose.runtime.Immutable import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.PromoOfferConfig import com.revenuecat.purchases.paywalls.components.common.ResilientPromoOfferConfigSerializer import dev.drewhamilton.poko.Poko @@ -26,4 +27,17 @@ public class PackageComponent( @Serializable(with = ResilientPromoOfferConfigSerializer::class) @SerialName("play_store_offer") public val playStoreOffer: PromoOfferConfig? = null, + @get:JvmSynthetic + public val visible: Boolean? = null, + @get:JvmSynthetic + public val overrides: List> = emptyList(), ) : PaywallComponent + +@InternalRevenueCatAPI +@Poko +@Serializable +@Immutable +public class PartialPackageComponent( + @get:JvmSynthetic + public val visible: Boolean? = true, +) : PartialComponent diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt index 7ec845ec10..586ef0f535 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt @@ -187,6 +187,87 @@ internal class PackageComponentTests(@Suppress("UNUSED_PARAMETER") name: String, ) ), ), + arrayOf( + "visible = true", + Args( + json = """ + { + "type": "package", + "package_id": "${"$"}rc_weekly", + "is_selected_by_default": true, + "visible": true, + "stack": { + "type": "stack", + "components": [] + } + } + """.trimIndent(), + expected = PackageComponent( + packageId = "${"$"}rc_weekly", + isSelectedByDefault = true, + stack = StackComponent(components = emptyList()), + visible = true, + ) + ), + ), + arrayOf( + "visible = false", + Args( + json = """ + { + "type": "package", + "package_id": "${"$"}rc_weekly", + "is_selected_by_default": true, + "visible": false, + "stack": { + "type": "stack", + "components": [] + } + } + """.trimIndent(), + expected = PackageComponent( + packageId = "${"$"}rc_weekly", + isSelectedByDefault = true, + stack = StackComponent(components = emptyList()), + visible = false, + ) + ), + ), + arrayOf( + "overrides with visible = false", + Args( + json = """ + { + "type": "package", + "package_id": "${"$"}rc_weekly", + "is_selected_by_default": true, + "stack": { + "type": "stack", + "components": [] + }, + "overrides": [ + { + "conditions": [{"type": "intro_offer"}], + "properties": {"visible": false} + } + ] + } + """.trimIndent(), + expected = PackageComponent( + packageId = "${"$"}rc_weekly", + isSelectedByDefault = true, + stack = StackComponent(components = emptyList()), + overrides = listOf( + com.revenuecat.purchases.paywalls.components.common.ComponentOverride( + conditions = listOf( + com.revenuecat.purchases.paywalls.components.common.ComponentOverride.Condition.IntroOffer + ), + properties = PartialPackageComponent(visible = false), + ) + ), + ) + ), + ), ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPackagePartial.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPackagePartial.kt new file mode 100644 index 0000000000..e9486c6af7 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPackagePartial.kt @@ -0,0 +1,30 @@ +package com.revenuecat.purchases.ui.revenuecatui.components + +import com.revenuecat.purchases.paywalls.components.PartialPackageComponent +import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError +import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList +import com.revenuecat.purchases.ui.revenuecatui.helpers.Result +import dev.drewhamilton.poko.Poko + +@Poko +internal class PresentedPackagePartial( + @get:JvmSynthetic val partial: PartialPackageComponent, +) : PresentedPartial { + + companion object { + @JvmSynthetic + operator fun invoke( + from: PartialPackageComponent, + ): Result> = + Result.Success(PresentedPackagePartial(partial = from)) + } + + override fun combine(with: PresentedPackagePartial?): PresentedPackagePartial { + val otherPartial = with?.partial + return PresentedPackagePartial( + partial = PartialPackageComponent( + visible = otherPartial?.visible ?: partial.visible, + ), + ) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentState.kt new file mode 100644 index 0000000000..082386820a --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentState.kt @@ -0,0 +1,107 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.pkg + +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.window.core.layout.WindowWidthSizeClass +import com.revenuecat.purchases.ui.revenuecatui.CustomVariableValue +import com.revenuecat.purchases.ui.revenuecatui.components.ComponentViewState +import com.revenuecat.purchases.ui.revenuecatui.components.ConditionContext +import com.revenuecat.purchases.ui.revenuecatui.components.ScreenCondition +import com.revenuecat.purchases.ui.revenuecatui.components.buildPresentedPartial +import com.revenuecat.purchases.ui.revenuecatui.components.state.PackageAwareDelegate +import com.revenuecat.purchases.ui.revenuecatui.components.style.PackageComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.composables.OfferEligibility +import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState + +@Stable +@JvmSynthetic +@Composable +internal fun rememberUpdatedPackageComponentState( + style: PackageComponentStyle, + paywallState: PaywallState.Loaded.Components, +): PackageComponentState = rememberUpdatedPackageComponentState( + style = style, + selectedPackageInfoProvider = { paywallState.selectedPackageInfo }, + selectedTabIndexProvider = { paywallState.selectedTabIndex }, + selectedOfferEligibilityProvider = { paywallState.selectedOfferEligibility }, + customVariablesProvider = { paywallState.mergedCustomVariables }, +) + +@Suppress("LongParameterList") +@Stable +@JvmSynthetic +@Composable +private fun rememberUpdatedPackageComponentState( + style: PackageComponentStyle, + selectedPackageInfoProvider: () -> PaywallState.Loaded.Components.SelectedPackageInfo?, + selectedTabIndexProvider: () -> Int, + selectedOfferEligibilityProvider: () -> OfferEligibility, + customVariablesProvider: () -> Map, +): PackageComponentState { + val windowSize = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + + return remember(style) { + PackageComponentState( + initialWindowSize = windowSize, + style = style, + selectedPackageInfoProvider = selectedPackageInfoProvider, + selectedTabIndexProvider = selectedTabIndexProvider, + selectedOfferEligibilityProvider = selectedOfferEligibilityProvider, + customVariablesProvider = customVariablesProvider, + ) + }.apply { + update(windowSize = windowSize) + } +} + +@Suppress("LongParameterList") +@Stable +internal class PackageComponentState( + initialWindowSize: WindowWidthSizeClass, + private val style: PackageComponentStyle, + private val selectedPackageInfoProvider: () -> PaywallState.Loaded.Components.SelectedPackageInfo?, + private val selectedTabIndexProvider: () -> Int, + private val selectedOfferEligibilityProvider: () -> OfferEligibility, + private val customVariablesProvider: () -> Map = { emptyMap() }, +) { + private var windowSize by mutableStateOf(initialWindowSize) + + private val packageAwareDelegate = PackageAwareDelegate( + style = style, + selectedPackageInfoProvider = selectedPackageInfoProvider, + selectedTabIndexProvider = selectedTabIndexProvider, + selectedOfferEligibilityProvider = selectedOfferEligibilityProvider, + ) + + private val presentedPartial by derivedStateOf { + val windowCondition = ScreenCondition.from(windowSize) + val componentState = + if (packageAwareDelegate.isSelected) ComponentViewState.SELECTED else ComponentViewState.DEFAULT + + style.overrides.buildPresentedPartial( + windowCondition, + packageAwareDelegate.offerEligibility, + componentState, + conditionContext = ConditionContext( + selectedPackageId = selectedPackageInfoProvider()?.rcPackage?.identifier, + customVariables = customVariablesProvider(), + ), + ) + } + + @get:JvmSynthetic + val visible by derivedStateOf { presentedPartial?.partial?.visible ?: style.visible } + + @JvmSynthetic + fun update(windowSize: WindowWidthSizeClass) { + this.windowSize = windowSize + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt index a30ae5de5b..9ccb4753f3 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt @@ -19,6 +19,10 @@ internal fun PackageComponentView( clickHandler: suspend (PaywallAction) -> Unit, modifier: Modifier = Modifier, ) { + val packageState = rememberUpdatedPackageComponentState(style = style, paywallState = state) + + if (!packageState.visible) return + StackComponentView( style = style.stackComponentStyle, state = state, diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt index 89eb447278..be6b11e85f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/PackageComponentStyle.kt @@ -3,28 +3,41 @@ package com.revenuecat.purchases.ui.revenuecatui.components.style import androidx.compose.runtime.Immutable import com.revenuecat.purchases.Package import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.ui.revenuecatui.components.PresentedOverride +import com.revenuecat.purchases.ui.revenuecatui.components.PresentedPackagePartial +import com.revenuecat.purchases.ui.revenuecatui.components.state.PackageContext +import com.revenuecat.purchases.ui.revenuecatui.composables.OfferEligibility import com.revenuecat.purchases.ui.revenuecatui.helpers.ResolvedOffer +@Suppress("LongParameterList") @Immutable internal data class PackageComponentStyle( @get:JvmSynthetic - val rcPackage: Package, + override val rcPackage: Package, @get:JvmSynthetic val isSelectedByDefault: Boolean, @get:JvmSynthetic val stackComponentStyle: StackComponentStyle, @get:JvmSynthetic val isSelectable: Boolean, + @get:JvmSynthetic + override val resolvedOffer: ResolvedOffer? = null, + @get:JvmSynthetic + override val visible: Boolean, + @get:JvmSynthetic + val overrides: List>, /** - * The resolved Play Store offer for this package, if configured. - * Used for purchase flow and template variables. + * Pre-computed offer eligibility for this package, used for evaluating intro/promo offer conditions + * in package-level overrides. */ @get:JvmSynthetic - val resolvedOffer: ResolvedOffer? = null, -) : ComponentStyle { - override val visible: Boolean = stackComponentStyle.visible + override val offerEligibility: OfferEligibility? = null, +) : ComponentStyle, PackageContext { override val size: Size = stackComponentStyle.size + @get:JvmSynthetic + override val tabIndex: Int? = null + /** * Unique identifier for this package component, combining package ID and offer ID. * This allows distinguishing between multiple components that reference the same package diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 4ce24e5da7..fdcd4c3d6a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -39,6 +39,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.LocalizedTextPartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedCarouselPartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedIconPartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedImagePartial +import com.revenuecat.purchases.ui.revenuecatui.components.PresentedPackagePartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedStackPartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedTabsPartial import com.revenuecat.purchases.ui.revenuecatui.components.PresentedTimelineItemPartial @@ -573,19 +574,33 @@ internal class StyleFactory( // visually become "selected" if its tab control parent is. tabControlIndex = null, ) { + val packageOfferEligibility = offerEligibility + + val presentedOverridesResult = component.overrides + .toPresentedOverrides(stripRules) { partial -> + PresentedPackagePartial(from = partial) + } + .mapError { nonEmptyListOf(it) } + val (stackComponentStyleResult, purchaseButtons) = withCount( predicate = { it is PurchaseButtonComponent }, ) { createStackComponentStyle(component.stack) } - stackComponentStyleResult.map { stack -> + zipOrAccumulate( + first = presentedOverridesResult, + second = stackComponentStyleResult, + ) { presentedOverrides, stack -> PackageComponentStyle( stackComponentStyle = stack, rcPackage = rcPackage, isSelectedByDefault = component.isSelectedByDefault, isSelectable = purchaseButtons == 0, resolvedOffer = resolvedOffer, + visible = component.visible ?: DEFAULT_VISIBILITY, + overrides = presentedOverrides, + offerEligibility = packageOfferEligibility, ) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index 55f2bf13cf..eb0866dd2e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -463,7 +463,7 @@ internal fun PaywallComponent.containsUnsupportedCondition(): Boolean = when (th -> false } } - is PackageComponent -> stack.containsUnsupportedCondition() + is PackageComponent -> overrides.hasUnsupportedCondition() || stack.containsUnsupportedCondition() is PurchaseButtonComponent -> stack.containsUnsupportedCondition() is StickyFooterComponent -> stack.containsUnsupportedCondition() is CarouselComponent -> overrides.hasUnsupportedCondition() || diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt index 1803011bcf..6873c4ac87 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt @@ -17,6 +17,7 @@ import com.revenuecat.purchases.paywalls.components.CarouselComponent import com.revenuecat.purchases.paywalls.components.IconComponent import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.PartialCarouselComponent +import com.revenuecat.purchases.paywalls.components.PartialPackageComponent import com.revenuecat.purchases.paywalls.components.PartialStackComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.PartialTimelineComponent @@ -1961,4 +1962,190 @@ class VisibilityConditionTests { } // endregion + + // region Package visibility + + /** + * PackageComponent with visible=false is hidden. + */ + @Test + fun `Package hidden when visible is false`(): Unit = with(composeTestRule) { + val monthlyPkg = PackageComponent( + packageId = TestData.Packages.monthly.identifier, + isSelectedByDefault = false, + visible = false, + stack = StackComponent( + components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), + ), + ) + + val data = PaywallComponentsData( + id = "pkg_visible_false", + templateName = "components", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(monthlyPkg)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = localeId, + ) + val offering = Offering( + identifier = "pkg-visible-false", + serverDescription = "Package visible=false test", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! + val state = offering.toComponentsPaywallState(validated) + val factory = StyleFactory(localizations = localizations, offering = offering) + val pkgStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle + + setContent { + PackageComponentView(style = pkgStyle, state = state, clickHandler = { }) + } + + onNodeWithText(monthlyLabelValue).assertDoesNotExist() + } + + /** + * PackageComponent with visible=true is shown. + */ + @Test + fun `Package visible when visible is true`(): Unit = with(composeTestRule) { + val monthlyPkg = PackageComponent( + packageId = TestData.Packages.monthly.identifier, + isSelectedByDefault = false, + visible = true, + stack = StackComponent( + components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), + ), + ) + + val data = PaywallComponentsData( + id = "pkg_visible_true", + templateName = "components", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(monthlyPkg)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = localeId, + ) + val offering = Offering( + identifier = "pkg-visible-true", + serverDescription = "Package visible=true test", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! + val state = offering.toComponentsPaywallState(validated) + val factory = StyleFactory(localizations = localizations, offering = offering) + val pkgStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle + + setContent { + PackageComponentView(style = pkgStyle, state = state, clickHandler = { }) + } + + onNodeWithText(monthlyLabelValue).assertIsDisplayed() + } + + /** + * PackageComponent hidden via override with selected_package condition. + * Selecting a specific package hides this package component. + */ + @Test + fun `Package hidden by selected_package override`(): Unit = with(composeTestRule) { + val monthlyPkg = PackageComponent( + packageId = TestData.Packages.monthly.identifier, + isSelectedByDefault = false, + stack = StackComponent( + components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), + ), + overrides = listOf( + ComponentOverride( + conditions = listOf( + ComponentOverride.Condition.SelectedPackage( + operator = ComponentOverride.ArrayOperator.IN, + packages = listOf(TestData.Packages.monthly.identifier), + ), + ), + properties = PartialPackageComponent(visible = false), + ), + ), + ) + val annualPkg = PackageComponent( + packageId = TestData.Packages.annual.identifier, + isSelectedByDefault = false, + stack = StackComponent( + components = listOf(TextComponent(text = annualLabelKey, color = textColor)), + ), + ) + + val data = PaywallComponentsData( + id = "pkg_override_test", + templateName = "components", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = localeId, + ) + val offering = Offering( + identifier = "pkg-override-test", + serverDescription = "Package override test", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! + val state = offering.toComponentsPaywallState(validated) + val factory = StyleFactory(localizations = localizations, offering = offering) + val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle + val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle + + setContent { + Column { + PackageComponentView( + style = monthlyStyle, + state = state, + clickHandler = { }, + modifier = Modifier.testTag("monthly"), + ) + PackageComponentView( + style = annualStyle, + state = state, + clickHandler = { }, + modifier = Modifier.testTag("annual"), + ) + } + } + + // No package selected — monthly is visible + onNodeWithText(monthlyLabelValue).assertIsDisplayed() + + // Select monthly — monthly package hides itself via override + state.update(TestData.Packages.monthly.identifier) + onNodeWithText(monthlyLabelValue).assertDoesNotExist() + + // Select annual — monthly is visible again + state.update(TestData.Packages.annual.identifier) + onNodeWithText(monthlyLabelValue).assertIsDisplayed() + } + + // endregion } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index 31a5c68ae6..bcab2fa6aa 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.paywalls.components.ButtonComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.PartialImageComponent +import com.revenuecat.purchases.paywalls.components.PartialPackageComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.PurchaseButtonComponent import com.revenuecat.purchases.paywalls.components.StackComponent @@ -1167,4 +1168,66 @@ class StyleFactoryTests { val secondImage = style.children[1] as ImageComponentStyle assertThat(secondImage.ignoreTopWindowInsets).isFalse() } + + @Test + fun `PackageComponentStyle visible defaults to true when component visible is null`() { + // Arrange + val packageComponent = PackageComponent( + packageId = "\$rc_annual", + isSelectedByDefault = false, + visible = null, + stack = StackComponent(components = emptyList()), + ) + + // Act + val result = styleFactory.create(packageComponent) + + // Assert + assertThat(result).isInstanceOf(Result.Success::class.java) + val pkgStyle = (result as Result.Success).value.componentStyle as PackageComponentStyle + assertThat(pkgStyle.visible).isTrue() + } + + @Test + fun `PackageComponentStyle visible is false when component visible is false`() { + // Arrange + val packageComponent = PackageComponent( + packageId = "\$rc_annual", + isSelectedByDefault = false, + visible = false, + stack = StackComponent(components = emptyList()), + ) + + // Act + val result = styleFactory.create(packageComponent) + + // Assert + assertThat(result).isInstanceOf(Result.Success::class.java) + val pkgStyle = (result as Result.Success).value.componentStyle as PackageComponentStyle + assertThat(pkgStyle.visible).isFalse() + } + + @Test + fun `PackageComponentStyle overrides are populated from component overrides`() { + // Arrange + val packageComponent = PackageComponent( + packageId = "\$rc_annual", + isSelectedByDefault = false, + stack = StackComponent(components = emptyList()), + overrides = listOf( + ComponentOverride( + conditions = listOf(ComponentOverride.Condition.IntroOffer), + properties = PartialPackageComponent(visible = false), + ), + ), + ) + + // Act + val result = styleFactory.create(packageComponent) + + // Assert + assertThat(result).isInstanceOf(Result.Success::class.java) + val pkgStyle = (result as Result.Success).value.componentStyle as PackageComponentStyle + assertThat(pkgStyle.overrides).hasSize(1) + } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt index d5b7a93dfb..d3a5f2f88f 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ContainsUnsupportedConditionTests.kt @@ -7,6 +7,7 @@ import com.revenuecat.purchases.paywalls.components.IconComponent import com.revenuecat.purchases.paywalls.components.ImageComponent import com.revenuecat.purchases.paywalls.components.PackageComponent import com.revenuecat.purchases.paywalls.components.PartialImageComponent +import com.revenuecat.purchases.paywalls.components.PartialPackageComponent import com.revenuecat.purchases.paywalls.components.PartialStackComponent import com.revenuecat.purchases.paywalls.components.PartialTextComponent import com.revenuecat.purchases.paywalls.components.PurchaseButtonComponent @@ -288,6 +289,23 @@ internal class ContainsUnsupportedConditionTests { assertTrue(stack.containsUnsupportedCondition()) } + @Test + fun `PackageComponent detects unsupported in its own overrides`() { + val pkg = PackageComponent( + packageId = "monthly", + isSelectedByDefault = true, + stack = emptyStack(), + overrides = listOf( + ComponentOverride( + conditions = listOf(ComponentOverride.Condition.Unsupported), + properties = PartialPackageComponent(visible = false), + ) + ), + ) + val stack = emptyStack(components = listOf(pkg)) + assertTrue(stack.containsUnsupportedCondition()) + } + // endregion // region PurchaseButtonComponent From 405a23f2a94bd94b9535dd306cabcab43ed59b66 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 26 Mar 2026 14:59:03 +0100 Subject: [PATCH 2/8] fix: reconcile hidden package selection --- .../components/pkg/PackageComponentView.kt | 17 ++ .../components/style/StyleFactory.kt | 5 +- .../ui/revenuecatui/data/PaywallState.kt | 100 +++++++++++ .../components/VisibilityConditionTests.kt | 156 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt index 9ccb4753f3..882b33e88c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt @@ -4,6 +4,8 @@ package com.revenuecat.purchases.ui.revenuecatui.components.pkg import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView @@ -21,6 +23,21 @@ internal fun PackageComponentView( ) { val packageState = rememberUpdatedPackageComponentState(style = style, paywallState = state) + // Notify PaywallState whenever this package's visibility changes so that purchase / pricing + // state never references a hidden package. These effects run before the early-return so they + // remain active even while invisible. + LaunchedEffect(packageState.visible) { + state.setPackageVisible(uniqueId = style.uniqueId, isVisible = packageState.visible) + } + DisposableEffect(style.uniqueId) { + onDispose { + // Clear rather than mark-false so that packages leaving one tab (and potentially + // re-entering in another) are treated as unknown rather than hidden, preventing + // reconcileSelectedIfHidden from evicting the selection prematurely. + state.clearPackageVisible(uniqueId = style.uniqueId) + } + } + if (!packageState.visible) return StackComponentView( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index fdcd4c3d6a..581bb8484a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -567,7 +567,10 @@ internal class StyleFactory( withSelectedScope( packageInfo = AvailablePackages.Info( pkg = rcPackage, - isSelectedByDefault = component.isSelectedByDefault, + // A statically hidden package must never drive the initial selection, even if + // isSelectedByDefault is true. Dynamic override visibility is reconciled at + // runtime via PackageComponentView reporting visibility to PaywallState. + isSelectedByDefault = component.isSelectedByDefault && component.visible != false, resolvedOffer = resolvedOffer, ), // If a tab control contains a package, which is already an edge case, the package should not diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 12aea758e6..1b2930609a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -246,6 +246,100 @@ internal sealed interface PaywallState { private var selectedPackageUniqueId by mutableStateOf(initialSelectedPackageUniqueId) + /** + * Maps each package uniqueId to its most recently reported visibility state. + * Absence means the package hasn't rendered yet (visibility is unknown). + * Uses [mutableStateMapOf] so reads inside [derivedStateOf] / composables react to changes. + */ + private val packageVisibility = mutableStateMapOf() + + /** + * Tracks packages that have been visible **at least once** during this session. + * Used to distinguish between two categories of "selected package becomes hidden": + * - Never visible → hidden (e.g. intro_offer hides the default from the first frame): + * the selection is stale and should be reconciled to the first visible alternative. + * - Was visible → user selected it → now hidden (e.g. selected_package override): + * the hiding is intentional; the selection must not be changed. + */ + private val packagesEverVisible = mutableSetOf() + + /** + * Called by [PackageComponentView] whenever a package's visibility changes. + * + * Invariants maintained: + * - When a package that has **never been visible** is the initial selection and + * becomes hidden, selection is reconciled to the first visible alternative so that + * purchase / pricing state never references an invisible package. + * - When a package that **was previously visible** becomes hidden (e.g. because a + * [ComponentOverride.Condition.SelectedPackage] override fires after the user + * tapped it), the selection is intentionally preserved. + * - A package with unknown visibility (not yet rendered) is never evicted — we wait + * for its [LaunchedEffect] to fire. + */ + fun setPackageVisible(uniqueId: String, isVisible: Boolean) { + packageVisibility[uniqueId] = isVisible + if (isVisible) { + packagesEverVisible.add(uniqueId) + // Auto-select the first visible package only when there was an intended default + // that fell to null (e.g. it was hidden by a dynamic override before the user + // could interact). When initialSelectedPackageUniqueId is null the paywall has + // no intended default and selection should stay null. + if (selectedPackageUniqueId == null && initialSelectedPackageUniqueId != null) { + selectedPackageUniqueId = uniqueId + } + } else { + // Only reconcile if this package was NEVER visible before it became hidden + // while selected. If it was previously visible (packagesEverVisible contains it), + // the hiding is user-triggered (e.g. selected_package override) and must not + // disturb the selection. + if (selectedPackageUniqueId == uniqueId && !packagesEverVisible.contains(uniqueId)) { + selectedPackageUniqueId = findFirstVisiblePackageUniqueId(excluding = uniqueId) + } + } + } + + /** + * Called by [PackageComponentView.DisposableEffect] when the composable leaves the + * composition (e.g. a tab switch). Removes the visibility record so the package is + * treated as **unknown** rather than hidden — preventing [reconcileSelectedIfHidden] + * from evicting it when the same package is about to re-enter. + * + * [packagesEverVisible] is intentionally **not** cleared so that the historical + * "was ever visible" guard survives tab round-trips. + */ + fun clearPackageVisible(uniqueId: String) { + packageVisibility.remove(uniqueId) + } + + /** + * If the currently selected package is **known** to be hidden (it has reported + * `isVisible = false`), replaces the selection with the first visible alternative. + * Packages that have not yet reported their visibility are left alone — their + * [LaunchedEffect] will call [setPackageVisible] and trigger reconciliation then. + */ + private fun reconcileSelectedIfHidden() { + val current = selectedPackageUniqueId ?: return + if (packageVisibility[current] == false) { + selectedPackageUniqueId = findFirstVisiblePackageUniqueId(excluding = current) + } + } + + /** + * Returns the first visible package uniqueId, searching the same bucket as [excluding] + * first and then the other bucket as a fallback so that outside-tabs packages can + * reclaim selection when every in-tab package is hidden (and vice versa). + */ + private fun findFirstVisiblePackageUniqueId(excluding: String): String? { + val (sameBucket, crossBucket) = if (packagesOutsideTabsUniqueIds.contains(excluding)) { + packages.packagesOutsideTabs to packages.packagesByTab[selectedTabIndex].orEmpty() + } else { + packages.packagesByTab[selectedTabIndex].orEmpty() to packages.packagesOutsideTabs + } + return (sameBucket + crossBucket) + .firstOrNull { it.uniqueId != excluding && packageVisibility[it.uniqueId] == true } + ?.uniqueId + } + val selectedPackageInfo by derivedStateOf { selectedPackageUniqueId?.let { uniqueId -> findPackageInfoByUniqueId(uniqueId)?.let { info -> @@ -307,6 +401,9 @@ internal sealed interface PaywallState { "This could be caused by not having any package marked as selected by default.", ) } + // The new tab's packages may not have rendered yet; reconcile only if the + // candidate's visibility is already known to be false. + reconcileSelectedIfHidden() } if (actionInProgress != null) this.actionInProgress = actionInProgress @@ -328,6 +425,9 @@ internal sealed interface PaywallState { packages.packagesByTab[selectedTabIndex]?.firstOrNull { it.isSelectedByDefault }?.uniqueId ?: initialSelectedPackageOutsideTabs ?: selectedPackageByTab[selectedTabIndex] + // The sheet may have been dismissed while an override was hiding the default package. + // Reconcile immediately if visibility is already known; otherwise LaunchedEffect will fix it. + reconcileSelectedIfHidden() } private fun LocaleList.toLocaleId(): LocaleId { diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt index 6873c4ac87..ef008be11d 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt @@ -2148,4 +2148,160 @@ class VisibilityConditionTests { } // endregion + + // region Package visibility — selection reconciliation + + /** + * A statically hidden package (visible=false) is never the initial selection, even when + * isSelectedByDefault=true — the StyleFactory suppresses it. The visible alternative is + * rendered but not automatically selected (no other package is marked as default). + */ + @Test + fun `Statically hidden default is not selected`(): Unit = with(composeTestRule) { + // Monthly is the intended default but is statically hidden. + val monthlyPkg = PackageComponent( + packageId = TestData.Packages.monthly.identifier, + isSelectedByDefault = true, + visible = false, + stack = StackComponent( + components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), + ), + ) + val annualPkg = PackageComponent( + packageId = TestData.Packages.annual.identifier, + isSelectedByDefault = false, + stack = StackComponent( + components = listOf(TextComponent(text = annualLabelKey, color = textColor)), + ), + ) + + val data = PaywallComponentsData( + id = "hidden_default_not_selected", + templateName = "components", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = localeId, + ) + val offering = Offering( + identifier = "hidden-default-not-selected", + serverDescription = "Statically hidden default test", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! + val state = offering.toComponentsPaywallState(validated) + val factory = StyleFactory(localizations = localizations, offering = offering) + val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle + val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle + + setContent { + Column { + PackageComponentView(style = monthlyStyle, state = state, clickHandler = { }) + PackageComponentView(style = annualStyle, state = state, clickHandler = { }) + } + } + + // Monthly is invisible — it must not be rendered at all. + onNodeWithText(monthlyLabelValue).assertDoesNotExist() + // Annual is visible. + onNodeWithText(annualLabelValue).assertIsDisplayed() + // Monthly must not be selected (purchase state must not reference an invisible package). + assertTrue( + "Monthly must not be selected when statically hidden", + state.selectedPackageInfo?.rcPackage?.identifier != TestData.Packages.monthly.identifier, + ) + } + + /** + * When a dynamic override hides the currently selected package (via a variable condition), + * the first visible alternative should be auto-selected so purchase state remains consistent. + */ + @Test + fun `Dynamic override hides selected package — first visible alternative is auto-selected`(): Unit = + with(composeTestRule) { + // Monthly is default-selected and hides itself when a custom variable is set. + val hideMonthlyKey = "hide_monthly" + val monthlyPkg = PackageComponent( + packageId = TestData.Packages.monthly.identifier, + isSelectedByDefault = true, + stack = StackComponent( + components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), + ), + overrides = listOf( + ComponentOverride( + conditions = listOf( + ComponentOverride.Condition.Variable( + operator = ComponentOverride.EqualityOperator.EQUALS, + variable = hideMonthlyKey, + value = JsonPrimitive(true), + ), + ), + properties = PartialPackageComponent(visible = false), + ), + ), + ) + val annualPkg = PackageComponent( + packageId = TestData.Packages.annual.identifier, + isSelectedByDefault = false, + stack = StackComponent( + components = listOf(TextComponent(text = annualLabelKey, color = textColor)), + ), + ) + + val data = PaywallComponentsData( + id = "dynamic_hidden_default", + templateName = "components", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = localeId, + ) + val offering = Offering( + identifier = "dynamic-hidden-default", + serverDescription = "Dynamic hidden default test", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), + paywallComponents = Offering.PaywallComponents(UiConfig(), data), + ) + val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! + // Set the custom variable so the variable condition fires and hides monthly. + val state = offering.toComponentsPaywallState( + validated, + customVariables = mapOf(hideMonthlyKey to CustomVariableValue.Boolean(true)), + ) + val factory = StyleFactory(localizations = localizations, offering = offering) + val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle + val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle + + setContent { + Column { + PackageComponentView(style = monthlyStyle, state = state, clickHandler = { }) + PackageComponentView(style = annualStyle, state = state, clickHandler = { }) + } + } + + // Monthly is hidden by the variable condition — annual should be auto-selected. + onNodeWithText(monthlyLabelValue).assertDoesNotExist() + onNodeWithText(annualLabelValue).assertIsDisplayed() + assertTrue( + "Annual should be auto-selected when monthly is hidden by variable override", + state.selectedPackageInfo?.rcPackage?.identifier == TestData.Packages.annual.identifier, + ) + } + + // endregion } From 5cba7467e5d80c59e83e0c8bd74bc79a1a5c0986 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Wed, 8 Apr 2026 17:08:34 +0200 Subject: [PATCH 3/8] fix: use ComponentOverride import instead of inline FQN in PackageComponentTests; document builder invariant in StyleFactory Co-Authored-By: Claude Sonnet 4.6 --- .../purchases/paywalls/components/PackageComponentTests.kt | 5 +++-- .../ui/revenuecatui/components/style/StyleFactory.kt | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt index 586ef0f535..9a43f8a30a 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/PackageComponentTests.kt @@ -4,6 +4,7 @@ import com.revenuecat.purchases.ColorAlias import com.revenuecat.purchases.JsonTools import com.revenuecat.purchases.LogHandler import com.revenuecat.purchases.common.currentLogHandler +import com.revenuecat.purchases.paywalls.components.common.ComponentOverride import com.revenuecat.purchases.paywalls.components.common.LocalizationKey import com.revenuecat.purchases.paywalls.components.common.PromoOfferConfig import com.revenuecat.purchases.paywalls.components.properties.ColorInfo @@ -258,9 +259,9 @@ internal class PackageComponentTests(@Suppress("UNUSED_PARAMETER") name: String, isSelectedByDefault = true, stack = StackComponent(components = emptyList()), overrides = listOf( - com.revenuecat.purchases.paywalls.components.common.ComponentOverride( + ComponentOverride( conditions = listOf( - com.revenuecat.purchases.paywalls.components.common.ComponentOverride.Condition.IntroOffer + ComponentOverride.Condition.IntroOffer ), properties = PartialPackageComponent(visible = false), ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 581bb8484a..4b01a62d19 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -570,6 +570,9 @@ internal class StyleFactory( // A statically hidden package must never drive the initial selection, even if // isSelectedByDefault is true. Dynamic override visibility is reconciled at // runtime via PackageComponentView reporting visibility to PaywallState. + // Note: the paywall builder enforces that the default-selected package cannot + // be statically hidden (visible: false), so this guard is a defensive + // fallback rather than a normal code path. isSelectedByDefault = component.isSelectedByDefault && component.visible != false, resolvedOffer = resolvedOffer, ), From f7289e24b64b6e8be99fdc2d5363a1f2bf868487 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 9 Apr 2026 10:25:57 +0200 Subject: [PATCH 4/8] refactor: rely on dashboard to prevent hidden default packages instead of defensive check The paywall builder already enforces that a default-selected package cannot be statically hidden. Documenting the invariant in a comment rather than duplicating the constraint in code. Co-Authored-By: Claude Sonnet 4.6 --- examples/paywall-tester/build.gradle.kts | 2 +- examples/paywall-tester/src/main/AndroidManifest.xml | 3 ++- .../paywallstester/ConfigurePurchasesUseCase.kt | 3 +++ .../ui/revenuecatui/components/style/StyleFactory.kt | 12 +++++------- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/paywall-tester/build.gradle.kts b/examples/paywall-tester/build.gradle.kts index 864300626c..dca5871ea7 100644 --- a/examples/paywall-tester/build.gradle.kts +++ b/examples/paywall-tester/build.gradle.kts @@ -23,7 +23,7 @@ android { namespace = "com.revenuecat.paywallstester" defaultConfig { - applicationId = "com.revenuecat.paywall_tester" + applicationId = "com.revenuecat.purchases_sample" minSdk = 24 versionCode = (project.properties["paywallTesterVersionCode"] as String).toInt() versionName = project.properties["paywallTesterVersionName"] as String diff --git a/examples/paywall-tester/src/main/AndroidManifest.xml b/examples/paywall-tester/src/main/AndroidManifest.xml index c46ebc92ca..343346bd4a 100644 --- a/examples/paywall-tester/src/main/AndroidManifest.xml +++ b/examples/paywall-tester/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ android:roundIcon="@mipmap/icon_round" android:backupAgent="com.revenuecat.purchases.backup.RevenueCatBackupAgent" android:supportsRtl="true" - android:theme="@style/Theme.Purchasesandroid"> + android:theme="@style/Theme.Purchasesandroid" + android:networkSecurityConfig="@xml/network_security_config"> diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt index 35f4c3eed3..0e16aaa342 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt @@ -4,12 +4,15 @@ import android.content.Context import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesConfiguration +import java.net.URL internal class ConfigurePurchasesUseCase( private val context: Context, ) { operator fun invoke(apiKey: String) { + Purchases.proxyURL = URL("http://localhost:8000") + val builder = PurchasesConfiguration.Builder(context.applicationContext, apiKey) .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT) .appUserID(null) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 4b01a62d19..0da1e379a0 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -567,13 +567,11 @@ internal class StyleFactory( withSelectedScope( packageInfo = AvailablePackages.Info( pkg = rcPackage, - // A statically hidden package must never drive the initial selection, even if - // isSelectedByDefault is true. Dynamic override visibility is reconciled at - // runtime via PackageComponentView reporting visibility to PaywallState. - // Note: the paywall builder enforces that the default-selected package cannot - // be statically hidden (visible: false), so this guard is a defensive - // fallback rather than a normal code path. - isSelectedByDefault = component.isSelectedByDefault && component.visible != false, + // The paywall builder enforces that the default-selected package cannot be + // statically hidden (visible: false), so we trust isSelectedByDefault as-is. + // Dynamic override visibility is reconciled at runtime via PackageComponentView + // reporting visibility to PaywallState. + isSelectedByDefault = component.isSelectedByDefault, resolvedOffer = resolvedOffer, ), // If a tab control contains a package, which is already an edge case, the package should not From a149e059cab958017b24c49e4db8c12e7ab71ee7 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 9 Apr 2026 10:31:14 +0200 Subject: [PATCH 5/8] fix: revert accidental local dev changes from paywall-tester Proxy URL, applicationId override, and network security config were local development settings that were unintentionally committed. Co-Authored-By: Claude Sonnet 4.6 --- examples/paywall-tester/build.gradle.kts | 2 +- examples/paywall-tester/src/main/AndroidManifest.xml | 3 +-- .../com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/paywall-tester/build.gradle.kts b/examples/paywall-tester/build.gradle.kts index dca5871ea7..864300626c 100644 --- a/examples/paywall-tester/build.gradle.kts +++ b/examples/paywall-tester/build.gradle.kts @@ -23,7 +23,7 @@ android { namespace = "com.revenuecat.paywallstester" defaultConfig { - applicationId = "com.revenuecat.purchases_sample" + applicationId = "com.revenuecat.paywall_tester" minSdk = 24 versionCode = (project.properties["paywallTesterVersionCode"] as String).toInt() versionName = project.properties["paywallTesterVersionName"] as String diff --git a/examples/paywall-tester/src/main/AndroidManifest.xml b/examples/paywall-tester/src/main/AndroidManifest.xml index 343346bd4a..c46ebc92ca 100644 --- a/examples/paywall-tester/src/main/AndroidManifest.xml +++ b/examples/paywall-tester/src/main/AndroidManifest.xml @@ -9,8 +9,7 @@ android:roundIcon="@mipmap/icon_round" android:backupAgent="com.revenuecat.purchases.backup.RevenueCatBackupAgent" android:supportsRtl="true" - android:theme="@style/Theme.Purchasesandroid" - android:networkSecurityConfig="@xml/network_security_config"> + android:theme="@style/Theme.Purchasesandroid"> diff --git a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt index 0e16aaa342..35f4c3eed3 100644 --- a/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt +++ b/examples/paywall-tester/src/main/java/com/revenuecat/paywallstester/ConfigurePurchasesUseCase.kt @@ -4,15 +4,12 @@ import android.content.Context import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesConfiguration -import java.net.URL internal class ConfigurePurchasesUseCase( private val context: Context, ) { operator fun invoke(apiKey: String) { - Purchases.proxyURL = URL("http://localhost:8000") - val builder = PurchasesConfiguration.Builder(context.applicationContext, apiKey) .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT) .appUserID(null) From c99a314db34eaa2925bef6c51e5bc42d83ec0eee Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 9 Apr 2026 10:41:10 +0200 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20remove=20visibility=20reconcili?= =?UTF-8?q?ation=20=E2=80=94=20assume=20default=20package=20is=20always=20?= =?UTF-8?q?visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paywall builder guarantees the default-selected package is never hidden, so tracking package visibility in PaywallState and reconciling selection away from hidden packages is unnecessary complexity. - Remove packageVisibility map, packagesEverVisible set - Remove setPackageVisible / clearPackageVisible / reconcileSelectedIfHidden - Remove LaunchedEffect / DisposableEffect from PackageComponentView - Remove reconciliation tests from VisibilityConditionTests PackageComponentView still gates rendering on packageState.visible so hidden packages are simply not rendered. Co-Authored-By: Claude Sonnet 4.6 --- .../components/pkg/PackageComponentView.kt | 17 -- .../ui/revenuecatui/data/PaywallState.kt | 100 ----------- .../components/VisibilityConditionTests.kt | 155 ------------------ 3 files changed, 272 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt index 882b33e88c..9ccb4753f3 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt @@ -4,8 +4,6 @@ package com.revenuecat.purchases.ui.revenuecatui.components.pkg import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentView @@ -23,21 +21,6 @@ internal fun PackageComponentView( ) { val packageState = rememberUpdatedPackageComponentState(style = style, paywallState = state) - // Notify PaywallState whenever this package's visibility changes so that purchase / pricing - // state never references a hidden package. These effects run before the early-return so they - // remain active even while invisible. - LaunchedEffect(packageState.visible) { - state.setPackageVisible(uniqueId = style.uniqueId, isVisible = packageState.visible) - } - DisposableEffect(style.uniqueId) { - onDispose { - // Clear rather than mark-false so that packages leaving one tab (and potentially - // re-entering in another) are treated as unknown rather than hidden, preventing - // reconcileSelectedIfHidden from evicting the selection prematurely. - state.clearPackageVisible(uniqueId = style.uniqueId) - } - } - if (!packageState.visible) return StackComponentView( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 1b2930609a..12aea758e6 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -246,100 +246,6 @@ internal sealed interface PaywallState { private var selectedPackageUniqueId by mutableStateOf(initialSelectedPackageUniqueId) - /** - * Maps each package uniqueId to its most recently reported visibility state. - * Absence means the package hasn't rendered yet (visibility is unknown). - * Uses [mutableStateMapOf] so reads inside [derivedStateOf] / composables react to changes. - */ - private val packageVisibility = mutableStateMapOf() - - /** - * Tracks packages that have been visible **at least once** during this session. - * Used to distinguish between two categories of "selected package becomes hidden": - * - Never visible → hidden (e.g. intro_offer hides the default from the first frame): - * the selection is stale and should be reconciled to the first visible alternative. - * - Was visible → user selected it → now hidden (e.g. selected_package override): - * the hiding is intentional; the selection must not be changed. - */ - private val packagesEverVisible = mutableSetOf() - - /** - * Called by [PackageComponentView] whenever a package's visibility changes. - * - * Invariants maintained: - * - When a package that has **never been visible** is the initial selection and - * becomes hidden, selection is reconciled to the first visible alternative so that - * purchase / pricing state never references an invisible package. - * - When a package that **was previously visible** becomes hidden (e.g. because a - * [ComponentOverride.Condition.SelectedPackage] override fires after the user - * tapped it), the selection is intentionally preserved. - * - A package with unknown visibility (not yet rendered) is never evicted — we wait - * for its [LaunchedEffect] to fire. - */ - fun setPackageVisible(uniqueId: String, isVisible: Boolean) { - packageVisibility[uniqueId] = isVisible - if (isVisible) { - packagesEverVisible.add(uniqueId) - // Auto-select the first visible package only when there was an intended default - // that fell to null (e.g. it was hidden by a dynamic override before the user - // could interact). When initialSelectedPackageUniqueId is null the paywall has - // no intended default and selection should stay null. - if (selectedPackageUniqueId == null && initialSelectedPackageUniqueId != null) { - selectedPackageUniqueId = uniqueId - } - } else { - // Only reconcile if this package was NEVER visible before it became hidden - // while selected. If it was previously visible (packagesEverVisible contains it), - // the hiding is user-triggered (e.g. selected_package override) and must not - // disturb the selection. - if (selectedPackageUniqueId == uniqueId && !packagesEverVisible.contains(uniqueId)) { - selectedPackageUniqueId = findFirstVisiblePackageUniqueId(excluding = uniqueId) - } - } - } - - /** - * Called by [PackageComponentView.DisposableEffect] when the composable leaves the - * composition (e.g. a tab switch). Removes the visibility record so the package is - * treated as **unknown** rather than hidden — preventing [reconcileSelectedIfHidden] - * from evicting it when the same package is about to re-enter. - * - * [packagesEverVisible] is intentionally **not** cleared so that the historical - * "was ever visible" guard survives tab round-trips. - */ - fun clearPackageVisible(uniqueId: String) { - packageVisibility.remove(uniqueId) - } - - /** - * If the currently selected package is **known** to be hidden (it has reported - * `isVisible = false`), replaces the selection with the first visible alternative. - * Packages that have not yet reported their visibility are left alone — their - * [LaunchedEffect] will call [setPackageVisible] and trigger reconciliation then. - */ - private fun reconcileSelectedIfHidden() { - val current = selectedPackageUniqueId ?: return - if (packageVisibility[current] == false) { - selectedPackageUniqueId = findFirstVisiblePackageUniqueId(excluding = current) - } - } - - /** - * Returns the first visible package uniqueId, searching the same bucket as [excluding] - * first and then the other bucket as a fallback so that outside-tabs packages can - * reclaim selection when every in-tab package is hidden (and vice versa). - */ - private fun findFirstVisiblePackageUniqueId(excluding: String): String? { - val (sameBucket, crossBucket) = if (packagesOutsideTabsUniqueIds.contains(excluding)) { - packages.packagesOutsideTabs to packages.packagesByTab[selectedTabIndex].orEmpty() - } else { - packages.packagesByTab[selectedTabIndex].orEmpty() to packages.packagesOutsideTabs - } - return (sameBucket + crossBucket) - .firstOrNull { it.uniqueId != excluding && packageVisibility[it.uniqueId] == true } - ?.uniqueId - } - val selectedPackageInfo by derivedStateOf { selectedPackageUniqueId?.let { uniqueId -> findPackageInfoByUniqueId(uniqueId)?.let { info -> @@ -401,9 +307,6 @@ internal sealed interface PaywallState { "This could be caused by not having any package marked as selected by default.", ) } - // The new tab's packages may not have rendered yet; reconcile only if the - // candidate's visibility is already known to be false. - reconcileSelectedIfHidden() } if (actionInProgress != null) this.actionInProgress = actionInProgress @@ -425,9 +328,6 @@ internal sealed interface PaywallState { packages.packagesByTab[selectedTabIndex]?.firstOrNull { it.isSelectedByDefault }?.uniqueId ?: initialSelectedPackageOutsideTabs ?: selectedPackageByTab[selectedTabIndex] - // The sheet may have been dismissed while an override was hiding the default package. - // Reconcile immediately if visibility is already known; otherwise LaunchedEffect will fix it. - reconcileSelectedIfHidden() } private fun LocaleList.toLocaleId(): LocaleId { diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt index ef008be11d..1dd62049f9 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt @@ -2149,159 +2149,4 @@ class VisibilityConditionTests { // endregion - // region Package visibility — selection reconciliation - - /** - * A statically hidden package (visible=false) is never the initial selection, even when - * isSelectedByDefault=true — the StyleFactory suppresses it. The visible alternative is - * rendered but not automatically selected (no other package is marked as default). - */ - @Test - fun `Statically hidden default is not selected`(): Unit = with(composeTestRule) { - // Monthly is the intended default but is statically hidden. - val monthlyPkg = PackageComponent( - packageId = TestData.Packages.monthly.identifier, - isSelectedByDefault = true, - visible = false, - stack = StackComponent( - components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), - ), - ) - val annualPkg = PackageComponent( - packageId = TestData.Packages.annual.identifier, - isSelectedByDefault = false, - stack = StackComponent( - components = listOf(TextComponent(text = annualLabelKey, color = textColor)), - ), - ) - - val data = PaywallComponentsData( - id = "hidden_default_not_selected", - templateName = "components", - assetBaseURL = URL("https://assets.pawwalls.com"), - componentsConfig = ComponentsConfig( - base = PaywallComponentsConfig( - stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), - background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), - stickyFooter = null, - ), - ), - componentsLocalizations = localizations, - defaultLocaleIdentifier = localeId, - ) - val offering = Offering( - identifier = "hidden-default-not-selected", - serverDescription = "Statically hidden default test", - metadata = emptyMap(), - availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), - paywallComponents = Offering.PaywallComponents(UiConfig(), data), - ) - val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! - val state = offering.toComponentsPaywallState(validated) - val factory = StyleFactory(localizations = localizations, offering = offering) - val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle - val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle - - setContent { - Column { - PackageComponentView(style = monthlyStyle, state = state, clickHandler = { }) - PackageComponentView(style = annualStyle, state = state, clickHandler = { }) - } - } - - // Monthly is invisible — it must not be rendered at all. - onNodeWithText(monthlyLabelValue).assertDoesNotExist() - // Annual is visible. - onNodeWithText(annualLabelValue).assertIsDisplayed() - // Monthly must not be selected (purchase state must not reference an invisible package). - assertTrue( - "Monthly must not be selected when statically hidden", - state.selectedPackageInfo?.rcPackage?.identifier != TestData.Packages.monthly.identifier, - ) - } - - /** - * When a dynamic override hides the currently selected package (via a variable condition), - * the first visible alternative should be auto-selected so purchase state remains consistent. - */ - @Test - fun `Dynamic override hides selected package — first visible alternative is auto-selected`(): Unit = - with(composeTestRule) { - // Monthly is default-selected and hides itself when a custom variable is set. - val hideMonthlyKey = "hide_monthly" - val monthlyPkg = PackageComponent( - packageId = TestData.Packages.monthly.identifier, - isSelectedByDefault = true, - stack = StackComponent( - components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), - ), - overrides = listOf( - ComponentOverride( - conditions = listOf( - ComponentOverride.Condition.Variable( - operator = ComponentOverride.EqualityOperator.EQUALS, - variable = hideMonthlyKey, - value = JsonPrimitive(true), - ), - ), - properties = PartialPackageComponent(visible = false), - ), - ), - ) - val annualPkg = PackageComponent( - packageId = TestData.Packages.annual.identifier, - isSelectedByDefault = false, - stack = StackComponent( - components = listOf(TextComponent(text = annualLabelKey, color = textColor)), - ), - ) - - val data = PaywallComponentsData( - id = "dynamic_hidden_default", - templateName = "components", - assetBaseURL = URL("https://assets.pawwalls.com"), - componentsConfig = ComponentsConfig( - base = PaywallComponentsConfig( - stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), - background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), - stickyFooter = null, - ), - ), - componentsLocalizations = localizations, - defaultLocaleIdentifier = localeId, - ) - val offering = Offering( - identifier = "dynamic-hidden-default", - serverDescription = "Dynamic hidden default test", - metadata = emptyMap(), - availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), - paywallComponents = Offering.PaywallComponents(UiConfig(), data), - ) - val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! - // Set the custom variable so the variable condition fires and hides monthly. - val state = offering.toComponentsPaywallState( - validated, - customVariables = mapOf(hideMonthlyKey to CustomVariableValue.Boolean(true)), - ) - val factory = StyleFactory(localizations = localizations, offering = offering) - val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle - val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle - - setContent { - Column { - PackageComponentView(style = monthlyStyle, state = state, clickHandler = { }) - PackageComponentView(style = annualStyle, state = state, clickHandler = { }) - } - } - - // Monthly is hidden by the variable condition — annual should be auto-selected. - onNodeWithText(monthlyLabelValue).assertDoesNotExist() - onNodeWithText(annualLabelValue).assertIsDisplayed() - assertTrue( - "Annual should be auto-selected when monthly is hidden by variable override", - state.selectedPackageInfo?.rcPackage?.identifier == TestData.Packages.annual.identifier, - ) - } - - // endregion } From 7244363da37a1b6962e5f020cf7ae4f214825fd1 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 9 Apr 2026 10:50:40 +0200 Subject: [PATCH 7/8] chore: remove stale comment from StyleFactory per review Co-Authored-By: Claude Sonnet 4.6 --- .../ui/revenuecatui/components/style/StyleFactory.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index 0da1e379a0..fdcd4c3d6a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -567,10 +567,6 @@ internal class StyleFactory( withSelectedScope( packageInfo = AvailablePackages.Info( pkg = rcPackage, - // The paywall builder enforces that the default-selected package cannot be - // statically hidden (visible: false), so we trust isSelectedByDefault as-is. - // Dynamic override visibility is reconciled at runtime via PackageComponentView - // reporting visibility to PaywallState. isSelectedByDefault = component.isSelectedByDefault, resolvedOffer = resolvedOffer, ), From 0abf6ab2b9ab3c0dc1d47c313f9eba37a4a95657 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 9 Apr 2026 11:26:07 +0200 Subject: [PATCH 8/8] fix: PartialPackageComponent.visible defaults to null; remove self-hiding selected_package test - visible = null matches the partial component pattern (null = inherit from base, not-null = override). visible = true caused any future override partial to silently force visibility to true. - Self-hiding via selected_package override is not a valid config: a package hiding itself when selected would leave the selected purchase target invisible with no way to reconcile. Co-Authored-By: Claude Sonnet 4.6 --- .../paywalls/components/PackageComponent.kt | 2 +- .../components/VisibilityConditionTests.kt | 87 ------------------- 2 files changed, 1 insertion(+), 88 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt index 072a098854..2270d0aa45 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PackageComponent.kt @@ -39,5 +39,5 @@ public class PackageComponent( @Immutable public class PartialPackageComponent( @get:JvmSynthetic - public val visible: Boolean? = true, + public val visible: Boolean? = null, ) : PartialComponent diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt index 1dd62049f9..37e982524e 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/VisibilityConditionTests.kt @@ -2059,93 +2059,6 @@ class VisibilityConditionTests { onNodeWithText(monthlyLabelValue).assertIsDisplayed() } - /** - * PackageComponent hidden via override with selected_package condition. - * Selecting a specific package hides this package component. - */ - @Test - fun `Package hidden by selected_package override`(): Unit = with(composeTestRule) { - val monthlyPkg = PackageComponent( - packageId = TestData.Packages.monthly.identifier, - isSelectedByDefault = false, - stack = StackComponent( - components = listOf(TextComponent(text = monthlyLabelKey, color = textColor)), - ), - overrides = listOf( - ComponentOverride( - conditions = listOf( - ComponentOverride.Condition.SelectedPackage( - operator = ComponentOverride.ArrayOperator.IN, - packages = listOf(TestData.Packages.monthly.identifier), - ), - ), - properties = PartialPackageComponent(visible = false), - ), - ), - ) - val annualPkg = PackageComponent( - packageId = TestData.Packages.annual.identifier, - isSelectedByDefault = false, - stack = StackComponent( - components = listOf(TextComponent(text = annualLabelKey, color = textColor)), - ), - ) - - val data = PaywallComponentsData( - id = "pkg_override_test", - templateName = "components", - assetBaseURL = URL("https://assets.pawwalls.com"), - componentsConfig = ComponentsConfig( - base = PaywallComponentsConfig( - stack = StackComponent(components = listOf(monthlyPkg, annualPkg)), - background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), - stickyFooter = null, - ), - ), - componentsLocalizations = localizations, - defaultLocaleIdentifier = localeId, - ) - val offering = Offering( - identifier = "pkg-override-test", - serverDescription = "Package override test", - metadata = emptyMap(), - availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual), - paywallComponents = Offering.PaywallComponents(UiConfig(), data), - ) - val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! - val state = offering.toComponentsPaywallState(validated) - val factory = StyleFactory(localizations = localizations, offering = offering) - val monthlyStyle = factory.create(monthlyPkg).getOrThrow().componentStyle as PackageComponentStyle - val annualStyle = factory.create(annualPkg).getOrThrow().componentStyle as PackageComponentStyle - - setContent { - Column { - PackageComponentView( - style = monthlyStyle, - state = state, - clickHandler = { }, - modifier = Modifier.testTag("monthly"), - ) - PackageComponentView( - style = annualStyle, - state = state, - clickHandler = { }, - modifier = Modifier.testTag("annual"), - ) - } - } - - // No package selected — monthly is visible - onNodeWithText(monthlyLabelValue).assertIsDisplayed() - - // Select monthly — monthly package hides itself via override - state.update(TestData.Packages.monthly.identifier) - onNodeWithText(monthlyLabelValue).assertDoesNotExist() - - // Select annual — monthly is visible again - state.update(TestData.Packages.annual.identifier) - onNodeWithText(monthlyLabelValue).assertIsDisplayed() - } // endregion