diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 4a63905bf..f97e335c1 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 ef6d71ace..afdeda647 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage38.8% \ No newline at end of file +coverage39.1% \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 714a56c49..8f492cf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.12 + +## Enhancements +- 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 ## 2.7.11 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 3a6cf572c..3e2adc91d 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 = @@ -203,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 = 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 24ecd969c..f993bb81e 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/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 29b80b2c0..7b21f4508 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) @@ -841,6 +843,8 @@ class DependencyContainer( ?.paywall ?.identifier + override fun currentCustomerInfo(): CustomerInfo = Superwall.instance.getCustomerInfo() + override fun makeDeviceInfo(): DeviceInfo = DeviceInfo( appInstalledAtString = deviceHelper.appInstalledAtString, @@ -1108,6 +1112,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 3baeb9e92..ec4cd057e 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/models/customer/CustomerInfo.kt b/superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt index 802cdc0f1..044317f15 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 000000000..df5e8fe38 --- /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 a13bb848e..4cb102c72 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 ae11c18f7..008657cf9 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,10 @@ data class PaywallInfo( "is_scroll_enabled" to isScrollEnabled, "state" to state, ) + val customerParams = customerInfo.toParams() + if (customerParams.isNotEmpty()) { + params["customer_info"] = customerParams + } params.values.removeAll { it == null } val filteredParams = params as MutableMap output.putAll(filteredParams) 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 748c3d0d2..bde46b80e 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,16 @@ 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 import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -94,35 +101,111 @@ object PaywallLogic { productItems: List, productsByFullId: Map, isFreeTrialAvailableOverride: Boolean?, + customerInfo: CustomerInfo, + introOfferEligibility: IntroOfferEligibility = IntroOfferEligibility.AUTOMATIC, ): ProductProcessingOutcome { val productVariables = mutableListOf() - var hasFreeTrial = false for (productItem in productItems) { - // Get storeProduct - val storeProduct = productsByFullId[productItem.fullProductId] ?: continue - - val productVariable = - ProductVariable( - name = productItem.name, - attributes = storeProduct.attributes, + val storeProduct = productsByFullId[productItem.fullProductId] + if (storeProduct != null) { + productVariables.add( + ProductVariable( + name = productItem.name, + attributes = storeProduct.attributes, + ), ) - - productVariables.add(productVariable) - - if (!hasFreeTrial) { - hasFreeTrial = storeProduct.hasFreeTrial } } - // Use the override if it is set - isFreeTrialAvailableOverride?.let { - hasFreeTrial = it - } + val hasFreeTrial = + if (isFreeTrialAvailableOverride != null) { + isFreeTrialAvailableOverride + } else { + computeHasFreeTrial(productItems, productsByFullId, customerInfo, introOfferEligibility) + } return ProductProcessingOutcome( productVariables = productVariables, isFreeTrialAvailable = hasFreeTrial, ) } + + 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, + ) + } + } + + private fun isWebTrialAvailable( + name: String, + trialDays: Int?, + entitlements: Set, + 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 (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 !hasEverHadEntitlement(entitlements, customerInfo) + } + + 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 8e48a5add..ece937db7 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,8 @@ class PaywallRequestManager( productItems = result.productItems, 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/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index 453dac6c1..232f871af 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 ba607d264..1bdaf9050 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?, 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 6e30a0a21..7066e0224 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) 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 a452d779c..531bff726 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,13 @@ package com.superwall.sdk.paywall.request +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.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 +17,9 @@ import org.junit.Assert.assertTrue import org.junit.Test class PaywallLogicTest { + private val playStoreType = mockk(relaxed = true) + private val emptyCustomerInfo = CustomerInfo.empty() + @Test fun test_requestHash_withIdentifier() { val hash = @@ -117,6 +126,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 +136,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(1, outcome.productVariables.size) @@ -145,6 +156,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 +166,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(1, outcome.productVariables.size) @@ -172,6 +185,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 +195,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = true, + customerInfo = emptyCustomerInfo, ) assertTrue(outcome.isFreeTrialAvailable) @@ -198,6 +213,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 +223,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = false, + customerInfo = emptyCustomerInfo, ) assertFalse(outcome.isFreeTrialAvailable) @@ -230,12 +247,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 +268,7 @@ class PaywallLogicTest { productItems = listOf(productItem1, productItem2), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(2, outcome.productVariables.size) @@ -261,6 +281,7 @@ class PaywallLogicTest { mockk { every { name } returns "primary" every { fullProductId } returns "com.example.product1" + every { type } returns playStoreType } val productsByFullId = emptyMap() @@ -270,6 +291,7 @@ class PaywallLogicTest { productItems = listOf(productItem), productsByFullId = productsByFullId, isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(0, outcome.productVariables.size) @@ -283,9 +305,310 @@ class PaywallLogicTest { productItems = emptyList(), productsByFullId = emptyMap(), isFreeTrialAvailableOverride = null, + customerInfo = emptyCustomerInfo, ) assertEquals(0, outcome.productVariables.size) assertFalse(outcome.isFreeTrialAvailable) } + + private val proEntitlement = Entitlement(id = "pro") + + private fun stripeItem( + trialDays: Int?, + entitlements: Set = setOf(proEntitlement), + ): 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 + every { this@mockk.entitlements } returns entitlements + } + } + + private fun paddleItem( + trialDays: Int?, + entitlements: Set = setOf(proEntitlement), + ): 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 + 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_whenCustomerHasNoEntitlementHistory() { + val outcome = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 7)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), + ) + + assertTrue(outcome.isFreeTrialAvailable) + } + + @Test + 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 = customerInfoWithEntitlements(active), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + 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 = 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 = + PaywallLogic.getVariablesAndFreeTrial( + productItems = listOf(stripeItem(trialDays = 0)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + 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_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 = customerInfoWithEntitlements(consumed), + ) + + assertFalse(outcome.isFreeTrialAvailable) + } + + @Test + fun test_play_trialNotAffected_byEntitlementHistory() { + 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 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 = 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_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)), + productsByFullId = emptyMap(), + isFreeTrialAvailableOverride = null, + customerInfo = customerInfoWithEntitlements(), + introOfferEligibility = IntroOfferEligibility.ELIGIBLE, + ) + + assertFalse(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) + } } 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 37030aca6..f8455f558 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) diff --git a/version.env b/version.env index c1719d2f5..c2372f0b0 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.11 +SUPERWALL_VERSION=2.7.12