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 @@
-
\ No newline at end of file
+
\ 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 @@
-
\ No newline at end of file
+
\ 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