From 828ce8282e99cf836c996568db57400913e48049 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 14:37:54 +0000 Subject: [PATCH 01/11] Update coverage badge [skip ci] --- .github/badges/jacoco.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ef6d71ac..509e27ef 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage38.8% \ No newline at end of file +coverage38.9% \ No newline at end of file From 05b6ce656907213fcbbf898fd74c2ebe77e71f7c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 15 Apr 2026 12:47:00 +0200 Subject: [PATCH 02/11] Add customer info to paywall info --- .../sdk/dependencies/DependencyContainer.kt | 5 +++++ .../sdk/dependencies/FactoryProtocols.kt | 5 +++++ .../sdk/paywall/presentation/PaywallInfo.kt | 17 +++++++++++++++++ .../superwall/sdk/paywall/view/PaywallView.kt | 11 +++++++++++ .../sdk/paywall/view/PaywallViewState.kt | 13 ++++++++++++- 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 29b80b2c..45777e70 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -31,6 +31,7 @@ import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.PaywallPreload import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.customer.CustomerInfoManager +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.debug.DebugManager import com.superwall.sdk.debug.DebugView import com.superwall.sdk.deeplinks.DeepLinkRouter @@ -179,6 +180,7 @@ class DependencyContainer( GoogleBillingWrapper.Factory, ClassifierDataFactory, ExperimentalPropertiesFactory, + CustomerInfoFactory, WebPaywallRedeemer.Factory { internal val getPaywallComponentsFactory: GetPaywallComponentsFactory by lazy { DefaultGetPaywallComponentsFactory(Superwall.instance) @@ -1108,6 +1110,9 @@ class DependencyContainer( override fun delegate(): SuperwallDelegateAdapter = delegateAdapter + override fun customerInfoFlow(): StateFlow = + Superwall.instance.customerInfo + override fun updatePaywallInfo(paywallInfo: PaywallInfo) { Superwall.instance.presentationItems.paywallInfo = paywallInfo } diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index 3baeb9e9..ec4cd057 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -20,6 +20,7 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall @@ -147,6 +148,10 @@ interface DelegateAdapterFactory { fun delegate(): SuperwallDelegateAdapter } +fun interface CustomerInfoFactory { + fun customerInfoFlow(): StateFlow +} + interface PresentationFactory { fun updatePaywallInfo(paywallInfo: PaywallInfo) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt index ae11c18f..4d2fd13c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.camelCaseToSnakeCase import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureGatingBehavior +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.LocalNotification import com.superwall.sdk.models.paywall.PaywallPresentationInfo @@ -62,6 +63,7 @@ data class PaywallInfo( val isScrollEnabled: Boolean, @Serializable(with = AnyMapSerializer::class) val state: Map = emptyMap(), + val customerInfo: CustomerInfo = CustomerInfo.empty(), ) { constructor( databaseId: String, @@ -96,6 +98,7 @@ data class PaywallInfo( cacheKey: String, isScrollEnabled: Boolean, state: Map = emptyMap(), + customerInfo: CustomerInfo = CustomerInfo.empty(), ) : this( databaseId = databaseId, identifier = identifier, @@ -183,6 +186,7 @@ data class PaywallInfo( buildId = buildId, isScrollEnabled = isScrollEnabled, state = state, + customerInfo = customerInfo, ) fun eventParams( @@ -217,6 +221,19 @@ data class PaywallInfo( "is_scroll_enabled" to isScrollEnabled, "state" to state, ) + if (!customerInfo.isPlaceholder) { + params["customer_user_id"] = customerInfo.userId + params["customer_active_entitlement_ids"] = + customerInfo.entitlements + .filter { it.isActive } + .map { it.id } + .sorted() + .joinToString(",") + params["customer_active_product_ids"] = + customerInfo.activeSubscriptionProductIds.sorted().joinToString(",") + params["customer_has_active_subscription"] = + customerInfo.subscriptions.any { it.isActive } + } params.values.removeAll { it == null } val filteredParams = params as MutableMap output.putAll(filteredParams) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index 453dac6c..232f871a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -27,6 +27,7 @@ import com.superwall.sdk.analytics.superwall.SuperwallEvents import com.superwall.sdk.config.options.PaywallOptions import com.superwall.sdk.config.options.computedShouldPreload import com.superwall.sdk.dependencies.AttributesFactory +import com.superwall.sdk.dependencies.CustomerInfoFactory import com.superwall.sdk.dependencies.DelegateAdapterFactory import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.EnrichmentFactory @@ -154,6 +155,7 @@ class PaywallView( EnrichmentFactory, TrackingFactory, DelegateAdapterFactory, + CustomerInfoFactory, PresentationFactory //region Public properties @@ -215,10 +217,13 @@ class PaywallView( //region Initialization private var stateListener: Job? = null + private var customerInfoListener: Job? = null private fun stopStateListener() { stateListener?.cancel() stateListener = null + customerInfoListener?.cancel() + customerInfoListener = null } private fun startStateListener() { @@ -231,6 +236,12 @@ class PaywallView( .distinctUntilChanged { old, new -> old::class == new::class } .collectLatest { loadingStateDidChange() } } + customerInfoListener = + ioScope.launch { + factory.customerInfoFlow().collect { + controller.updateState(PaywallViewState.Updates.SetCustomerInfo(it)) + } + } } init { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt index ba607d26..1bdaf905 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.paywall.view +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.models.triggers.TriggerRuleOccurrence @@ -17,6 +18,7 @@ import java.util.Date data class PaywallViewState( val paywall: Paywall, val locale: String, + val customerInfo: CustomerInfo = CustomerInfo.empty(), val request: PresentationRequest? = null, val presentationStyle: PaywallPresentationStyle = paywall.presentation.style, val paywallStatePublisher: MutableSharedFlow? = null, @@ -45,7 +47,10 @@ data class PaywallViewState( val lastOpen: Date? = null, ) { val info: PaywallInfo - get() = paywall.getInfo(request?.presentationInfo?.eventData) + get() = + paywall + .getInfo(request?.presentationInfo?.eventData) + .copy(customerInfo = customerInfo) internal val cacheKey: String = PaywallCacheLogic.key(paywall.identifier, locale) @@ -93,6 +98,12 @@ data class PaywallViewState( state.copy(paywall = merged) }) + class SetCustomerInfo( + val customerInfo: CustomerInfo, + ) : Updates({ state -> + state.copy(customerInfo = customerInfo) + }) + class SetRequest( val req: PresentationRequest, val publisher: MutableSharedFlow?, From 5d4484231b76771ee38b3c1123396bab76384b99 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 16 Apr 2026 17:33:57 +0200 Subject: [PATCH 03/11] Add free trial check based on previous stripe or paddle products --- .../request/PaywallRequestManagerTest.kt | 4 + .../sdk/dependencies/DependencyContainer.kt | 2 + .../sdk/paywall/request/PaywallLogic.kt | 40 +++-- .../paywall/request/PaywallRequestManager.kt | 4 + .../sdk/paywall/request/PaywallLogicTest.kt | 158 ++++++++++++++++++ .../request/PaywallRequestManagerTest.kt | 3 + 6 files changed, 201 insertions(+), 10 deletions(-) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt index 3a6cf572..a89d16e4 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt @@ -5,6 +5,7 @@ import Then import When import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallIdentifier @@ -86,6 +87,7 @@ class PaywallRequestManagerTest { coEvery { factory.makeDeviceInfo() } returns DeviceInfo("123", "en_US") coEvery { factory.activePaywallId() } returns null + coEvery { factory.currentCustomerInfo() } returns CustomerInfo.empty() When("getting the paywall") { val result = paywallRequestManager.getPaywall(request) @@ -130,6 +132,7 @@ class PaywallRequestManagerTest { coEvery { factory.makeDeviceInfo() } returns DeviceInfo("123", "en_US") coEvery { factory.activePaywallId() } returns null + coEvery { factory.currentCustomerInfo() } returns CustomerInfo.empty() // Make first request to cache the paywall paywallRequestManager.getPaywall(request) @@ -177,6 +180,7 @@ class PaywallRequestManagerTest { coEvery { factory.makeDeviceInfo() } returns DeviceInfo("123", "en_US") coEvery { factory.activePaywallId() } returns null + coEvery { factory.currentCustomerInfo() } returns CustomerInfo.empty() When("making multiple concurrent requests") { val results = diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 45777e70..7b21f450 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -843,6 +843,8 @@ class DependencyContainer( ?.paywall ?.identifier + override fun currentCustomerInfo(): CustomerInfo = Superwall.instance.getCustomerInfo() + override fun makeDeviceInfo(): DeviceInfo = DeviceInfo( appInstalledAtString = deviceHelper.appInstalledAtString, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt index 748c3d0d..e022a4e6 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt @@ -4,9 +4,11 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.TrackingResult import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.ProductVariable +import com.superwall.sdk.models.product.Store import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -94,25 +96,40 @@ object PaywallLogic { productItems: List, productsByFullId: Map, isFreeTrialAvailableOverride: Boolean?, + customerInfo: CustomerInfo, ): ProductProcessingOutcome { val productVariables = mutableListOf() var hasFreeTrial = false for (productItem in productItems) { - // Get storeProduct - val storeProduct = productsByFullId[productItem.fullProductId] ?: continue + val storeProduct = productsByFullId[productItem.fullProductId] - val productVariable = - ProductVariable( - name = productItem.name, - attributes = storeProduct.attributes, + if (storeProduct != null) { + productVariables.add( + ProductVariable( + name = productItem.name, + attributes = storeProduct.attributes, + ), ) + } - productVariables.add(productVariable) + if (hasFreeTrial) continue - if (!hasFreeTrial) { - hasFreeTrial = storeProduct.hasFreeTrial - } + hasFreeTrial = + when (val type = productItem.type) { + is ProductItem.StoreProductType.PlayStore, + is ProductItem.StoreProductType.AppStore, + is ProductItem.StoreProductType.Other, + -> storeProduct?.hasFreeTrial == true + + is ProductItem.StoreProductType.Stripe -> + (type.product.trialDays ?: 0) > 0 && + !customerInfo.hasEverSubscribedOn(Store.STRIPE) + + is ProductItem.StoreProductType.Paddle -> + (type.product.trialDays ?: 0) > 0 && + !customerInfo.hasEverSubscribedOn(Store.PADDLE) + } } // Use the override if it is set @@ -125,4 +142,7 @@ object PaywallLogic { isFreeTrialAvailable = hasFreeTrial, ) } + + private fun CustomerInfo.hasEverSubscribedOn(store: Store): Boolean = + subscriptions.any { it.store == store } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 8e48a5ad..6923449c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -15,6 +15,7 @@ import com.superwall.sdk.misc.map import com.superwall.sdk.misc.mapError import com.superwall.sdk.misc.onError import com.superwall.sdk.misc.then +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.network.Network @@ -36,6 +37,8 @@ interface PaywallRequestManagerDepFactory : DeviceInfoFactory, ConfigManagerFactory { fun activePaywallId(): String? + + fun currentCustomerInfo(): CustomerInfo } class PaywallRequestManager( @@ -342,6 +345,7 @@ class PaywallRequestManager( productItems = result.productItems, productsByFullId = result.productsByFullId, isFreeTrialAvailableOverride = request.overrides.isFreeTrial, + customerInfo = factory.currentCustomerInfo(), ) paywall.productVariables = outcome.productVariables paywall.isFreeTrialAvailable = outcome.isFreeTrialAvailable diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt index a452d779..f557a4d8 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt @@ -1,7 +1,12 @@ package com.superwall.sdk.paywall.request +import com.superwall.sdk.models.customer.CustomerInfo +import com.superwall.sdk.models.customer.SubscriptionTransaction import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.product.PaddleProduct import com.superwall.sdk.models.product.ProductItem +import com.superwall.sdk.models.product.Store +import com.superwall.sdk.models.product.StripeProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import io.mockk.every import io.mockk.mockk @@ -11,6 +16,12 @@ import org.junit.Assert.assertTrue import org.junit.Test class PaywallLogicTest { + private val playStoreType = mockk(relaxed = true) + private val emptyCustomerInfo = CustomerInfo.empty() + + private fun subscriptionOn(store: Store): SubscriptionTransaction = + SubscriptionTransaction.empty().copy(store = store) + @Test fun test_requestHash_withIdentifier() { val hash = @@ -117,6 +128,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = mapOf("com.example.product1" to storeProduct) @@ -126,6 +138,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(1, outcome.productVariables.size) @@ -145,6 +158,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = mapOf("com.example.product1" to storeProduct) @@ -154,6 +168,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(1, outcome.productVariables.size) @@ -172,6 +187,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = mapOf("com.example.product1" to storeProduct) @@ -181,6 +197,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = true, + customerInfo = emptyCustomerInfo, ) assertTrue(outcome.isFreeTrialAvailable) @@ -198,6 +215,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = mapOf("com.example.product1" to storeProduct) @@ -207,6 +225,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = false, + customerInfo = emptyCustomerInfo, ) assertFalse(outcome.isFreeTrialAvailable) @@ -230,12 +249,14 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productItem2 = mockk { every { name } returns "secondary" every { fullProductId } returns "com.example.product2" + every { type } returns playStoreType } val productsByFullId = @@ -249,6 +270,7 @@ class PaywallLogicTest { productItems = listOf(productItem1, productItem2), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(2, outcome.productVariables.size) @@ -261,6 +283,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = emptyMap() @@ -270,6 +293,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(0, outcome.productVariables.size) @@ -283,9 +307,143 @@ class PaywallLogicTest { productItems = emptyList(), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(0, outcome.productVariables.size) assertFalse(outcome.isFreeTrialAvailable) } + + private fun stripeItem(trialDays: Int?): ProductItem { + val stripeType = + ProductItem.StoreProductType.Stripe( + StripeProduct( + environment = "live", + productIdentifier = "price_stripe_1", + trialDays = trialDays, + ), + ) + return mockk { + every { name } returns "primary" + every { fullProductId } returns "price_stripe_1" + every { type } returns stripeType + } + } + + private fun paddleItem(trialDays: Int?): ProductItem { + val paddleType = + ProductItem.StoreProductType.Paddle( + PaddleProduct( + environment = "live", + productIdentifier = "price_paddle_1", + trialDays = trialDays, + ), + ) + return mockk { + every { name } returns "primary" + every { fullProductId } returns "price_paddle_1" + every { type } returns paddleType + } + } + + @Test + fun test_stripe_trialAvailable_whenNoPriorSubscription() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_trialBlocked_whenPriorStripeSubscription() { + val customerInfo = + emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.STRIPE))) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfo, + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_trialNotBlocked_byPaddleSubscription() { + val customerInfo = + emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PADDLE))) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfo, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_noTrial_whenTrialDaysZero() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 0)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_paddle_trialBlocked_whenPriorPaddleSubscription() { + val customerInfo = + emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PADDLE))) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(paddleItem(trialDays = 14)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfo, + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_play_trialNotAffected_byPriorPlaySubscription() { + val storeProduct = + mockk { + every { hasFreeTrial } returns true + every { attributes } returns mapOf("price" to "9.99") + } + val productItem = + mockk { + every { name } returns "primary" + every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType + } + val customerInfo = + emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PLAY_STORE))) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(productItem), + productsByFullId = mapOf("com.example.product1" to storeProduct), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfo, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt index 37030aca..f8455f55 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.paywall.request import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.triggers.Experiment @@ -52,6 +53,7 @@ class PaywallRequestManagerTest { every { makeDeviceInfo() } returns deviceInfo every { makeStaticPaywall(any(), any()) } returns null every { activePaywallId() } returns null + every { currentCustomerInfo() } returns CustomerInfo.empty() } ioScope = IOScope(Dispatchers.Unconfined) @@ -410,6 +412,7 @@ class PaywallRequestManagerTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns mockk(relaxed = true) } val storeProduct = mockk(relaxed = true) From 31d3ec1c285971c76ddb70be75aa38c6c7444fb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 16:31:27 +0000 Subject: [PATCH 04/11] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 4a63905b..f97e335c 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches30% \ No newline at end of file +branches30.3% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ef6d71ac..075e3475 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage38.8% \ No newline at end of file +coverage39% \ No newline at end of file From 4a60423afe323cae6968bb316128c198de0813ab Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 12:45:43 +0200 Subject: [PATCH 05/11] Fix issues with BottomSheet dismissal on specific Samsungs --- .../sdk/paywall/view/SuperwallPaywallActivity.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 6e30a0a2..7066e022 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -667,6 +667,22 @@ class SuperwallPaywallActivity : AppCompatActivity() { } private fun hideBottomSheetAndFinish() { + val content = contentView as? ViewGroup + if (content != null && content is CoordinatorLayout && content.childCount > 0) { + val bottomSheetBehavior = BottomSheetBehavior.from(content.getChildAt(0)) + // Remove the callback so the STATE_HIDDEN transition below + // doesn't re-enter finish() via paywallView().dismiss(). + bottomSheetCallback?.let { + bottomSheetBehavior.removeBottomSheetCallback(it) + bottomSheetCallback = null + } + // Post the state change so the slide-down animation runs + // correctly on all devices (including Samsung). + content.post { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + } + val colorFrom = Color.argb(200, 0, 0, 0) val colorTo = Color.argb(0, 0, 0, 0) From 3ce6c0958e97ad771d4a332d63d15944a08dd2bd Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 15:18:03 +0200 Subject: [PATCH 06/11] Bump version and changelog --- CHANGELOG.md | 7 +++++++ version.env | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 714a56c4..075a33e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.12 + +## Enhancements +- Add fallback for local assets + +## Fixes +- Fix dismiss animation for bottom sheets and modals on newer Samsung devices ## 2.7.11 diff --git a/version.env b/version.env index c1719d2f..c2372f0b 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.11 +SUPERWALL_VERSION=2.7.12 From 06c6186175854c205ef7517e4667dee31fa3c6ca Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 16:17:41 +0200 Subject: [PATCH 07/11] Update customer info serde + ensure force eligible --- .../sdk/analytics/internal/TrackingLogic.kt | 3 +- .../sdk/models/customer/CustomerInfo.kt | 33 ++- .../models/paywall/IntroOfferEligibility.kt | 16 ++ .../superwall/sdk/models/paywall/Paywall.kt | 2 + .../sdk/paywall/presentation/PaywallInfo.kt | 15 +- .../sdk/paywall/request/PaywallLogic.kt | 113 +++++++-- .../paywall/request/PaywallRequestManager.kt | 1 + .../sdk/paywall/request/PaywallLogicTest.kt | 214 ++++++++++++++++-- 8 files changed, 331 insertions(+), 66 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/models/paywall/IntroOfferEligibility.kt diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt index 24ecd969..f993bb81 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt @@ -141,8 +141,7 @@ sealed class TrackingLogic { input?.let { value -> when (value) { is List<*> -> null - is LinkedHashMap<*, *> -> value.mapValues { clean(it.value) }.filterValues { it != null }.toMap() - is Map<*, *> -> value.mapValues { clean(it.value) }.filterValues { it != null }.toMap() + is Map<*, *> -> value is String -> value is Int, is Float, is Double, is Long, is Boolean -> value is JsonElement -> value.convertFromJsonElement() diff --git a/superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt b/superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt index 802cdc0f..044317f1 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt @@ -6,9 +6,13 @@ import com.superwall.sdk.models.product.Store import com.superwall.sdk.storage.LatestDeviceCustomerInfo import com.superwall.sdk.storage.LatestRedemptionResponse import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.core_data.convertFromJsonElement import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy +import kotlinx.serialization.json.jsonObject /** * A class that contains the latest subscription and entitlement info about the customer. @@ -46,6 +50,19 @@ data class CustomerInfo( .map { it.productId } .toSet() + /** + * Serializes the full CustomerInfo into a params map suitable for templates and analytics. + * + * Returns an empty map when [isPlaceholder] is true — callers should not send partial data + * before CustomerInfo has loaded. + */ + fun toParams(): Map { + if (isPlaceholder) return emptyMap() + val obj = paramsJson.encodeToJsonElement(serializer(), this).jsonObject + return obj + .mapValues { (_, value) -> value.convertFromJsonElement() } + } + override fun toString(): String = buildString { appendLine("CustomerInfo(") @@ -70,6 +87,16 @@ data class CustomerInfo( } companion object { + private val paramsJson = Json { + try { + + namingStrategy = JsonNamingStrategy.SnakeCase + } catch (e: Throwable) { + } + encodeDefaults = true; + ignoreUnknownKeys = true + } + /** * Creates a blank CustomerInfo instance for testing or default states. */ @@ -127,14 +154,16 @@ data class CustomerInfo( when (subscriptionStatus) { is SubscriptionStatus.Active -> subscriptionStatus.entitlements.filter { it.store == Store.PLAY_STORE } + SubscriptionStatus.Inactive, SubscriptionStatus.Unknown, - -> emptyList() + -> emptyList() } // Merge: active from external controller + all web + inactive device // This gives us complete history while respecting external controller as source of truth - val allEntitlements = externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements + val allEntitlements = + externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements val finalEntitlements = mergeEntitlementsPrioritized(allEntitlements).sortedBy { it.id } return CustomerInfo( diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/IntroOfferEligibility.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/IntroOfferEligibility.kt new file mode 100644 index 00000000..df5e8fe3 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/IntroOfferEligibility.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.models.paywall + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class IntroOfferEligibility { + @SerialName("eligible") + ELIGIBLE, + + @SerialName("ineligible") + INELIGIBLE, + + @SerialName("automatic") + AUTOMATIC, +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index a13bb848..4cb102c7 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -139,6 +139,8 @@ data class Paywall( val isScrollEnabled: Boolean? = true, @SerialName("reroute_back_button") val rerouteBackButton: ToggleMode? = null, + @SerialName("intro_offer_eligibility") + val introOfferEligibility: IntroOfferEligibility = IntroOfferEligibility.AUTOMATIC, ) : SerializableEntity { val playStoreProducts: List get() = diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt index 4d2fd13c..008657cf 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt @@ -221,18 +221,9 @@ data class PaywallInfo( "is_scroll_enabled" to isScrollEnabled, "state" to state, ) - if (!customerInfo.isPlaceholder) { - params["customer_user_id"] = customerInfo.userId - params["customer_active_entitlement_ids"] = - customerInfo.entitlements - .filter { it.isActive } - .map { it.id } - .sorted() - .joinToString(",") - params["customer_active_product_ids"] = - customerInfo.activeSubscriptionProductIds.sorted().joinToString(",") - params["customer_has_active_subscription"] = - customerInfo.subscriptions.any { it.isActive } + val customerParams = customerInfo.toParams() + if (customerParams.isNotEmpty()) { + params["customer_info"] = customerParams } params.values.removeAll { it == null } val filteredParams = params as MutableMap diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt index e022a4e6..3aca790c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt @@ -4,8 +4,13 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.TrackingResult import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.customer.CustomerInfo +import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.paywall.IntroOfferEligibility import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.models.product.Store @@ -97,13 +102,12 @@ object PaywallLogic { productsByFullId: Map, isFreeTrialAvailableOverride: Boolean?, customerInfo: CustomerInfo, + introOfferEligibility: IntroOfferEligibility = IntroOfferEligibility.AUTOMATIC, ): ProductProcessingOutcome { val productVariables = mutableListOf() - var hasFreeTrial = false for (productItem in productItems) { val storeProduct = productsByFullId[productItem.fullProductId] - if (storeProduct != null) { productVariables.add( ProductVariable( @@ -112,37 +116,96 @@ object PaywallLogic { ), ) } + } - if (hasFreeTrial) continue + val hasFreeTrial = + if (isFreeTrialAvailableOverride != null) { + isFreeTrialAvailableOverride + } else { + computeHasFreeTrial(productItems, productsByFullId, customerInfo, introOfferEligibility) + } - hasFreeTrial = - when (val type = productItem.type) { - is ProductItem.StoreProductType.PlayStore, - is ProductItem.StoreProductType.AppStore, - is ProductItem.StoreProductType.Other, - -> storeProduct?.hasFreeTrial == true + return ProductProcessingOutcome( + productVariables = productVariables, + isFreeTrialAvailable = hasFreeTrial, + ) + } - is ProductItem.StoreProductType.Stripe -> - (type.product.trialDays ?: 0) > 0 && - !customerInfo.hasEverSubscribedOn(Store.STRIPE) + private fun computeHasFreeTrial( + productItems: List, + productsByFullId: Map, + customerInfo: CustomerInfo, + introOfferEligibility: IntroOfferEligibility, + ): Boolean = + productItems.any { productItem -> + when (val type = productItem.type) { + is ProductItem.StoreProductType.PlayStore, + is ProductItem.StoreProductType.AppStore, + is ProductItem.StoreProductType.Other, + -> productsByFullId[productItem.fullProductId]?.hasFreeTrial == true + + is ProductItem.StoreProductType.Stripe -> + isWebTrialAvailable( + name = productItem.name, + trialDays = type.product.trialDays, + entitlements = productItem.entitlements, + customerInfo = customerInfo, + introOfferEligibility = introOfferEligibility, + ) + + is ProductItem.StoreProductType.Paddle -> + isWebTrialAvailable( + name = productItem.name, + trialDays = type.product.trialDays, + entitlements = productItem.entitlements, + customerInfo = customerInfo, + introOfferEligibility = introOfferEligibility, + ) + } + } - is ProductItem.StoreProductType.Paddle -> - (type.product.trialDays ?: 0) > 0 && - !customerInfo.hasEverSubscribedOn(Store.PADDLE) - } + private fun isWebTrialAvailable( + name: String, + trialDays: Int?, + entitlements: Set, + customerInfo: CustomerInfo, + introOfferEligibility: IntroOfferEligibility, + ): Boolean { + when (introOfferEligibility) { + IntroOfferEligibility.INELIGIBLE -> return false + IntroOfferEligibility.ELIGIBLE -> return true + IntroOfferEligibility.AUTOMATIC -> Unit } - // Use the override if it is set - isFreeTrialAvailableOverride?.let { - hasFreeTrial = it + if ((trialDays ?: 0) <= 0) return false + + if (entitlements.isEmpty()) { + Logger.debug( + logLevel = LogLevel.warn, + scope = LogScope.paywallPresentation, + message = "$name has trialDays > 0 but no entitlements — skipping trial eligibility check", + ) + return false } - return ProductProcessingOutcome( - productVariables = productVariables, - isFreeTrialAvailable = hasFreeTrial, - ) + return !hasEverHadEntitlement(entitlements, customerInfo) } - private fun CustomerInfo.hasEverSubscribedOn(store: Store): Boolean = - subscriptions.any { it.store == store } + private fun hasEverHadEntitlement( + productEntitlements: Set, + customerInfo: CustomerInfo, + ): Boolean { + // Placeholder guard: if data hasn't loaded yet, assume the trial was consumed + // to avoid falsely offering a trial. + if (customerInfo.isPlaceholder) return true + + val relevantCustomerEntitlementIds = + customerInfo.entitlements + .asSequence() + .filter { it.latestProductId != null || it.store == Store.SUPERWALL || it.isActive } + .map { it.id } + .toSet() + + return productEntitlements.any { it.id in relevantCustomerEntitlementIds } + } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 6923449c..ece937db 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -346,6 +346,7 @@ class PaywallRequestManager( productsByFullId = result.productsByFullId, isFreeTrialAvailableOverride = request.overrides.isFreeTrial, customerInfo = factory.currentCustomerInfo(), + introOfferEligibility = paywall.introOfferEligibility, ) paywall.productVariables = outcome.productVariables paywall.isFreeTrialAvailable = outcome.isFreeTrialAvailable diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt index f557a4d8..26da548e 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt @@ -1,8 +1,9 @@ package com.superwall.sdk.paywall.request import com.superwall.sdk.models.customer.CustomerInfo -import com.superwall.sdk.models.customer.SubscriptionTransaction +import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.paywall.IntroOfferEligibility import com.superwall.sdk.models.product.PaddleProduct import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.Store @@ -19,9 +20,6 @@ class PaywallLogicTest { private val playStoreType = mockk(relaxed = true) private val emptyCustomerInfo = CustomerInfo.empty() - private fun subscriptionOn(store: Store): SubscriptionTransaction = - SubscriptionTransaction.empty().copy(store = store) - @Test fun test_requestHash_withIdentifier() { val hash = @@ -314,7 +312,12 @@ class PaywallLogicTest { assertFalse(outcome.isFreeTrialAvailable) } - private fun stripeItem(trialDays: Int?): ProductItem { + private val proEntitlement = Entitlement(id = "pro") + + private fun stripeItem( + trialDays: Int?, + entitlements: Set = setOf(proEntitlement), + ): ProductItem { val stripeType = ProductItem.StoreProductType.Stripe( StripeProduct( @@ -327,10 +330,14 @@ class PaywallLogicTest { every { name } returns "primary" every { fullProductId } returns "price_stripe_1" every { type } returns stripeType + every { this@mockk.entitlements } returns entitlements } } - private fun paddleItem(trialDays: Int?): ProductItem { + private fun paddleItem( + trialDays: Int?, + entitlements: Set = setOf(proEntitlement), + ): ProductItem { val paddleType = ProductItem.StoreProductType.Paddle( PaddleProduct( @@ -343,54 +350,112 @@ class PaywallLogicTest { every { name } returns "primary" every { fullProductId } returns "price_paddle_1" every { type } returns paddleType + every { this@mockk.entitlements } returns entitlements } } + private fun customerInfoWithEntitlements(vararg entitlements: Entitlement): CustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "", + entitlements = entitlements.toList(), + isPlaceholder = false, + ) + @Test - fun test_stripe_trialAvailable_whenNoPriorSubscription() { + fun test_stripe_trialAvailable_whenCustomerHasNoEntitlementHistory() { val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(stripeItem(trialDays = 7)), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, - customerInfo = emptyCustomerInfo, + customerInfo = customerInfoWithEntitlements(), ) assertTrue(outcome.isFreeTrialAvailable) } @Test - fun test_stripe_trialBlocked_whenPriorStripeSubscription() { - val customerInfo = - emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.STRIPE))) + fun test_stripe_trialBlocked_whenEntitlementHasLatestProductId() { + val consumed = + proEntitlement.copy(latestProductId = "price_stripe_old", store = Store.STRIPE) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(consumed), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_trialBlocked_whenEntitlementIsActive() { + val active = proEntitlement.copy(isActive = true) val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(stripeItem(trialDays = 7)), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, - customerInfo = customerInfo, + customerInfo = customerInfoWithEntitlements(active), ) assertFalse(outcome.isFreeTrialAvailable) } @Test - fun test_stripe_trialNotBlocked_byPaddleSubscription() { - val customerInfo = - emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PADDLE))) + fun test_stripe_trialBlocked_whenEntitlementFromSuperwallStore() { + val superwallGranted = proEntitlement.copy(store = Store.SUPERWALL) val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(stripeItem(trialDays = 7)), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, - customerInfo = customerInfo, + customerInfo = customerInfoWithEntitlements(superwallGranted), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_trialAvailable_whenConfigOnlyEntitlement() { + // Config-only: no latestProductId, not SUPERWALL store, not active. + val configOnly = + proEntitlement.copy( + latestProductId = null, + store = null, + isActive = false, + ) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(configOnly), ) assertTrue(outcome.isFreeTrialAvailable) } + @Test + fun test_stripe_trialBlocked_whenCustomerInfoIsPlaceholder() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = CustomerInfo.empty(), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + @Test fun test_stripe_noTrial_whenTrialDaysZero() { val outcome = @@ -398,30 +463,43 @@ class PaywallLogicTest { productItems = listOf(stripeItem(trialDays = 0)), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, - customerInfo = emptyCustomerInfo, + customerInfo = customerInfoWithEntitlements(), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_stripe_noTrial_whenEntitlementsEmpty() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7, entitlements = emptySet())), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), ) assertFalse(outcome.isFreeTrialAvailable) } @Test - fun test_paddle_trialBlocked_whenPriorPaddleSubscription() { - val customerInfo = - emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PADDLE))) + fun test_paddle_trialBlocked_whenEntitlementConsumed() { + val consumed = + proEntitlement.copy(latestProductId = "price_paddle_old", store = Store.PADDLE) val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(paddleItem(trialDays = 14)), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, - customerInfo = customerInfo, + customerInfo = customerInfoWithEntitlements(consumed), ) assertFalse(outcome.isFreeTrialAvailable) } @Test - fun test_play_trialNotAffected_byPriorPlaySubscription() { + fun test_play_trialNotAffected_byEntitlementHistory() { val storeProduct = mockk { every { hasFreeTrial } returns true @@ -433,15 +511,101 @@ class PaywallLogicTest { every { fullProductId } returns "com.example.product1" every { type } returns playStoreType } - val customerInfo = - emptyCustomerInfo.copy(subscriptions = listOf(subscriptionOn(Store.PLAY_STORE))) + val consumed = proEntitlement.copy(latestProductId = "com.example.product1", store = Store.PLAY_STORE) val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(productItem), productsByFullId = mapOf("com.example.product1" to storeProduct), isFreeTrialAvailableOverride = null, - customerInfo = customerInfo, + customerInfo = customerInfoWithEntitlements(consumed), + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_paywallIneligible_forcesFalse_forStripe() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), + introOfferEligibility = IntroOfferEligibility.INELIGIBLE, + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_paywallIneligible_doesNotAffectPlay() { + // Paywall-level eligibility only applies to Stripe/Paddle; Play defers to the store. + val storeProduct = + mockk { + every { hasFreeTrial } returns true + every { attributes } returns mapOf("price" to "9.99") + } + val productItem = + mockk { + every { name } returns "primary" + every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType + } + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(productItem), + productsByFullId = mapOf("com.example.product1" to storeProduct), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), + introOfferEligibility = IntroOfferEligibility.INELIGIBLE, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_paywallEligible_forcesTrue_skippingEntitlementChecks() { + // Customer already consumed the entitlement — ELIGIBLE should still force true. + val consumed = + proEntitlement.copy(latestProductId = "price_stripe_old", store = Store.STRIPE) + + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(consumed), + introOfferEligibility = IntroOfferEligibility.ELIGIBLE, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_paywallEligible_forcesTrue_evenWithoutTrialDays() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 0)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), + introOfferEligibility = IntroOfferEligibility.ELIGIBLE, + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + fun test_override_winsOverIneligible() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = true, + customerInfo = customerInfoWithEntitlements(), + introOfferEligibility = IntroOfferEligibility.INELIGIBLE, ) assertTrue(outcome.isFreeTrialAvailable) From e275d5c286ea687c1ece965f63359ec84cf81c83 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 16:19:18 +0200 Subject: [PATCH 08/11] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 075a33e7..8f492cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.7.12 ## Enhancements -- Add fallback for local assets +- Add customer info to paywall info and tracked events +- Add stripe/paddle intro offer eligibility ## Fixes - Fix dismiss animation for bottom sheets and modals on newer Samsung devices From 6690bbbca06f4880009fb60a9d4c5ed7f79bba73 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 16:43:43 +0200 Subject: [PATCH 09/11] Improve ELIGIBLE handling --- .../java/com/superwall/sdk/paywall/request/PaywallLogic.kt | 4 ++-- .../com/superwall/sdk/paywall/request/PaywallLogicTest.kt | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt index 3aca790c..bde46b80 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt @@ -171,14 +171,14 @@ object PaywallLogic { customerInfo: CustomerInfo, introOfferEligibility: IntroOfferEligibility, ): Boolean { + if ((trialDays ?: 0) <= 0) return false + when (introOfferEligibility) { IntroOfferEligibility.INELIGIBLE -> return false IntroOfferEligibility.ELIGIBLE -> return true IntroOfferEligibility.AUTOMATIC -> Unit } - if ((trialDays ?: 0) <= 0) return false - if (entitlements.isEmpty()) { Logger.debug( logLevel = LogLevel.warn, diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt index 26da548e..531bff72 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt @@ -584,7 +584,8 @@ class PaywallLogicTest { } @Test - fun test_paywallEligible_forcesTrue_evenWithoutTrialDays() { + fun test_paywallEligible_stillRequiresTrialDays() { + // ELIGIBLE is a force-allow when a trial is configured, not a pure force-show flag. val outcome = PaywallLogic.getVariablesAndFreeTrial( productItems = listOf(stripeItem(trialDays = 0)), @@ -594,7 +595,7 @@ class PaywallLogicTest { introOfferEligibility = IntroOfferEligibility.ELIGIBLE, ) - assertTrue(outcome.isFreeTrialAvailable) + assertFalse(outcome.isFreeTrialAvailable) } @Test From e6be95ed1008a2a191d6810b1f1a57eb1ee17d8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 15:21:28 +0000 Subject: [PATCH 10/11] Update coverage badge [skip ci] --- .github/badges/jacoco.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 1c7174b1..afdeda64 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage39% +coverage39.1% \ No newline at end of file From 4140d06dc2aa7ac783d098f690eaf70a9db260cc Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 21 Apr 2026 17:45:40 +0200 Subject: [PATCH 11/11] Fix test issue --- .../superwall/sdk/paywall/request/PaywallRequestManagerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt index a89d16e4..3e2adc91 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt @@ -207,6 +207,7 @@ class PaywallRequestManagerTest { fun test_preload_active_paywall() = runTest { manager(this@runTest.coroutineContext) + every { factory.currentCustomerInfo() } returns CustomerInfo.empty() Given("a preload request for an active paywall") { val paywallId = "test_paywall" val originalExperiment =